From 8b5b5818e01e2a4a85faf9f643c2c97bb18958c9 Mon Sep 17 00:00:00 2001 From: johnnyfish Date: Wed, 15 Apr 2026 00:20:28 +0300 Subject: [PATCH 001/185] fix: forward ONECLI_API_KEY to OneCLI SDK for authenticated container config Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config.ts | 3 +++ src/container-runner.ts | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 1d15b8d..a84cb61 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,7 @@ const envConfig = readEnvFile([ 'ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', + 'ONECLI_API_KEY', 'TZ', ]); @@ -52,6 +53,8 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt( 10, ); // 10MB default export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL; +export const ONECLI_API_KEY = + process.env.ONECLI_API_KEY || envConfig.ONECLI_API_KEY; export const MAX_MESSAGES_PER_PROMPT = Math.max( 1, parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10, diff --git a/src/container-runner.ts b/src/container-runner.ts index dafa143..1e1e3db 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -13,6 +13,7 @@ import { DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, + ONECLI_API_KEY, ONECLI_URL, TIMEZONE, } from './config.js'; @@ -28,7 +29,7 @@ import { OneCLI } from '@onecli-sh/sdk'; import { validateAdditionalMounts } from './mount-security.js'; import { RegisteredGroup } from './types.js'; -const onecli = new OneCLI({ url: ONECLI_URL }); +const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY }); // Sentinel markers for robust output parsing (must match agent-runner) const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; From 38163bc5c4981b59a3e3bac6589578f41f237845 Mon Sep 17 00:00:00 2001 From: johnnyfish Date: Wed, 15 Apr 2026 00:23:07 +0300 Subject: [PATCH 002/185] fix: add ONECLI_API_KEY to config mock in container-runner tests Co-Authored-By: Claude Opus 4.6 (1M context) --- src/container-runner.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts index 36fca0a..4f3314f 100644 --- a/src/container-runner.test.ts +++ b/src/container-runner.test.ts @@ -14,6 +14,7 @@ vi.mock('./config.js', () => ({ DATA_DIR: '/tmp/nanoclaw-test-data', GROUPS_DIR: '/tmp/nanoclaw-test-groups', IDLE_TIMEOUT: 1800000, // 30min + ONECLI_API_KEY: '', ONECLI_URL: 'http://localhost:10254', TIMEZONE: 'America/Los_Angeles', })); From da9a73b50abb3d6a9cdd9fd8e44adbacae9b0a7b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Apr 2026 11:48:46 +0000 Subject: [PATCH 003/185] chore: bump version to 1.2.53 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ebd7b83..cb16bdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.52", + "version": "1.2.53", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.52", + "version": "1.2.53", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index be913a9..ce85188 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.52", + "version": "1.2.53", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From eba94b721ab8c7476e97d6600ca7ee4c0e53249c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Apr 2026 11:48:49 +0000 Subject: [PATCH 004/185] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?3.8k=20tokens=20=C2=B7=2022%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 52e70b4..81b608a 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 43.7k tokens, 22% of context window + + 43.8k tokens, 22% of context window @@ -15,8 +15,8 @@ tokens - - 43.7k + + 43.8k From caf23208f0ef9d37f4dbc45f789168e31cb2f96e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 18 Apr 2026 12:03:26 +0300 Subject: [PATCH 005/185] docs: add v2 preview announcement to README Adds a callout for the Chat SDK + approval dialogs preview with a collapsible containing the fork/checkout instructions. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 874a8d7..55fa16e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,22 @@ --- +> **🔥 New Version Preview: Chat SDK + Approval Dialogs** +> +> A new version of NanoClaw is available for preview, featuring Vercel Chat SDK integration (15 messaging platforms from one codebase) and one-tap approval dialogs for sensitive agent actions. [Read the announcement →](https://venturebeat.com/orchestration/should-my-enterprise-ai-agent-do-that-nanoclaw-and-vercel-launch-easier-agentic-policy-setting-and-approval-dialogs-across-15-messaging-apps) +> +>
+> Try the preview +> +> ```bash +> gh repo fork qwibitai/nanoclaw --clone && cd nanoclaw +> git checkout v2 +> claude +> ``` +> Then run `/setup`. Feedback welcome on [Discord](https://discord.gg/VDdww8qS42). Expect breaking changes before merge to main. +> +>
+ ## Why I Built NanoClaw [OpenClaw](https://github.com/openclaw/openclaw) is an impressive project, but I wouldn't have been able to sleep if I had given complex software I didn't understand full access to my life. OpenClaw has nearly half a million lines of code, 53 config files, and 70+ dependencies. Its security is at the application level (allowlists, pairing codes) rather than true OS-level isolation. Everything runs in one Node process with shared memory. From 47e320380971fb173470ef1ceab34e7c4d5e6e79 Mon Sep 17 00:00:00 2001 From: Bryan Lozano Date: Sun, 19 Apr 2026 01:11:46 -0700 Subject: [PATCH 006/185] feat: add /add-ollama-provider skill and docs/ollama.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new operational skill that routes any agent group to a local Ollama instance instead of the Anthropic API. Ollama speaks the Anthropic /v1/messages endpoint natively, so no new provider code is needed — just env var overrides and a model setting in the shared settings file. The skill also documents and applies two prerequisite source changes: - ContainerConfig gains env and blockedHosts fields (container-config.ts) - container-runner wires those fields as -e and --add-host Docker flags - Dockerfile home dir set to chmod 777 so containers running as the host uid can write ~/.claude config (discovered during implementation) docs/ollama.md covers the architecture, OneCLI proxy bypass rationale, network isolation via blockedHosts, model selection tradeoffs for Apple Silicon, and revert instructions. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/add-ollama-provider/SKILL.md | 179 ++++++++++++++++++++ docs/ollama.md | 88 ++++++++++ 2 files changed, 267 insertions(+) create mode 100644 .claude/skills/add-ollama-provider/SKILL.md create mode 100644 docs/ollama.md diff --git a/.claude/skills/add-ollama-provider/SKILL.md b/.claude/skills/add-ollama-provider/SKILL.md new file mode 100644 index 0000000..83f7e5a --- /dev/null +++ b/.claude/skills/add-ollama-provider/SKILL.md @@ -0,0 +1,179 @@ +--- +name: add-ollama-provider +description: Route a NanoClaw agent group to a local Ollama model instead of the Anthropic API. Ollama speaks the Anthropic API natively (v1/messages), so no provider code changes are needed — just env var overrides and a model setting. Use when the user wants to run their agent locally, cut API costs, or experiment with open-weight models. See docs/ollama.md for background. +--- + +# Add Ollama Provider + +Routes an agent group to a local Ollama instance instead of the Anthropic API. +See `docs/ollama.md` for how this works and the tradeoffs involved. + +## Prerequisites + +1. **Ollama is installed and running** on the host — verify: `curl -s http://localhost:11434/api/tags` +2. **A model is pulled** — e.g. `ollama pull gemma4` or `ollama pull qwen3-coder` +3. **The agent group already exists** — run `/init-first-agent` first if needed + +## 1. Check source support + +The feature requires two fields in `ContainerConfig` (`env` and `blockedHosts`) and their +corresponding wiring in `container-runner.ts`. Check if already present: + +```bash +grep -c 'blockedHosts' src/container-config.ts src/container-runner.ts +``` + +If either count is 0, apply the changes in steps 1a and 1b. Otherwise skip to step 2. + +### 1a. Extend ContainerConfig + +In `src/container-config.ts`, add to the `ContainerConfig` interface: + +```typescript +env?: Record; +blockedHosts?: string[]; +``` + +And in `readContainerConfig`, add inside the returned object: + +```typescript +env: raw.env, +blockedHosts: raw.blockedHosts, +``` + +### 1b. Wire into container-runner + +In `src/container-runner.ts`, after the `NANOCLAW_MCP_SERVERS` block, add: + +```typescript +// Per-agent-group env overrides — applied last to win over OneCLI values. +if (containerConfig.env) { + for (const [key, value] of Object.entries(containerConfig.env)) { + args.push('-e', `${key}=${value}`); + } +} + +// Blocked hosts: resolve to 0.0.0.0 so they are unreachable inside the container. +if (containerConfig.blockedHosts) { + for (const host of containerConfig.blockedHosts) { + args.push('--add-host', `${host}:0.0.0.0`); + } +} +``` + +### 1c. Fix home directory permissions (if not already done) + +The container may run as your host uid (not uid 1000). Check the Dockerfile: + +```bash +grep 'chmod.*home/node' container/Dockerfile +``` + +If it shows `chmod 755`, change it to `chmod 777` so any uid can write there. +Then rebuild the container image: `./container/build.sh` + +## 2. Identify the setup + +Ask the user (plain text, not AskUserQuestion): + +1. **Which agent group?** List available groups: `sqlite3 data/v2.db "SELECT folder, name FROM agent_groups;"` +2. **Which Ollama model?** List available: `curl -s http://localhost:11434/api/tags | grep '"name"'` +3. **Block Anthropic API?** Recommended yes — prevents accidental spend if config drifts. + +Record as `FOLDER`, `MODEL`, and `BLOCK_ANTHROPIC`. + +## 3. Configure container.json + +Read `groups//container.json`. Add (or merge into) an `env` block and optionally `blockedHosts`: + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "http://host.docker.internal:11434", + "ANTHROPIC_API_KEY": "ollama", + "NO_PROXY": "host.docker.internal", + "no_proxy": "host.docker.internal" + }, + "blockedHosts": ["api.anthropic.com"] +} +``` + +Omit `blockedHosts` if the user declined step 2. + +**Why these vars:** `ANTHROPIC_BASE_URL` redirects the Anthropic SDK to Ollama. +`ANTHROPIC_API_KEY=ollama` satisfies the SDK's key requirement (Ollama ignores it). +`NO_PROXY` bypasses the OneCLI HTTPS proxy for requests to `host.docker.internal` +so they reach Ollama directly instead of going through the credential gateway. + +## 4. Set the model + +Read the agent group's shared Claude settings: + +```bash +# Find the agent group ID +AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups WHERE folder='';") +SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json +``` + +Add `"model": ""` to that settings file. Create the file if it doesn't exist: + +```json +{ + "model": "gemma4:latest" +} +``` + +If the file already has content, merge the `model` key in — don't overwrite existing keys. + +**Why here and not container.json:** Claude Code reads its model from its own settings +file, not from env vars. This file is bind-mounted into the container as `~/.claude/settings.json`. + +## 5. Build and restart + +```bash +export PATH="/opt/homebrew/bin:$PATH" +pnpm run build +launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist +launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist +# Linux: systemctl --user restart nanoclaw +``` + +## 6. Verify + +Send a message to the agent. Then confirm: + +```bash +# Ollama shows the model as active +curl -s http://localhost:11434/api/ps | grep '"name"' + +# Container has the right env vars +CTR=$(docker ps --filter "name=nanoclaw-v2-" --format "{{.Names}}" | head -1) +docker inspect "$CTR" --format '{{json .HostConfig.ExtraHosts}}' +docker exec "$CTR" env | grep ANTHROPIC +``` + +Expected: `api.anthropic.com:0.0.0.0` in ExtraHosts, `ANTHROPIC_BASE_URL=http://host.docker.internal:11434`. + +## Reverting to Claude + +To switch back to the Anthropic API: + +1. Remove the `env` and `blockedHosts` keys from `groups//container.json` +2. Remove `"model"` from the shared settings file +3. Restart the service + +No rebuild needed — both files are read at container spawn time. + +## Troubleshooting + +**Agent hangs, no response:** Ollama may be loading the model cold (large models take 10–30s). +Watch `curl -s http://localhost:11434/api/ps` — the model appears once loaded. + +**"model not found" error in container logs:** The model name in settings.json doesn't match +what Ollama has. Run `ollama list` on the host and use the exact name shown. + +**Responses claim to be Claude:** The model was trained on data that includes Claude conversations. +Add a line to `groups//CLAUDE.md` telling it what model it runs on. + +**Agent responds but Ollama shows no activity:** `NO_PROXY` may not have taken effect for +`http_proxy` (lowercase). Add both `NO_PROXY` and `no_proxy` to the env block. diff --git a/docs/ollama.md b/docs/ollama.md new file mode 100644 index 0000000..0ea0253 --- /dev/null +++ b/docs/ollama.md @@ -0,0 +1,88 @@ +# Running Agents on Local Ollama + +NanoClaw agents can be routed to a local [Ollama](https://ollama.com) instance instead of the Anthropic API. This cuts API costs to zero and keeps all inference on your hardware. + +## How It Works + +Ollama exposes an Anthropic-compatible `/v1/messages` endpoint. The Claude Code CLI (which runs inside agent containers) uses the Anthropic SDK, which reads `ANTHROPIC_BASE_URL` to find the API host. Pointing that variable at Ollama is all that's needed — no new provider code, no changes to the agent runtime. + +``` +┌─────────────────────────────┐ +│ Agent container │ +│ │ +│ Claude Code CLI │ +│ ↓ ANTHROPIC_BASE_URL │ +│ http://host.docker. │ ┌──────────────────┐ +│ internal:11434 ───────┼─────▶│ Ollama :11434 │ +│ │ │ gemma4:latest │ +└─────────────────────────────┘ └──────────────────┘ +``` + +`host.docker.internal` is Docker's magic hostname that resolves to the host machine from inside a container — so Ollama running on your Mac or Linux box is reachable at that address. + +## The OneCLI Complication + +NanoClaw normally runs API calls through an OneCLI HTTPS proxy that injects real credentials in place of a placeholder key. When redirecting to Ollama you need to bypass that proxy so requests go direct. Two env vars handle this: + +- `NO_PROXY=host.docker.internal` — tells the Anthropic SDK's HTTP client to skip the proxy for that hostname +- `no_proxy=host.docker.internal` — lowercase variant for tools that check the lowercase form + +Both are set in the agent group's `container.json` alongside `ANTHROPIC_BASE_URL`. + +## Network Isolation + +Setting `ANTHROPIC_BASE_URL` redirects requests but doesn't prevent a misconfigured agent from accidentally reaching `api.anthropic.com` directly. The `blockedHosts` field in `container.json` adds a Docker `--add-host` flag that resolves the domain to `0.0.0.0`, making it physically unreachable from inside the container: + +```json +"blockedHosts": ["api.anthropic.com"] +``` + +With this in place, even if the model setting drifts back to a Claude model name, the API call will fail immediately rather than silently billing your account. + +## Model Selection + +The Claude Code CLI reads its model from `~/.claude/settings.json` inside the container, which NanoClaw bind-mounts from `data/v2-sessions//.claude-shared/settings.json`. Set `"model": "gemma4:latest"` (or whatever Ollama model you've pulled) there. Use the exact name from `ollama list`. + +Model selection considerations for Apple Silicon: + +| Model | Size | Quality | Speed (M4 Pro) | +|-------|------|---------|----------------| +| `gemma4:latest` | 12B | Good general-purpose | Fast | +| `qwen3-coder:latest` | 32B | Excellent for coding tasks | Moderate | +| `llama3.2:latest` | 3B | Basic | Very fast | + +The agent uses tool calls extensively (read/write files, shell commands). Models that support tool use reliably work best. Gemma 4 and Qwen 3 Coder both handle structured tool calls well. + +## What Changes at the Code Level + +Three files need to support this feature. See `/add-ollama-provider` for the exact changes. + +**`src/container-config.ts`** — `ContainerConfig` interface needs `env` and `blockedHosts` fields so the per-group JSON can carry them. + +**`src/container-runner.ts`** — At container spawn time, `env` entries become `-e KEY=VAL` Docker flags (applied after OneCLI's injected vars so they win), and `blockedHosts` entries become `--add-host HOST:0.0.0.0` flags. + +**`container/Dockerfile`** — The container runs as the host user's uid (e.g. 501 on macOS), not as the `node` user (uid 1000). The home directory must be `chmod 777` so any uid can write `~/.claude.json` and `~/.claude/settings.json`. + +## Tradeoffs + +| | Ollama (local) | Anthropic API | +|---|---|---| +| Cost | Free | Pay-per-token | +| Privacy | Fully local | Data sent to Anthropic | +| Model quality | Good (open-weight) | Excellent (Claude) | +| Cold start | 5–30s (model load) | ~1s | +| Context window | Varies by model | 200k tokens (Sonnet) | +| Tool use reliability | Good (large models) | Excellent | +| Hardware req. | 16GB+ RAM | None | + +For personal automation on capable hardware, the tradeoff favors local. For complex multi-step tasks requiring large context or high reliability, Claude is still ahead. + +## Reverting to Claude + +Remove the `env` and `blockedHosts` keys from `groups//container.json`, remove `"model"` from the shared settings file, and restart the service. No rebuild needed. + +## See Also + +- `/add-ollama-provider` — step-by-step skill to configure any agent group for Ollama +- [Ollama Anthropic compatibility docs](https://ollama.com/blog/openai-compatibility) — upstream docs on the API bridge +- `docs/architecture.md` — how the container spawn and env injection pipeline works From 0dae3498c3595649c1f72d54aa850a8747133b03 Mon Sep 17 00:00:00 2001 From: Tal Moskovich Date: Sun, 19 Apr 2026 23:06:11 +0300 Subject: [PATCH 007/185] docs(add-opencode): pin SDK/CLI to 1.4.17, document overlay propagation and env vars - Pin @opencode-ai/sdk and opencode-ai CLI both to 1.4.17; warn against latest (1.14.x has a breaking session API rewrite incompatible with the current provider code) - Add step 7: propagate provider files into existing per-group overlays (data/v2-sessions/*/agent-runner-src/providers/) which override the image at runtime and are never auto-updated by rebuilds - Add build cache gotcha: prune builder if "Unknown provider" after rebuild - Document ANTHROPIC_BASE_URL as required for non-anthropic providers, with correct base URL per provider (DeepSeek, OpenRouter examples) - Add OPENCODE_SMALL_MODEL to all examples - Document OneCLI credential grant (set-secrets replaces, not appends) Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/add-opencode/SKILL.md | 90 ++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/.claude/skills/add-opencode/SKILL.md b/.claude/skills/add-opencode/SKILL.md index 1dd31df..08a558f 100644 --- a/.claude/skills/add-opencode/SKILL.md +++ b/.claude/skills/add-opencode/SKILL.md @@ -60,10 +60,10 @@ import './opencode.js'; ### 4. Add the agent-runner dependency -Pinned. Bump deliberately, not with `bun update`. +Pinned. Bump deliberately, not with `bun update`. Use `1.4.17` — must match the `opencode-ai` CLI version pinned in step 5. The 1.14.x SDK has a completely different API and is **incompatible** with the current provider code. ```bash -cd container/agent-runner && bun add @opencode-ai/sdk@1.4.3 && cd - +cd container/agent-runner && bun add @opencode-ai/sdk@1.4.17 && cd - ``` ### 5. Add `opencode-ai` to the container Dockerfile @@ -73,9 +73,11 @@ Two edits to `container/Dockerfile`, both idempotent (skip if already present): **(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG VERCEL_VERSION=latest`: ```dockerfile -ARG OPENCODE_VERSION=latest +ARG OPENCODE_VERSION=1.4.17 ``` +> **Do not use `latest`** — the CLI and SDK must be the same version. `latest` silently upgrades the CLI to 1.14.x which has a breaking session API change (UUID session IDs → `ses_` prefix) incompatible with SDK 1.4.x. + **(b)** In the `pnpm install -g` block (around line 80), append `"opencode-ai@${OPENCODE_VERSION}"` to the list: ```dockerfile @@ -94,6 +96,25 @@ pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typ ./container/build.sh # agent image ``` +> **Build cache gotcha:** The container buildkit caches COPY steps aggressively. If provider files were already present in the build context before, the new files may not be picked up. If you see "Unknown provider: opencode" after the build, prune the builder and rebuild: +> ```bash +> docker builder prune -f && ./container/build.sh +> ``` + +### 7. Propagate to existing per-group overlays + +Each agent group has a live source overlay at `data/v2-sessions//agent-runner-src/providers/` that **overrides the image at runtime**. This overlay is created when the group is first wired and never auto-updated by image rebuilds. Any group that already existed before this skill ran needs the new files copied in manually. + +```bash +for overlay in data/v2-sessions/*/agent-runner-src/providers/; do + [ -d "$overlay" ] || continue + cp container/agent-runner/src/providers/opencode.ts "$overlay" + cp container/agent-runner/src/providers/mcp-to-opencode.ts "$overlay" + cp container/agent-runner/src/providers/index.ts "$overlay" + echo "Updated: $overlay" +done +``` + ## Configuration ### Host `.env` (typical) @@ -102,35 +123,62 @@ Set model/provider strings in the form OpenCode expects (often `provider/model-i These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the DB still needs `agent_provider` set (below). -- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic` (if unset, the runner defaults to `anthropic`). -- `OPENCODE_MODEL` — full model id, e.g. `openrouter/anthropic/claude-sonnet-4`. -- `OPENCODE_SMALL_MODEL` — optional second model for "small" tasks. +- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic`, `deepseek`. +- `OPENCODE_MODEL` — full model id in `provider/model` form, e.g. `deepseek/deepseek-chat`. +- `OPENCODE_SMALL_MODEL` — optional second model for lighter tasks; defaults to `OPENCODE_MODEL` if unset. +- `ANTHROPIC_BASE_URL` — **required for non-`anthropic` providers.** The opencode container provider passes this as the `baseURL` for the upstream provider config so requests route through OneCLI's credential proxy or directly to the provider's API. Set it to the provider's API base URL (e.g. `https://api.deepseek.com/v1`, `https://openrouter.ai/api/v1`). -Credentials: OneCLI / credential proxy patterns are unchanged. For non-`anthropic` OpenCode providers, the runner registers a placeholder API key and **`ANTHROPIC_BASE_URL`** (the credential proxy) as `baseURL` so the real key never lives in the container. +Credentials: register provider API keys in OneCLI with the matching `--host-pattern` (e.g. `api.deepseek.com`, `openrouter.ai`). OneCLI injects them via `HTTPS_PROXY` in the container — the key never lives in `.env` or the container environment. + +After adding a secret, **grant the agent access** — agents in `selective` mode only receive secrets they've been explicitly assigned: + +```bash +# Find the agent id and secret id, then: +onecli agents set-secrets --id --secret-ids , +``` + +Always include existing secret IDs in the list — `set-secrets` replaces, not appends. + +#### Example: DeepSeek + +```env +OPENCODE_PROVIDER=deepseek +OPENCODE_MODEL=deepseek/deepseek-chat +OPENCODE_SMALL_MODEL=deepseek/deepseek-chat +ANTHROPIC_BASE_URL=https://api.deepseek.com/v1 +``` + +Register the key: +```bash +onecli secrets create --name "DeepSeek" --type generic \ + --value YOUR_KEY --host-pattern "api.deepseek.com" \ + --header-name "Authorization" --value-format "Bearer {value}" +``` #### Example: OpenRouter ```env -# OpenCode — host passes these into the container when agent_provider is opencode OPENCODE_PROVIDER=openrouter OPENCODE_MODEL=openrouter/anthropic/claude-sonnet-4 OPENCODE_SMALL_MODEL=openrouter/anthropic/claude-haiku-4.5 +ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1 ``` -#### Example: Anthropic via existing proxy env +Register the key: +```bash +onecli secrets create --name "OpenRouter" --type generic \ + --value YOUR_KEY --host-pattern "openrouter.ai" \ + --header-name "Authorization" --value-format "Bearer {value}" +``` -When `OPENCODE_PROVIDER` is `anthropic`, OpenCode uses normal Anthropic env inside the container (proxy + placeholder key pattern unchanged). +#### Example: Anthropic (no ANTHROPIC_BASE_URL needed) + +When `OPENCODE_PROVIDER` is `anthropic`, OpenCode uses normal Anthropic env inside the container — the proxy + placeholder key pattern is unchanged and `ANTHROPIC_BASE_URL` is not required. ```env OPENCODE_PROVIDER=anthropic OPENCODE_MODEL=anthropic/claude-sonnet-4-20250514 -``` - -#### Example: only a main model - -```env -OPENCODE_PROVIDER=openrouter -OPENCODE_MODEL=openrouter/google/gemini-2.5-pro-preview +OPENCODE_SMALL_MODEL=anthropic/claude-haiku-4-5-20251001 ``` #### OpenCode Zen (`x-api-key`, not Bearer) @@ -142,13 +190,9 @@ Zen's HTTP API (e.g. `POST …/zen/v1/messages`) expects the key in the **`x-api **Host `.env` (typical Zen shape):** ```env -# NanoClaw still resolves AGENT_PROVIDER from agent_groups / sessions; set agent_provider to opencode there. -# OpenCode SDK: Zen as the upstream provider + models under opencode/… OPENCODE_PROVIDER=opencode OPENCODE_MODEL=opencode/big-pickle OPENCODE_SMALL_MODEL=opencode/big-pickle - -# Point the credential proxy at Zen's Anthropic-compatible base URL (host + OneCLI must forward this host). ANTHROPIC_BASE_URL=https://opencode.ai/zen/v1 ``` @@ -162,8 +206,6 @@ onecli secrets create --name "OpenCode Zen" --type generic \ --header-name "x-api-key" --value-format "{value}" ``` -For comparison, OpenRouter uses `Authorization` + `Bearer {value}`. Zen is different by design. - ### Per group / per session Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group). @@ -173,7 +215,7 @@ Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config ## Operational notes - OpenCode keeps a local **`opencode serve`** process and SSE subscription; the provider tears down with **`stream.return`** and **SIGKILL** on the server process on **`abort()`** / shared runtime reset to avoid MCP/zombie hangs. -- Session continuation is opaque (`ses_*` ids); stale sessions are cleared using **`isSessionInvalid`** on OpenCode-specific errors (timeouts, connection resets, not-found patterns) in addition to the poll-loop's existing recovery. +- Session continuation uses UUID format (SDK 1.4.x / CLI 1.4.x). Stale sessions are cleared by `isSessionInvalid` on OpenCode-specific error patterns. If you see UUID-related errors after an accidental CLI upgrade, clear `session_state` in `outbound.db` and wipe the `opencode-xdg` directory under the session folder. - **`NO_PROXY`** for localhost matters when the OpenCode client talks to `127.0.0.1` inside the container while HTTP(S)_PROXY is set (e.g. OneCLI). ## Verify From 47950671fafcf0da34f4459d783be322f827f270 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 01:00:04 +0300 Subject: [PATCH 008/185] =?UTF-8?q?docs:=20add=20v1=E2=86=92v2=20action-it?= =?UTF-8?q?ems=20analysis=20+=20SDK=20signal=20probe=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/v1-vs-v2/: full v1→v2 regression analysis (SUMMARY + 21 per-module docs + ACTION-ITEMS rollup with decisions + timezone recreation spec). - container/agent-runner/scripts/sdk-signal-probe.ts: empirical harness used to characterise Claude Agent SDK event/hook/stderr timing for the stuck-detection design in item 9. - src/channels/chat-sdk-bridge.ts: document the conversations Map staleness in a code comment; fix deferred to when dynamic group registration lands (ACTION-ITEMS item 17). No runtime behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agent-runner/scripts/sdk-signal-probe.ts | 169 ++++++ docs/v1-vs-v2/ACTION-ITEMS.md | 530 ++++++++++++++++ docs/v1-vs-v2/SUMMARY.md | 146 +++++ docs/v1-vs-v2/channels.md | 305 ++++++++++ docs/v1-vs-v2/config.md | 99 +++ docs/v1-vs-v2/container-index.md | 72 +++ docs/v1-vs-v2/container-mcp-tools.md | 59 ++ docs/v1-vs-v2/container-runner.md | 51 ++ docs/v1-vs-v2/container-runtime.md | 46 ++ docs/v1-vs-v2/db.md | 542 +++++++++++++++++ docs/v1-vs-v2/env.md | 38 ++ docs/v1-vs-v2/formatting-test.md | 154 +++++ docs/v1-vs-v2/group-folder.md | 38 ++ docs/v1-vs-v2/group-queue.md | 48 ++ docs/v1-vs-v2/index-host.md | 70 +++ docs/v1-vs-v2/ipc.md | 240 ++++++++ docs/v1-vs-v2/logger.md | 38 ++ docs/v1-vs-v2/remote-control.md | 90 +++ docs/v1-vs-v2/router.md | 67 ++ docs/v1-vs-v2/sender-allowlist.md | 46 ++ docs/v1-vs-v2/session-cleanup.md | 44 ++ docs/v1-vs-v2/task-scheduler.md | 100 +++ .../timezone-formatting-v1-recreation.md | 570 ++++++++++++++++++ docs/v1-vs-v2/timezone.md | 27 + docs/v1-vs-v2/types.md | 58 ++ src/channels/chat-sdk-bridge.ts | 6 + 26 files changed, 3653 insertions(+) create mode 100644 container/agent-runner/scripts/sdk-signal-probe.ts create mode 100644 docs/v1-vs-v2/ACTION-ITEMS.md create mode 100644 docs/v1-vs-v2/SUMMARY.md create mode 100644 docs/v1-vs-v2/channels.md create mode 100644 docs/v1-vs-v2/config.md create mode 100644 docs/v1-vs-v2/container-index.md create mode 100644 docs/v1-vs-v2/container-mcp-tools.md create mode 100644 docs/v1-vs-v2/container-runner.md create mode 100644 docs/v1-vs-v2/container-runtime.md create mode 100644 docs/v1-vs-v2/db.md create mode 100644 docs/v1-vs-v2/env.md create mode 100644 docs/v1-vs-v2/formatting-test.md create mode 100644 docs/v1-vs-v2/group-folder.md create mode 100644 docs/v1-vs-v2/group-queue.md create mode 100644 docs/v1-vs-v2/index-host.md create mode 100644 docs/v1-vs-v2/ipc.md create mode 100644 docs/v1-vs-v2/logger.md create mode 100644 docs/v1-vs-v2/remote-control.md create mode 100644 docs/v1-vs-v2/router.md create mode 100644 docs/v1-vs-v2/sender-allowlist.md create mode 100644 docs/v1-vs-v2/session-cleanup.md create mode 100644 docs/v1-vs-v2/task-scheduler.md create mode 100644 docs/v1-vs-v2/timezone-formatting-v1-recreation.md create mode 100644 docs/v1-vs-v2/timezone.md create mode 100644 docs/v1-vs-v2/types.md diff --git a/container/agent-runner/scripts/sdk-signal-probe.ts b/container/agent-runner/scripts/sdk-signal-probe.ts new file mode 100644 index 0000000..a4a3c98 --- /dev/null +++ b/container/agent-runner/scripts/sdk-signal-probe.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env bun +/** + * SDK signal probe: run a prompt, log every signal the Agent SDK emits — + * async-iterator events + hook callbacks + CLI stderr — with absolute + * and relative timing. + * + * Usage: + * bun run scripts/sdk-signal-probe.ts "" # simple string mode + * bun run scripts/sdk-signal-probe.ts --stream "" # streaming-input mode + * bun run scripts/sdk-signal-probe.ts --stream "

" \ + * --push "5000:" --push "15000:" --timeout 60000 # multi-push + * + * Streaming mode (`--stream`) passes an AsyncIterable prompt to `query()`, + * which keeps the CLI subprocess alive past the first result (per SDK + * deep dive). Required for post-result pushes, agent teams, background + * task notifications. + */ +import { query } from '@anthropic-ai/claude-agent-sdk'; + +const args = process.argv.slice(2); +const prompts: string[] = []; +const pushes: Array<{ atMs: number; text: string }> = []; +let streamMode = false; +let timeoutMs: number | undefined; + +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--stream') streamMode = true; + else if (a === '--push') { + const val = args[++i] ?? ''; + const ix = val.indexOf(':'); + if (ix === -1) throw new Error(`bad --push (want MS:text): ${val}`); + pushes.push({ atMs: parseInt(val.slice(0, ix), 10), text: val.slice(ix + 1) }); + } else if (a === '--timeout') timeoutMs = parseInt(args[++i] ?? '0', 10); + else if (a === '--prompt') prompts.push(args[++i] ?? ''); + else prompts.push(a); +} + +const prompt = prompts.join(' '); +if (!prompt) { + console.error('usage: sdk-signal-probe.ts [--stream] "" [--push MS:]... [--timeout MS]'); + process.exit(1); +} + +const T0 = Date.now(); +let LAST = T0; + +function log(source: string, type: string, payload: unknown = {}): void { + const now = Date.now(); + const entry = { t_ms: now - T0, d_ms: now - LAST, source, type, payload }; + LAST = now; + console.log(JSON.stringify(entry)); +} + +function hookLogger(eventName: string) { + return async (input: unknown, toolUseID: string | undefined): Promise => { + log('hook', eventName, { toolUseID, input }); + // Stuck-tool simulation: if env flag is set and this is a PreToolUse for Bash, + // never resolve — simulates a tool that hangs forever. + if (process.env.PROBE_HANG === 'true' && eventName === 'PreToolUse') { + const toolName = (input as any)?.tool_name ?? (input as any)?.name; + if (toolName === 'Bash') { + log('meta', 'pre_tool_use_hanging', { toolUseID, toolName }); + await new Promise(() => { + /* never resolves */ + }); + } + } + return { continue: true }; + }; +} + +const HOOK_EVENTS = [ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'Notification', + 'UserPromptSubmit', + 'SessionStart', + 'SessionEnd', + 'Stop', + 'SubagentStart', + 'SubagentStop', + 'PreCompact', + 'PermissionRequest', +] as const; + +const hooks: Record = {}; +for (const ev of HOOK_EVENTS) hooks[ev] = [{ hooks: [hookLogger(ev)] }]; + +// Build prompt — string (single-turn) or AsyncIterable (streaming-input) +let promptInput: any; + +if (streamMode) { + const sessionId = `probe-${Date.now()}`; + async function* streamPrompt() { + // Initial user message + yield { + type: 'user' as const, + message: { role: 'user' as const, content: prompt }, + parent_tool_use_id: null, + session_id: sessionId, + }; + // Schedule subsequent pushes + const startT = Date.now(); + const sorted = [...pushes].sort((a, b) => a.atMs - b.atMs); + for (const p of sorted) { + const waitMs = Math.max(0, p.atMs - (Date.now() - startT)); + if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs)); + log('meta', 'push_message', { atMs: p.atMs, text: p.text }); + yield { + type: 'user' as const, + message: { role: 'user' as const, content: p.text }, + parent_tool_use_id: null, + session_id: sessionId, + }; + } + // Keep stream open for tail events; iterator ends when we return + // (no more work expected). For post-result-idle scenarios, wait here. + await new Promise((r) => setTimeout(r, 5000)); + } + promptInput = streamPrompt(); +} else { + promptInput = prompt; +} + +log('meta', 'probe_start', { prompt, streamMode, pushes, timeoutMs }); + +const q = query({ + prompt: promptInput, + options: { + includePartialMessages: true, + hooks: hooks as any, + stderr: (data: string) => log('stderr', 'chunk', { data }), + settingSources: [], + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + }, +}); + +// Absolute time cap — exit cleanly so the log flushes +if (timeoutMs) { + setTimeout(() => { + log('meta', 'timeout_hit', { timeoutMs }); + setTimeout(() => process.exit(0), 250); + }, timeoutMs); +} + +try { + for await (const event of q) { + const snapshot: any = { ...event }; + try { + const raw = JSON.stringify(snapshot); + if (raw.length > 2000) { + snapshot._truncated_bytes = raw.length; + if (snapshot.message?.content) { + const c = JSON.stringify(snapshot.message.content); + snapshot.message = { ...snapshot.message, content: c.slice(0, 500) + `…<+${c.length - 500}b>` }; + } + } + } catch { + /* best-effort */ + } + log('event', snapshot.type ?? 'unknown', { subtype: snapshot.subtype, event: snapshot }); + } + log('meta', 'iterator_done'); +} catch (err: any) { + log('meta', 'iterator_error', { message: err?.message, stack: err?.stack?.split('\n').slice(0, 5) }); +} diff --git a/docs/v1-vs-v2/ACTION-ITEMS.md b/docs/v1-vs-v2/ACTION-ITEMS.md new file mode 100644 index 0000000..806bff4 --- /dev/null +++ b/docs/v1-vs-v2/ACTION-ITEMS.md @@ -0,0 +1,530 @@ +# v1 → v2 Action Items + +Working doc for each finding from [SUMMARY.md](SUMMARY.md). Decisions were made one-by-one; this rollup summarizes the outcome. + +**Status legend**: `pending` · `discussing` · `decided` · `deferred` · `dropped` · `done` + +--- + +## Rollup + +### To implement (~800 LOC total, roughly) + +| # | Topic | LOC | Notes | +|---|---|---|---| +| 1 | Engage modes + sender scope + accumulate/drop + fan-out + tool blocklist | ~315 | DB migration drops `trigger_rules`/`response_scope`, adds `engage_mode`/`engage_pattern`/`sender_scope`/`ignored_message_policy` + `trigger` column on `messages_in`; router `pickAgents` fan-out; adapter-level gating via new hooks | +| 5 | `request_approval` flow for unknown senders (default policy flips from `strict` to `request_approval`) | ~175 | New `pending_sender_approvals` table; reuses existing `pickApprover` + card infra | +| 9 | Stuck detection (60s claim-age rule), heartbeat-based lifecycle, `max(30m, bash_timeout)` absolute ceiling, SDK tool blocklist (`AskUserQuestion`, `EnterPlanMode`, `ExitPlanMode`, `EnterWorktree`, `ExitWorktree`), remove `IDLE_TIMEOUT` setTimeout + `IDLE_END_MS` machinery | ~115 | Container state row for Bash timeout tracking | +| 15 | Delete three dead config constants from `src/config.ts` | 3 | `POLL_INTERVAL`, `SCHEDULER_POLL_INTERVAL`, `IPC_POLL_INTERVAL` | +| 18 | Timezone + formatting recreation — port v1 bit-for-bit (`formatLocalTime`, `` header, `reply_to` + `` XML, `stripInternalTags`) + scheduling tool TZ normalization + cron TZ parsing | ~195 (75 prod + 120 tests) | Full spec in [timezone-formatting-v1-recreation.md](timezone-formatting-v1-recreation.md) | + +### Deferred (wait for trigger) + +| # | Topic | Trigger | +|---|---|---| +| 2 | `nonMainReadOnly` mount isolation | If multi-tenant / untrusted-group use ever surfaces. In the meantime, mount-declaration skill must explicitly prompt RO/RW when added | +| 3a | End-to-end recovery test | When next touching `host-sweep.ts` / `index.ts` startup | +| 14 | Remote control subsystem | When someone needs it. Opt-in skill, provider-specific (Claude SDK only) | +| 17 | Dynamic group-add (bridge conversations cache refresh) | When implementing dynamic group registration feature. Code comment added at `chat-sdk-bridge.ts:73` | + +### Dropped (won't implement / not-a-regression) + +| # | Topic | Why | +|---|---|---| +| 3 | Explicit pending-message recovery | Working as designed via sweep's immediate first tick + `cleanupOrphans` | +| 4 | `response_scope` enforcement | Folded into item 1 migration (column deleted, values backfilled) | +| 6 | Per-group container timeout | Not a regression — v1's hard-kill was worse than v2's keep-alive-after-idle | +| 7 | Container streaming output markers | Replaced by `send_message` MCP tool; latency ~1s is fine for chat UX | +| 8 | Per-exit container log files | Underlying info still recoverable (session DBs, heartbeat mtime, exit code) | +| 10 | Host-level retry on agent error | Folded into item 9's kill + sweep-reset loop | +| 11 | Process ID in logger output | Single host process; container stderr already tagged with `agentGroup.folder` | +| 12 | Task dedup via unique series_id index | Recurrence logic is structurally dedup-safe; not a real issue | +| 13 | Silent-drop sender mode | Admin can use `unknown_sender_policy='strict'` or remove from members instead | +| 16 | Configurable retention thresholds | Personal-assistant scale; source constants are fine | + +### Extras recorded during discussion +- **1a**: Implementation-ordering plan for item 1 +- **6a**: Remove `IDLE_END_MS` from `poll-loop.ts` (folded into item 9) +- **3a**: E2E recovery test (deferred) + +--- + +## HIGH + +### 1. Trigger-rule matching in `pickAgent` +**Finding**: `src/router.ts:246` TODO. Confirmed trigger filtering is non-functional end-to-end: `trigger_rules` JSON is parsed into `ConversationConfig` and passed to adapters, but the Chat SDK bridge never reads it, and router's `pickAgent` picks by priority only. `response_scope` on `messaging_group_agents` is stored but never enforced. Chat SDK bridge hard-subscribes on every mention (bridge:173) and every DM (bridge:189). + +**Status**: decided — design locked; implementation pending + +**Decision**: replace `trigger_rules` JSON + `response_scope` with four explicit orthogonal columns on `messaging_group_agents`. Fan out inbound messages to all matching agents (N containers for N agents). Adapter-level gating in the bridge. `sender_scope` enforcement moves to the permissions module. + +**Schema** (`messaging_group_agents`): +``` +engage_mode TEXT NOT NULL DEFAULT 'mention' + -- 'pattern' | 'mention' | 'mention-sticky' +engage_pattern TEXT -- required when mode='pattern'; '.' = always +sender_scope TEXT NOT NULL DEFAULT 'all' -- 'all' | 'known' +ignored_message_policy TEXT NOT NULL DEFAULT 'drop' -- 'drop' | 'accumulate' +``` +Drop `trigger_rules` + `response_scope`. **No per-wiring accumulate cap** — storage is unbounded. + +**Global wake cap** (not a column): reuse `MAX_MESSAGES_PER_PROMPT` in `src/config.ts` (already defined, default 10, currently dead code from v1). Pass to container via `NANOCLAW_MAX_MESSAGES_PER_PROMPT`. Container applies `ORDER BY seq DESC LIMIT $N` when pulling pending messages on wake. + +**Session DB** (`messages_in`): +``` +trigger INTEGER NOT NULL DEFAULT 1 -- 0 = context-only, 1 = wake agent +``` +Host's `countDueMessages` / wake logic gates on `trigger=1`. Container reads all messages for context regardless. + +**Decisions locked**: +- `always` collapses into `pattern` with `engage_pattern='.'` (three modes total) +- `mention` and `mention-sticky` are separate modes (stickiness is user-visible) +- `pattern` is a JS regex string — applied as `new RegExp(pattern).test(text)` +- Accumulate cap = last N messages, default 10 +- Fan-out: each matching agent gets its own session + container +- Per-channel defaults live in the setup/register flow, not in the schema: + - DM → `pattern` with `.` + - Threaded group → `mention-sticky` + - Non-threaded group → `mention` + +**Routing flow** (future): +1. Inbound → resolve messaging_group → group-level `unknown_sender_policy` gate +2. `pickAgents()` returns all wired agents (not just priority 0) +3. For each agent: + a. `sender_scope` check (permissions module) + b. `engage_mode` check (regex / mention / mention-sticky) + c. Matched → write with `trigger=1`, wake container + d. Not matched + `accumulate` → write with `trigger=0`, don't wake (no cap — stored forever) + e. Not matched + `drop` → skip + +On wake, container pulls pending messages with `ORDER BY seq DESC LIMIT MAX_MESSAGES_PER_PROMPT` so only the most recent N reach the prompt regardless of accumulation depth. + +**Adapter bridge**: +- Read `conversations.get(channelId)` before `setupConfig.onInbound(...)` +- For `pattern` mode: test regex +- For `mention` / `mention-sticky`: require bot to be mentioned +- Only `thread.subscribe()` when mode is `mention-sticky` (today it subscribes unconditionally) + +**LOC estimate**: ~315 (~255 prod + ~60 test) +- schema migration + backfill: 40 +- session DB `trigger` column: 25 +- types + adapter contract: 20 +- DB helpers (CRUD): 20 +- host→adapter plumbing (including `NANOCLAW_MAX_MESSAGES_PER_PROMPT` env): 10 +- router fan-out + gating: 70 +- sender-scope in permissions module: 15 +- Chat SDK bridge gating + subscribe control: 40 +- container-side `LIMIT N` on pending-message pull: 5 +- smart defaults in setup/register flow: 15 +- tests: 60 + +(Note: earlier plan's "accumulate prune-to-N in router" is dropped — host doesn't prune. Cap is container-side only.) + +**Core vs module split**: +- Core (`src/`): schema, engage_mode enforcement, pickAgents fan-out, bridge gating, `trigger` column, accumulate/drop +- Permissions module: `sender_scope` enforcement (extends existing access gate). Default `sender_scope='all'` → no-op when permissions module absent + +**Next step**: new action item for implementation — see item 1a. + +--- + +### 1a. Implementation plan for engage/sender/ignored columns +**Status**: pending — ready to implement +**Order**: (a) migration + backfill, (b) types + DB helpers, (c) router fan-out + gating, (d) bridge gating, (e) permissions sender_scope, (f) setup-flow defaults, (g) tests +**Next step**: draft the migration + write up the PR plan when ready + +### 2. `nonMainReadOnly` mount isolation +**Finding**: `mount-security.ts` moved to `src/modules/mount-security/index.ts` during the refactor. `validateMount(mount)` no longer takes an `isMain` param; `MountAllowlist` has no `nonMainReadOnly` field. Regression is real. But v1's "main vs non-main" concept doesn't map cleanly to v2 — `agent_groups` has no `is_main` flag. + +**Status**: deferred + +**Decision**: do not restore the v1 flag. Trust admin-declared `readonly` values in `container.json`. The allowlist's per-root `allowReadWrite` + path gating is sufficient for the current threat model (personal-assistant use, single admin). If multi-tenant / untrusted auxiliary groups become a real use case, prefer framing B (add `agent_groups.mount_access: 'rw' | 'ro'` column) over resurrecting `isMain`. + +**Rationale**: v2 deliberately dropped the "main" concept. Reintroducing `isMain` to restore a defense-in-depth check that was designed for a different entity model is the wrong trade. Admin already has to opt-in twice (allowlist `allowReadWrite: true` + container.json `readonly: false`) to get RW — that's two deliberate keys. The v1 flag was a triple-check for a rare class of admin mistakes in a shared-infra setup. + +**Follow-up (required)**: when building the skill / guide / setup flow that lets admins declare additional mounts (e.g. self-customize, manage-mounts, or a new `/add-mount` skill), the flow **must clearly surface the RO vs RW distinction** to the admin — explicit choice, explicit warning when RW is selected, and default to RO. This replaces v1's automatic enforcement with informed consent. + +**Next step**: when the mount-declaration skill/flow is next touched, add explicit RO/RW prompting. Track as a sub-item if a skill exists yet. + +### 3. Explicit pending-message recovery on startup +**Finding**: v1 had a named `recoverPendingMessages()` function at startup. v2 relies on the host sweep. Verified: the recovery path exists and is correct — just renamed/relocated. + +**Status**: decided — working as designed, no code change + +**Current mechanism** (verified against tree): +1. `cleanupOrphans()` at startup kills any leftover container from the previous run (`src/index.ts:69`) +2. `startHostSweep()` runs its first sweep **immediately** — no 60s delay (`src/host-sweep.ts:38`) +3. Sweep per session: `syncProcessingAcks` → `countDueMessages` → `wakeContainer` if work pending and no container → `detectStaleContainers` resets stuck `processing` rows with backoff + +**Scenarios covered**: +- Host crashed while container idle with pending messages → orphan cleanup + first sweep re-wakes +- Host crashed mid-processing → stale detection resets `processing → pending`, next sweep wakes +- Container crashed with host alive → heartbeat mtime catches it inside 10 min `STALE_THRESHOLD_MS` + +**Rationale**: the function got renamed (recovery → sweep) but the behavior is equivalent or better. Sweep is continuous; recovery used to be one-shot. + +**Next step**: see item 3a. + +--- + +### 3a. End-to-end recovery test +**Finding**: no test confirms the host-crash-restart scenario produces timely re-delivery. + +**Status**: pending — nice-to-have + +**Decision**: add an integration test: (1) write a pending message to inbound.db, (2) kill the host simulating crash, (3) start host, (4) assert container is woken and message processed within a bounded time (≤5s? ≤ one sweep interval). + +**Rationale**: the sweep logic is correct as written, but a regression here would be silent (messages just sit). Worth a safety net. + +**Next step**: draft test when touching `host-sweep.ts` or `index.ts` startup flow next. + +--- + +## MEDIUM + +### 4. `response_scope` enforcement +**Finding**: `messaging_group_agents.response_scope` stores `'all' | 'triggered' | 'allowlisted'` but nothing reads it. + +**Status**: decided — folded into item 1 + +**Decision**: delete the `response_scope` column as part of the item-1 migration. Values backfill into the new explicit columns: + +| Old `response_scope` | New columns | +|---|---| +| `all` | `engage_mode='pattern'`, `engage_pattern='.'`, `sender_scope='all'` | +| `triggered` | `engage_mode='mention'` (or `'pattern'` if legacy row has a pattern), `sender_scope='all'` | +| `allowlisted` | `engage_mode` derived from `trigger_rules`, `sender_scope='known'` | + +**Rationale**: `response_scope` conflated two orthogonal axes (engage + sender). Splitting them is the whole point of item 1. + +**Next step**: ensure the item-1 migration includes the `response_scope` backfill in its UP step. + +### 5. `request_approval` flow for unknown senders +**Finding**: `unknown_sender_policy='request_approval'` is scaffolded in `src/modules/permissions/index.ts:100-108` but falls through to log-and-drop (explicit TODO comment). Current default is `'strict'`, which silently drops — user has no signal that their agent isn't responding. + +**Status**: decided — implement, keep simple + +**Decision**: implement full approval flow **and** flip the schema default from `'strict'` to `'request_approval'`. UX rationale: users wire their DM during setup; silent drops create a mystery when the agent doesn't respond. Public is unsafe. Approval default → admin sees a card and explicitly decides. + +**Flow**: +1. Unknown sender writes to wired messaging group with policy `'request_approval'` +2. If pending approval for `(messaging_group, sender)` already exists → drop this message silently (in-flight dedup; not persistence) +3. Otherwise: insert into `pending_sender_approvals` with original message + timestamp +4. `pickApprover(agent_group_id)` + `pickApprovalDelivery(approverUserId)` — existing machinery in `src/access.ts` +5. Deliver a card via adapter's `deliver()` with `Card`/`Actions`/`Button` primitives (already in chat-sdk-bridge) +6. Card action id prefix `nsa::` (parallels existing `ncq:` prefix for `ask_user_question`) +7. On `allow`: upsert `users` row, insert into `agent_group_members`, deliver stored message through normal routing (original timestamp preserved), cleanup pending row +8. On `deny`: cleanup pending row, drop the message. No denial persistence — next attempt from same sender triggers a fresh card. + +**No denial persistence** explicit rationale: personal-assistant scale, admin can switch policy to `'strict'` per messaging group if a hostile sender starts spamming. Avoids a new table column and a TTL config. + +**New table**: +``` +pending_sender_approvals ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL, + agent_group_id TEXT NOT NULL, + sender_identity TEXT NOT NULL, -- channel_type:handle + sender_name TEXT, + original_message TEXT NOT NULL, -- JSON of the InboundEvent + approver_user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(messaging_group_id, sender_identity) -- enforces in-flight dedup +) +``` +Dedicated (not reusing `pending_approvals` which is OneCLI-specific). + +**Reuse**: +- `pickApprover` / `pickApprovalDelivery` in `src/access.ts` +- Card rendering primitives already in `src/channels/chat-sdk-bridge.ts` +- `onAction` dispatch — add the `nsa:` prefix handler alongside existing `ncq:` + +**LOC estimate**: ~175 +- Migration + CRUD for `pending_sender_approvals`: 55 +- `handleUnknownSender` request_approval branch + in-flight dedup: 25 +- Host-side card dispatcher (pick approver + deliver card): 25 +- `onAction` handler for `nsa:` prefix (allow/deny): 30 +- Schema default flip + router auto-create update: 5 +- Tests: 35 + +**Module location**: all in `src/modules/permissions/`. Module stays optional; default-allow fallback behavior when not loaded is preserved. + +**Open gotchas noted**: +- The router's auto-create at `router.ts:123` currently hardcodes `'strict'` — change to omit the field so schema default applies +- `pickApprover` may return null if no admin/owner exists (e.g. fresh install before first user registered). In that case: log + drop silently, treat as effectively `'strict'` for safety. Don't block message forever. + +**Next step**: implement alongside item 1 or as a follow-up. Same migration window is fine (one migration for engage columns + request_approval default change + new table). + +### 6. Per-group container timeout +**Finding**: v1's `containerConfig.timeout` override is gone. All groups share `IDLE_TIMEOUT`. Original framing (slow-but-healthy agents getting killed) was wrong — v1's `timeout` was a hard wall-clock kill on the whole spawn, totally different from v2's `IDLE_TIMEOUT` (keep-alive after last activity). V2's behavior is strictly better for long-running agents. + +**Status**: dropped — not a regression + +**Decision**: don't restore per-group timeout override. `IDLE_TIMEOUT=30min` global is the right model. If per-group idle tuning ever becomes useful it's ~15 LOC (new column, env injection at spawn) — small feature add, not a regression to repair. + +**Rationale**: v2 lets long-running agents finish; v1 would have hard-killed them at 30min. Current behavior is an improvement. + +**Next step**: see 6a. + +--- + +### 6a. Remove IDLE_END_MS (container-side query idle termination) +**Finding**: `container/agent-runner/src/poll-loop.ts:11` defines `IDLE_END_MS = 20_000`. Inside `processQuery`, a concurrent interval ends the active Agent SDK `query()` stream after 20s of SDK silence. Any SDK event (tool use, tool result, streamed text, new pushed message) resets the timer. + +This is a general "SDK silence detector," not specifically post-result. It fires any time: +- Mid-work: slow tool call with no intermediate SDK events (`npm install`, `pytest`, long `WebFetch`, etc.) +- Post-result: agent finished, stream waiting for potential follow-up +- Any other pause in the SDK stream + +**Status**: decided — remove, pending SDK verification + +**Decision**: delete `IDLE_END_MS` and its setInterval check. Let the `query()` stream stay open as long as the container is active. Container's 30-min `IDLE_TIMEOUT` (host-side in `container-runner.ts`) is the single source of truth for "when to let go." + +**Rationale**: +- When new messages arrive mid-stream, they push in via `query.push()` with no reconnect — stream-open is cheaper per-message than close-and-reopen +- Closing early forces a reconnect + cold prompt cache for the next message +- Container stays alive anyway; ending the stream doesn't save resources at the container level +- `CLAUDE_CODE_AUTO_COMPACT_WINDOW=165000` already handles context window growth within a long-lived query +- Anthropic API's own stream timeout will fire if needed; SDK should handle it transparently +- Avoids the false-positive kill during legitimate slow tool calls (common case: agent running `npm install` gets cut off at 20s) + +**Caveat (must verify before removal)**: confirm Claude Agent SDK doesn't require explicit `query.end()` for prompt-cache commit or session-state persistence. Expected to be fine (SDK checkpoints per turn) but double-check docs / run a quick test where container idles with stream open, then processes a follow-up. + +**LOC estimate**: ~−15 (net deletion — remove constant, setInterval idle check, the `done` flag plumbing may also simplify) + +**Next step**: when implementing item 1's changes (or standalone), verify SDK behavior with stream-open-indefinite, then delete IDLE_END_MS block. Watch for any test assertions on it. + +### 7. Container streaming output (marker-based pre-delivery) +**Finding**: v1's `---NANOCLAW_OUTPUT_START/END---` markers enabled pre-completion delivery. v2's two paths (final-result `dispatchResultText` + mid-turn `send_message` MCP tool) both write to outbound.db; host polls every `ACTIVE_POLL_MS = 1000ms`. + +**Status**: dropped — not a regression + +**Decision**: v2's `send_message` MCP tool is the correct replacement for v1's marker-based streaming. Latency is ≤1s (poll interval), which is fine for chat UX. + +**Rationale**: v1's marker model required the agent and host to share a fragile state machine over stdout. v2 uses explicit tool calls and a DB surface — cleaner architecture, comparable latency, and control stays with the agent. If perceived latency ever becomes a real complaint, tune `ACTIVE_POLL_MS` down (500ms / 250ms) — low-cost knob. + +**Next step**: none. + +### 8. Per-exit container log files +**Finding**: v1 wrote timestamped per-exit logs with full I/O + mounts + stderr. v2: stderr → `log.debug` (invisible at default `LOG_LEVEL=info`), container close → `log.info` with exit code, session DBs preserved on disk. Real gap: stderr on abnormal exit isn't auto-surfaced. + +**Status**: dropped + +**Decision**: skip — no per-exit file restoration, no stderr-on-crash buffer. + +**Rationale**: underlying forensic info is still recoverable (session DBs on disk, heartbeat mtime, exit code in log). `LOG_LEVEL=debug` surfaces stderr when needed. The cost of adding buffered crash-log promotion (~15 LOC) isn't justified by the frequency of post-mortem cases. + +**Next step**: none. + +### 9. Stuck detection + heartbeat-based container lifecycle +**Finding**: v2's sweep detects stale heartbeats (10 min) and resets messages with backoff, but doesn't kill the container. Idle timeout is delivery-count-based (30 min since last messages_out). Together these produce a gap where a stuck container holds resources + blocks new wakes for up to 30 min. + +**Empirical findings from SDK probe** (`container/agent-runner/scripts/sdk-signal-probe.ts`, runs logged in `/tmp/probe-*.jsonl`): +- Silent Bash tools (e.g. `sleep 30`) produce 30+ seconds of zero SDK events — heartbeat goes stale during legitimate work +- Natural intra-stream silences up to ~12s observed mid-tool-use JSON streaming +- `PreToolUse` / `PostToolUse` hook pair is reliable; `PostToolUseFailure` fires on blocked requests +- `SubagentStart`/`SubagentStop` and `system/task_started`/`system/task_notification` pairs also reliable +- **Pushing a new message mid-active-turn does NOT fire `UserPromptSubmit`** (fires only at start of a new turn, after `result`) +- SDK's built-in `AskUserQuestion` doesn't actually block; returns placeholder +- Bash tool's declared `timeout` param is visible in `tool_use` input — we can read it container-side +- Stuck tools (hook that never resolves) produce indefinite silence — no SDK-side timeout + +**Status**: decided + +**Decision**: replace existing IDLE_TIMEOUT setTimeout + STALE_THRESHOLD=10min combo with message-scoped stuck detection + absolute 30-min ceiling. Reset messages inline when we kill. Blocklist SDK tools that don't fit our async model. + +**Sweep logic** (per active session): + +If container isn't running → reset any `'processing'` rows in processing_ack to `'pending'` + tries++ + backoff. Done. + +If container IS running, apply in order: + +1. **Absolute ceiling**: if `heartbeat_mtime` older than `max(30 min, current_bash_timeout)` → kill + reset any processing to pending + retry++. + Rationale: 30 min idle ceiling, extended only if agent is currently inside a Bash tool with longer declared timeout. Agents needing >30 min should use `run_in_background`. + +2. **Message-scoped stuck**: for each `processing_ack` row with status=`'processing'`: + - `claim_age = now - status_changed` + - `tolerance = max(60s, current_bash_timeout)` if Bash in flight, else `60s` + - If `claim_age > tolerance` AND `heartbeat_mtime <= status_changed` → kill + reset this message + retry++ + + Semantics: "container claimed a message and went silent for >tolerance since claim." + +No separate idle rule — rule 1 covers it. An idle container hits 30-min stale with no processing rows; kill has nothing to reset. + +**Container state surface** (for Bash timeout tracking): +New table in outbound.db (or session_state row): +``` +container_state ( + session_id TEXT PRIMARY KEY, + current_tool TEXT, -- null when no tool in flight + tool_declared_timeout_ms INTEGER, + tool_started_at TEXT +) +``` +Container writes on `PreToolUse` (reads Bash `timeout` from tool input), clears on `PostToolUse` / `PostToolUseFailure`. Host reads in sweep decision. + +**Tool blocklist** (initial): +- `AskUserQuestion` — SDK built-in; we have our own DB-backed MCP version +- `EnterPlanMode` / `ExitPlanMode` — Claude Code UI only +- `EnterWorktree` / `ExitWorktree` — Claude Code UI only + +Enforcement: +- Pass `disallowedTools: [...]` to `query()` options — agent never sees them in its tool list +- `PreToolUse` hook guard (defense-in-depth): if a blocklisted tool name somehow fires, immediately reset the current message + kill (treat as stuck) + +**Kill old machinery**: +- Remove `setTimeout` + `resetIdle` plumbing in `container-runner.ts:128-140` +- Remove `resetContainerIdleTimer` export + its caller in `delivery.ts:26` +- Remove `IDLE_END_MS = 20_000` in `poll-loop.ts:11` (item 6a decision) — stream stays open as long as container alive +- Existing `detectStaleContainers` logic merges into the new sweep rules; the heartbeat-stale-10-min path disappears + +**LOC estimate**: ~115 +- New sweep decision logic replacing existing detectStaleContainers + IDLE_TIMEOUT path: 50 +- Container state table + PreToolUse/PostToolUse write, host read: 25 +- Tool blocklist (disallowedTools + hook guard): 15 +- Deletions (IDLE_TIMEOUT setTimeout, IDLE_END_MS): −25 +- Tests (kill paths, Bash-timeout grace, blocklist hit): 50 + +**Why this converged here** (rationale summary): +- Empirical data showed we can't reliably tell stuck from legitimate-silent-work without state. Bash-declared-timeout is the cleanest per-tool signal available. +- 60s-since-claim is tight enough for normal work (WebSearch/WebFetch finish in ~8s) but generous enough for reasonable delays. Exception for Bash covers agents running scripts with user-declared timeouts. +- 30-min absolute ceiling prevents infinitely-stuck containers; agents needing longer have `run_in_background`. +- Pushing messages can't serve as a liveness probe (they're silent mid-turn), so detection is state-driven, not push-driven. +- Blocklist prevents a whole class of "SDK tool designed for interactive UI" footguns that would appear stuck in our async model. + +**Next step**: implement as a focused PR. Order: (a) tool blocklist — safe to ship alone, (b) container state table + PreToolUse writes, (c) sweep rewrite + message reset, (d) delete old IDLE_TIMEOUT + IDLE_END_MS machinery, (e) tests. + +### 10. Host-level retry with backoff on agent error +**Finding**: v1 had MAX_RETRIES=5 + exp. backoff on `processGroupMessages` failure. v2's equivalent is now covered by item 9's sweep logic — any time the container isn't running with `'processing'` rows present, they get reset to pending with backoff + retry++. + +**Status**: folded into item 9 + +**Decision**: no separate action. Agent-error retry happens via container-exit → sweep reset. Container errors also surface via provider-side session invalidation check (`poll-loop.ts:200-211` — `provider.isSessionInvalid(err)` → clears stored session id → fresh retry). Both paths preserved. + +**Next step**: none. + +--- + +### 11. Process ID in logger output +**Finding**: v1 emitted `(${process.pid})` after the level tag. v2 dropped it. + +**Status**: dropped + +**Decision**: don't restore. Host is single-process (PID is constant). Container stderr already gets tagged with `{ container: agentGroup.folder }` at `container-runner.ts:121`, which is more informative than a PID. + +**Next step**: none. + +--- + +## LOW + +### 11. Process ID in logger output +**Finding**: v1 emitted `(${process.pid})` after the level tag. v2 dropped it. +**Status**: pending +**Decision**: +**Rationale**: +**Next step**: + +### 12. Task dedup via unique `(kind, series_id)` index +**Finding**: verified — `messages_in.series_id` column exists with a non-unique index. Concern was theoretical: two pending rows with same series could coexist. + +**Status**: dropped + +**Decision**: not a real issue. Recurrence logic at `src/modules/scheduling/recurrence.ts` is structurally dedup-safe: only `completed` rows with `recurrence` get cloned, and after cloning `recurrence` is cleared on the original so it can't re-clone. Plus container's atomic `markProcessing` prevents double-execution at claim time. + +**Next step**: none. + +### 13. Silent-drop mode for noisy senders +**Finding**: v1's `mode:'drop'` let you ignore specific users without logging. v2 only has binary allow/deny via access gate. + +**Status**: dropped — won't implement + +**Decision**: not worth the table + gate complexity for a personal-assistant scale. If a specific sender becomes a problem, admin can switch the messaging_group's `unknown_sender_policy` to `'strict'` or remove the sender from `agent_group_members`. + +**Next step**: none. + +### 14. Remote control subsystem +**Finding**: v1's `/remote-control` command spawned `claude remote-control` CLI detached, polled stdout for session URL, persisted PID/URL state. Entirely gone in v2. + +**Status**: deferred — opt-in skill when needed + +**Decision**: reintroduce as an opt-in install skill (e.g. `/add-remote-control`), not on trunk. Provider-specific: only works with `claude` provider (Claude Agent SDK); not supported by OpenCode or other providers. Skill should check `agent_group.provider` at install time and bail gracefully with an error message if not `'claude'`. + +**Rationale**: niche feature valuable only for direct agent SDK attachment during dev/debugging. Keeping it off trunk matches v2's "infra-only trunk, features-via-skills" philosophy. Also avoids carrying code for a feature that simply doesn't exist in non-Claude providers. + +**Next step**: none until someone needs it. When implementing, likely lives on the `providers` branch (since it's provider-specific) or its own branch, installed via skill that copies files + checks provider. + +### 15. Dead config constants +**Finding**: verified — `POLL_INTERVAL` (line 13), `SCHEDULER_POLL_INTERVAL` (line 14), and `IPC_POLL_INTERVAL` (line 32) in `src/config.ts` have zero imports elsewhere in v2. Container's `POLL_INTERVAL_MS` in `poll-loop.ts` is a distinct local constant, unrelated. + +**Status**: decided — delete + +**Decision**: remove the three constants from `src/config.ts`. Trivial 3-line deletion. + +**Next step**: do as part of any sweep-touching PR, or standalone. + +### 16. Configurable retention thresholds +**Finding**: `STALE_THRESHOLD_MS` (10 min) and `MAX_TRIES` (5) in `host-sweep.ts` are hardcoded. Item 9's redesign replaces `STALE_THRESHOLD_MS` with new constants (60s claim-age, 30 min ceiling). + +**Status**: dropped — keep as constants + +**Decision**: leave the new item-9 thresholds + `MAX_TRIES` as source constants. Adding config surface for them isn't worth it at personal-assistant scale. If operational tuning ever becomes a real need, revisit — they're small centralized constants, one-line change each. + +**Next step**: none. + +### 17. Dynamic group-add (IPC watcher equivalent) +**Finding**: not actually a restart requirement — investigation showed: +- Router reads `messaging_groups` + `messaging_group_agents` fresh per inbound (dynamic by design) +- Chat SDK bridge has a `conversations: Map` populated at setup + `updateConversations()` method +- **Nothing in the bridge currently reads the map**, and no code calls `updateConversations()` after startup +- Today: stale map has no observable effect (dead state) +- After item 1 ships (adapter-level gating): stale map would matter; new wirings wouldn't apply in the adapter gate until restart + +**Status**: deferred — comment added now, implement alongside dynamic group registration feature + +**Decision**: don't refactor the adapter interface now. Added a NOTE comment at `src/channels/chat-sdk-bridge.ts:73` flagging the staleness issue so the next person touching the bridge or adding dynamic-registration sees it. When dynamic group registration is implemented (admin adds a new messaging_group_agents row while host is running), handle cache refresh then — most likely by calling `adapter.updateConversations(freshConfigs)` after the mutation, keyed off the adapter's `channelType`. + +**Rationale**: item 1's initial landing can keep the adapter gating responsibilities small or skip adapter-side gating entirely. Refactoring ConversationConfig now would add scope; better to ship item 1 first, see if over-subscription bites, address if it does. + +**Next step**: when building the admin-skill path for adding messaging_group ↔ agent_group wirings, include a `refreshAdapterConversations(channelType)` call after the INSERT. ~10 LOC when needed. + +--- + +## Test regressions (v1 `formatting.test.ts` assertions) + +### 18+19+20+21. Timezone + formatting recreation (merged) +**Finding**: v1 had a full timezone-aware formatting pipeline. v2 lost most of it, producing real bugs where the agent misinterprets user intent (scheduling for wrong times, suggesting time-inappropriate things). + +**Scope** — recreate v1 behavior faithfully wherever times touch the agent: +- Timestamp formatting on inbound messages: `formatLocalTime(utcIso, TIMEZONE)` producing "Jan 1, 2024, 1:30 PM" format via `Intl.DateTimeFormat('en-US', {...})` (v1 `timezone.ts`) +- `` header prepended to message block (v1 `router.ts:20-22`) +- Reply-to with message ID: `......` (v1 `router.ts:10-18`) +- `stripInternalTags()`: regex `/[\s\S]*?<\/internal>/g` applied to outbound text, then `.trim()` (v1 `router.ts:25-27`) +- Cron expressions parsed with explicit user TZ: `CronExpressionParser.parse(expr, { tz: TIMEZONE })` (v1 `task-scheduler.ts:20-49`) +- User-specified times normalized via the user's TZ: in v1 this was the host-side task scheduler; in v2 it's the new-in-v2 scheduling MCP tool (`mcp-tools/scheduling.ts`). Same principle — accept user-local times, normalize to UTC for storage, interpret cron in user's TZ. + +**Status**: decided — recreate with tests + +**Decision**: port v1's formatter + timezone behavior faithfully. Full recreation spec at [`timezone-formatting-v1-recreation.md`](timezone-formatting-v1-recreation.md) — includes exact v1 code, line numbers at commit `27c5220`, complete test inventory from `src/v1/formatting.test.ts` and `src/v1/task-scheduler.test.ts`. + +**Core principle** (per Gavriel): the agent operates in the user's timezone. Every timestamp the agent sees is user-local. Every time the agent outputs is interpreted as user-local. This is load-bearing for correctness, not a nice-to-have. + +**Porting plan** (from recreation spec): +1. `container/agent-runner/src/formatter.ts` — replace `formatTime` with `formatLocalTime(ts, TIMEZONE)` call; add reply_to attribute + `` element exactly as v1 +2. Prepend `\n` to the messages block at formatter entry +3. Extract `stripInternalTags` as a named function; apply in outbound dispatch path (`poll-loop.ts:389` currently uses inline regex) +4. `container/agent-runner/src/mcp-tools/scheduling.ts` — clarify `processAfter` description, normalize to UTC ISO in handler +5. `src/modules/scheduling/recurrence.ts` — pass `{ tz: TIMEZONE }` to `CronExpressionParser.parse()` explicitly +6. Port all test cases from v1's `formatting.test.ts` and `task-scheduler.test.ts` to v2's test tree + +**LOC estimate**: ~75 prod + ~120 tests (reproducing v1's 40+ test cases) + +**Next step**: implement as a focused PR. Order: (a) formatter changes + tests, (b) context header + tests, (c) reply_to + tests, (d) stripInternalTags extraction + tests, (e) scheduling tool + cron TZ + tests. + +### 19, 20, 21 — merged into 18 above +See item 18 for the full recreation plan and spec reference. + +--- + +## Notes +- `src/v1/` was deleted upstream (commit 86becf8) after this analysis was written. v2 tree has since had a major module extraction (approvals, interactive, scheduling, permissions, agent-to-agent, self-mod) and a new CLI channel. **Verify each item against the current tree before deciding** — some may already be addressed. diff --git a/docs/v1-vs-v2/SUMMARY.md b/docs/v1-vs-v2/SUMMARY.md new file mode 100644 index 0000000..30e7d38 --- /dev/null +++ b/docs/v1-vs-v2/SUMMARY.md @@ -0,0 +1,146 @@ +# v1 → v2 Deep Dive: Aggregate Summary + +Per-file deep-dives were produced for every file in `src/v1/` and `container/agent-runner/src/v1/`. This document aggregates findings across all 21 modules. + +## Per-file docs + +| Topic | File | v1 source(s) | +|---|---|---| +| Configuration | [config.md](config.md) | `src/v1/config.ts` | +| Environment helpers | [env.md](env.md) | `src/v1/env.ts` | +| Types | [types.md](types.md) | `src/v1/types.ts` | +| Logger | [logger.md](logger.md) | `src/v1/logger.ts` | +| Timezone | [timezone.md](timezone.md) | `src/v1/timezone.ts` | +| Database layer | [db.md](db.md) | `src/v1/db.ts` | +| Container runner | [container-runner.md](container-runner.md) | `src/v1/container-runner.ts` | +| Container runtime + mounts | [container-runtime.md](container-runtime.md) | `src/v1/container-runtime.ts`, `mount-security.ts` | +| Group folder | [group-folder.md](group-folder.md) | `src/v1/group-folder.ts` | +| Group queue | [group-queue.md](group-queue.md) | `src/v1/group-queue.ts` | +| Host index | [index-host.md](index-host.md) | `src/v1/index.ts` | +| IPC (host + container) | [ipc.md](ipc.md) | `src/v1/ipc.ts`, `container/.../v1/ipc-mcp-stdio.ts` | +| Remote control | [remote-control.md](remote-control.md) | `src/v1/remote-control.ts` | +| Router | [router.md](router.md) | `src/v1/router.ts` + `index.ts` routing | +| Sender allowlist | [sender-allowlist.md](sender-allowlist.md) | `src/v1/sender-allowlist.ts` | +| Session cleanup | [session-cleanup.md](session-cleanup.md) | `src/v1/session-cleanup.ts` | +| Task scheduler | [task-scheduler.md](task-scheduler.md) | `src/v1/task-scheduler.ts` | +| Channels | [channels.md](channels.md) | `src/v1/channels/*` | +| Agent-runner entry | [container-index.md](container-index.md) | `container/.../v1/index.ts` | +| Agent-runner MCP tools | [container-mcp-tools.md](container-mcp-tools.md) | `container/.../v1/mcp-tools.ts` | +| Formatting test (orphan) | [formatting-test.md](formatting-test.md) | `src/v1/formatting.test.ts` | + +## The big shift + +v2 rewrote the fundamental transport between host and container. The one-line version: + +> **v1 = IPC files + stdin/stdout + in-memory GroupQueue + polling message loop. +> v2 = two SQLite DBs per session + event-driven routing + 60s host sweep.** + +Everything else flows from that. Removing IPC forced a rewrite of the router, the container-runner, the agent-runner entry, and the MCP-tool bridge. The 60s sweep absorbed the task scheduler, session cleanup, and pending-message recovery. The entity model (users/roles/messaging_groups) replaced the flat sender allowlist and chat-level config. Provider abstraction + Chat SDK bridge replaced hardcoded Claude SDK + per-channel adapters. + +Net LOC: v1 (~7.4k host + monolithic container-runner) → v2 (~5.5k host, split modules). Fewer lines, cleaner boundaries, more coverage. + +## What's kept (identical or near-identical) +- `timezone.ts` — byte-identical +- `group-folder.ts` — byte-identical validation; v2 adds `group-init.ts` for filesystem scaffold +- `container-runtime.ts` — nearly identical (only logger import swapped) +- `mount-security.ts` — same structure, one field removed (see regressions) +- `config.ts` / `env.ts` — same structure, same `.env` surface; several constants now dead code +- `logger.ts` — same levels/colors/routing, but API shape changed (message-first instead of data-first) +- MCP `send_message` tool — kept + enhanced with named destinations + +## What's new in v2 +- **Two-DB session model** (`inbound.db` + `outbound.db`) with even/odd seq parity, journal_mode=DELETE for cross-mount visibility +- **Entity model** — `users`, `user_roles` (owner/admin/scoped), `agent_group_members`, `messaging_groups`, `messaging_group_agents`, `user_dms` (cold-DM cache) +- **Host sweep** (60s) — absorbs scheduler, cleanup, pending-message recovery, recurrence firing, stale detection, orphan cleanup +- **Chat SDK bridge** — unifies Discord/Slack/Teams/other adapters through `@anthropic-ai/chat` +- **Provider abstraction** — default Claude + opt-in OpenCode etc. via `providers` branch +- **OneCLI integration** — credential gateway + approval flow (`src/onecli-approvals.ts`) +- **16 new MCP tools** — scheduling (6), interactive (2), self-mod (3), agent mgmt (1), message manipulation (3), plus enhanced `send_message` +- **Heartbeat file mtime** — replaces IPC liveness +- **Session persistence** — session ID survives container restarts +- **Dual-rate polling** — 1000ms idle / 500ms active inside container +- **Idle stream termination** — 20s timeout prevents zombie queries +- **Processing ACK** — reverse channel (outbound → inbound) for idempotence +- **Migration system** — 9 numbered migrations vs v1's ad-hoc ALTERs +- **Webhook server** (new for HTTP-based channels) +- **Container typing indicator refresh** via delivery + +## What's removed (deliberately) +- **IPC transport** (files, stdin/stdout JSON, MCP-over-stdio bridge) — replaced by DB polling +- **`GroupQueue`** in-memory state machine — serialization via `messages_in.status` +- **Output markers** (`---NANOCLAW_OUTPUT_START/END---`) — results land in `messages_out` +- **State persistence** (`router_state`, `lastAgentTimestamp` map) — each message is independent +- **Per-exit container log files** — only logger.debug to host log +- **Flat sender allowlist** (JSON config) — replaced by role-based access + `unknown_sender_policy` +- **Remote control subsystem** (`/remote-control` command → spawned CLI) +- **IPC watcher** (dynamic group-add while running) +- **`task_runs` audit table** — no task execution log +- **Cron/interval task types** as first-class entities — tasks are `messages_in` rows with `kind='task'` + `recurrence` +- **Stdin protocol** for agent input — container reads from inbound.db + +## Regressions worth fixing (ranked) + +### HIGH priority +1. **Trigger-rule matching in `pickAgent`** (`src/router.ts:198` TODO). + Without this, a messaging group wired to multiple agents fires ALL of them on every message. Schema (`messaging_group_agents.trigger_rules`) is ready; the check is ~10 lines. **Likely broken-by-default for multi-agent setups.** + +2. **`nonMainReadOnly` mount isolation removed** (`src/mount-security.ts`). + Non-main/shared agent groups can now mount read-write on any path the allowlist permits. v1 enforced read-only-for-non-main regardless of allowlist. **Security regression** for multi-tenant setups. Restore: add field + restore `isMain` param flow. + +3. **Pending-message recovery on startup** (`src/v1/index.ts:465-473`). + v1 explicitly scanned for unprocessed messages on restart. v2 relies on the sweep to notice. Likely works in practice, but worth a test: kill container mid-message, restart host, verify redelivery within ≤5s. + +### MEDIUM priority +4. **`response_scope` enforcement** (`messaging_group_agents.response_scope` stored but unused). + Values `'all' | 'triggered' | 'allowlisted'` are saved but nothing reads them. + +5. **`request_approval` flow for unknown senders** (`src/router.ts:295` TODO). + `unknown_sender_policy='request_approval'` is scaffolded but doesn't actually produce an approval card. + +6. **Per-group container timeout**. + v1's `containerConfig.timeout` override is gone; all groups share `IDLE_TIMEOUT`. Slow-but-healthy agents get killed with fast agents' timeout. + +7. **Container streaming output**. + v1's marker-based pre-completion delivery is gone. v2 must wait for outbound.db poll. Latency-sensitive UX regresses. + +8. **Per-exit container logs**. + v1 wrote timestamped per-exit log files with full I/O + mounts + stderr. v2 only has logger.debug. Zero-cost on success, high-value on crash. Restore at least for non-zero exit. + +9. **Explicit container kill on stale detection**. + v2's sweep marks messages for retry but doesn't stop the stale container. Only `cleanupOrphans()` at startup removes them. Add `stopContainer()` when heartbeat stale AND processing stuck. + +10. **Host-level retry with backoff on agent error**. + v1 had MAX_RETRIES=5 + exp. backoff on `processGroupMessages` failure. v2 only retries on stale-heartbeat. Explicit agent-error retry could close the gap. + +### LOW priority +11. **Process ID in logger output** — lost multi-process debugging info +12. **Task dedup via unique `(kind, series_id)` index** — v2 can have two pending rows with same series; best-effort via atomic status update +13. **Silent-drop mode for noisy senders** — v1's `mode:'drop'` had a use case; orthogonal to privilege +14. **Remote control** — decide: restore as opt-in skill or document as removed +15. **Dead config constants** (`POLL_INTERVAL`, `SCHEDULER_POLL_INTERVAL`, `IPC_POLL_INTERVAL`) — delete from `src/config.ts` +16. **Configurable retention thresholds** (`STALE_THRESHOLD_MS`, `MAX_TRIES`) — move from constants to `config.ts` +17. **Dynamic group-add** (IPC watcher equivalent) — probably not worth; document that restart is required + +## Things kept as test-only regression risk +The orphan `src/v1/formatting.test.ts` asserted behaviors that aren't fully exercised in v2: +- **Timezone-aware formatted timestamps** — v1 emitted locale strings ("Jan 1, 2024, 1:30 PM"); v2 emits UTC HH:MM +- **`` header** — gone +- **`reply_to=""` attribute** — v2 only stores sender name + truncated preview +- **Trigger-pattern unit tests** — no direct equivalent (logic moved to DB but isn't tested at the router level) +- **Internal tag stripping** tests — no isolated tests in agent-runner + +These are specs worth porting to v2 tests once trigger matching is implemented. + +## Files entirely gone in v2 +- `src/v1/ipc.ts` + `src/v1/ipc-auth.test.ts` — IPC is dead +- `container/.../v1/ipc-mcp-stdio.ts` — MCP-over-stdio bridge dead +- `src/v1/group-queue.ts` — serialization via DB +- `src/v1/session-cleanup.ts` — merged into `host-sweep.ts` +- `src/v1/task-scheduler.ts` — merged into `host-sweep.ts` + system actions in `delivery.ts` +- `src/v1/remote-control.ts` — feature removed +- `src/v1/sender-allowlist.ts` — entity model supersedes + +## Net architectural assessment +v2 is strictly simpler, more consistent, and more robust in its happy path. The remaining TODOs (trigger matching, response_scope, request_approval) reflect scaffolding that was checked in ahead of the feature — none are deep design issues. The one actual regression is `nonMainReadOnly` mount isolation; it was a defense-in-depth feature and deserves to come back. The removal of per-exit container logs and streaming output markers are judgment calls that traded observability for simplicity — both can be restored cheaply if needed. + +No file in v1 contains a behavior that v2 is architecturally unable to express. The outstanding work is feature-completion, not architecture. diff --git a/docs/v1-vs-v2/channels.md b/docs/v1-vs-v2/channels.md new file mode 100644 index 0000000..bd4dda4 --- /dev/null +++ b/docs/v1-vs-v2/channels.md @@ -0,0 +1,305 @@ +# channels: v1 vs v2 + +## Scope + +### v1 +- **Paths**: `src/v1/channels/index.ts`, `src/v1/channels/registry.ts`, `src/v1/channels/registry.test.ts` +- **LOC**: 62 total (1 + 23 + 38) +- **Purpose**: Registry and interface stubs for external channel adapters (real adapters live on `channels` branch) + +### v2 counterparts +- **Paths**: `src/channels/adapter.ts`, `src/channels/channel-registry.ts`, `src/channels/chat-sdk-bridge.ts`, `src/channels/index.ts`, `src/channels/ask-question.ts`, and tests +- **LOC**: 1,055 total (excluding tests: ~757) +- **Purpose**: Full adapter interface, registry with lifecycle, Chat SDK bridge (new in v2), ask_question normalization, plus integration tests + +--- + +## Adapter Interface Diff + +### v1: `Channel` (from src/v1/types.ts:87–98) + +```typescript +export interface Channel { + name: string; + connect(): Promise; + sendMessage(jid: string, text: string): Promise; + isConnected(): boolean; + ownsJid(jid: string): boolean; + disconnect(): Promise; + setTyping?(jid: string, isTyping: boolean): Promise; // Optional + syncGroups?(force: boolean): Promise; // Optional +} +``` + +**Callbacks** (src/v1/types.ts:101–112): +- `OnInboundMessage(chatJid: string, message: NewMessage): void` +- `OnChatMetadata(chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean): void` + +**Factory & Registration** (src/v1/channels/registry.ts:3–23): +```typescript +export interface ChannelOpts { + onMessage: OnInboundMessage; + onChatMetadata: OnChatMetadata; + registeredGroups: () => Record; +} +export type ChannelFactory = (opts: ChannelOpts) => Channel | null; +registerChannel(name: string, factory: ChannelFactory): void; +getChannelFactory(name: string): ChannelFactory | undefined; +getRegisteredChannelNames(): string[]; +``` + +--- + +### v2: `ChannelAdapter` (from src/channels/adapter.ts:61–106) + +```typescript +export interface ChannelAdapter { + name: string; + channelType: string; + supportsThreads: boolean; // NEW: declares thread model + + // Lifecycle (was: connect/disconnect) + setup(config: ChannelSetup): Promise; + teardown(): Promise; + isConnected(): boolean; + + // Message delivery (was: sendMessage, now structured) + deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise; + + // Optional + setTyping?(platformId: string, threadId: string | null): Promise; + syncConversations?(): Promise; + updateConversations?(conversations: ConversationConfig[]): void; + openDM?(userHandle: string): Promise; // NEW: cold-DM initiation +} +``` + +**Callbacks** (src/channels/adapter.ts:18–30): +```typescript +export interface ChannelSetup { + conversations: ConversationConfig[]; + onInbound(platformId: string, threadId: string | null, message: InboundMessage): void | Promise; + onMetadata(platformId: string, name?: string, isGroup?: boolean): void; + onAction(questionId: string, selectedOption: string, userId: string): void; // NEW +} +``` + +**Factory & Registration** (src/channels/channel-registry.ts:25–47): +```typescript +export type ChannelAdapterFactory = () => ChannelAdapter | Promise | null; +export interface ChannelRegistration { + factory: ChannelAdapterFactory; + containerConfig?: { mounts?: [...]; env?: Record; }; +} +registerChannelAdapter(name: string, registration: ChannelRegistration): void; +getChannelAdapter(channelType: string): ChannelAdapter | undefined; // RENAMED +getActiveAdapters(): ChannelAdapter[]; // NEW +getRegisteredChannelNames(): string[]; +getChannelContainerConfig(name: string): ChannelRegistration['containerConfig']; // NEW +``` + +--- + +## Capability Map + +| v1 Behavior | v2 Location | Status | Notes | +|---|---|---|---| +| **Interface & Lifecycle** | | | | +| `connect()` → `disconnect()` | `setup()` / `teardown()` | Renamed + consolidated | v2 groups init work; adds promise-based retry on NetworkError (src/channels/channel-registry.ts:73) | +| `Channel.name: string` | `ChannelAdapter.name` + `ChannelAdapter.channelType` | Split | `name` is identity; `channelType` is the key for active lookup | +| `ownsJid(jid)` | Implicit in platformId model | Removed | v2 uses structured platformId + threadId; ownership logic pushed to router | +| **Message Flow** | | | | +| `sendMessage(jid, text)` | `deliver(platformId, threadId, message)` | Refactored | v2 passes structured `OutboundMessage` with `kind` field; returns platform messageId; supports edit/reaction ops (src/channels/chat-sdk-bridge.ts:279–289) | +| Callbacks: `onMessage` | `onInbound(platformId, threadId, message)` | Refactored | v2 passes message object with `kind` enum ('chat' \| 'chat-sdk'); can be async | +| Callbacks: `onChatMetadata` | `onMetadata(platformId, name?, isGroup?)` | Simplified | Signature matches v1; removed `channel` param; timestamp now in inbound message itself | +| | `onAction(questionId, option, userId)` | **NEW** | Handles ask_question card button clicks via Chat SDK bridge (src/channels/chat-sdk-bridge.ts:193–218) | +| **Typing Indicator** | | | | +| `setTyping(jid, bool)` | `setTyping(platformId, threadId)` | Refactored | v2 omits boolean flag (always true, no off-toggle); threaded parameter | +| **Group/Conversation Sync** | | | | +| `syncGroups(force?)` | `syncConversations()?: Promise` | Renamed | Now returns structured list; decoupled from periodic init (optional hook) | +| | `updateConversations(configs)`: void | **NEW** | Push notifications of conversation changes from host to adapter (e.g., new wiring) | +| **Thread Model** | | | | +| Implicit (adapter-specific) | `supportsThreads: boolean` | **NEW** | v2 explicitly declares it; router uses this to collapse/expand thread context (src/channels/adapter.ts:73–75) | +| **DM Initiation** | | | | +| Not exposed | `openDM(userHandle)?: Promise` | **NEW** | For cold-DM reaching (approvals, onboarding, alerts) on platforms that distinguish user-id from DM-channel-id. Optional; fallback in user-dm.ts if absent (src/channels/adapter.ts:94–105) | +| **Inbound Message Structure** | | | | +| v1 `NewMessage` object | v2 `InboundMessage` (generic JSON) | Generalized | v1 had flat fields (sender, content, timestamp, thread_id, reply_to_*); v2 wraps serialized Chat SDK Message or native JSON in `content` field; Chat SDK bridge enriches (adds senderId, senderName) before sending (src/channels/chat-sdk-bridge.ts:124–141) | +| **Outbound Message Structure** | | | | +| Plain text + typing flag | v2 `OutboundMessage` (typed `kind` + flexible `content`) | Generalized | Supports 'chat', 'chat-sdk', edit ops, reactions, ask_question cards (src/channels/adapter.ts:46–51, src/channels/chat-sdk-bridge.ts:279–317) | +| **Factory Pattern** | | | | +| `ChannelFactory(opts) → Channel \| null` | `ChannelAdapterFactory() → ChannelAdapter \| Promise<...> \| null` | Async + cred check | v2 supports async factory (for loading credentials); promise-based retry on NetworkError (src/channels/channel-registry.ts:68–87) | +| **Container Config** | | | | +| Not exposed | `ChannelRegistration.containerConfig` | **NEW** | Adapters can declare mounts + env vars for their container (used by container-runner); see src/channels/channel-registry.ts:45–47 | + +--- + +## Message Conversion & Error Handling + +### v1 Flow +- Adapter calls `onMessage(chatJid, NewMessage)` synchronously +- Router extracts fields, upserts user, creates/finds session, writes to `inbound.db` +- No built-in error handling; adapters catch and log themselves + +### v2 Flow (src/channels/chat-sdk-bridge.ts:85–141) +1. **Inbound**: Chat SDK `Message` → `InboundMessage` (kind='chat-sdk', content=serialized JSON) +2. **Attachment handling**: Downloads attachments, converts to base64 (src/channels/chat-sdk-bridge.ts:90–111) +3. **Reply context extraction**: Platform-specific hook (src/channels/chat-sdk-bridge.ts:115–120) +4. **User field normalization**: Maps Chat SDK author → senderId, sender, senderName (src/channels/chat-sdk-bridge.ts:124–131) +5. **Raw data drop**: Removes `raw` to save DB space (src/channels/chat-sdk-bridge.ts:134) +6. **Call onInbound**: Async-capable (can await router writes) + +**Outbound** (src/channels/chat-sdk-bridge.ts:273–344): +- Supports multiple operation types via `content.operation`: + - `'edit'` + `messageId` → `adapter.editMessage()` + - `'reaction'` + `emoji` → `adapter.addReaction()` + - `type: 'ask_question'` → render Card with buttons + - Normal text/markdown → `adapter.postMessage()` with optional files + +**Error Propagation**: +- Network errors on setup get retry (src/channels/channel-registry.ts:73; duck-type check for Error.name==='NetworkError') +- Delivery errors logged but don't block (src/channels/chat-sdk-bridge.ts:213–214, 484–486) + +--- + +## New: Chat SDK Bridge + +The v2 `Chat` abstraction (from `@anthropic-ai/chat`) wraps platform-specific adapters (Discord.js, Slack SDK, etc.) into a unified API. The NanoClaw `createChatSdkBridge()` (src/channels/chat-sdk-bridge.ts:68–384) adapts that `Chat` instance to the `ChannelAdapter` interface. + +**Key methods**: +- `setup(hostConfig)`: Initialize Chat, set up event handlers (subscribed messages, DMs, mentions, actions), start Gateway listener or register webhook (src/channels/chat-sdk-bridge.ts:149–271) +- `deliver()`: Route outbound payloads (text, edit, reaction, ask_question card) to Chat SDK (src/channels/chat-sdk-bridge.ts:273–344) +- `setTyping()`: Delegate to `adapter.startTyping()` (src/channels/chat-sdk-bridge.ts:346–349) +- `teardown()`: Abort Gateway, shutdown Chat (src/channels/chat-sdk-bridge.ts:351–355) +- `updateConversations()`: Rebuild conversation map on changes (src/channels/chat-sdk-bridge.ts:361–363) +- `openDM()`: Conditional; only if underlying adapter supports it (src/channels/chat-sdk-bridge.ts:366–381) + +**Event routing** (src/channels/chat-sdk-bridge.ts:163–191): +- `chat.onSubscribedMessage()` → `onInbound()` for all known threads +- `chat.onNewMention()` → `onInbound()` + auto-subscribe +- `chat.onDirectMessage()` → `onInbound()` for DMs +- `chat.onAction()` → `onAction()` for ask_question button clicks (src/channels/chat-sdk-bridge.ts:193–218) + +**Gateway listener** (src/channels/chat-sdk-bridge.ts:222–268): +- Adapters like Discord that support websocket connection declare `startGatewayListener()`. +- NanoClaw runs it, forwards interactions (button clicks) to a local HTTP webhook server (src/channels/chat-sdk-bridge.ts:392–506). +- Non-Gateway adapters (Slack, Teams) register on the shared webhook-server instead (src/channels/chat-sdk-bridge.ts:266–268). + +--- + +## Test Fixtures + +### v1 (src/v1/channels/registry.test.ts:10–38) +- Simple lambda factories: `() => null` +- No mock adapters (tests only verify registry API mechanics) +- Test count: 4 (unknown-channel, round-trip, listing, overwrite) + +### v2 (src/channels/channel-registry.test.ts + src/channels/chat-sdk-bridge.test.ts) + +**Mock Adapter** (src/channels/channel-registry.test.ts:31–71): +```typescript +createMockAdapter(channelType): ChannelAdapter & { delivered, inbound, setupConfig } + - Properties: name, channelType, supportsThreads, delivered[], inbound[], setupConfig + - Methods: setup(config), teardown(), isConnected(), deliver(), setTyping(), updateConversations() +``` + +**Registry Tests** (src/channels/channel-registry.test.ts:84–119): +- Adapter registration with container config (src/channels/channel-registry.test.ts:88–98) +- Credential-missing adapters skipped (src/channels/channel-registry.test.ts:101–119) + +**Integration Tests** (src/channels/channel-registry.test.ts:122–234): +- Router receives inbound from adapter, writes to inbound.db (src/channels/channel-registry.test.ts:166–197) +- Delivery adapter bridge calls adapter.deliver() (src/channels/channel-registry.test.ts:199–233) + +**Chat SDK Bridge Tests** (src/channels/chat-sdk-bridge.test.ts:11–38): +- Conditional openDM exposure (src/channels/chat-sdk-bridge.test.ts:12–18) +- openDM delegation to underlying adapter (src/channels/chat-sdk-bridge.test.ts:20–37) + +--- + +## Missing from v2 + +### 1. `ownsJid(jid: string): boolean` +- **v1 use**: Adapters declared ownership of a JID (e.g., "does this Telegram numeric ID belong to me?") +- **v2 model**: JIDs → platformId + threadId; ownership is implicit in `platformId` format (e.g., `"telegram:6037840640"` vs `"discord:guildId:channelId"`). Router uses this to route inbound to the right adapter. +- **Impact**: Adapters no longer need explicit ownership checks; the structured ID handles it. + +### 2. `syncGroups(force?: boolean): Promise` +- **v1 use**: Periodic or on-demand sync of all groups/channels from the platform. +- **v2 model**: Optional `syncConversations()` returns metadata instead of mutating internal state; host calls it when needed (not baked into adapter init). Conversations are tracked in central DB `messaging_groups` table. +- **Impact**: Host has more control; adapters don't side-effect their own state. + +### 3. `registeredGroups` callback in `ChannelOpts` +- **v1 use**: Passed at init time; adapters could query which groups were registered. +- **v2 model**: Conversations provided upfront in `ChannelSetup.conversations`; can be updated via `updateConversations()`. +- **Impact**: Cleaner dependency injection; avoids callback nesting. + +### 4. `channel` parameter in `OnChatMetadata` +- **v1 use**: Metadata callback could optionally return which channel type made the discovery. +- **v2 model**: Not needed; `platformId` in `onMetadata(platformId, name, isGroup)` encodes the channel type. + +--- + +## Behavioral Discrepancies + +### 1. Thread-ID Handling +- **v1**: Some adapters (Telegram, WhatsApp) don't use threads; JIDs are the same as channel IDs. Others (Discord, Slack) embed thread IDs in reply_to logic. +- **v2**: Explicit `supportsThreads` flag; adapters that don't support threads pass `threadId: null` to `onInbound()`. Router uses this to decide session granularity (file:src/channels/adapter.ts:73–75). + +### 2. Outbound Message Structure +- **v1**: Plain text + optional typing flag. +- **v2**: Structured `{ kind, content, files? }` with operation support (edit, reaction, ask_question cards). Allows multi-op delivery without repeated deliver() calls. + +### 3. Inbound Serialization +- **v1**: Adapters directly passed `NewMessage` interface objects. +- **v2**: Adapters pass `InboundMessage` with generic `content` field (JSON-serializable JS object). Chat SDK bridge converts Chat SDK Message → JSON, then stringifies for DB (file:src/channels/chat-sdk-bridge.ts:136–140). + +### 4. Ask-Question Handling +- **v1**: No native support; would be custom per-adapter. +- **v2**: Unified via `ask_question` payload type. Chat SDK bridge renders as Card + Buttons; handles button clicks via `onAction()` callback and updates card to show selection (file:src/channels/chat-sdk-bridge.ts:292–317, 459–486). + +### 5. Cold-DM Initiation +- **v1**: Not exposed. +- **v2**: `openDM(userHandle): Promise` allows host to initiate DMs to users without prior message. Adapters that need it (Discord, Slack, Teams) implement; others omit and fall back to direct handle as platformId (file:src/user-dm.ts fallback). + +### 6. Async Factory +- **v1**: `ChannelFactory` returns `Channel | null` synchronously. +- **v2**: `ChannelAdapterFactory` returns `ChannelAdapter | Promise | null`, supporting async credential loading. Registry retries on `NetworkError` (file:src/channels/channel-registry.ts:68–87). + +### 7. Lifecycle Promises +- **v1**: `connect()` / `disconnect()` are separate. +- **v2**: `setup()` / `teardown()` grouped; no intermediate "starting/stopping" state. Gateway listeners and webhook servers are started inside `setup()`, torn down inside `teardown()` (file:src/channels/chat-sdk-bridge.ts:149–271, 351–355). + +--- + +## Worth Preserving? + +**All v1 patterns are preserved in v2, just restructured:** + +1. **Adapter interface model**: v1's optional hooks (`setTyping?`, `syncGroups?`) become v2's optional methods (`setTyping?`, `syncConversations?`, `openDM?`). Structural compatibility for native adapters. + +2. **Registry pattern**: v1's `registerChannel(name, factory)` → v2's `registerChannelAdapter(name, registration)`. Same self-registration barrel; v2 adds container config metadata. + +3. **Callback-driven message flow**: v1's `onMessage` and `onChatMetadata` callbacks live on as `onInbound` and `onMetadata`. v2 adds `onAction` for interactive features (ask_question buttons). + +4. **No built-in state mutation**: v1 adapters own their group state; v2 adapters are stateless (conversations pushed in). Both respect adapter autonomy. + +**What's genuinely new and worth keeping:** + +- **Chat SDK bridge**: Unifies platform SDKs without duplicating channel adapters per SDK. Huge reduction in code duplication (one Discord adapter instead of native + Chat SDK versions). +- **Structured message payloads**: v2's `kind` field and flexible `content` JSON allow single delivery path for text, edits, reactions, and rich interactions. +- **Ask-question cards**: Native support for interactive approvals and user input, reducing agent-side boilerplate. +- **openDM**: Enables host-initiated contact (onboarding, alerts, approvals) without waiting for inbound. +- **supportsThreads**: Explicit declaration lets router make informed session granularity decisions, vs. hardcoded per-adapter assumptions. + +**Minimal migration burden:** + +Native adapters written for v1 need only: +1. Rename `connect` → `setup` (add `ChannelSetup` param). +2. Rename `disconnect` → `teardown`. +3. Rename `sendMessage(jid, text)` → `deliver(platformId, threadId, message)` (wrap text in `{ kind: 'chat', content: { text } }`). +4. Add `supportsThreads: boolean`, `name`, `channelType` fields. +5. Add `isConnected()` stub if not already present. +6. Optional: Implement `setTyping?`, `syncConversations?`, `openDM?` for feature parity. + +Nothing is fundamentally broken; it's a straightforward refactor of the adapter contract. + diff --git a/docs/v1-vs-v2/config.md b/docs/v1-vs-v2/config.md new file mode 100644 index 0000000..c646499 --- /dev/null +++ b/docs/v1-vs-v2/config.md @@ -0,0 +1,99 @@ +# config: v1 vs v2 + +## Scope + +- **v1**: `/Users/gavriel/nanoclaw4/src/v1/config.ts` (63 lines) + `/Users/gavriel/nanoclaw4/src/v1/env.ts` (42 lines) +- **v2 counterparts**: `/Users/gavriel/nanoclaw4/src/config.ts` (63 lines, **identical**), `/Users/gavriel/nanoclaw4/src/env.ts` (42 lines, **identical**), plus host-level polling in `/Users/gavriel/nanoclaw4/src/host-sweep.ts` and `/Users/gavriel/nanoclaw4/src/delivery.ts`; container agent-runner reads at `/Users/gavriel/nanoclaw4/container/agent-runner/src/index.ts` + +## Capability map + +| v1 behavior | v2 location | Status | Notes | +|---|---|---|---| +| **ASSISTANT_NAME** env var (default: 'Andy') | `src/config.ts:10`; read from `.env` or `process.env` | Kept, partially used | v2 exports it but doesn't use it in host. Container receives via `NANOCLAW_ASSISTANT_NAME` env var (set by `src/container-runner.ts:302`) for transcript archiving only. v1 used it for CLAUDE.md substitution, trigger pattern, and prompt context. | +| **ASSISTANT_HAS_OWN_NUMBER** boolean env var | `src/config.ts:11-12` | **Removed, unused** | Exported but neither v1 nor v2 use it. No evidence of any implementation. | +| **POLL_INTERVAL = 2000ms** | `src/config.ts:13` | **Removed, unused** | v1 used in `index.ts:457` (IPC watcher polling). v2 replaced IPC with session DBs; no polling needed at this interval. | +| **SCHEDULER_POLL_INTERVAL = 60000ms** | `src/config.ts:14` | **Removed, unused** | v1 used in `task-scheduler.ts:231`. v2 uses hard-coded `SWEEP_INTERVAL_MS = 60_000` in `host-sweep.ts:31` instead (same value, different source). | +| **IPC_POLL_INTERVAL = 1000ms** | `src/config.ts:32` | **Removed, unused** | v1 used in `ipc.ts:50, ipc.ts:122`. v2 replaced file-based IPC with SQLite session DBs; this interval has no meaning. | +| **MOUNT_ALLOWLIST_PATH** = `~/.config/nanoclaw/mount-allowlist.json` | `src/config.ts:21` | Kept, same behavior | Used by `src/mount-security.ts` (host) to whitelist directories containers can read. Same in both versions. | +| **SENDER_ALLOWLIST_PATH** = `~/.config/nanoclaw/sender-allowlist.json` | `src/config.ts:22` | Kept, same behavior | Stored outside project root for security. Path derivation identical in v1 and v2. **Unused in v2** (no grep hits outside v1 folder). | +| **STORE_DIR** = `store/` | `src/config.ts:23` | **Removed, unused** | v1 used in `db.ts`. v2 uses central DB (`data/v2.db`) and per-session DBs (`data/v2-sessions//{inbound,outbound}.db`). `store/` directory no longer part of v2 architecture. | +| **GROUPS_DIR** = `groups/` | `src/config.ts:24` | Kept, same behavior | Per-agent-group filesystem (CLAUDE.md, skills, config). Used in `src/container-runner.ts`, `src/delivery.ts`, `src/group-init.ts`. Identical role in both versions. | +| **DATA_DIR** = `data/` | `src/config.ts:25` | Kept, extended usage | v1: IPC files, task DB. v2: central DB, session DBs, heartbeat files. More central in v2. Used in `src/index.ts`, `src/session-manager.ts`, `src/group-init.ts`, etc. | +| **CONTAINER_IMAGE** env var (default: 'nanoclaw-agent:latest') | `src/config.ts:27` | Kept, same behavior | Specifies Docker image name. Used in `src/container-runner.ts`. Identical in both versions. | +| **CONTAINER_TIMEOUT** env var (default: 1800000ms = 30min) | `src/config.ts:28` | Kept, same behavior | Maximum wall-clock time for a single container invocation. Used in `src/container-runner.ts`. Identical in both versions. | +| **CONTAINER_MAX_OUTPUT_SIZE** env var (default: 10485760 bytes = 10MB) | `src/config.ts:29` | **Removed, unused** | Exported but never referenced in v1 or v2. No evidence of implementation. | +| **ONECLI_URL** env var (no default) | `src/config.ts:30` | Kept, same behavior | OneCLI gateway URL for credential management. Read from `.env` or `process.env`. Used in `src/onecli-approvals.ts`. Identical in both versions. | +| **MAX_MESSAGES_PER_PROMPT** env var (default: 10) | `src/config.ts:31` | **Removed, unused** | v1 used in message batching for prompt formatting (`v1/index.ts:192-193, 434-435, 467`). v2 removed MAX_MESSAGES limit; agent processes all pending messages in a turn. | +| **IDLE_TIMEOUT** env var (default: 1800000ms = 30min) | `src/config.ts:33` | Kept, same behavior | How long to keep container alive after last result before killing due to inactivity. Used in `src/container-runner.ts:134-139`. Identical in both versions. | +| **MAX_CONCURRENT_CONTAINERS** env var (default: 5) | `src/config.ts:34` | **Removed, unused** | v1 used in `group-queue.ts` for queue management. v2 removed group queueing (no group-queue.ts equivalent). Sessions start containers independently; no global cap enforced. | +| **escapeRegex()** helper | `src/config.ts:36-38` | Kept, same implementation | Escapes regex special characters. Used by `buildTriggerPattern()`. Identical in both versions. | +| **buildTriggerPattern()** helper | `src/config.ts:40-42` | Kept, same implementation | Builds case-insensitive word-boundary regex from trigger string. Used in v2 by... (no grep hits in non-v1 v2 code). Exported but **unused in v2**. | +| **DEFAULT_TRIGGER** = `@${ASSISTANT_NAME}` | `src/config.ts:44` | Kept, **unused** | Default trigger pattern for agent activation. Computed from ASSISTANT_NAME. Exported but not used in v2 (no grep hits outside v1). | +| **getTriggerPattern()** helper | `src/config.ts:46-49` | Kept, **unused** | Returns regex for trigger matching. Used in v1 for routing decisions. Exported but **not used in v2** (trigger logic moved to DB `messaging_group_agents.trigger_rules`). | +| **TRIGGER_PATTERN** = computed | `src/config.ts:51` | Kept, **unused** | Pre-built DEFAULT_TRIGGER pattern. Exported but **not used in v2**. | +| **resolveConfigTimezone()** helper | `src/config.ts:55-61` | Kept, same implementation | Resolves IANA timezone from TZ env var → `.env` TZ → system timezone → 'UTC'. Identical logic in both versions. | +| **TIMEZONE** const | `src/config.ts:62` | Kept, same behavior | Current timezone for scheduled tasks, message timestamps. Used in `src/host-sweep.ts`, `container/agent-runner/src/index.ts`. Identical in both versions. | +| **readEnvFile()** function | `src/env.ts:11-42` | Kept, identical | Reads `.env` file, returns only requested keys, does not pollute `process.env`. Used by config.ts. Prevents secrets leak to child processes. Identical in both versions. | + +--- + +## Missing from v2 + +- **POLL_INTERVAL** (2000ms hardcoded constant) — v1 polling loop. v2 has no direct equivalent; delivery uses hard-coded `ACTIVE_POLL_MS = 1000` (`src/delivery.ts:56`). Not configurable. + +- **SCHEDULER_POLL_INTERVAL** (60000ms hardcoded constant) — v1 task scheduler. v2 uses hard-coded `SWEEP_INTERVAL_MS = 60_000` (`src/host-sweep.ts:31`). Same interval, not configurable from config.ts. + +- **IPC_POLL_INTERVAL** (1000ms hardcoded constant) — v1 IPC file watcher. No v2 equivalent; IPC replaced with session DBs. + +- **MAX_MESSAGES_PER_PROMPT** (env var, default 10) — v1 message batching. v2 has no message batching limit; all pending messages in a turn are processed together. + +- **MAX_CONCURRENT_CONTAINERS** (env var, default 5) — v1 group queue. v2 has no group-level concurrency cap; sessions start containers independently. + +- **STORE_DIR** (store/ directory) — v1 task/group storage. v2 uses central DB + session DBs; no store/ directory needed. + +- **SENDER_ALLOWLIST_PATH** — Path is defined but never used in either version. + +--- + +## Behavioral discrepancies + +1. **ASSISTANT_NAME usage** + - v1: Used for CLAUDE.md template substitution (`v1/index.ts:135-137`), getLastBotMessageTimestamp comparison, and trigger pattern building. + - v2: Only passed to container as `NANOCLAW_ASSISTANT_NAME` env var (`src/container-runner.ts:302`); container uses it for transcript archiving only. Host does not use it. + - **Impact**: v1 personalized CLAUDE.md by name; v2 relies on statically authored CLAUDE.md in `groups//`. + +2. **Trigger pattern handling** + - v1: Trigger pattern from `getTriggerPattern()` used at host routing layer (`v1/index.ts:200, 419`). + - v2: Trigger rules stored in DB (`messaging_group_agents.trigger_rules` JSON field), evaluated at delivery time by router. `getTriggerPattern()` exported but unused. + - **Impact**: v1 required config-level trigger changes; v2 allows per-messaging-group customization via DB. + +3. **Timezone resolution** + - v1: `resolveConfigTimezone()` used in `task-scheduler.ts:5`. + - v2: Same function; `TIMEZONE` used in `host-sweep.ts`, `container/agent-runner/src/index.ts:45` (but never actually referenced in agent-runner). + - **Impact**: Identical behavior; minor: container reads env var but doesn't use it. + +4. **Poll intervals** + - v1: `POLL_INTERVAL`, `SCHEDULER_POLL_INTERVAL`, `IPC_POLL_INTERVAL` all separately configured. + - v2: Hard-coded `ACTIVE_POLL_MS = 1000`, `SWEEP_POLL_MS = 60_000` in `src/delivery.ts`. Container poll loop uses hard-coded `POLL_INTERVAL_MS = 1000`, `ACTIVE_POLL_INTERVAL_MS = 500` in `container/agent-runner/src/poll-loop.ts:10-11`. + - **Impact**: v2 intervals are not tunable via env vars; requires code change. + +5. **Message batching** + - v1: `MAX_MESSAGES_PER_PROMPT` limits messages per turn (`v1/index.ts:467`). + - v2: No limit; all pending messages (minus filtered/denied commands) are formatted and sent to agent in one turn. + - **Impact**: v2 may send larger prompts; unbounded context risk if message queue grows. + +6. **Container concurrency** + - v1: `MAX_CONCURRENT_CONTAINERS` enforced via group queue (`v1/group-queue.ts`). + - v2: No global or per-group limit. Each session independently starts its container on wake. + - **Impact**: v2 can spawn many containers simultaneously; no backpressure mechanism. + +7. **IPC → Session DB** + - v1: Uses file-based IPC (JSON files, `IPC_POLL_INTERVAL` polling). + - v2: Uses SQLite session DBs (`inbound.db` host-owned, `outbound.db` container-owned). + - **Impact**: v2 is more reliable (ACID semantics) but less debuggable (binary format). + +--- + +## Worth preserving? + +**No.** The config.ts file is largely a legacy artifact. Most of its exports are unused in v2, and the few that remain (TIMEZONE, IDLE_TIMEOUT, ONECLI_URL, paths) are minimally invasive. The hardcoded poll intervals and removed features (MAX_MESSAGES, MAX_CONCURRENT_CONTAINERS, IPC_POLL_INTERVAL) reflect architectural changes that are intentional and correct for v2. The trigger pattern and ASSISTANT_NAME handling in config.ts should be removed from the host layer entirely — they're now managed by the DB and container env vars. Consolidate host-level config into a smaller, focused module that only exports what v2 actually uses: TIMEZONE, IDLE_TIMEOUT, CONTAINER_TIMEOUT, ONECLI_URL, path constants, and the env file reader. diff --git a/docs/v1-vs-v2/container-index.md b/docs/v1-vs-v2/container-index.md new file mode 100644 index 0000000..4b61d87 --- /dev/null +++ b/docs/v1-vs-v2/container-index.md @@ -0,0 +1,72 @@ +# container index (agent-runner entry): v1 vs v2 + +## Scope +- v1: `container/agent-runner/src/v1/index.ts` (736 LOC) — monolithic: arg parsing, IPC polling, SDK integration, output marshaling +- v2 (split): `container/agent-runner/src/index.ts` (124 LOC) + `poll-loop.ts` (436 LOC) + `destinations.ts` (118 LOC) + `formatter.ts` (228 LOC) + `db/*.ts` + `providers/*.ts` + +## Startup sequence diff + +| Step | v1 (IPC) | v2 (SQLite poll) | +|------|----------|------------------| +| Arg parsing | stdin JSON via `readStdin()` (v1:105-115) | env vars: `AGENT_PROVIDER`, `NANOCLAW_*` (v2 index.ts:44-51) | +| Env setup | `sdkEnv` + `CLAUDE_CODE_AUTO_COMPACT_WINDOW` (v1:626-629) | same, delegated to provider (index.ts:109) | +| DB open | — (IPC files only) | inbound.db (RO) + outbound.db (RW) + `session_state` table | +| MCP server config | hardcoded nanoclaw server (v1:477-486) | same + `NANOCLAW_MCP_SERVERS` env for additional (index.ts:94-104) | +| Message loop | `waitForIpcMessage()` polling (v1:350-366) | `poll-loop.ts:62+` `getPendingMessages()` every 1000ms idle / 500ms active | +| Provider | Claude SDK direct | provider abstraction factory (`providers/factory.ts`, supports claude/mock/custom) | +| Message stream | `MessageStream` iterable (v1:71-103) | same pattern in `providers/claude.ts:51-80` | +| System prompt | manual CLAUDE.md load + hardcoded destinations (v1:416-420) | `buildSystemPromptAddendum()` from inbound.db destinations (`destinations.ts:76-117`) | +| Query execution | `runQuery()` with IPC polling during query (v1:374-545) | `processQuery()` polls messages_in + `provider.query()` (`poll-loop.ts:259-319`) | +| Session resumption | sessionId on stdin + `resumeAt` tracking | `getStoredSessionId()` from outbound.db; cleared on `/clear` admin command | +| Shutdown | stdout output markers + exit(1) on error | no markers; logs errors; host manages lifecycle | +| Heartbeat | — | file touch at `SESSION_HEARTBEAT_PATH` on each result | + +## Capability map + +| v1 behavior | v2 location | Status | Notes | +|---|---|---|---| +| Parse prompt/session/group/chat/etc. from stdin | env + inbound.db | kept | | +| Env injection (ANTHROPIC_BASE_URL, proxy) | passed to provider.query() (index.ts:109) | kept | | +| Stdin JSON parsing | — | **removed** | | +| IPC file polling | `messages_in` table | modernized | Same semantics, DB-backed | +| IPC `_close` sentinel | implicit (process killed by host) | simplified | | +| Output wrapping markers | writes to `messages_out` | **removed** | | +| Session archiving PreCompact hook | `providers/claude.ts` hook | kept | | +| Session resumption by ID | `getStoredSessionId()` (poll-loop.ts:51) | **persisted** | Survives container restart | +| Scheduled task script execution | `task-script.ts:applyPreTaskScripts()` (poll-loop.ts:159) | kept | | +| Command filtering (`/help`, `/login`) | `categorizeMessage()` + filtered set (formatter.ts:14, poll-loop.ts:95-100) | **enhanced** | Explicit categories | +| Admin commands (`/clear`, etc.) | `categorizeMessage` + `NANOCLAW_ADMIN_USER_IDS` gate (poll-loop.ts:102-131) | kept | Explicit admin role from env | +| Destination routing `to=` | `destinations` table + `dispatchResultText()` (poll-loop.ts:350-432) | modernized | Named destinations instead of raw JIDs | +| Multi-destination message blocks | `MESSAGE_RE` regex (poll-loop.ts:350-414) | kept | | +| Tool allowlist | `providers/claude.ts:19-39` | kept | | +| MCP server setup | index.ts:81-104 | kept + extensible | | +| `@-syntax` additional dirs | `/workspace/extra/*` discovered at startup (index.ts:64-74) | kept | | +| Global CLAUDE.md | SDK preset append (index.ts:56-58) | kept | | +| Idle stream termination | — | **new** (IDLE_END_MS = 20s prevents zombies) | +| Admin user ID prefixing (chat-sdk) | explicit `channel_type:` prefix (formatter.ts:58-66) | **new** | | +| Processing ACK | **new** | prevents re-processing on container restart | +| Message kind formatting | `formatMessages()` (formatter.ts) | enhanced | Routes by kind: chat/task/webhook/system | + +## Missing from v2 +None of v1's core capabilities dropped. Notes on format/protocol shifts: +1. **Stdout markers removed** — host now parses `messages_out` table instead of stdout +2. **Stdin protocol gone** — follow-up messages via `messages_in` table +3. **Script-phase fast exit removed** — v1 could skip container entirely if `wakeAgent=false`; v2 gates message processing but container keeps polling (slightly more idle cost) + +## Behavioral discrepancies +1. **Idle timeout**: v1 had no query-level timeout → zombies possible. v2 ends stream after 20s with no SDK events +2. **Resume**: v1 re-read sessionId from stdin each run; v2 persists in `session_state` across restarts +3. **Admin gating**: v1 passed everything through; v2 categorizes + admin-gates `/clear` etc. +4. **Destination naming**: v1 raw JID; v2 human names from destinations table +5. **Poll cadence**: v2 dual-rate — 1000ms idle, 500ms active (CPU efficiency + responsiveness) +6. **Message kind routing**: v1 uniform; v2 distinguishes chat/chat-sdk/task/webhook/system with per-kind formatting + +## Worth preserving? +v1 should remain historical reference only. v2 strictly supersedes: +- DB-backed state survives restarts +- Provider abstraction allows non-Claude agents +- Dynamic destinations from inbound.db +- Session invalidation detection + processing ACK idempotence +- Dual poll rate + idle termination prevent pathological query hangs + +No merge-back candidates identified. diff --git a/docs/v1-vs-v2/container-mcp-tools.md b/docs/v1-vs-v2/container-mcp-tools.md new file mode 100644 index 0000000..95c23b3 --- /dev/null +++ b/docs/v1-vs-v2/container-mcp-tools.md @@ -0,0 +1,59 @@ +# container mcp-tools: v1 vs v2 + +## Scope +- v1: `container/agent-runner/src/v1/mcp-tools.ts` (81 LOC) — single tool (`send_message`) +- v2: `container/agent-runner/src/mcp-tools/` — 7 modules (~971 LOC): `index.ts`, `core.ts`, `scheduling.ts`, `interactive.ts`, `agents.ts`, `self-mod.ts`, `types.ts` + +## Tool map + +| v1 tool | v2 file | Status | Schema / behavior diff | +|---|---|---|---| +| `send_message(text, channel, platformId, threadId)` | `core.ts:50-95` | **kept, enhanced** | v2 uses named destinations (`to`), auto-resolves via session default or lookup, preserves `thread_id` intelligently | +| — | `core.ts:133-177` `send_file` | **new** | Copies file to outbox dir, routes via destinations | +| — | `core.ts:179-218` `edit_message` | **new** | Edit previously-sent message by seq id | +| — | `core.ts:220-259` `add_reaction` | **new** | Emoji reaction by seq id | +| — | `scheduling.ts:33-79` `schedule_task` | **new** | One-shot or recurring (cron) | +| — | `scheduling.ts:81-137` `list_tasks` | **new** | Pending/paused tasks grouped by series | +| — | `scheduling.ts:139-165` `cancel_task` | **new** | | +| — | `scheduling.ts:167-192` `pause_task` | **new** | | +| — | `scheduling.ts:194-219` `resume_task` | **new** | | +| — | `scheduling.ts:221-266` `update_task` | **new** | Modify prompt/recurrence/processAfter/script | +| — | `interactive.ts:36-129` `ask_user_question` | **new** | Blocking with timeout — writes to outbound.db then polls inbound.db for response | +| — | `interactive.ts:131-166` `send_card` | **new** | Structured Chat SDK cards | +| — | `self-mod.ts:34-74` `install_packages` | **new** | apt/npm install, regex name validation, admin approval | +| — | `self-mod.ts:76-113` `add_mcp_server` | **new** | Wire existing MCP server | +| — | `self-mod.ts:115-141` `request_rebuild` | **new** | Async container rebuild | +| — | `agents.ts:30-63` `create_agent` | **new** | Admin-only sub-agent creation; not exposed to non-admin containers | + +## New tools in v2 +16 new tools split across 5 capability domains: +- **Message manipulation**: `send_file`, `edit_message`, `add_reaction` +- **Scheduling**: 6 task-management tools +- **Interactive**: `ask_user_question`, `send_card` +- **Self-modification**: `install_packages`, `add_mcp_server`, `request_rebuild` +- **Agent management**: `create_agent` + +## Missing from v2 +**None.** v2 strictly adds; v1's only tool (`send_message`) was kept and enhanced. + +## Behavioral discrepancies +1. **Destination resolution**: v1 used explicit channel/platformId/threadId params; v2 resolves named destinations from `destinations` map with fallback to session routing +2. **Two-DB split pattern**: all scheduling/self-mod tools write system actions to **outbound.db**; host processes (applies to inbound.db). Container never writes directly to inbound +3. **`ask_user_question` is blocking**: synchronously polls inbound.db until response arrives or timeout — agent perception is blocking, transport is async +4. **Admin enforcement**: `create_agent` + self-mod tools check admin approval host-side (`NANOCLAW_ADMIN_USER_IDS` env controls tool visibility) +5. **Message editing/reactions**: use internal seq id (not user-visible numeric message ID) — requires outbound.db lookup + +## Transport pattern (v2 common) +1. Agent invokes tool → validation (regex, enum, length) +2. Tool writes `messages_out` or system-action row +3. Tool returns success immediately (fire-and-forget) +4. Host polls outbound.db, applies approval / routing / side effects + +## Worth preserving? +**Yes, fully.** The v2 modular architecture is a large improvement: +- Clear separation by capability domain +- Two-DB constraint cleanly encoded (container → outbound, host → inbound) +- Named destination abstraction (better UX than raw JIDs) +- Admin-only tool filtering at the MCP server level + +v1 is retained as historical reference only. No merge-back. diff --git a/docs/v1-vs-v2/container-runner.md b/docs/v1-vs-v2/container-runner.md new file mode 100644 index 0000000..c598df7 --- /dev/null +++ b/docs/v1-vs-v2/container-runner.md @@ -0,0 +1,51 @@ +# container-runner: v1 vs v2 + +## Scope +- v1: `src/v1/container-runner.ts` (677 LOC) + `container-runner.test.ts` (204 LOC) — spawn + IPC plumbing + stdin/stdout JSON + process supervision + output-marker parsing +- v2: `src/container-runner.ts` (405 LOC) + `src/container-config.ts` (114 LOC) + `src/session-manager.ts` (DB paths). Net ~272 LOC removed by eliminating IPC and output parsing + +## Capability map + +| v1 behavior | v2 location | Status | Notes | +|---|---|---|---| +| Image selection | `container-runner.ts:348-349` | kept | Reads `imageTag` from container.json or env | +| Env injection | `container-runner.ts:266-284` | **changed** | Replaced IPC vars with `SESSION_INBOUND/OUTBOUND_DB_PATH`, `SESSION_HEARTBEAT_PATH`, `AGENT_PROVIDER`, `NANOCLAW_*` admin IDs | +| Volume mounts | `container-runner.ts:200-252` | **changed** | Removed per-group IPC dir; added session folder `/workspace` + agent group `/workspace/agent` | +| Mount validation | `container-runner.ts:240-244` | kept | Validates `additionalMounts` from container.json | +| Provider integration | `container-runner.ts:184-198` | **new** | `resolveProviderContribution()` wires provider host-side configs | +| stdin/stdout IPC | — | **removed** | v1 lines 318-387; v2 uses DB polling only; stdio=`['ignore','pipe','pipe']` | +| Process spawn | `container-runner.ts:119` | kept | | +| OneCLI `ensureAgent` + `applyContainerConfig` | `container-runner.ts:301-313` | enhanced | v2 calls `ensureAgent` first | +| Admin ID injection | `container-runner.ts:289-295` | **new** | Queries `getOwners/getGlobalAdmins/getAdminsOfAgentGroup` at wake | +| Idle timeout | `container-runner.ts:135-140` | changed | v2 uses `resetIdle()` callback on activeContainers entry, settable by `delivery.ts` | +| Timeout logic | — | **removed** | v1 had configurable per-group timeout reset on output markers | +| Output parsing | — | **removed** | v1 parsed `---NANOCLAW_OUTPUT_START/END---` from stdout; v2 ignores stdout | +| Streaming output callback | — | **removed** | v1 had `onOutput()` for real-time delivery | +| Per-exit log file | — | **removed** | v1 wrote `groups//logs/container-*.log` with full I/O; v2 only logs stderr to logger.debug | +| Graceful SIGTERM→SIGKILL | — | simplified | v2 just calls `stopContainer()` | +| Concurrent wake dedup | `container-runner.ts:44-82` | **new** | `wakePromises` Map prevents race on spawn | +| Per-group image builds | `container-runner.ts:357-405` | **new** | `buildAgentGroupImage()` writes `imageTag` | +| Session folder init | `container-runner.ts:210` | **new** | `initGroupFilesystem()` at spawn | +| Heartbeat file `/workspace/.heartbeat` | session-manager.ts | **new** | File-touch replaces IPC liveness | +| Task/group JSON snapshots (`current_tasks.json`, `available_groups.json`) | — | **removed** | v2 pushes data via inbound.db writeDestinations/writeSessionRouting | +| Container name | `container-runner.ts:103` | changed | `nanoclaw-v2-${folder}-${Date.now()}` | + +## Missing from v2 +1. **Streaming output markers** — `---NANOCLAW_OUTPUT_START/END---` enabled pre-completion delivery; v2 must wait for outbound.db poll to deliver results +2. **Configurable per-group timeout** — `group.containerConfig.timeout` override is gone; all groups share `IDLE_TIMEOUT` +3. **Per-exit detailed logs** — v1 wrote timestamped logs with full I/O + mounts + stderr + stdout; invaluable for post-mortem +4. **Graceful-stop sentinel** — v1 sent SIGTERM and waited for `_close` marker before SIGKILL +5. **JSON snapshots for tasks/groups** — `current_tasks.json` / `available_groups.json` in the group IPC dir + +## Behavioral discrepancies +1. **Async result model**: v1 `runContainerAgent()` returned `Promise` with inline result; v2 `wakeContainer()` is fire-and-forget — results asynchronous via delivery poll +2. **No stdin**: v1 wrote full `ContainerInput` JSON to stdin; v2 container reads everything from inbound.db +3. **Admin injection at wake**: v2 queries admins fresh on every spawn (`NANOCLAW_ADMIN_USER_IDS`) +4. **Destination routing timing**: v2 calls `writeDestinations()` + `writeSessionRouting()` on every wake so changes apply without restart +5. **Session lifecycle**: v1 created a session per spawn; v2 resolves session via router before wake + +## Worth preserving? +- **Streaming output**: Meaningful latency improvement. Hybrid model (DB polling + optional marker pre-delivery) could reduce perceived latency for long outputs +- **Per-group timeout**: Restore — different agent groups have different expected latencies +- **Per-exit logs**: At minimum, restore on non-zero exit. Cheap forensics, huge debug value +- **Graceful-stop sentinel**: Not critical — bun container is disposable diff --git a/docs/v1-vs-v2/container-runtime.md b/docs/v1-vs-v2/container-runtime.md new file mode 100644 index 0000000..e240247 --- /dev/null +++ b/docs/v1-vs-v2/container-runtime.md @@ -0,0 +1,46 @@ +# container-runtime + mount-security: v1 vs v2 + +## Scope +- v1: `src/v1/container-runtime.ts` (81 LOC), `container-runtime.test.ts` (148 LOC), `mount-security.ts` (406 LOC) +- v2: `src/container-runtime.ts` (81 LOC), `container-runtime.test.ts` (149 LOC), `mount-security.ts` (390 LOC) + +## Capability map + +### container-runtime.ts + +| v1 behavior | v2 location | Status | Notes | +|---|---|---|---| +| `CONTAINER_RUNTIME_BIN = 'docker'` | `container-runtime.ts:1` | kept | Hardcoded; Apple Container runtime is NOT handled here in either version | +| `hostGatewayArgs()` | `container-runtime.ts` | kept | Identical | +| `readonlyMountArgs()` | `container-runtime.ts` | kept | Identical | +| `stopContainer()` | `container-runtime.ts` | kept | Identical | +| `ensureContainerRuntimeRunning()` | `container-runtime.ts` | kept | Identical | +| `cleanupOrphans()` | `container-runtime.ts:60-80` | kept | Identical logic | +| Logging module | | **changed** | v1 imports `logger` (data-first); v2 imports `log` (message-first) | + +### mount-security.ts + +| v1 behavior | v2 location | Status | Notes | +|---|---|---|---| +| `AdditionalMount` / `AllowedRoot` / `MountAllowlist` types | `mount-security.ts:16-29` | kept | Same shape except `nonMainReadOnly` removed | +| Default blocked patterns | `mount-security.ts:39` | kept | Same list | +| Allowlist load + file-watch cache | `mount-security.ts:64-102` | kept | | +| Path expansion (`~`) | `mount-security.ts` | kept | | +| Symlink resolution | `mount-security.ts` | kept | | +| Container-path validation | `mount-security.ts` | kept | | +| Template generation | `mount-security.ts:362-386` | changed | v2 template omits `nonMainReadOnly: true` | +| `validateMount(mount, isMain)` | `mount-security.ts:230-307` | **signature changed** | v2 is `validateMount(mount)` — no `isMain` | +| `validateAdditionalMounts(mounts, groupName, isMain)` | same | **signature changed** | v2 drops `isMain` | +| Non-main groups forced to read-only | — | **removed** | v1 lines 283-291; v2 only checks `allowedRoot.allowReadWrite` | + +## Missing from v2 +1. **`nonMainReadOnly` flag on `MountAllowlist`** — v1 could force non-main agent groups to read-only even when their allowlist permitted RW +2. **`isMain` param flow** through `validateMount` / `validateAdditionalMounts` +3. **Non-main group RW enforcement** at mount-validation time — now delegated entirely to `allowedRoot.allowReadWrite` + +## Behavioral discrepancies +1. **Isolation model weakened**: a non-main ("shared" or auxiliary) agent group can now mount RW on any path its root permits. v1's defense-in-depth (allowlist permits RW + group must be main) is reduced to just the allowlist check +2. **Logger import**: only surface difference in container-runtime.ts + +## Worth preserving? +**`nonMainReadOnly` restoration has security value** for multi-tenant setups where shared/sandbox agent groups should not mutate filesystem even if the allowlist is permissive. Low-cost to reintroduce: restore the field on `MountAllowlist`, restore the `isMain` param, restore the check in `validateMount()`. If v2 has explicitly decided isolation is enforced elsewhere (agent-group config), document that; otherwise this is a regression. diff --git a/docs/v1-vs-v2/db.md b/docs/v1-vs-v2/db.md new file mode 100644 index 0000000..97ee0f8 --- /dev/null +++ b/docs/v1-vs-v2/db.md @@ -0,0 +1,542 @@ +# db: v1 vs v2 + +## Scope + +**v1 (historical, not runtime):** +- `/Users/gavriel/nanoclaw4/src/v1/db.ts` (659 lines) +- `/Users/gavriel/nanoclaw4/src/v1/db.test.ts` (592 lines) +- `/Users/gavriel/nanoclaw4/src/v1/db-migration.test.ts` (60 lines) +- **Single database:** `/messages.db` (better-sqlite3) +- No session/agent-runner separation; chat metadata + message history only + +**v2 counterparts:** +- Central: `/Users/gavriel/nanoclaw4/src/db/*.ts` (index, schema, connection, 9 modules + 7 migrations) +- Session: `/Users/gavriel/nanoclaw4/src/db/session-db.ts` (200+ lines) +- Chat SDK state: `/Users/gavriel/nanoclaw4/src/state-sqlite.ts` (250+ lines) +- Docs: `docs/db.md`, `docs/db-central.md`, `docs/db-session.md` + +--- + +## High-Level Shift + +| Aspect | v1 | v2 | +|--------|----|----| +| **Database count** | 1 | 3 (central + per-session inbound + per-session outbound) | +| **Primary purpose** | Message history for a WhatsApp/multi-channel bot | Admin plane (identity, wiring, approvals) + per-session message queues | +| **Writer model** | Single process | Single writer per file (host writes central + inbound; container writes outbound) | +| **Schema evolution** | Ad-hoc ALTER TABLE in `createSchema()` | Versioned migrations in `src/db/migrations/` | +| **Multi-tenant** | No (one bot per instance) | Yes (multiple agent groups, isolation levels, approval flows) | +| **Key invariants** | Bot prefix filter, last-bot-timestamp cursor | Seq parity (even host, odd container), journal_mode=DELETE cross-mount visibility | + +--- + +## Capability Map + +| v1 Behavior | v2 Location | Status | Notes | +|-------------|-------------|--------|-------| +| **`chats` table** (jid, name, last_message_time, channel, is_group) | `messaging_groups` (central DB) | Kept, renamed | v1: chat metadata only, no messages stored. v2: per-platform chat, with `unknown_sender_policy`, routing to multiple agents. | +| **`messages` table** (id, chat_jid, sender, content, timestamp, is_from_me, is_bot_message, reply_to_*) | `messages_in` (session inbound) | Moved to session DB | v1: indexed by `timestamp`, filtered by bot prefix + flag. v2: indexed by `series_id` (recurring), seq-numbered, multi-kind (chat|task|system), host-written with even seq. Container reads pending/unprocessed. | +| **`scheduled_tasks` table** (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, next_run, context_mode, status) | `messages_in` (session inbound, kind='task') | Moved to session messages | v1: separate table with status='active'\|'paused'\|'completed'. v2: unified into `messages_in` with kind='task', status per message. Scheduling engine lives in v2 `host-sweep.ts`. | +| **`task_run_logs` table** (task_id, run_at, duration_ms, status, result, error) | No direct counterpart | Removed | v2 doesn't persist task execution logs in DB; host-sweep handles recurrence in-memory and via `processing_ack` acks. | +| **`router_state` table** (key, value) | Not needed in v2 | Removed | v1: stored `last_timestamp`, `last_agent_timestamp` for polling cursor. v2: central DB and message tables eliminate need for manual state; routing is deterministic via `messaging_group_agents` and session queues. | +| **`sessions` table** (group_folder, session_id) | `sessions` (central DB) | Kept, extended | v1: maps group folder to session ID. v2: central registry: id, agent_group_id, messaging_group_id, thread_id, status, container_status, last_active. Keyed by `(agent_group_id, messaging_group_id, thread_id)` tuples. | +| **`registered_groups` table** (jid, name, folder, trigger_pattern, requires_trigger, is_main, container_config) | `agent_groups` (central DB) | Converted | v1: per-JID trigger; one agent per bot instance. v2: agent_groups independent of channels; multiple messaging_groups wire to each agent via `messaging_group_agents`. Container config moved to disk (`groups//container.json`). | +| **Bot message filtering (is_bot_message flag + prefix)** | `messages_in` schema + container read filter | Kept, schema-level | v1: dual check (flag + `content LIKE 'Andy:%'` backstop). v2: container-side filtering in agent-runner. | +| **Reply context (reply_to_message_id, reply_to_content, reply_to_sender_name)** | `messages_in` columns | Kept | v1: nullable columns added via migration. v2: same schema, inherited from v1 shape. | +| **Chat metadata sync (last_message_time, channel, is_group)** | `messaging_groups` + lazy platform discovery | Converted | v1: timestamps in `chats.last_message_time`. v2: platform metadata in `messaging_groups`; `last_active` in `sessions` for activity tracking. | +| **Group discovery** (getAllChats) | Channel adapters + `messaging_groups` query | Removed from DB | v1: `getAllChats()` queries local DB. v2: adapters populate `messaging_groups` on first message; host discovers channels via routing, not polling DB. | +| **Message filtering by timestamp window** | `getNewMessages()`, `getMessagesSince()` with LIMIT subquery | Moved to session inbound | v1: queries with ORDER BY DESC, LIMIT N, then re-sort chronologically. v2: host writes to inbound; container polls. Cursor logic inverted (container drives processing, host feeds). | +| **Limit behavior (cap to N most recent)** | Hardcoded LIMIT 200 with timestamp filter | Kept, per-session | v1: `getNewMessages(limit=200)` by default. v2: `messages_in` has process-after and recurrence; container pulls per poll batch. | +| **Journal mode** | Not explicitly configured | DELETE (session), WAL (central) | v1: better-sqlite3 default (volatile). v2: `journal_mode=DELETE` on session DBs for cross-mount visibility; WAL on central DB for consistency. See `db/connection.ts:17` and `db/session-db.ts:15`. | +| **Foreign key constraints** | Soft (checked in code) | Hard (enforced in schema) | v1: no FK constraints. v2: all references are `REFERENCES table(id)` with implicit RESTRICT. Central DB enforces full FK graph. | +| **Pragmas** | Not set | `foreign_keys=ON`, `busy_timeout=5000` | v1: defaults only. v2: explicit, cross-mount-safe timeouts. | +| **Index coverage** | `idx_timestamp` on messages, `idx_next_run` on tasks, `idx_status` on tasks | Expanded | v1: 3 indexes. v2: series_id, user_roles scope, sessions lookup, agent_destinations target, pending_approvals action+status. | + +--- + +## Schema Diff: Table-by-Table + +### **Chats → Messaging Groups** + +**v1 `chats` (PK: jid):** +```sql +jid, name, last_message_time, channel, is_group +``` + +**v2 `messaging_groups` (PK: id, UNIQUE: channel_type, platform_id):** +```sql +id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at +``` + +**Diff:** +- v1: jid is the platform ID directly (`"tg:123"`, `"group@g.us"`) +- v2: splits into `channel_type` ("telegram", "whatsapp") + `platform_id` (normalized ID) +- v1: no `unknown_sender_policy`; dropped messages silently +- v2: adds policy for first-time senders: `strict` (drop), `request_approval` (ask admin), `public` (allow) +- v1: `last_message_time` per chat; v2: moved to `sessions.last_active` +- **Table lifecycle:** `chats` is ephemeral in v2 (discovered lazily); `messaging_groups` is central registry + +### **Messages → Messages In (Session)** + +**v1 `messages` (PK: id + chat_jid):** +```sql +id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, +reply_to_message_id, reply_to_message_content, reply_to_sender_name +``` + +**v2 `messages_in` (PK: id, UNIQUE: seq):** +```sql +id, seq, kind, timestamp, status, process_after, recurrence, series_id, tries, +platform_id, channel_type, thread_id, content +``` + +**Diff:** +- v1: single-session messages; chat_jid is the routing key +- v2: per-session inbound queue; platform_id + channel_type + thread_id from routing, not payload +- v1: sender/sender_name as columns +- v2: content is JSON (all fields, including sender, are inside) +- v1: `is_bot_message` flag +- v2: `kind` field (`'chat'`, `'task'`, `'system'`) replaces ad-hoc bot detection +- v1: no seq, no status, no recurrence +- v2: **seq invariant** — even numbers only (host-assigned); see `nextEvenSeq()` at `src/db/session-db.ts:75` +- v1: `reply_to_*` columns preserved in v2 +- v1: indexed on timestamp; v2: indexed on series_id (for recurring task grouping) + +### **Scheduled Tasks → Messages In + Processing** + +**v1 `scheduled_tasks` (PK: id):** +```sql +id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, +next_run, last_run, last_result, context_mode, status, created_at +``` + +**v2 spread across:** +- `messages_in` (host writes kind='task') +- `processing_ack` (container reads/writes status) +- No persistent `task_run_logs` + +**Diff:** +- v1: tasks are a separate schema; v2: tasks are messages (kind='task') +- v1: `prompt`, `script`, `context_mode` in task row; v2: in JSON `content` +- v1: `schedule_type` (once, recurring), `schedule_value` (cron); v2: same, in `recurrence` field (cron string) +- v1: `next_run`, `last_run` tracked in table; v2: `process_after`, `status` in messages_in; recurrence logic in host-sweep +- v1: `last_result` stored; v2: no persistence; result is in container logs or delivery flow +- v1: status='active'|'paused'|'completed'; v2: status='pending'|'processing'|'completed'|'failed'|'paused' (per message, unified with chat) + +### **Task Run Logs → Removed** + +**v1 `task_run_logs` (PK: id auto-increment, FK: task_id):** +```sql +task_id, run_at, duration_ms, status, result, error +``` + +**v2:** Not in DB. + +**Rationale:** v2 doesn't persist execution history in-DB; logs are streamed to host and written to operational logs. Task state is tracked via `processing_ack` status on the message itself, not a separate log table. + +### **Router State → Removed** + +**v1 `router_state` (PK: key):** +```sql +key (last_timestamp, last_agent_timestamp), value +``` + +**v2:** Not needed. + +**Rationale:** v1 used this to track polling cursors across restarts. v2 uses message IDs and seq numbers; polling logic is implicit in the session queue architecture. + +### **Sessions Table** + +**v1 `sessions` (PK: group_folder):** +```sql +group_folder, session_id +``` + +**v2 `sessions` (PK: id):** +```sql +id, agent_group_id, messaging_group_id, thread_id, agent_provider, status, container_status, last_active, created_at +``` + +**Diff:** +- v1: simple folder → session mapping +- v2: full session tuple: agent group + messaging group + thread, with lookup index on (messaging_group_id, thread_id) +- v1: no status tracking; v2: `status` (active|paused|archived), `container_status` (stopped|starting|running) +- v2: `agent_provider` override per session +- v2: `last_active` timestamp for stale detection + +### **Registered Groups → Agent Groups + Messaging Group Agents** + +**v1 `registered_groups` (PK: jid):** +```sql +jid, name, folder, trigger_pattern, requires_trigger, is_main, added_at, container_config +``` + +**v2 split into:** +- `agent_groups` (PK: id): `id, name, folder, agent_provider, created_at` — container config on disk +- `messaging_group_agents` (PK: id): bridges messaging groups to agents with wiring rules + +**Diff:** +- v1: one-to-one chat ↔ group; v2: many-to-many messaging group ↔ agent group +- v1: `trigger_pattern` on chat; v2: `trigger_rules` (JSON) on the `messaging_group_agents` wiring +- v1: `container_config` JSON in DB; v2: lives on disk at `groups//container.json` +- v1: `requires_trigger`, `is_main` flags; v2: `session_mode` (shared|per-thread|agent-shared) on wiring + +### **New v2 Tables (Central)** + +**`users`:** +```sql +id, kind, display_name, created_at +``` +Platform identities: `"tg:123"`, `"discord:abc"`, `"phone:+1555..."`, `"email:a@x.com"`. No v1 counterpart (permissions were implicit). + +**`user_roles`:** +```sql +user_id, role (owner|admin), agent_group_id (NULL=global), granted_by, granted_at +``` +v1 had no explicit permissions; v2 enforces owner/admin privilege with audit trail. + +**`agent_group_members`:** +```sql +user_id, agent_group_id, added_by, added_at +``` +Non-privileged user membership. v1: implied (everyone could message the bot). + +**`user_dms`:** +```sql +user_id, channel_type, messaging_group_id, resolved_at +``` +Cached DM channel discovery (avoids repeated API calls). No v1 equivalent. + +**`pending_questions`:** +```sql +question_id, session_id, message_out_id, platform_id, channel_type, thread_id, title, options_json, created_at +``` +Interactive multiple-choice questions. v1: no interactive prompts. + +**`agent_destinations`:** +```sql +agent_group_id, local_name, target_type, target_id, created_at +``` +Per-agent ACL and name-resolution map for `send_message(to="name")`. Projected into session inbound as `destinations` table (see db-session.md §2.3). v1: no permission model for outbound sends. + +**`pending_approvals`:** +```sql +approval_id, session_id, request_id, action, payload, agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status, title, options_json, created_at +``` +Approval queue for `install_packages`, `add_mcp_server`, `request_rebuild`, OneCLI credential flows. v1: no approval model. + +**`unregistered_senders` (via migration 008):** +```sql +user_id, messaging_group_id, first_seen, last_seen +``` +Audit trail of unknown senders (strict unknown_sender_policy). v1: silently dropped. + +**Chat SDK tables (via migration 002):** +- `chat_sdk_kv` (key, value, expires_at) +- `chat_sdk_subscriptions` (thread_id, subscribed_at) +- `chat_sdk_locks` (thread_id, token, expires_at) +- `chat_sdk_lists` (key, idx, value, expires_at) + +Backing store for Chat SDK state adapter. No v1 equivalent (Chat SDK didn't exist). + +### **New v2 Session Tables (Inbound, Host-written)** + +**`delivered`:** +```sql +message_out_id, platform_message_id, status, delivered_at +``` +Host tracks delivery outcomes without writing to container-owned outbound.db. + +**`destinations` (projection from central):** +```sql +name, display_name, type, channel_type, platform_id, agent_group_id +``` +Local ACL cache; rewritten on every container wake. Container queries this live to authorize sends. + +**`session_routing` (single-row table):** +```sql +id=1, channel_type, platform_id, thread_id +``` +Default reply routing for the session. Allows container to default outbound messages without querying central DB. + +### **New v2 Session Tables (Outbound, Container-written)** + +**`messages_out`:** +```sql +id, seq (ODD), in_reply_to, timestamp, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content +``` +Container-produced: chat replies, edits, reactions, cards, system actions. Seq always odd (container-assigned); see `src/db/session-db.ts:76` for parity logic. + +**`processing_ack`:** +```sql +message_id, status (processing|completed|failed), status_changed +``` +Container writes status for each message_in it touched. Host polls and syncs back into messages_in (avoids container writing inbound.db). + +**`session_state` (KV):** +```sql +key, value, updated_at +``` +Container persistent store (Chat SDK session ID, conversation state). Cleared by `/clear`. + +--- + +## Missing from v2 + +1. **Per-message sender/sender_name columns** — moved into JSON `content`. Container unpacks on read. +2. **`task_run_logs` persistent history** — v2 streams logs to host; no in-DB history. +3. **`last_agent_timestamp` cursor state** — implicit in session message seq. +4. **Message filtering by bot prefix** — handled by container when writing to outbound; inbound doesn't filter. +5. **Direct chat timestamp tracking** — replaced by `sessions.last_active` and message timestamps. +6. **Single-writer assumption for one bot** — v2: one writer per file, across multiple agent groups (containers). + +--- + +## Behavioral Discrepancies + +### Sequence Numbering (Load-Bearing Invariant) + +**v1:** No seq; messages identified by (id, chat_jid). + +**v2:** +- Host assigns **even** seq (2, 4, 6, …) to `messages_in`; see `nextEvenSeq()` at `src/db/session-db.ts:75–78`. +- Container assigns **odd** seq (1, 3, 5, …) to `messages_out`; see container logic at `container/agent-runner/src/db/messages-out.ts:54`. +- **Invariant:** seq is globally unique within a session across both tables. Parity disambiguates table membership for `edit_message(seq=5)` (odd → messages_out, even → messages_in). +- **If violated:** edits target wrong table; messaging breaks. + +### Message Status Lifecycle + +**v1:** `messages` are immutable once written; `scheduled_tasks` have status (active|paused|completed). + +**v2:** `messages_in` have status (pending|processing|completed|failed|paused). Container writes status into `processing_ack`; host syncs back. Processing is non-blocking (container reads when status='pending'). + +### Journal Mode (Cross-Mount Visibility) + +**v1:** Not configured (better-sqlite3 defaults to `PRAGMA journal_mode = memory` or implicit rollback). + +**v2:** **`journal_mode = DELETE` on session DBs** (see `db/session-db.ts:15`), **WAL on central** (see `db/connection.ts:17`). + +**Rationale:** v1 is single-process. v2 has host and container accessing the same session DBs across a Docker mount or Apple Container mount. WAL has issues with cross-mount visibility (rolled WAL files don't sync reliably); DELETE forces each write to flush the main file, so readers see the latest state. + +### Unknown Sender Handling + +**v1:** Silently dropped or stored with no policy tracking. + +**v2:** `unknown_sender_policy` on `messaging_groups`: `strict` (drop), `request_approval` (admin card), `public` (allow). Dropped senders tracked in `unregistered_senders` audit table (migration 008). + +### Recurring Tasks + +**v1:** `scheduled_tasks.recurrence` (cron); `schedule_type` (once|recurring); status tracking in row. + +**v2:** `messages_in.recurrence` (cron string), `series_id` (groups occurrences). Host-sweep recalculates next run via cron parser; no persistence. Status per message (pending|paused|completed). + +### Chat Metadata Sync + +**v1:** `getAllChats()` queries local DB; `last_message_time` updated by each message insert. + +**v2:** Metadata lives in `messaging_groups` (central, discovered lazily by adapters). Activity tracked in `sessions.last_active`. No global "last message" timestamp per chat. + +### Destinations and Permissions + +**v1:** No model; all agents can send to all chats. + +**v2:** +- Central: `agent_destinations` (source of truth) +- Session: `destinations` (projection in inbound.db, rewritten on wake) +- Container: queries `destinations` live; sends rejected if name not found +- Invariant: if wiring changes mid-session and `writeDestinations()` isn't called, container sees stale data + +### Foreign Key Enforcement + +**v1:** No constraints; referential integrity checked in code. + +**v2:** All FKs enforced; central DB will reject orphaned references. Session DBs don't need as many FKs (immutable projections). + +--- + +## Pragmas & Configuration + +### v1 + +```javascript +// Implicit defaults — not set in code +``` + +### v2 + +**Central DB (src/db/connection.ts:17–18):** +```javascript +_db.pragma('journal_mode = WAL'); +_db.pragma('foreign_keys = ON'); +``` + +**Session Inbound (src/db/session-db.ts:23–24):** +```javascript +db.pragma('journal_mode = DELETE'); +db.pragma('busy_timeout = 5000'); +``` + +**Session Outbound (src/db/session-db.ts:31–32):** +```javascript +// Opens readonly +db.pragma('busy_timeout = 5000'); +``` + +--- + +## Migrations + +### v1 +Adhoc `ALTER TABLE` in `createSchema()` (src/v1/db.ts:82–134): +- context_mode → scheduled_tasks +- script → scheduled_tasks +- is_bot_message → messages +- is_main → registered_groups +- channel, is_group → chats +- reply_to_* → messages + +No versioning; all tables are `IF NOT EXISTS` and ALTERs are try-catch silent. + +### v2 +Numbered migrations in `src/db/migrations/` (1–9, note: 5–6 missing): + +1. **001-initial.ts** — all core tables (agent_groups, messaging_groups, users, user_roles, agent_group_members, user_dms, sessions, pending_questions) +2. **002-chat-sdk-state.ts** — chat_sdk_kv, chat_sdk_subscriptions, chat_sdk_locks, chat_sdk_lists +3. **003-pending-approvals.ts** — pending_approvals table with action, payload, status +4. **004-agent-destinations.ts** — agent_destinations table + backfill from existing messaging_group_agents wirings +5. **(missing)** +6. **(missing)** +7. **007-pending-approvals-title-options.ts** — adds title, options_json columns to pending_approvals +8. **008-dropped-messages.ts** — unregistered_senders audit table +9. **009-drop-pending-credentials.ts** — cleanup (if any) + +**Runner:** `runMigrations()` (src/db/migrations/index.ts:28–60) tracks version in `schema_version` table; applies pending migrations in transaction. + +--- + +## Index Coverage + +### v1 + +- `idx_timestamp` on `messages(timestamp)` — range queries for new messages +- `idx_next_run` on `scheduled_tasks(next_run)` — sweep query for due tasks +- `idx_status` on `scheduled_tasks(status)` — filter for active tasks +- `idx_task_run_logs` on `task_run_logs(task_id, run_at)` — log lookup + +### v2 + +- `idx_user_roles_scope` on `user_roles(agent_group_id, role)` — permission queries +- `idx_sessions_agent_group` on `sessions(agent_group_id)` — session lookup per agent +- `idx_sessions_lookup` on `sessions(messaging_group_id, thread_id)` — resolve session from channel+thread +- `idx_messages_in_series` on `messages_in(series_id)` — recurring task grouping +- `idx_agent_dest_target` on `agent_destinations(target_type, target_id)` — reverse lookup (find agents that can send to this target) +- `idx_pending_approvals_action_status` on `pending_approvals(action, status)` — sweep query for pending/expired approvals + +--- + +## Prepared Queries & Helpers + +### v1 Helpers (src/v1/db.ts) + +``` +storeChatMetadata(jid, timestamp, name?, channel?, isGroup?) + — INSERT OR REPLACE into chats (ON CONFLICT upsert) + +storeMessage(NewMessage) +storeMessageDirect({id, chat_jid, sender, ...}) + — INSERT OR REPLACE into messages + +getNewMessages(jids[], lastTimestamp, botPrefix, limit=200) + — SELECT from messages, filter by jid list, timestamp > last, bot filter + — Returns {messages, newTimestamp} + +getMessagesSince(chatJid, sinceTimestamp, botPrefix, limit=200) + — SELECT from messages, filter by chat, timestamp > since, bot filter, ORDER DESC + outer sort + +getLastBotMessageTimestamp(chatJid, botPrefix) + — SELECT MAX(timestamp) from messages WHERE (is_bot_message=1 OR content LIKE prefix) + +createTask(ScheduledTask) / updateTask(id, fields) / getTaskById(id) / deleteTask(id) + — Standard CRUD + +getDueTasks() + — SELECT * WHERE status='active' AND next_run <= now + +updateTaskAfterRun(id, nextRun, lastResult) + — UPDATE task set next_run, last_run, last_result, status + +logTaskRun(TaskRunLog) + — INSERT into task_run_logs + +getRouterState(key) / setRouterState(key, value) + — KV table + +getSession(groupFolder) / setSession(groupFolder, sessionId) / deleteSession(groupFolder) + — Simple mapping + +getRegisteredGroup(jid) / setRegisteredGroup(jid, group) / getAllRegisteredGroups() + — CRUD on registered_groups +``` + +### v2 Helpers + +**Central DB (src/db/*.ts):** +- `createAgentGroup`, `getAgentGroup`, `getAgentGroupByFolder`, `updateAgentGroup`, `deleteAgentGroup` +- `createMessagingGroup`, `getMessagingGroup`, `getMessagingGroupByPlatform`, `updateMessagingGroup`, `deleteMessagingGroup` +- `createMessagingGroupAgent`, `getMessagingGroupAgents`, `getMessagingGroupAgentByPair`, `updateMessagingGroupAgent`, `deleteMessagingGroupAgent` +- `grantRole`, `revokeRole`, `getUserRoles`, `isOwner`, `isGlobalAdmin`, `isAdminOfAgentGroup`, `hasAdminPrivilege` +- `createUser`, `upsertUser`, `getUser`, `getAllUsers`, `updateDisplayName`, `deleteUser` +- `addMember`, `removeMember`, `getMembers`, `isMember` +- `upsertUserDm`, `getUserDm`, `getUserDmsForUser`, `deleteUserDm` +- `createSession`, `getSession`, `findSession`, `findSessionByAgentGroup`, `getSessionsByAgentGroup`, `getActiveSessions`, `getRunningSessions`, `updateSession`, `deleteSession` +- `createPendingQuestion`, `getPendingQuestion`, `deletePendingQuestion` +- `createPendingApproval`, `getPendingApproval`, `updatePendingApprovalStatus`, `deletePendingApproval`, `getPendingApprovalsByAction` + +**Session DB (src/db/session-db.ts):** +- `ensureSchema(dbPath, 'inbound'|'outbound')` — idempotent schema setup +- `openInboundDb(dbPath)`, `openOutboundDb(dbPath)` — safe open with pragmas +- `nextEvenSeq(db)` — helper for host seq assignment +- `insertMessage(db, {id, kind, timestamp, platformId, channelType, threadId, content, processAfter, recurrence})` +- `insertTask(db, {id, processAfter, recurrence, ...})` +- `cancelTask(db, taskId)`, `pauseTask(db, taskId)`, `resumeTask(db, taskId)` +- `upsertSessionRouting(db, {channel_type, platform_id, thread_id})` +- `replaceDestinations(db, entries: DestinationRow[])` + +--- + +## Key Invariants + +### v1 +- **Bot message filtering:** is_bot_message flag + content prefix as backstop (for pre-migration rows) +- **Cursor recovery:** getLastBotMessageTimestamp() to resume after stale downtime +- **Single writer:** Process that imports db.ts owns all writes; no IPC +- **Chat metadata immutability:** chats table updated only on metadata sync or first message, never deleted + +### v2 (Load-Bearing) + +1. **Single writer per file** — host writes central + inbound; container writes outbound only +2. **Seq parity invariant** — even in messages_in, odd in messages_out; parity disambiguates edit target +3. **Journal mode = DELETE on session DBs** — `DELETE` mode ensures cross-mount visibility (no WAL rollback issues) +4. **Foreign keys enforced** — central DB rejects orphans; schema_version tracks migrations +5. **Projection consistency** — `agent_destinations` (central) must be projected to `destinations` (session inbound) on every container wake; if wiring changes mid-session, must call `writeDestinations()` or container sees stale ACL +6. **Seq monotonicity** — no gaps, no reuse. `nextEvenSeq()` and container logic both scan MAX(seq) across both tables before assigning next +7. **Processing_ack as reverse channel** — container never writes to inbound.db; all status goes through outbound.db processing_ack, which host polls +8. **Heartbeat out of band** — `.heartbeat` file mtime is liveness signal, not a DB write; avoids serialization with message processing +9. **Admin at A implies membership in A** — invariant enforced in code (src/db/user-roles.ts, src/access.ts); no FK prevents deletion + +--- + +## Worth Preserving? + +**Yes — all v1 features are preserved or evolved:** +- Message history: v1 stores per-chat; v2 per-session. Content and metadata shapes mostly compatible. +- Scheduled tasks: v1 separate table; v2 unified into messages_in with kind='task'. Recurrence logic identical (cron). +- Bot filtering: v1 dual-check (flag + prefix); v2 single flag. Backstop logic removed (assumed migrated by now). +- Reply context: All v1 columns kept; v2 schema inherited. + +**What's gone and why:** +- `task_run_logs` — v2 doesn't persist execution history; logging is operational only. +- `router_state` — v1 polling cursors; v2 implicit in message queuing. +- Single-bot assumption — v2 is multi-tenant; this is a feature, not a loss. + +**Migration path:** v1 `src/v1/db-migration.test.ts` shows the pattern: create legacy table, init v2 schema, backfill. Migration 004 does this for agent_destinations (backfill from messaging_group_agents wirings). \ No newline at end of file diff --git a/docs/v1-vs-v2/env.md b/docs/v1-vs-v2/env.md new file mode 100644 index 0000000..560362c --- /dev/null +++ b/docs/v1-vs-v2/env.md @@ -0,0 +1,38 @@ +# env: v1 vs v2 + +## Scope +- v1: `src/v1/env.ts` (42 LOC), `src/v1/config.ts` (63 LOC) +- v2 counterparts: `src/env.ts` (identical), `src/config.ts` (identical structure); plus new consumers `src/webhook-server.ts`, `src/log.ts`, `src/container-runner.ts`, `container/build.sh`, `container/agent-runner/src/index.ts` + +## Capability map + +| v1 behavior | v2 location | Status | Notes | +|---|---|---|---| +| `readEnvFile(keys)` | `src/env.ts:11-42` | kept | Identical — reads `.env` without polluting `process.env` | +| `ASSISTANT_NAME` / `ASSISTANT_HAS_OWN_NUMBER` | `src/config.ts:8-12` | kept | Same read order: process.env → .env → default | +| `ONECLI_URL` | `src/config.ts:30` | kept | Used host-side + container-side | +| `TZ` + `isValidTimezone` guard | `src/config.ts:56-62` | kept | Passes to containers | +| `CONTAINER_IMAGE` / `CONTAINER_TIMEOUT` / `CONTAINER_MAX_OUTPUT_SIZE` | `src/config.ts:27-29` | kept | Same defaults | +| `MAX_MESSAGES_PER_PROMPT` | `src/config.ts:31` | kept | **Unused in v2** | +| `IDLE_TIMEOUT` | `src/config.ts:33` | kept | Used by container heartbeat model | +| `MAX_CONCURRENT_CONTAINERS` | `src/config.ts:34` | kept | Enforced in `container-runner.ts` | +| `POLL_INTERVAL` / `SCHEDULER_POLL_INTERVAL` / `IPC_POLL_INTERVAL` | `src/config.ts:13-32` | **dead code** | Defined but not imported anywhere in v2 runtime | +| `MOUNT_ALLOWLIST_PATH` / `SENDER_ALLOWLIST_PATH` | `src/config.ts:21-22` | kept | SENDER_ALLOWLIST_PATH unused (model replaced by `user_roles`) | +| `STORE_DIR` / `GROUPS_DIR` / `DATA_DIR` | `src/config.ts:23-25` | kept | `DATA_DIR` now hosts `v2.db` + `v2-sessions//*` | +| `buildTriggerPattern` / `getTriggerPattern` / `TRIGGER_PATTERN` / `DEFAULT_TRIGGER` | `src/config.ts:40-51` | kept | Used sparingly; trigger model largely DB-driven now | +| Container env injection via stdin JSON | `src/container-runner.ts:266-338` | **changed** | Replaced with `docker run -e`. New vars: `SESSION_INBOUND_DB_PATH`, `SESSION_OUTBOUND_DB_PATH`, `SESSION_HEARTBEAT_PATH`, `AGENT_PROVIDER`, `NANOCLAW_AGENT_GROUP_ID`, `NANOCLAW_AGENT_GROUP_NAME`, `NANOCLAW_MCP_SERVERS`, `NANOCLAW_ADMIN_USER_IDS` | +| `INSTALL_CJK_FONTS` | `container/build.sh:18-26`, `container/Dockerfile:13` | **new in v2** | Build-time arg, not runtime env | +| `WEBHOOK_PORT` (default 3000) | `src/webhook-server.ts:82` | **new in v2** | | +| `LOG_LEVEL` | `src/log.ts:16` | **new in v2** | | + +## Missing from v2 +Nothing user-facing. Container-only vars (`SESSION_*_DB_PATH`, `AGENT_PROVIDER`, `NANOCLAW_*`) are dynamic per-session and never belong in `.env`. + +## Behavioral discrepancies +1. **Dead constants**: `POLL_INTERVAL`, `SCHEDULER_POLL_INTERVAL`, `IPC_POLL_INTERVAL` remain in `src/config.ts` but are not imported by any v2 runtime code — safe to delete +2. **Container transport**: v1 piped config via stdin JSON; v2 injects via `-e` at spawn +3. **Build-time vs runtime**: `INSTALL_CJK_FONTS` is a Dockerfile build-arg, not a process env var +4. **Output markers**: v1's `---NANOCLAW_OUTPUT_START/END---` stdout markers are gone — v2 reads from `messages_out` table + +## Worth preserving? +Dead constants (`POLL_INTERVAL`, `SCHEDULER_POLL_INTERVAL`, `IPC_POLL_INTERVAL`) should be **removed** from `src/config.ts` — they're confusing carry-overs. Everything else is either actively used or deliberately dynamic. The `.env`-based config surface is byte-identical and correct to keep. diff --git a/docs/v1-vs-v2/formatting-test.md b/docs/v1-vs-v2/formatting-test.md new file mode 100644 index 0000000..c9be286 --- /dev/null +++ b/docs/v1-vs-v2/formatting-test.md @@ -0,0 +1,154 @@ +# formatting (test-only) : v1 vs v2 + +## Scope + +- **v1**: `/Users/gavriel/nanoclaw4/src/v1/formatting.test.ts` (316 lines) +- **v1 production sibling**: `/Users/gavriel/nanoclaw4/src/v1/router.ts` (43 lines) — `escapeXml()`, `formatMessages()`, `stripInternalTags()`, `formatOutbound()`, plus `/Users/gavriel/nanoclaw4/src/v1/config.ts` (63 lines) — `getTriggerPattern()`, `TRIGGER_PATTERN`, `buildTriggerPattern()`, `DEFAULT_TRIGGER` +- **v2 counterparts**: + - Inbound message formatting: `/Users/gavriel/nanoclaw4/container/agent-runner/src/formatter.ts` (228 lines) — `formatMessages()`, `categorizeMessage()`, `extractRouting()` + - Outbound tag stripping: embedded in container delivery logic + - Trigger patterns: moved to DB model (`messaging_group_agents.trigger_rules` JSON) — no code-level function + - v2 tests: `/Users/gavriel/nanoclaw4/container/agent-runner/src/poll-loop.test.ts:26–84` (formatter section only) + +--- + +## Test-case map + +| v1 Test Case | v2 Formatter Handling | Status | Notes | +|---|---|---|---| +| **escapeXml: ampersands** (src/v1/formatting.test.ts:22–23) | `/container/agent-runner/src/formatter.ts:225` `escapeXml()` with `&` → `&` | ✅ Preserved | Both use identical regex replacement. V2 escaping is used in `formatSingleChat()` for sender, time, text. | +| **escapeXml: less-than** (test:26–27) | `formatter.ts:225` `escapeXml()` with `<` → `<` | ✅ Preserved | Used in XML attributes and content. | +| **escapeXml: greater-than** (test:30–31) | `formatter.ts:225` with `>` → `>` | ✅ Preserved | Same. | +| **escapeXml: double quotes** (test:34–35) | `formatter.ts:225` with `"` → `"` | ✅ Preserved | Same. | +| **escapeXml: multiple special characters** (test:38–39) | `formatter.ts:225` (regex composition) | ✅ Preserved | Single pass through all four replacements. | +| **escapeXml: passthrough clean text** (test:42–43) | `formatter.ts:225` (no-op if no specials) | ✅ Preserved | Same. | +| **escapeXml: empty string** (test:46–47) | `formatter.ts:225` (no-op on empty) | ✅ Preserved | Same. | +| **formatMessages: single message with context header & time** (test:56–62) | `/container/agent-runner/src/formatter.ts:124–158` `formatChatMessages()` & `formatSingleChat()` | ⚠️ Changed | v1 formats as `\n...\n` with full timestamp in US locale. v2 uses `...` with 24-hour time only. No context header. | +| **formatMessages: multiple messages** (test:64–84) | `formatter.ts:124–134` (batch wrapping in `` tag) | ⚠️ Changed | v2 wraps multiple chat messages in `` tags but structure differs: no timezone attribute, different time format, `from` attribute added. | +| **formatMessages: escape sender names** (test:86–88) | `formatter.ts:157` `sender="${escapeXml(sender)}"` | ✅ Preserved | Same escaping strategy. | +| **formatMessages: escape content** (test:91–93) | `formatter.ts:157` `${escapeXml(text)}` | ✅ Preserved | Same. | +| **formatMessages: empty array** (test:96–99) | `formatter.ts:98` — returns empty string if no messages | ❌ Incompatible | v1 returns `\n\n\n` even for empty. v2 returns empty string. Different expected output. | +| **formatMessages: reply context (quoted_message)** (test:102–116) | `formatter.ts:143, 183–188` `formatReplyContext()` | ⚠️ Changed | v1 renders `reply_to="42"` attribute + `text` child. v2 renders as `preview` without message ID attribute. | +| **formatMessages: omit reply when absent** (test:119–122) | `formatter.ts:183` (conditional) | ✅ Preserved | Both check for presence before rendering. | +| **formatMessages: omit quoted_message when content missing** (test:125–136) | `formatter.ts:184` (check `replyTo.text`) | ✅ Preserved | Both guard against missing content. | +| **formatMessages: escape reply context** (test:139–151) | `formatter.ts:188` `escapeXml()` on sender and text | ✅ Preserved | Same escaping applied. | +| **formatMessages: timezone conversion** (test:154–160) | `formatter.ts:216–223` `formatTime()` — HH:MM UTC only | ❌ Incompatible | v1 uses `formatLocalTime()` (full locale string with date, month, am/pm) from `timezone.ts:26–37`. v2 uses 24-hour `HH:MM` UTC only; no timezone localization. | +| **TRIGGER_PATTERN: matches @name at start** (test:170–171) | No v2 code equivalent | ❌ Not in v2 | v2 moved trigger rules to DB; no regex pattern in code. Router evaluates `messaging_group_agents.trigger_rules` JSON. | +| **TRIGGER_PATTERN: case-insensitive** (test:174–176) | DB model (applied at runtime by router) | ❌ Not in v2 | Same behavior (case-insensitive in router) but no test coverage for trigger logic in v2. | +| **TRIGGER_PATTERN: word boundary checks** (test:179–192) | DB model (router enforces) | ❌ Not in v2 | Router evaluates trigger rules; no unit tests for pattern matching. | +| **getTriggerPattern: custom per-group trigger** (test:201–206) | `/src/router.ts` evaluates `messaging_group_agents.trigger_rules` at delivery time | ❌ Not tested in v2 | v2 has no unit test for custom trigger selection. Behavior preserved in router but untested. | +| **getTriggerPattern: regex characters literal** (test:215–219) | DB-stored rule (router uses literal match or regex) | ❌ Not tested | v2 stores trigger as string in DB; runtime evaluation depends on router implementation (not inspected here). | +| **stripInternalTags: single-line** (test:226–227) | No direct v2 function — embedded in polling | ❌ Not isolated | v1 regex `/[\s\S]*?<\/internal>/g` with `.trim()`. v2 container poll-loop does not test this; no dedicated outbound function in v2 agent-runner. | +| **stripInternalTags: multi-line** (test:230–231) | Not tested in v2 | ❌ Not isolated | v1 regex handles `[\s\S]*?` (newlines included). | +| **stripInternalTags: multiple blocks** (test:234–235) | Not tested in v2 | ❌ Not isolated | Regex global flag `/g` handles multiple. Not verified in v2 tests. | +| **stripInternalTags: only internal tags** (test:238–239) | Not tested in v2 | ❌ Not isolated | v1 returns empty after trim; behavior not verified in v2. | +| **formatOutbound: passthrough clean text** (test:244–245) | Not tested in v2 | ❌ Not isolated | v1 calls `stripInternalTags()` then returns. v2 does not have isolated test. | +| **formatOutbound: empty after strip** (test:248–249) | Not tested in v2 | ❌ Not isolated | v1 returns empty if all was internal. | +| **formatOutbound: strip tags from text** (test:252–253) | Not tested in v2 | ❌ Not isolated | v1 example: `thinkingThe answer is 42` → `The answer is 42`. | +| **trigger gating: main group always processes** (test:277–279) | No unit test in v2; logic in `/src/router.ts` routing decision | ❌ Not tested | v1 shows that main groups bypass trigger check. Behavior likely preserved (main group always forwards to agent) but not verified by test. | +| **trigger gating: main group ignores requiresTrigger flag** (test:282–284) | Not tested in v2 | ❌ Not tested | v1 shows `isMainGroup=true` overrides `requiresTrigger` flag. No v2 test. | +| **trigger gating: non-main needs trigger (default)** (test:287–289) | Not tested in v2 | ❌ Not tested | v1 default behavior: non-main group requires trigger unless explicitly disabled. | +| **trigger gating: custom per-group trigger enforcement** (test:302–309) | Not tested in v2 | ❌ Not tested | v1 shows per-group trigger override. Behavior in v2 DB but no test. | +| **trigger gating: requiresTrigger=false disables check** (test:312–314) | Not tested in v2 | ❌ Not tested | v1 allows opting out of trigger requirement per group. | + +--- + +## Missing from v2 + +1. **Timezone-aware time formatting** + - v1: `formatLocalTime(utcIso, timezone)` in `src/v1/timezone.ts:26–37` converts UTC ISO timestamp to user's local timezone with full locale formatting (date, month, am/pm). + - v2: `formatTime()` in `container/agent-runner/src/formatter.ts:216–223` only extracts `HH:MM` in UTC, no localization. + - **Impact**: v2 loses per-agent timezone context. Timestamps appear in UTC only, potentially confusing users in different timezones. + +2. **Context header with timezone attribute** + - v1: Every message batch includes `` header. + - v2: No context header; timestamp is a message attribute only. + - **Impact**: Agent sees no explicit timezone declaration; must infer from message times or system prompt. + +3. **Reply context with message ID attribute** + - v1: `reply_to=""` attribute on message; separate `content` child. + - v2: Consolidated into `preview` without message ID; preview truncated to 100 chars. + - **Impact**: v2 loses structured reply tracking; agent can't reference specific message IDs in follow-ups. + +4. **Message ID sequence in XML** + - v1: No `id` attribute on messages (WhatsApp-era design). + - v2: Each message has `id="seq"` (database sequence number). + - **Impact**: Allows agent to reference messages by ID, but v1 tests do not verify this. + +5. **Trigger pattern unit tests** + - v1: Comprehensive tests for `getTriggerPattern()`, `TRIGGER_PATTERN`, case-insensitivity, word boundaries, regex escaping. + - v2: No unit tests; trigger logic moved to DB and router. Untested. + - **Impact**: Trigger matching behavior not verified by tests; regression risk if router changes. + +6. **Internal tag stripping tests** + - v1: `stripInternalTags()` and `formatOutbound()` tested for single-line, multi-line, multiple blocks, edge cases. + - v2: No isolated tests for outbound tag stripping. + - **Impact**: No verification that internal tags are reliably removed before delivery. + +7. **Trigger gating (requiresTrigger flag) tests** + - v1: Detailed tests of main-group bypass, per-group override, default behavior, flag combinations. + - v2: No tests; logic moved to DB schema and router evaluation. + - **Impact**: Trigger enforcement behavior not verified. + +8. **Empty message batch handling** + - v1: Explicitly returns `\n\n\n` for empty array. + - v2: Returns empty string. + - **Impact**: No clear protocol for "no messages to process" signals. + +--- + +## Behavioral discrepancies + +### 1. Message XML structure (formatMessages) +- **v1**: `\n\ncontent\n` +- **v2**: `content` (no wrapper for single message) +- **v1 line**: `src/v1/router.ts:9–23` +- **v2 line**: `container/agent-runner/src/formatter.ts:124–158` + +### 2. Time formatting +- **v1**: Full locale string (e.g., "Jan 1, 2024, 1:30 PM") using `Intl.DateTimeFormat` with timezone localization (`src/v1/timezone.ts:26–37`) +- **v2**: 24-hour UTC only (e.g., "13:30") without timezone info (`container/agent-runner/src/formatter.ts:216–223`) +- **Impact**: v2 loses timezone awareness; agent cannot distinguish between user's local time and server time. + +### 3. Reply context structure +- **v1**: Two-part — `reply_to=""` attribute + `text` child element +- **v2**: Single element — `100-char preview` (no ID, preview truncated) +- **v1 line**: `src/v1/router.ts:12–16` +- **v2 line**: `container/agent-runner/src/formatter.ts:143, 183–188` +- **Impact**: v2 cannot support message-ID-based threading; loses structured reply metadata. + +### 4. Trigger pattern matching +- **v1**: Implemented as regex returned by `getTriggerPattern()` with word-boundary enforcement (`config.ts:40–49`) +- **v2**: Stored in DB as JSON in `messaging_group_agents.trigger_rules`; evaluated by router at delivery time +- **v1 line**: `src/v1/config.ts:40–49` +- **v2 line**: `/src/router.ts` (router logic, not inspected in detail here) +- **Impact**: v1 enforces word boundaries via regex (`\b`); v2 implementation unknown (DB-driven). + +### 5. Empty message handling +- **v1**: Returns `\n\n\n` — preserves structure +- **v2**: Returns empty string +- **v1 line**: `src/v1/router.ts:22` +- **v2 line**: `container/agent-runner/src/formatter.ts:98` + +### 6. Internal tag stripping +- **v1**: Regex-based, `.trim()` called after removal +- **v2**: Not isolated; no dedicated function or test in v2 formatter +- **v1 line**: `src/v1/router.ts:25–26` +- **v2 line**: No equivalent + +--- + +## Worth preserving? + +**Partially.** The v1 formatting test suite is **essential for documenting lost functionality**, not for v2 regression. Key behaviors that should be preserved in v2 but are currently missing: + +1. **Timezone-aware message timestamps** — v2 should restore `formatLocalTime()` from `src/v1/timezone.ts` and include timezone context in the XML header. Without this, agents cannot reason about when messages arrived relative to the user's clock. + +2. **Reply context with message IDs** — v2's truncated reply preview is lossy. Consider restoring the `reply_to=""` attribute so agents can reference prior messages by sequence number for structured threading. + +3. **Trigger pattern unit tests** — v2 moved trigger logic to the DB but lost test coverage. The DB schema and router must enforce the same invariants (word boundaries, case-insensitivity, custom per-group overrides) that v1 tested. Recommend adding integration tests to `src/router.ts` or `src/channels/adapter.ts` to verify trigger matching. + +4. **Internal tag stripping tests** — v2 agent-runner should include unit tests for `stripInternalTags()` (if the skill applies) to prevent regression when Claude adds `` thinking tags. + +The v1 test file serves as a **specification document** for channel formatting and trigger gating that v2 partially refactored away. Keeping it in the repo (even unpowered) documents the intended semantics. + diff --git a/docs/v1-vs-v2/group-folder.md b/docs/v1-vs-v2/group-folder.md new file mode 100644 index 0000000..bf0d890 --- /dev/null +++ b/docs/v1-vs-v2/group-folder.md @@ -0,0 +1,38 @@ +# group-folder: v1 vs v2 + +## Scope +- v1: `src/v1/group-folder.ts` (45 LOC), `group-folder.test.ts` (35 LOC) — validation + path resolution only +- v2 counterparts: + - `src/group-folder.ts` (45 LOC) — byte-identical to v1 + - `src/group-init.ts` (128 LOC) — **new** filesystem bootstrap + - `src/container-config.ts` (115 LOC) — **new** per-group container.json management + - `src/group-folder.test.ts` (35 LOC) — identical to v1 + +## Capability map + +| v1 behavior | v2 location | Status | Notes | +|---|---|---|---| +| `GROUP_FOLDER_PATTERN` (alphanumeric + `-` + `_`, 1-64) | `group-folder.ts:5-6` | identical | | +| Reserved folder `global` | `group-folder.ts:6` | identical | `RESERVED_FOLDERS` set | +| `isValidGroupFolder()` (reject empty, whitespace, traversal, absolute) | `group-folder.ts:8-16` | identical | | +| `assertValidGroupFolder()` | `group-folder.ts:18-22` | identical | | +| `resolveGroupFolderPath()` + `ensureWithinBase()` | `group-folder.ts:31-36` | identical | | +| `resolveGroupIpcPath()` (resolves `data/ipc/`) | `group-folder.ts:38-44` | kept | IPC dir is legacy — no longer used since v2 moved to session DBs | +| Filesystem scaffold (CLAUDE.md, skills, overlays) | — | **new in v2** | `group-init.ts:48-127` | +| Global memory symlink (`.claude-global.md` → `/workspace/global/CLAUDE.md`) | `group-init.ts:55-70` | **new** | Uses `lstat` to detect dangling symlinks | +| Per-group `container.json` init | `group-init.ts:83-85` + `container-config.ts:109-114` | **new** | Graceful fallback on corruption | +| `.claude-shared` session dir | `group-init.ts:87-92` | **new** | Under `data/v2-sessions//` | +| `settings.json` with `CLAUDE_CODE_*` flags | `group-init.ts:94-98` | **new** | | +| Recursive skill copy from `container/skills/` | `group-init.ts:100-107` | **new** | | +| Per-group agent-runner src overlay copy | `group-init.ts:109-117` | **new** | | +| Idempotent init (every step gates on `fs.existsSync()`) | `group-init.ts:44-127` | **new** | Safe to re-run | +| Step logging via `log.info()` | `group-init.ts:119-126` | **new** | | + +## Missing from v2 +None. All v1 validation/resolution behavior is preserved byte-for-byte. + +## Behavioral discrepancies +None on the validation layer. v2 adds the filesystem-scaffold layer as a separate module (`group-init.ts`) so validation stays pure. + +## Worth preserving? +Clean split — keep as-is. `group-folder.ts` = names + paths; `group-init.ts` = file creation. Both modules are small and single-purpose. diff --git a/docs/v1-vs-v2/group-queue.md b/docs/v1-vs-v2/group-queue.md new file mode 100644 index 0000000..da21d03 --- /dev/null +++ b/docs/v1-vs-v2/group-queue.md @@ -0,0 +1,48 @@ +# group-queue: v1 vs v2 + +## Scope +- v1: `src/v1/group-queue.ts` (325 LOC), `group-queue.test.ts` (457 LOC) — in-memory per-group state machine, IPC-file dispatch +- v2: **no equivalent class**. Serialization is now DB-based and distributed across `src/session-manager.ts`, `src/host-sweep.ts`, `src/container-runner.ts`, `src/delivery.ts` + +## Capability map + +| v1 behavior | v2 location | Status | Notes | +|---|---|---|---| +| Per-group message queue | `inbound.db.messages_in` + `status='pending'` | replaced | Atomic status transitions serialize work per-session | +| Per-group task queue | `inbound.db.messages_in` with `kind='task'` | replaced | Same table; `kind` discriminates | +| `MAX_CONCURRENT_CONTAINERS` global cap | `container-runner.ts:42-52` `activeContainers` Map + `wakeContainer` dedup | kept | Enforced at spawn | +| One container per group invariant | One container per **session** | redefined | Session is identity unit now | +| Task-before-message priority (`drainGroup`) | `host-sweep.ts` recurrence + `delivery.ts` active poll | **partially lost** | No priority; polled by `process_after` timestamp ordering | +| Exponential retry backoff | `host-sweep.ts:145-147` `BACKOFF_BASE_MS * 2^tries` | kept | Max 5 tries, same shape | +| Idle preemption (`notifyIdle`/`closeStdin`) | heartbeat file mtime | **removed** | No interrupt signal — container polls continuously | +| Message dispatch to active container (`sendMessage`) | Write to `messages_in` table | replaced | Host writes; container polls | +| Cascading drain on task arrival | `delivery.ts` (~1s) + `host-sweep.ts` (~60s) polls | **async-ized** | Work discovery on next tick, not synchronous | +| Shutdown without kill | containers continue under `--rm` | similar | Host shutdown does not stop containers | +| Task dedup (`pendingTasks.some(t => t.id === id)`) | PK on `messages_in.id` | partial | Unique ID prevents DB duplicates; does not prevent two distinct rows with same series_id | +| `drainWaiting` (waiting-group fairness) | Implicit: any session can wake if slot free | async | No explicit fairness | + +## Serialization model diff +**v1 (push-based):** `GroupState` in memory per group: `active`, `pendingMessages`, `pendingTasks`, `idleWaiting`, `runningTaskId`. `drainGroup()` synchronously dispatches. IPC file write signals container readiness. State lost on restart. + +**v2 (pull-based via DB):** `messages_in.status` is the queue (`pending` → `processing` → `completed`/`failed`). Host writes rows + calls `wakeContainer()`; container polls + atomic UPDATE to take work. One writer per DB file (host→inbound, container→outbound) eliminates cross-mount contention. Heartbeat file mtime replaces IPC for liveness. State persisted; survives crashes. + +## Missing from v2 +1. **Idle-state preemption** — v1 could interrupt an idle container on task arrival via `closeStdin`. v2 has no interrupt; container finishes current work and polls again +2. **Synchronous drain cascade** — v1's `drainGroup` immediately ran the next item; v2 discovers it on the next poll tick (~1s active, ~60s sweep) +3. **In-memory task dedup** — v1 checked pending-task list before enqueue. v2 can have two task rows with the same series_id coexisting (both pending) — relies on atomic `status` update for single-execution, best-effort +4. **Priority ordering** — v1 tasks preempted messages; v2 is timestamp-ordered only + +## Behavioral discrepancies +| Aspect | v1 | v2 | +|---|----|----| +| Wake trigger | on enqueue (sync) | on `wakeContainer()` call, or poll finding due message | +| Idle timeout | implicit via IPC | explicit heartbeat mtime (10 min) | +| Task ordering | FIFO within group, tasks preempt messages | `process_after` timestamp; ties by insert seq | +| Retry | host `scheduleRetry()` | host sweep detects stale, increments `tries`, sets backoff | +| Concurrency cap | same | same (enforced in `spawnContainer` dedup) | + +## Worth preserving? +1. **Explicit task dedup** — add `(kind, series_id, session_id)` unique index on `messages_in`, or dedup in `host-sweep.ts` before inserting retry rows. Currently best-effort via atomic status update +2. **Priority ordering** — add a `priority` column or document the ~1s task-wake latency as the SLA +3. **Idle preemption** — not critical; 1s polling is acceptable for most workflows +4. **Fairness** — v1's `drainWaiting` ensured no group starved. v2 is fair by timestamp but untested under concurrent load. Monitor in production diff --git a/docs/v1-vs-v2/index-host.md b/docs/v1-vs-v2/index-host.md new file mode 100644 index 0000000..277daf4 --- /dev/null +++ b/docs/v1-vs-v2/index-host.md @@ -0,0 +1,70 @@ +# host index: v1 vs v2 + +## Scope +- v1: `src/v1/index.ts` (647 LOC) — monolithic entry: config, DB, state, channels, queues, scheduler, IPC watcher, message loop +- v2: `src/index.ts` (345 LOC) — lean entry: DB+migrations, channels, delivery/sweep polls, OneCLI handler + +## Startup sequence diff + +| # | v1 step | v2 step | Status | +|---|---------|---------|--------| +| 1 | `ensureContainerRuntimeRunning()` + `cleanupOrphans()` | same | kept | +| 2 | `initDatabase()` | `initDb()` + `runMigrations()` | enhanced (explicit migrations) | +| 3 | `loadState()` — cursor, groups, agent timestamps | — | removed (no global state) | +| 4 | OneCLI `ensureAgent` per group | — | removed (now per-wake in `container-runner.ts`) | +| 5 | `restoreRemoteControl()` | — | removed | +| 6 | SIGTERM/SIGINT handlers | same | kept | +| 7 | `handleRemoteControl` bind | — | removed | +| 8 | Channel options + callbacks | `initChannelAdapters()` | rewritten (adapter API) | +| 9 | Channel discovery + connection | absorbed into adapters | — | +| 10 | `startSchedulerLoop()` | — | removed (folded into `startHostSweep`) | +| 11 | `startIpcWatcher()` | — | removed (no IPC in v2) | +| 12 | `startSessionCleanup()` | — | removed (folded into `startHostSweep`) | +| 13 | `queue.setProcessMessagesFn()` | — | removed (GroupQueue gone) | +| 14 | `recoverPendingMessages()` | — | **removed** (implicit in sweep) | +| 15 | `startMessageLoop()` (polling) | `startActiveDeliveryPoll()` + `startSweepDeliveryPoll()` | **fundamentally changed** (event-driven) | +| 16 | — | `startHostSweep()` | **new** | +| 17 | — | `startOneCLIApprovalHandler()` | **new** | + +## Capability map + +| v1 behavior | v2 location | Status | Notes | +|---|---|---|---| +| Arg/env parsing | `src/config.ts` (shared) | kept | | +| Central DB init | `src/index.ts:47-50` | kept | + `runMigrations()` | +| Container runtime bring-up | `src/index.ts:52-54` | kept | identical | +| Global cursor + timestamps state | — | **removed** | v2 session-scoped state in outbound.db | +| Periodic message polling loop | — | **removed** | Replaced by event-driven delivery + 60s sweep | +| OneCLI group-wide sync at startup | — | **removed** | Per-wake in `container-runner.ts:303` | +| Remote control subsystem | — | **removed** | No equivalent — feature deferred | +| Group message queue (`GroupQueue`) | — | **removed** | DB-based serialization | +| Channel adapter array + callbacks | `src/channels/channel-registry.ts` | refactored | `ChannelAdapter` interface | +| Pending message recovery on startup | — | **removed** | Sweep detects stale containers + resets messages | +| IPC watcher (dynamic group add) | — | **removed** | Static topology at startup; restart to add groups | +| Signal handlers | `src/index.ts:339-340` | kept | Simplified teardown | +| Top-level error handling | `src/index.ts:342-345` | kept | Same fatal exit | + +## Missing from v2 +1. **Polling message loop** (v1:370-459) — replaced by event-driven + sweep (net improvement) +2. **GroupQueue state machine** — now DB-based +3. **Cross-restart cursor state** — no `lastAgentTimestamp` persisted; recovery implicit via DB scan +4. **Remote control** — gone +5. **Explicit `recoverPendingMessages()`** — implicit in sweep; worth verifying via post-crash test +6. **IPC watcher** (`startIpcWatcher`) — cannot add groups dynamically; restart required +7. **Scheduler loop** — merged into sweep's due-message wake + +## Behavioral discrepancies +| Aspect | v1 | v2 | +|---|----|----| +| Startup time | ~500ms (long loop init) | ~200ms | +| Message fetch | polling every POLL_INTERVAL | event-driven callbacks + 1s delivery poll | +| Container spawn | on-demand via GroupQueue | per-message wake via router/sweep | +| Group topology | dynamic (IPC watcher) | static at startup | +| Error recovery | per-message cursor rollback | implicit via stale detection | +| Shutdown | GroupQueue 10s grace then disconnect | stop handlers/polls/sweep/adapters in order | + +## Worth preserving? +1. **Polling loop**: No — event-driven is superior. Verify delivery poll latency regression vs old POLL_INTERVAL under load +2. **Pending-message recovery**: Worth explicit restoration — kill a container mid-message, restart host, verify re-delivery within ≤5s. If sweep doesn't cover this, add startup-phase scan +3. **Remote control**: Unknown — either restore as opt-in skill or document removal +4. **Dynamic group add (IPC watcher)**: Probably not worth — modern flow is "admin skill adds group to DB, restart". But document that restart is required diff --git a/docs/v1-vs-v2/ipc.md b/docs/v1-vs-v2/ipc.md new file mode 100644 index 0000000..10c643f --- /dev/null +++ b/docs/v1-vs-v2/ipc.md @@ -0,0 +1,240 @@ +# IPC: v1 vs v2 + +## Scope + +### v1 +- **Host side:** `/Users/gavriel/nanoclaw4/src/v1/ipc.ts` (127 lines) — file-system watcher, task authorization, message routing +- **Auth/handshake tests:** `/Users/gavriel/nanoclaw4/src/v1/ipc-auth.test.ts` (614 lines) — authorization gates, schedule types, cron validation +- **Container side:** `/Users/gavriel/nanoclaw4/container/agent-runner/src/v1/ipc-mcp-stdio.ts` (509 lines) — MCP server over stdio, file-based message writes +- **Total v1 codebase:** ~1,250 lines (v1/ subtree) + +### v2 counterparts +This is not a file-for-file mapping. The entire IPC abstraction layer has been replaced with SQLite DBs: + +- **Host delivery/routing:** `/Users/gavriel/nanoclaw4/src/delivery.ts` (912 lines) — polls outbound.db, delivers, handles system actions +- **Host sweep/recurrence:** `/Users/gavriel/nanoclaw4/src/host-sweep.ts` (174 lines) — 60s maintenance, stale detection via heartbeat, processing_ack sync +- **Session setup/DB:** `/Users/gavriel/nanoclaw4/src/session-manager.ts` (361 lines) — DB paths, folder init, destinations + routing writes +- **Container poll loop:** `/Users/gavriel/nanoclaw4/container/agent-runner/src/poll-loop.ts` (200+ lines) — fetches messages_in, marks status in processing_ack +- **Container destinations:** `/Users/gavriel/nanoclaw4/container/agent-runner/src/destinations.ts` (118 lines) — reads inbound.db's destinations table live +- **DB layer (host):** `src/db/session-db.ts` — insertMessage, getDueOutboundMessages, markDelivered, syncProcessingAcks, etc. +- **DB layer (container):** `container/agent-runner/src/db/{messages-in,messages-out,session-state,connection}.ts` +- **Schema:** `/Users/gavriel/nanoclaw4/docs/db-session.md` (184 lines) — definitive per-session DB layout + +--- + +## Paradigm shift + +**v1: IPC as explicit message files + stdio tunnel + MCP server** + +In v1, the host spawned an MCP server inside each container's stdio. The container's `ipc-mcp-stdio.ts` exposed tools (`send_message`, `schedule_task`, `register_group`, etc.) by writing JSON files to the host's `data/ipc/{groupFolder}/{messages|tasks}/` directory. The host's `ipc.ts` file-watcher scanned these directories every `IPC_POLL_INTERVAL` (~1s), parsed the JSON, applied authorization gates (isMain? folder-match?), executed side effects (DB writes, group registration), and deleted the files. Ordering, atomicity, and backpressure were implicit in the filesystem. + +**v2: Everything is a message in two persistent DBs** + +The IPC abstraction has been *entirely removed*. All host↔container communication now flows through two SQLite files per session: +- **inbound.db** (host writes, container reads): `messages_in` for inbound chat/tasks, `destinations` for the routing map, `session_routing` for default reply channel +- **outbound.db** (container writes, host reads): `messages_out` for agent responses, `processing_ack` for status acks, `session_state` for continuation storage + +There is no MCP server inside the container that exposes system tools. Instead: +- **Container side** calls `writeMessageOut()` directly, writing a JSON `content` blob with `action="schedule_task"` (or similar) into the `messages_out` table. +- **Host side** polls `getDueOutboundMessages()` from outbound.db, deserializes the `content`, and in `handleSystemAction()` interprets the action, validates it, and applies it directly to inbound.db (no IPC file write). + +The single-writer-per-file invariant (host writes inbound.db, container writes outbound.db) replaces the file-system locking and atomic rename semantics. + +**Key ownership shift:** +- v1: Container owned the "request to do something" (file write). Host decided whether to act (authorization on read). +- v2: Host owns the "task is pending" state (messages_in row). Container marks its progress (processing_ack). Host syncs status, detects stale containers, and triggers recurrence. + +--- + +## Capability map + +| v1 IPC Behavior | v2 Equivalent | Status | Notes | +|---|---|---|---| +| **Handshake / auth** | Database schema + envelope ID | ✓ Functional but different | v1: read `isMain` env var at startup, gate each IPC op. v2: host resolves session once, container reads `destinations` table on every query. No per-message auth envelope. | +| **Message framing** | JSON in files (atomic rename) | ✓ Replaced with DB schema | v1: `writeIpcFile()` temp-then-rename. v2: `better-sqlite3` with `journal_mode=DELETE` + open-close-per-op for cross-mount visibility. | +| **Transport (pipes/sockets)** | SQLite on FUSE mount | ✓ Completely different | v1: filesystem watching (no network). v2: cross-mount DB access (requires `journal_mode=DELETE` pragma, see session-manager.ts:9–11). | +| **Message types** | `kind` field in messages_in/out | ✓ Expanded | v1: message/task files. v2: `kind=chat|task|system|...` in DB rows, content shape in [api-details.md](../api-details.md). | +| **Auth / authorization gates** | Host-side permission checks in delivery.ts | ◐ Simplified but different | v1: checked per file (isMain flag, folder-match). v2: admin perms checked at container startup (adminUserIds set in poll-loop.ts:22–33), destination ACL in agent_destinations table, delivery.ts enforces on send. No per-message envelope. | +| **Handshake semantics** | None (session exists at startup) | ✗ Removed | v1: env vars set identity at container boot. v2: session_id/agent_group_id is stable DB fixture; container learns routing from `session_routing` table. No negotiation. | +| **Backpressure / flow control** | Implicit (filesystem poll interval) | ◐ Different model | v1: host polls files at 1s intervals; if processing is slow, files pile up. v2: messages_in rows sit with `status='pending'` until container marks `processing_ack='processing'`, then host polls and syncs status. Host can enforce delivery retry budget (MAX_DELIVERY_ATTEMPTS=3 in delivery.ts:58). | +| **Keepalives / timeouts** | No explicit mechanism | ✓ Heartbeat file replaces | v1: IPC files served as implicit liveness. v2: container touches `.heartbeat` file (mtime tracked by host). Host uses heartbeat staleness (10min threshold in host-sweep.ts:32) to detect crash and reset stuck messages. | +| **Ordering / seq parity** | Implicit filename order (timestamp+random) | ✓ Enforced | v1: files had timestamps but no formal ordering. v2: `seq` is monotonic per session, even←host / odd←container (see db-session.md §3). Parity disambiguates edit/reaction targeting. | +| **Reconnect semantics** | Container restart picks up where it left off (env vars) | ✓ Improved | v1: continuation not persisted across restarts. v2: provider continuation (Claude JSON transcript, etc.) stored in `session_state.session_id` on every SDK result. Survives crash. | +| **Error handling / retries** | File left in `errors/` dir on parse failure | ✓ Better visibility | v1: failed IPC files moved to `data/ipc/errors/` for manual inspection. v2: `status='failed'` in messages_in; delivery.ts retries with exponential backoff (3 attempts), marks failed on max. Persisted in DB for audit. | +| **Task scheduling (schedule_task)** | IPC file write → host parses → DB insert | ✓ Same end result, different path | v1: container writes task JSON, host reads/validates cron. v2: container writes `system` message with `action="schedule_task"` to messages_out, host reads + inserts into messages_in as new `kind='task'` row. Validation still in host (cron parsing at delivery time). | +| **Admin commands (/clear, /setup)** | Not in v1 IPC | ✓ Implemented | v2 has `/clear` command in poll-loop.ts, checked against adminUserIds set. Clears `session_state.session_id`. No MCP server expose. | +| **Tool-call plumbing** | MCP server in container exposes send_message, schedule_task, etc. | ✗ Removed entirely | v1 tools are now plain SDK result processors. send_message writes messages_out. schedule_task writes messages_out with action="schedule_task". | +| **Message delivery tracking** | None (fire-and-forget) | ✓ Added | v1: host sends message, doesn't track if it reached the user. v2: `delivered` table in inbound.db (platform_message_id + status). delivery.ts marks as delivered/failed. Enables message edits, reactions, and retry logic. | +| **Stale container detection** | None | ✓ Added | v1: no heartbeat. v2: host-sweep.ts checks `.heartbeat` mtime. If >10min old and processing_ack has 'processing' entries, resets with backoff. | +| **Recurrence / cron re-firing** | Not in v1 | ✓ Added | v1: tasks were one-shot. v2: `recurrence` field in messages_in + `series_id`. host-sweep.ts fires next occurrence when completed message has recurrence. CronExpressionParser used at sync time. | + +--- + +## Missing from v2 + +### 1. **Auth handshake envelope** +v1 had explicit authorization gates for *every* IPC operation: +- Read `isMain` and `groupFolder` from env vars at startup (ipc-mcp-stdio.ts:19–21) +- For `schedule_task`: gate the `targetJid` — non-main groups can only schedule for `chatJid` (line 187–188) +- For `register_group`: only isMain=true can call (line 471–481) +- For `send_message`: isMain || (target group's folder == sender's folder) (ipc.ts:78) + +**v2 equivalent:** Authorization is now **split**: +- Container time: adminUserIds set passed at boot (poll-loop.ts:22–33), used to gate `/clear` command only +- Delivery time: host checks destination ACL via agent_destinations table, permission to send to a messaging group (delivery.ts:535–561) +- No per-message auth envelope; the session fixture itself represents authorization + +**What's lost:** Per-request explicit authorization metadata. The agent can't *prove* it's "main" anymore; instead the host verifies at delivery time using the central DB. This is arguably *better* security (no token in container to leak), but if the agent needs to know *why* a request failed, it no longer gets an explicit auth reject response. + +### 2. **Backpressure / request queuing** +v1 file-based IPC was **implicitly backpressured**: +- Container calls `send_message()` MCP tool, which calls `writeIpcFile()` and returns immediately (fire-and-forget) +- If the host is slow or overloaded, files pile up in `data/ipc/messages/` +- Container is blocked only if the filesystem fills + +**v2 equivalent:** No queueing or explicit backpressure: +- Container calls `writeMessageOut()`, which executes a synchronous SQLite INSERT into outbound.db +- Host polls outbound.db at 1s (active) or 60s (sweep) +- If delivery fails, messages sit in outbound.db with `status='pending'` until 3 retries exhausted + +**What's lost:** Queue depth visibility. In v1, you could see `ls data/ipc/messages/ | wc -l` to get backlog. In v2, you have to query the outbound DB. The container has no way to ask "how many pending messages are waiting for me?" — it just writes and hopes the host picks them up. + +### 3. **Explicit keepalive / ping** +v1 had implicit keepalives via file timestamps: +- Each IPC file wrote a `timestamp` field (ipc-mcp-stdio.ts:61, 202) +- Host could reason about "last IPC activity" + +**v2 equivalent:** Heartbeat file mtime: +- Container touches `.heartbeat` file (connection.ts `touchHeartbeat()`) +- Host checks mtime every 60s in host-sweep.ts +- Detects stale if >10min old and processing_ack has 'processing' entries + +**What's lost:** Sub-heartbeat timeouts. If the container is hung but the heartbeat is fresh (just stuck in a long computation), the host won't detect it. v1 had no explicit timeout either, so this is not a regression, but there's no keepalive *mechanism* (no ping/pong protocol). + +### 4. **Payload size limits / chunking** +v1 wrote task files with a single JSON blob: +- ipc-mcp-stdio.ts:31: `fs.writeFileSync(tempPath, JSON.stringify(data, null, 2))` +- Filesystem might have limits on inode size, but generally no explicit cap + +**v2:** No explicit chunking or size limits in the DB layer: +- messages_in.content and messages_out.content are TEXT +- SQLite TEXT default is ~1GB per cell +- No mention in the codebase of max payload size + +**What's lost:** Explicit awareness. In v1, if a task prompt was 10MB, it would be a 10MB JSON file. In v2, it's a 10MB DB cell. The system doesn't actively prevent this, and there's no mention of a sanitizer. + +--- + +## Behavioral discrepancies + +### 1. **Task scheduling authorization** +**v1** (ipc-auth.test.ts:71–127): +```typescript +// Main group can schedule for another group +await processTaskIpc({ type: 'schedule_task', targetJid: 'other@g.us' }, 'whatsapp_main', true, deps); +// Non-main group can ONLY schedule for itself +await processTaskIpc({ type: 'schedule_task', targetJid: 'main@g.us' }, 'other-group', false, deps); +// ↑ blocked by authorization gate (ipc.ts:170) +``` + +**v2** (delivery.ts:645–712): +The container writes a `system` message with `action="schedule_task"` directly into messages_out. The host reads it and calls `insertTask(inDb, {...})` **with no authorization gate**. The `targetJid` is derived from the system message `platformId` and `channelType`, not from an explicitly routed `targetJid` parameter. + +**Discrepancy:** v1 prevented non-main groups from scheduling cross-group tasks at the *request* stage. v2 has no equivalent gate — the container can write any task to any group (in theory) because it's the host that does the actual DB insert. In practice, the container only has one session and only sees messages for that session, so it can't *reach* another group's messages_in. But the authorization model is implicitly structural, not explicit. + +### 2. **Message send authorization** +**v1** (ipc-auth.test.ts:339–373): +```typescript +// Main can send to any chat +expect(isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups)).toBe(true); +// Non-main can send to its own chat +expect(isMessageAuthorized('other-group', false, 'other@g.us', groups)).toBe(true); +// Non-main cannot send to another group's chat +expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe(false); +``` + +**v2** (delivery.ts:550–561): +```typescript +const isOriginChat = session.messaging_group_id === mg.id; +if (!isOriginChat && !hasDestination(session.agent_group_id, 'channel', mg.id)) { + throw new Error(`unauthorized channel destination: ...`); +} +``` + +The container's session has a fixed `messaging_group_id` + `thread_id`. The agent can only reply to that origin or to a destination in the `agent_destinations` table. There is no isMain flag. + +**Discrepancy:** v1 was group-centric (folder-based identity). v2 is session-centric (agent is wired to one or more messaging groups via central DB, projected into inbound.db). If an agent is wired to multiple chats with `session_mode='agent-shared'`, it has one session and can see all of them as destinations. This is more flexible than v1's binary main/non-main gate. + +### 3. **Task update semantics** +**v1** (ipc-auth.test.ts:264–309): Container passes `type='update_task'`, host reads the task, re-computes `next_run` if schedule changed, updates DB. + +**v2** (delivery.ts:695–712): Container writes `system` message with `action="update_task"`, host applies the update directly. The host **does not** recompute `next_run` if the schedule changes — it only updates the fields the container specified. Recurrence is re-fired by the *host* in host-sweep.ts (line 160–165), not at update time. + +**Discrepancy:** v1 eagerly recomputed next_run on update. v2 lazily computes it during the 60s sweep. If an agent updates a task's cron expression, it won't take effect until the next sweep cycle. This is a ~60s latency increase. + +### 4. **Error handling** +**v1** (ipc.ts:85–91): Files that fail to parse are moved to `data/ipc/errors/` for manual inspection. + +**v2** (delivery.ts:422–459): Messages that fail delivery get up to 3 retries with exponential backoff. If they still fail, they're marked `status='failed'` in the DB. There's no "errors" directory; the audit trail is in the DB + logs. + +**Discrepancy:** v1's error handling was "fire-and-forget" (parse, move on). v2's is "retry + persistent state." This is better observability, but v1's "move to errors/" was a gentler way to pause processing without losing the file. + +### 5. **Reconnect / session resumption** +**v1:** No persistence. If the container crashed, the next restart had no knowledge of prior messages or state. + +**v2** (poll-loop.ts:51–55): Reads `session_state.session_id` at startup and passes it to the provider as `continuation`. The provider (Claude) deserializes a `.jsonl` transcript and resumes. Survives container crash. + +**Discrepancy:** v2 has explicit continuation support. v1 did not. This is a strict improvement. + +--- + +## Worth preserving? + +### 1. **Per-request authorization envelope** +**Recommendation:** No, v2's structural approach is better. In v1, a malicious container could spoof an isMain flag to bypass gates (though env vars are hard to spoof). v2's model — the host resolves identity once and checks permissions against the central DB — is more robust and easier to audit. + +### 2. **Message send ACL at request time** +**Recommendation:** Partially — v2 should validate `agent_destinations` rows exist *before* the agent attempts a send, so it fails fast instead of silently dropping at delivery time. Currently, if an agent tries `...`, it writes to messages_out and the host later rejects it. A pre-send validation in the container (via destinations.ts) would be better UX. + +### 3. **Backpressure / delivery acknowledgment** +**Recommendation:** Maybe. If an agent rapidly fires 100 `send_message()` calls, they all block on SQLite INSERT (fast) and return immediately. The host drains them at 1s per poll. If the channel adapter is slow, messages pile up in messages_out. There's no way for the agent to ask "is there backlog?" or "wait until sent." This is probably fine for most use cases (agents don't spam), but if latency-sensitive, a `send_message()` that returns `{delivered_at}` would help. + +### 4. **Heartbeat / stale detection** +**Recommendation:** Yes, and it's been preserved (`.heartbeat` file replaces file-based timestamps). But the 10min threshold is conservative. Consider shorter thresholds for interactive agents (containers should be responsive, stale is a sign of crash, not slow work). + +--- + +## File references + +### v1 (historical, in `src/v1/` and `container/agent-runner/src/v1/`) +- **ipc.ts:30–127** — startIpcWatcher loop, per-group folder scan, message/task file dispatch +- **ipc.ts:129–356** — processTaskIpc with authorization gates (lines 169, 228, 241, 254, 271, 313, 326) +- **ipc-auth.test.ts:71–127** — schedule_task authorization tests +- **ipc-auth.test.ts:339–373** — message send authorization tests +- **ipc-mcp-stdio.ts:37–68** — send_message MCP tool, writeIpcFile +- **ipc-mcp-stdio.ts:70–216** — schedule_task tool with validation, target_group_jid param +- **ipc-mcp-stdio.ts:445–504** — register_group tool, isMain gate + +### v2 (active, in `src/` and `container/agent-runner/src/`) +- **db-session.md:1–50** — inbound.db schema (messages_in, delivered, destinations, session_routing) +- **db-session.md:120–174** — outbound.db schema (messages_out, processing_ack, session_state) +- **db-session.md:104–117** — seq parity invariant +- **delivery.ts:383–394** — drainSession loop (active poll 1s, sweep 60s) +- **delivery.ts:467–638** — deliverMessage, handles all message kinds, permission checks, delivery retry +- **delivery.ts:645–906** — handleSystemAction, interprets action="schedule_task" etc. +- **host-sweep.ts:48–109** — sweepSession, syncProcessingAcks, stale detection via heartbeat, recurrence handling +- **session-manager.ts:1–12** — cross-mount invariant doc (journal_mode=DELETE, close-per-op) +- **session-manager.ts:122–130** — initSessionFolder, schema creation +- **session-manager.ts:152–222** — writeSessionRouting, writeDestinations (replaces static env vars with live table) +- **session-manager.ts:231–267** — writeSessionMessage (host writes to messages_in) +- **poll-loop.ts:22–33** — PollLoopConfig with adminUserIds set +- **poll-loop.ts:46–77** — runPollLoop entry, getPendingMessages, markProcessing +- **destinations.ts:44–52** — getAllDestinations, findByName (reads from inbound.db live) +- **db/messages-in.ts** — getPendingMessages, markProcessing, markCompleted +- **db/messages-out.ts** — writeMessageOut (container writes system actions here) +- **db/session-state.ts** — getStoredSessionId, setStoredSessionId (continuation persistence) +- **db/connection.ts** — touchHeartbeat, journal_mode=DELETE pragma, cross-mount setup + +--- + +Generated from deep-dive analysis of v1 IPC → v2 DB paradigm shift. diff --git a/docs/v1-vs-v2/logger.md b/docs/v1-vs-v2/logger.md new file mode 100644 index 0000000..26ac548 --- /dev/null +++ b/docs/v1-vs-v2/logger.md @@ -0,0 +1,38 @@ +# logger: v1 vs v2 + +## Scope +- v1: `src/v1/logger.ts` (70 LOC) — export `logger` +- v2 counterpart: `src/log.ts` (65 LOC) — export `log` + +## Capability map + +| v1 behavior | v2 location | Status | Notes | +|---|---|---|---| +| Levels (debug=20, info=30, warn=40, error=50, fatal=60) | `src/log.ts:1` | kept | Identical numeric map | +| `debug/info/warn/error/fatal` methods | `src/log.ts:50-54` | renamed | `logger.X(...)` → `log.X(...)` | +| Data-first signature `(data, msg)` | `src/log.ts:42-58` | **changed** | v2 requires message-first `(msg, data?)` — breaking for every callsite | +| Color codes (per-level + KEY_COLOR=magenta, MSG_COLOR=cyan) | `src/log.ts:4-14` | kept | Identical | +| LOG_LEVEL env threshold | `src/log.ts:16` | kept | `'info'` default | +| Timestamp `HH:MM:SS.mmm` | `src/log.ts:33-40` | kept | Refactored, same output | +| Error formatting | `src/log.ts:18-23` | **changed** | v1 pretty multi-line JSON; v2 single-line | +| Data formatting | `src/log.ts:25-31` | **changed** | v1 per-line indented; v2 inline `key=value` | +| Process ID in output | — | **removed** | v1 emitted `(${process.pid})`; v2 drops it | +| info/debug → stdout, warn/error/fatal → stderr | `src/log.ts:45` | kept | Identical routing | +| `uncaughtException` → fatal + exit(1) | `src/log.ts:57-60` | kept | Arg order swapped | +| `unhandledRejection` → error | `src/log.ts:62-64` | kept | Arg order swapped | + +## Missing from v2 +1. **Process ID in log output** — lost visibility into emitting process in multi-container scenarios +2. **Data-first overload** — v1 `logger.warn({err, path}, 'msg')` is a breaking API change in v2 +3. **Multi-line error formatting** — condensed single-line form is harder to read for stack traces + +## Behavioral discrepancies +1. **Argument order**: `logger.error({err}, 'failed')` must become `log.error('failed', {err})` at every callsite +2. **Error output**: v1 pretty-prints JSON over 3 lines; v2 collapses to one line +3. **Data output**: v1 newline+indent per key; v2 space-separated inline + +## Not in either +File rotation, redaction rules, on-disk logging — both stream to stdout/stderr only. + +## Worth preserving? +Restoring PID to v2 output is cheap and helps multi-process debugging. Multi-line error format is worth a verbose-mode flag for `error`/`fatal`. Signature swap is stylistic; not worth reverting but every v1 `logger` → `log` migration must swap `(data, msg)` → `(msg, data)`. diff --git a/docs/v1-vs-v2/remote-control.md b/docs/v1-vs-v2/remote-control.md new file mode 100644 index 0000000..7cba133 --- /dev/null +++ b/docs/v1-vs-v2/remote-control.md @@ -0,0 +1,90 @@ +# remote-control: v1 vs v2 + +## Scope + +**v1:** +- `/Users/gavriel/nanoclaw4/src/v1/remote-control.ts` (218 lines) +- `/Users/gavriel/nanoclaw4/src/v1/remote-control.test.ts` (379 lines) +- Integrated into v1 host via `restoreRemoteControl()` call at startup (v1/index.ts:42) + +**v2 Counterparts:** +- `/Users/gavriel/nanoclaw4/src/access.ts` (115 lines) — privilege/approval routing +- `/Users/gavriel/nanoclaw4/src/onecli-approvals.ts` (269 lines) — OneCLI credential-gated action approval +- `/Users/gavriel/nanoclaw4/src/webhook-server.ts` (134 lines) — HTTP webhook ingress for Chat SDK adapters +- `/Users/gavriel/nanoclaw4/src/router.ts` (start of file) — inbound message routing with access gates + +## Capability Map + +| v1 Behavior | v2 Location | Status | Notes | +|---|---|---|---| +| Start `claude remote-control` child process, extract URL | **Removed** | ❌ Removed | v2 has no equivalent. The `claude remote-control` CLI was a v1-only mechanism tied to individual Telegram chats. | +| Session state persistence (PID, URL, metadata) | **Removed** | ❌ Removed | v2 is stateless at the host level — all per-session state lives in `inbound.db` / `outbound.db`. | +| Auto-accept "Enable Remote Control?" prompt via stdin | **Removed** | ❌ Removed | v1 quirk tied to Claude CLI's interactive mode; no equivalent in v2. | +| Restore session from disk on startup | **Removed** | ❌ Removed | v2 has no startup recovery loop for stale processes. Sessions are created on-demand. | +| Detect dead process by signal check | **Removed** | ❌ Removed | v2 uses per-session heartbeat file (`/workspace/.heartbeat`) and inactivity detection via 60s sweep. | +| HTTP URL polling + timeout handling | **Webhook server** | ✅ Moved | v2's `webhook-server.ts` (line 16–124) runs a persistent HTTP server (default port 3000) for Chat SDK adapter webhooks. Routes via `/webhook/{adapterName}` (not URL-in-stdout polling). | +| Single active session per host | **Per-agent-group sessions** | ✅ Evolved | v2 supports unlimited concurrent sessions. Each `(agent_group, messaging_group, thread)` tuple is a separate session with its own container. | +| `getActiveSession()` getter | **Removed** | ❌ Removed | No global session concept. v2 queries sessions via `getSession(sessionId)` in `db/sessions.ts`. | +| Credential access approval | **OneCLI approval handler** | ✅ Moved | v2's `onecli-approvals.ts` (line 92–215) handles credential-gated action approval. OneCLI gateway intercepts HTTP, delivers ask_question card to approver, persists `pending_approvals` row (line 173–196). | +| Approver selection (admin → owner chain) | **access.ts** | ✅ Moved | `pickApprover()` (access.ts:55–72) returns ordered list: agent-group admins → global admins → owners. Same preference order as v1 logic. | +| Approval delivery to DM (same channel kind preferred) | **access.ts + user-dm.ts** | ✅ Moved | `pickApprovalDelivery()` (access.ts:83–101) walks approver list, prefers same channel kind via `channelTypeOf()` (line 112–115), falls back to any reachable DM. Uses `ensureUserDm()` for cold-DM resolution (user-dm.ts). | +| Ask_question card delivery | **onecli-approvals.ts** | ✅ Moved | v2 builds ask_question card (onecli-approvals.ts:148–167) with Approve/Reject buttons, routes via `deliveryAdapter.deliver()` with action_id for button callbacks. | +| Button click → approval resolution | **onecli-approvals.ts** | ✅ Moved | `resolveOneCLIApproval()` (line 68–83) matches approval_id, resolves Promise, updates status to approved/rejected, deletes `pending_approvals` row. | +| Approval expiry + cleanup | **onecli-approvals.ts** | ✅ Moved | Expiry timer fires just before gateway's TTL (line 200–211); `expireApproval()` (line 217–226) edits card to "Expired (reason)" and deletes row. Startup sweep cleans stale rows (line 247–255). | +| Rate limiting | **Not implemented** | ❌ Missing | Neither v1 nor v2 has rate limiting on remote-control or approval requests. | +| Audit logging | **Partial** | ⚠️ Partial | v1: `logger.info()` on session start/stop. v2: `log.info()` on approval resolved (onecli-approvals.ts:81), stale sweeps (line 250), expiry (line 225). Payload stored in `pending_approvals.payload` for audit (line 178–186). | +| Error recovery (process death) | **Minimal** | ⚠️ Minimal | v1: restores from disk, kills stale PID. v2: no equivalent — dead container is detected by stale heartbeat, then respawned via `wakeContainer()`. | +| Transport | HTTP via stdout polling | HTTP via standard webhook server | v1 is ephemeral per session; v2 is persistent, multi-tenant. | +| Auth | None (CLI subprocess) | OneCLI gateway (credential-gated via HTTP) | v1 has no auth; v2 gates on agent identity + OneCLI decision. | + +## Missing from v2 + +1. **CLI subprocess spawning** — v2 has no `claude remote-control` equivalent. Agents run in Docker containers, not standalone CLI processes. The OneCLI agent sandbox is managed by the agent-runner container, not the host. + +2. **Process-level lifecycle management** — v1 tracks individual process PIDs and signal-kills them. v2 uses container IDs + heartbeat file, handled by host-sweep (host-sweep.ts) and container-runner.ts. + +3. **Per-message URL polling with regex extraction** — v2's webhook server is push-based (HTTP handler), not pull-based polling of stdout files. + +4. **Direct user-to-bot communication model** — v1's remote-control was tied to a single Telegram JID + chat. v2 decouples messaging groups from agent groups, allowing one agent to serve multiple channels with different isolation levels. + +5. **State file on disk** (`remote-control.json`) — v2 stores all session state in SQLite central DB and per-session `inbound.db` / `outbound.db`. + +## Behavioral Discrepancies + +1. **Approval delivery model**: + - v1: Remote control was tied to a single message sender; approvals implicitly went to the initiator's contact or a hardcoded owner. + - v2: Approvals route to admins of the originating agent group, with tie-break by channel kind (pickApprovalDelivery line 87–94). Multiple approvers can be reached, decoupling approval from message sender. + +2. **Session multiplicity**: + - v1: One active `RemoteControlSession` per host at a time. + - v2: Unlimited concurrent sessions, each with independent state (`inbound.db`, `outbound.db`, heartbeat). + +3. **Timeout & cleanup**: + - v1: Explicit timeout on URL polling (30s), then kill process. No ongoing monitoring. + - v2: Heartbeat-based inactivity detection (60s sweep), graceful cleanup on stale. Approval expiry tied to OneCLI gateway TTL, not a fixed timeout. + +4. **Error transparency**: + - v1: Polling errors logged to stdout/stderr files; user doesn't see unless they debug. + - v2: All approval errors logged centrally; card is edited to "Expired" on failure, so approver sees state change. + +## Worth Preserving? + +**No — v2 supersedes v1's remote-control model.** + +v1's remote-control was a bridge between Telegram chats and a single Claude CLI session. v2 achieves equivalent (and superior) remote operation via: +- **OneCLI credential approvals** (onecli-approvals.ts): Admins approve API/credential requests from agents, just as v1 surfaced sensitive actions. +- **Approval routing** (access.ts): Automatically picks the right admin on the right channel, with fallback to any reachable DM. +- **Multi-tenant agent groups**: Agents can serve multiple channels with different approval chains, not just one chat JID. + +Users still get on-demand approval for sensitive actions; they just don't manage a CLI subprocess anymore. The host handles container lifecycle, and the container agent is managed by OneCLI. + +--- + +### Citation Summary + +- v1 remote-control: `/Users/gavriel/nanoclaw4/src/v1/remote-control.ts:1–218` +- v1 tests: `/Users/gavriel/nanoclaw4/src/v1/remote-control.test.ts:1–379` +- v2 access control: `/Users/gavriel/nanoclaw4/src/access.ts:29–115` (pickApprover, pickApprovalDelivery, canAccessAgentGroup) +- v2 approval handler: `/Users/gavriel/nanoclaw4/src/onecli-approvals.ts:50–270` (handleRequest, resolveOneCLIApproval, sweepStaleApprovals) +- v2 webhook server: `/Users/gavriel/nanoclaw4/src/webhook-server.ts:73–124` (registerWebhookAdapter, ensureServer) +- v2 router: `/Users/gavriel/nanoclaw4/src/router.ts:19–50` (inbound access gate, unknown_sender_policy) diff --git a/docs/v1-vs-v2/router.md b/docs/v1-vs-v2/router.md new file mode 100644 index 0000000..361edb1 --- /dev/null +++ b/docs/v1-vs-v2/router.md @@ -0,0 +1,67 @@ +# router: v1 vs v2 + +## Scope +- v1 (distributed across): `src/v1/index.ts` (startMessageLoop, trigger check), `group-queue.ts` (concurrency, retry), `router.ts` (outbound formatting, 44 LOC), `sender-allowlist.ts` (drop/allow) +- v2: `src/router.ts` (317 LOC), `src/session-manager.ts` (346 LOC), `src/container-runner.ts`, `src/access.ts`, `src/db/messaging-groups.ts` (trigger_rules schema) + +## Routing-flow diff + +### v1 (polling, per-group) +1. Channel receives message → `onMessage` → store in DB +2. Sender allowlist drop-mode filter → discard denied +3. `startMessageLoop` polls every POLL_INTERVAL +4. For each group: lookup channel (`findChannel` O(n)), check trigger requirement, load allowlist, scan for pattern, skip if no trigger +5. Pull messages since `lastAgentTimestamp`, XML-format with tz context +6. If active container: write JSON to IPC file; else `enqueueMessageCheck(groupJid)` → GroupQueue +7. Retry on failure (up to 5, exp. backoff); rollback cursor on agent error + +### v2 (event-driven, entity model) +1. Channel adapter → `routeInbound(platformId, threadId, message)` +2. Apply thread policy (`supportsThreads` → collapse to null) +3. Resolve `messaging_group` (lookup or auto-create) +4. Extract sender → upsert `users` row → `userId` (namespaced `channel_type:handle`) +5. Lookup wired agent groups via `messaging_group_agents`; drop if none +6. `pickAgent` (highest priority; **trigger_rules matching is TODO**) +7. `enforceAccess`: owner/admin/member gate; `unknown_sender_policy: strict | request_approval | public` +8. `resolveSession` by `session_mode` (`agent-shared`/`shared`/`per-thread`) +9. `insertMessage` to session `inbound.db`, write session_routing + destinations +10. `startTypingRefresh`; `wakeContainer(session)` (dedup by `activeContainers` + `wakePromises`) +11. Container polls inbound.db, writes outbound.db; host `delivery.ts` polls and sends via adapter; `stopTypingRefresh` on container exit + +## Capability map + +| v1 behavior | v2 location | Status | Notes | +|---|---|---|---| +| Sender allowlist drop/allow modes | — | **removed** | Replaced by access gate + `unknown_sender_policy` | +| Group registration auto-creating folder on first message | `router.ts` auto-creates messaging_group; group folder via `group-init.ts` on wake | moved | Admin skill path for agent groups | +| Trigger pattern matching (`requiresTrigger`, `DEFAULT_TRIGGER`) | `messaging_group_agents.trigger_rules` JSON | **deferred** | Schema ready; `pickAgent` has TODO comment | +| `lastAgentTimestamp` cursor tracking | — | **removed** | All messages written immediately to inbound.db | +| IPC file polling (`inputDir`, `_close` sentinel) | — | **removed** | DB polling replaces | +| GroupQueue concurrency + waiting-groups | `container-runner.ts:42-82` `activeContainers` + `wakePromises` | reimplemented | Per-session not per-group | +| Task scheduler → enqueue to GroupQueue | host-sweep due-wake + delivery system-actions | preserved | | +| Session reuse rules (session mode) | `session-manager.ts` (agent-shared/shared/per-thread) | **enhanced** | Explicit per-wiring | +| Remote control command interception | — | **removed** | | +| Idle timeout + stdin close | `container-runner.ts:135-140` `resetIdle` | kept | Heartbeat instead of stdin | +| Host-level retry on agent error | — | **removed** | Container is authority; host sweep retries stale only | +| Typing indicator | `delivery.ts:startTypingRefresh` | kept | Gated on heartbeat | + +## Missing from v2 +1. **Trigger-rule matching** — `router.ts:198` TODO. Currently every wired agent fires on every message (only priority breaks ties). **Without this, multi-agent wirings don't work as intended.** +2. **Sender drop mode** — v1's silent-drop for noisy users is gone. v2 only has binary allow/deny. +3. **Cursor / state recovery** — v2 writes immediately to DB. If container crashes mid-output, no host-level dedup guarantees (beyond `messages_in.id` PK) +4. **Remote control** — v1 intercepted `/remote-control` commands pre-storage; no v2 equivalent +5. **Host-level retry with backoff on agent error** — v1 had MAX_RETRIES=5 + exp. backoff on `processGroupMessages`; v2 only retries on stale heartbeat detection + +## Behavioral discrepancies +1. **Trigger evaluation**: v1 eager (skip group until trigger arrives, accumulate context); v2 TODO — once implemented, likely drops non-trigger messages at ingest (semantic change) +2. **Session reuse**: v1 single session per group; v2 multiple (one per thread on threaded platforms) +3. **Access control timing**: v1 pre-storage (cheap drop); v2 post-sender-resolution (requires `users` upsert) +4. **Unknown channels**: v1 silently ignored; v2 auto-creates `messaging_groups` row — no data loss but orphaned rows possible +5. **Formatting**: v1 host formats with tz + cursor-based message subset; v2 pushes raw JSON to inbound.db, container formats from full session history + +## Worth preserving? +1. **Trigger rule matching (HIGH priority)** — schema is ready; 10-line implementation in `pickAgent`. Currently broken-by-default for multi-agent wirings +2. **Sender drop mode (MEDIUM)** — add `(agent_group_id, sender_pattern)` drop table; orthogonal to privilege +3. **State recovery (LOW)** — add unique constraint on `messages_in.id` if not already; v2's model is simpler + more robust +4. **Host-level retry on agent error (MEDIUM)** — currently only stale containers retry. Explicit container-exit-error retry could be valuable +5. **Remote control** — decide: restore as opt-in skill or document deletion diff --git a/docs/v1-vs-v2/sender-allowlist.md b/docs/v1-vs-v2/sender-allowlist.md new file mode 100644 index 0000000..7f7c518 --- /dev/null +++ b/docs/v1-vs-v2/sender-allowlist.md @@ -0,0 +1,46 @@ +# sender-allowlist: v1 vs v2 + +## Scope +- v1: `src/v1/sender-allowlist.ts` (97 LOC), `sender-allowlist.test.ts` (217 LOC) — flat JSON config at `~/.config/nanoclaw/sender-allowlist.json` +- v2 counterparts: `src/access.ts` (116 LOC), `src/router.ts` (317 LOC), `src/db/schema.ts` (user_roles, agent_group_members, messaging_groups.unknown_sender_policy), `src/container-runner.ts:291-295` (admin injection), `src/types.ts` (MessagingGroupAgent.response_scope) + +## Capability map + +| v1 behavior | v2 location | Status | Notes | +|---|---|---|---| +| Per-chat entry (`chats[chatJid]`) | `messaging_groups.unknown_sender_policy` | replaced | Policy per channel, not allowlist entries | +| Default entry | Default `unknown_sender_policy = 'strict'` | **reversed** | v1 default-allow → v2 default-deny | +| `allow: '*'` wildcard | Not present | removed | | +| `allow: string[]` (exact-match list) | `agent_group_members` rows + `user_roles` | replaced | Role-based / membership-based | +| `mode: 'trigger'` (allow for processing) | Implicit (access granted → routed) | kept | | +| `mode: 'drop'` (silent drop) | `recordDroppedMessage()` (logs only) | **partially lost** | No silent-drop mode; denied = logged | +| Admin override | owner / global_admin / scoped_admin | **new in v2** | Richer privilege hierarchy | +| Static JSON file | Central DB (`users`, `user_roles`, `agent_group_members`) | changed | Runtime-mutable, queryable | +| Exact-string sender | Namespaced `channel_type:handle` user IDs | enhanced | Explicit channel scoping | +| `logDenied` flag | implicit (log at decision point) | kept | | + +## Access-model diff +**v1**: flat allowlist per chat → default-allow → binary allowed/denied. +**v2**: entity model (`users` + roles + memberships) + per-messaging-group policy (`strict | request_approval | public`) → default-deny for unknowns. + +**Strictly more expressive:** role hierarchy, per-agent-group scope, three-way unknown handling, user metadata (display_name/kind), runtime reconfig. +**Lost:** per-message `drop` mode, default-allow posture, simple JSON editing. + +## Missing from v2 +1. **`request_approval` flow** — marked TODO in `router.ts:295`. Approval-on-first-contact for unknown senders is scaffolded but not wired +2. **`response_scope` enforcement** — field exists (`'all' | 'triggered' | 'allowlisted'`) but is not checked in `router.ts` or `delivery.ts` +3. **Trigger-rule matching on `messaging_group_agents`** — `router.ts:198` TODO ("Future: trigger rule matching"); currently only priority-based agent selection +4. **Silent-drop option for known-noisy senders** — v1's `mode: 'drop'` allowed "I see you but I ignore you"; v2 can only log and drop + +## Behavioral discrepancies +1. **Default posture flipped**: v1 open-by-default vs v2 closed-by-default — **breaking for migrations that relied on default-allow** +2. **Drop semantics**: v1 silent drop; v2 `recordDroppedMessage()` always logs +3. **Admin bypass**: v1 had no implicit bypass; v2 grants owners/admins access regardless of membership — more permissive for privileged users +4. **Scope resolution**: v1 per-chat; v2 per-agent-group via `user_roles.agent_group_id` — misalignment if one chat routes to multiple agent groups with different admins + +## Worth preserving? +The v2 role-based model is architecturally superior. The gaps worth closing: +- **Finish `request_approval`** flow — half-implemented scaffolding +- **Finish `response_scope` enforcement** — exists in schema but unused +- **Finish trigger-rule matching** in `pickAgent` — without it, every wired agent fires on every message +- **Consider silent-drop via a dedicated table** (`(agent_group_id, sender_pattern)` with action=drop) — orthogonal to privilege diff --git a/docs/v1-vs-v2/session-cleanup.md b/docs/v1-vs-v2/session-cleanup.md new file mode 100644 index 0000000..87aa3d4 --- /dev/null +++ b/docs/v1-vs-v2/session-cleanup.md @@ -0,0 +1,44 @@ +# session-cleanup: v1 vs v2 + +## Scope +- v1: `src/v1/session-cleanup.ts` (26 LOC) + `scripts/cleanup-sessions.sh` (151 LOC) — cadence 24h +- v2: `src/host-sweep.ts` (174 LOC) primary, plus `src/container-runtime.ts:60-80` (orphan cleanup), `src/session-manager.ts` (heartbeat path) + +## Capability map + +| v1 behavior | v2 location | Status | Notes | +|---|---|---|---| +| Cleanup cadence 24h | `host-sweep.ts:31` 60s sweep | **changed** | Continuous monitoring | +| Stale session detection via JSONL mtime | `host-sweep.ts:116-151` heartbeat file mtime | simplified | Heartbeat replaces JSONL | +| Heartbeat threshold | `STALE_THRESHOLD_MS = 10 * 60 * 1000` (`host-sweep.ts:32`) | **new** | 10 min | +| Stuck-processing detection | `getStuckProcessingIds()` via outbound.db (`host-sweep.ts:134`) | **new** | | +| Retry with exponential backoff | `BACKOFF_BASE_MS * 2^tries` (`host-sweep.ts:145`) | **new** | | +| Max retries | `MAX_TRIES = 5` (`host-sweep.ts:33`) | **new** | Messages → failed after 5 | +| Explicit container kill on stale | — | **not done** | Stale detection resets messages, doesn't stop container | +| JSONL + tool-results cleanup | — | **removed** | No artifact cleanup (SQLite persists in DB) | +| Artifact cleanup (debug logs, todos, telemetry) | — | **removed** | Per-type retention windows gone | +| Orphan container cleanup | `container-runtime.ts:60-80` `cleanupOrphans()` | **new** | At startup only | +| Active session detection via `store/messages.db` | `getActiveSessions()` from `v2.db` (`host-sweep.ts:52`) | changed | DB schema different | +| Sync `processing_ack` (outbound.db → inbound.db) | `syncProcessingAcks()` (`host-sweep.ts:87`) | **new** | | +| Wake container for due messages | `countDueMessages()` + `wakeContainer()` (`host-sweep.ts:91-96`) | **new** | Replaces scheduler's role | +| Recurrence firing | `handleRecurrence()` (`host-sweep.ts:154-173`) | **new** | Cron-parsed next-run insertion | + +## Missing from v2 +1. **Artifact cleanup** — v1 pruned JSONLs (7d), debug logs (3d), todos (3d), telemetry (7d), group logs (7d). v2 has none; if v1 leftovers exist on disk, they'll accumulate +2. **Explicit container termination** on stale detection — v2 marks messages as retry-eligible but leaves the stale container running; orphan cleanup only runs at next startup +3. **Configurable retention windows** — v1 had per-artifact-type retention; v2 constants are hardcoded + +## Behavioral discrepancies +| Aspect | v1 | v2 | +|---|---|---| +| Cadence | daily batch | 60s continuous | +| Stale trigger | 24h-old JSONL | 10-min heartbeat | +| Retry | none (session removed) | 5 tries, exp. backoff | +| Container wake | via message loop | via `countDueMessages()` in sweep | +| Transactions | implicit (offline script) | explicit per-session try/finally | + +## Worth preserving? +1. **Stop running containers on stale detection** — currently only startup `cleanupOrphans()` removes them. If a container truly dies while the host runs, the host will retry messages but won't kill the shell. Low-cost fix: `stopContainer(name)` when heartbeat is stale AND processing_ack is stuck +2. **Artifact cleanup migration** — if v1 data exists on disk post-migration, one-time prune is worth scripting. Not a v2 runtime concern +3. **Configurable thresholds** — `STALE_THRESHOLD_MS` / `MAX_TRIES` could live in `config.ts` for operational tuning; minor improvement +4. **Continuous sweep + recurrence + orphan cleanup** are all **significant improvements**; keep as-is diff --git a/docs/v1-vs-v2/task-scheduler.md b/docs/v1-vs-v2/task-scheduler.md new file mode 100644 index 0000000..29a0606 --- /dev/null +++ b/docs/v1-vs-v2/task-scheduler.md @@ -0,0 +1,100 @@ +# task-scheduler: v1 vs v2 + +## Scope + +**v1 task scheduler:** +- Files: `src/v1/task-scheduler.ts` (241 lines), `src/v1/task-scheduler.test.ts` (122 lines) +- Self-contained scheduler loop with DB persistence and container execution +- Stores tasks in central DB table `scheduled_tasks` +- Runs a polling loop at `SCHEDULER_POLL_INTERVAL` (configurable, typically 5–60s) + +**v2 task distribution:** +- No central task-scheduler file; tasks spread across host sweep and session DBs +- Core files: `src/host-sweep.ts` (174 lines), `src/delivery.ts` (task handlers ~line 654–713), `src/db/session-db.ts` (task mutation logic) +- Optional: `container/agent-runner/src/task-script.ts` (pre-task script execution) +- Task rows live in per-session `inbound.db` table `messages_in` (polymorphic message kind) +- Recurrence computed in `host-sweep.ts` (host-sweep.ts:159–173) + +--- + +## Capability map + +| v1 Behavior | v2 Location | Status | Notes | +|---|---|---|---| +| **One-shot tasks** (schedule_type='once') | `insertTask()` in `src/db/session-db.ts:103–122`; processAfter field set, recurrence=NULL | ✅ Supported | Task inserted into messages_in with process_after timestamp, processed once, no recurrence | +| **Recurring via cron** (schedule_type='cron') | `insertTask()` with recurrence field; `host-sweep.ts:159–173` parses cron | ✅ Supported | Cron expression stored in messages_in.recurrence, next occurrence computed on completion via CronExpressionParser | +| **Recurring via fixed interval** (schedule_type='interval') | Not directly supported; v2 uses cron for all recurring | ⚠️ Removed | v2 requires cron syntax for recurrence. No interval-based scheduling (e.g., "every 5 minutes") without converting to cron | +| **Timezone handling** | `host-sweep.ts:159–161` uses CronExpressionParser with no explicit TZ param; cron-parser respects system TZ | ⚠️ Degraded | v1's explicit TIMEZONE config (via timezone.ts helpers) is absent in v2. Cron evaluation uses system/Node.js default TZ, not agent/session-level configuration | +| **Persistence** | Per-session `inbound.db` `messages_in` table + `series_id` grouping | ✅ Supported | Tasks persisted as DB rows with status (pending/completed/paused). Series_id backfilled for recurring task groups | +| **Restart recovery** | `host-sweep.ts:85–96` syncs processing_ack on startup to detect stale containers; tasks marked paused if container crashes | ✅ Supported | Stale container detection via heartbeat file mtime (host-sweep.ts:122–131); stuck messages retried with exponential backoff | +| **Due-message wake** | `host-sweep.ts:91–96` queries countDueMessages, wakes container if due tasks exist | ✅ Supported | 60s sweep checks for pending tasks with process_after in the past and wakes container if found | +| **Missed-run catch-up** (interval-based) | `computeNextRun()` skips past missed intervals to prevent cumulative drift; tests verify no infinite loop | ⚠️ Degraded | v2 doesn't handle missed intervals — if a recurring cron task gets skipped, next occurrence is computed from completion time only. No "make up" for missed runs | +| **Cancellation** | `updateTask(id, {status: 'paused'})` prevents retry churn | ✅ Supported | `cancelTask()` in `src/db/session-db.ts:128–132` sets status='completed' and clears recurrence; matches by id OR series_id | +| **Pause/resume** | `updateTask(id, {status: 'paused'})` / resume | ✅ Supported | `pauseTask()` (line 134–138) and `resumeTask()` (line 140–144); both match id or series_id | +| **Retry-on-failure** | `updateTaskAfterRun()` on error; no explicit retry logic in scheduler loop | ⚠️ Degraded | v2 uses `retryWithBackoff()` only when container goes stale (host-sweep.ts:147). No automatic retry for task execution errors | +| **Concurrent-run prevention** | Task status 'active' gate (task-scheduler.ts:221); no concurrent-run logic | ⚠️ Degraded | v2 allows multiple pending tasks to wake the container in the same sweep; container processes serially but no host-level concurrency control | +| **Idempotency** | Task ID is primary key; `insertTask()` will fail if re-run with same ID | ✅ Supported | messages_in.id is PRIMARY KEY; insertTask() fails on duplicate (caller must handle or use ON CONFLICT) | +| **Max-age drop** | No explicit max-age field; tasks can remain pending indefinitely | ⚠️ Missing | No max-age or TTL in v2 messages_in schema. A stuck task can remain pending forever unless manually cancelled | +| **Task context mode** (group vs isolated session) | v1: context_mode field drives session reuse (task-scheduler.ts:122) | ⚠️ Removed | v2 doesn't track context_mode; all tasks are processed in the container's default session context; no isolation toggle | +| **Task result logging** | `logTaskRun()` writes to task_runs table; stores error + result summary | ⚠️ Degraded | v2 has no equivalent task_runs table. Task output is written as system messages back to the agent; no persistent audit trail | +| **Task script execution** | v1: prompt + optional script field, passed to container | ✅ Supported | v2: `applyPreTaskScripts()` in `container/agent-runner/src/task-script.ts:79–121` runs scripts pre-prompt, enriches prompt with scriptOutput | + +--- + +## Missing from v2 + +1. **Interval-based recurrence** — v1 `schedule_type='interval'` (e.g., "every 5000ms") is gone. v2 only supports cron expressions. Workaround: convert to equivalent cron (e.g., `*/5 * * * * *` for every 5 min). + +2. **Timezone awareness** — v1 passed `TIMEZONE` config to cron parser and had explicit `formatLocalTime()` helpers. v2 has no way to specify a session/agent timezone for cron evaluation; it uses the system/Node.js TZ. + +3. **Task context modes** — v1's `context_mode: 'group' | 'isolated'` is removed. No way to force a task into a dedicated session vs. the agent group's shared session. + +4. **Task result audit trail** — v1 logged every run to `task_runs(task_id, run_at, duration_ms, status, result, error)`. v2 has no persistent task execution history; output is a system message only. + +5. **Max-age / task TTL** — v1 tasks could be implicitly aged out (not directly visible in the code, but conceivable via cleanup logic). v2 has no TTL; a paused/completed task lingers in messages_in forever. + +6. **Task-level concurrency control** — v1 prevented concurrent runs of the same task (single status check per loop iteration). v2 can queue multiple pending tasks in one sweep, though the container processes them serially. + +--- + +## Behavioral discrepancies + +1. **Missed-interval catch-up** (v1 `computeNextRun()` lines 32–46 vs. v2 absence): + - **v1:** If a task is due at 10:00, 10:05, 10:10 but the scheduler is down during 10:00–10:15, it computes `next_run = 10:20` (skips missed intervals, stays on the grid). + - **v2:** If the same recurring cron task is skipped, the next occurrence is computed from the *completion* time (host-sweep.ts:160–161), not from the original grid. A task that should run at :00 and :05 every 10 minutes might drift if completions are delayed. + +2. **Stale-container recovery** (v1 none vs. v2 heartbeat-based): + - **v1:** Tasks remain due if the container crashes; the scheduler will retry on the next poll. + - **v2:** If the heartbeat goes stale (container unresponsive for 10 min), stuck processing messages are retried with exponential backoff. Tasks stuck in 'processing' state are reset. + +3. **Task script pre-processing** (v1 prompt + script → container vs. v2 script → output enrichment): + - **v1:** Passes script alongside prompt to container; container execution model unclear from scheduler.ts (likely runs in group-queue). + - **v2:** Host runs script *before* waking container; script output (`scriptOutput`) is merged into prompt JSON via `applyPreTaskScripts()` (task-script.ts:115–117). If script fails or returns `wakeAgent=false`, the task is skipped entirely. + +4. **Retry semantics**: + - **v1:** On execution error (runTask throws), `updateTaskAfterRun()` is called with `error`. Next retry relies on scheduler polling the same task again (no backoff). + - **v2:** Execution errors are not retried; container processes the task once. If the container crashes mid-task, the message is retried with exponential backoff only up to `MAX_TRIES=5` (host-sweep.ts:145–150). + +--- + +## Worth preserving? + +**Interval-based recurrence** (v1 `schedule_type='interval'`) is a practical feature that v2 trades away. Cron syntax is powerful but less intuitive for simple "every X milliseconds" patterns. If users want "run every 30 seconds," they must learn cron (`*/30 * * * * *` for seconds doesn't exist in standard cron; workaround is job-level looping in the prompt). Consider a thin adapter layer in agent-facing APIs to accept `{interval: 5000}` and convert to cron, or extend the v2 schema to support an optional `interval_ms` alongside `recurrence`. + +**Task context modes** (`group` vs. `isolated`) were a way to isolate task execution context. v2's removal simplifies the model but loses the ability to run a task in a fresh container state. If a task needs a clean slate (no session history), that's now impossible; workaround is a manual system-action to clear session state before running the task. + +**Task result audit trail** is a gap for operational visibility. v2's system messages are ephemeral; there's no way to query "how many times did task X run and what were the outcomes?" Adding a lightweight `task_execution_log` table (optional, populated on task completion) would help without burdening the common case. + +--- + +## References by line + +- v1 task-scheduler: `src/v1/task-scheduler.ts:20–49` (computeNextRun), `:203–235` (startSchedulerLoop) +- v1 test coverage: `src/v1/task-scheduler.test.ts:49–121` (drift, missed-interval, once-task tests) +- v1 timezone: `src/v1/timezone.ts:26–37` (formatLocalTime with explicit TZ) +- v1 types: `src/v1/types.ts:60–74` (ScheduledTask interface with context_mode) +- v2 sweep: `src/host-sweep.ts:154–173` (handleRecurrence, insertRecurrence) +- v2 delivery system actions: `src/delivery.ts:645–713` (handleSystemAction switch on schedule_task/cancel_task/pause_task/resume_task/update_task) +- v2 session-db: `src/db/session-db.ts:103–198` (insertTask, cancelTask, pauseTask, resumeTask, updateTask, all with series_id matching) +- v2 task-script: `container/agent-runner/src/task-script.ts:79–121` (applyPreTaskScripts, wakeAgent logic) +- v2 DB schema: `docs/db-session.md:31–56` (messages_in table with process_after, recurrence, series_id) diff --git a/docs/v1-vs-v2/timezone-formatting-v1-recreation.md b/docs/v1-vs-v2/timezone-formatting-v1-recreation.md new file mode 100644 index 0000000..eabf012 --- /dev/null +++ b/docs/v1-vs-v2/timezone-formatting-v1-recreation.md @@ -0,0 +1,570 @@ +# v1 Timezone + Formatting — Recreation Spec + +## Source commits + +**Parent of deletion**: `86becf8^ = 27c52205f9fdeac0483600b2663f1c4d80aba45d` + +**Deletion commit**: `86becf8` (chore: delete v1 reference code) + +### Relevant v1 files at commit 27c5220 (v1^): +- `src/v1/router.ts` — message formatting logic (escapeXml, formatMessages, stripInternalTags, formatOutbound) +- `src/v1/timezone.ts` — timezone utility functions (isValidTimezone, resolveTimezone, formatLocalTime) +- `src/v1/config.ts` — configuration and trigger patterns (buildTriggerPattern, getTriggerPattern, TIMEZONE resolution) +- `src/v1/task-scheduler.ts` — scheduled task timezone handling (computeNextRun with cron-parser) +- `src/v1/types.ts` — data structures (NewMessage interface) +- `src/v1/formatting.test.ts` — comprehensive test suite for all formatting behavior +- `src/v1/timezone.test.ts` — timezone utility tests +- `src/v1/task-scheduler.test.ts` — scheduler tests + +--- + +## 1. Timestamp formatting on inbound messages + +### v1 behavior (exact) + +**Function**: `formatLocalTime()` in `src/v1/timezone.ts:26-36` + +```typescript +export function formatLocalTime(utcIso: string, timezone: string): string { + const date = new Date(utcIso); + return date.toLocaleString('en-US', { + timeZone: resolveTimezone(timezone), + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); +} +``` + +**Input**: UTC ISO 8601 timestamp (e.g., `'2024-01-01T00:00:00.000Z'`) + timezone name (e.g., `'America/New_York'`) + +**Output format example**: +- Input: `'2024-01-01T18:30:00.000Z'` with timezone `'America/New_York'` (EST, UTC-5) +- Output: `'1:30 PM'` (with additional date components: month short name, day, year, hour, 2-digit minute, 12-hour format) +- Full example output: `"Jan 1, 2024, 1:30 PM"` (exact format depends on browser/Node locale) + +**Critical Details**: +- Uses JavaScript's `Intl.DateTimeFormat` API with `en-US` locale +- Format options: `{ year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }` +- Handles invalid timezone gracefully by calling `resolveTimezone(timezone)` which falls back to UTC +- No external dependencies (no moment.js, date-fns, or day.js) + +**Where it's called**: +- `src/v1/router.ts:11` in `formatMessages()` function to convert each message's `m.timestamp` to display time +- The display time is then placed in the `time="..."` attribute of the XML message element + +### Test coverage + +From `src/v1/formatting.test.ts:51-84`: + +1. **Basic formatting with context header** + - Input: Single message with timestamp `'2024-01-01T00:00:00.000Z'`, timezone `'UTC'` + - Asserts: `result.toContain('Jan 1, 2024')` and `''` + - File:line: `src/v1/formatting.test.ts:51-56` + +2. **Timezone conversion to local time** + - Input: Timestamp `'2024-01-01T18:30:00.000Z'` with timezone `'America/New_York'` (EST) + - Asserts: Result contains `'1:30'` and `'PM'` (correct EST conversion, UTC-5) + - File:line: `src/v1/formatting.test.ts:74-78` + +From `src/v1/timezone.test.ts:10-30`: + +3. **formatLocalTime with timezone conversion** + - Input: `'2026-02-04T18:30:00.000Z'` with `'America/New_York'` + - Asserts: Contains `'1:30'`, `'PM'`, `'Feb'`, `'2026'` + - File:line: `src/v1/timezone.test.ts:10-16` + +4. **Multiple timezones comparison** + - Input: Same UTC time with different timezones (`'America/New_York'`, `'Asia/Tokyo'`) + - Asserts: NY shows `'8:00'` (EDT, UTC-4 in summer), Tokyo shows `'9:00'` (UTC+9) + - File:line: `src/v1/timezone.test.ts:18-26` + +5. **Invalid timezone fallback** + - Input: Invalid timezone `'IST-2'` + - Asserts: Does not throw, formats as UTC (falls back) + - File:line: `src/v1/timezone.test.ts:28-33` + +--- + +## 2. Context timezone header + +### v1 behavior (exact) + +**Location**: Prepended at the START of the formatted message block in `src/v1/router.ts:20-22` + +**Format**: +```xml + +``` + +**Code**: +```typescript +const header = `\n`; +return `${header}\n${lines.join('\n')}\n`; +``` + +**What it includes**: +- Only the timezone name (IANA identifier, e.g., `'UTC'`, `'America/New_York'`) +- **NOT** the current time (that's in each individual message's `time="..."` attribute) +- XML-escaped to prevent injection (via `escapeXml()`) + +**Per-message vs per-turn**: +- The header appears **once per call to `formatMessages()`**, which formats a batch of messages +- The entire batch (header + all messages) is passed to the agent as a single unit +- The `timezone` parameter is passed in from the caller (`src/v1/router.ts:9` line signature) + +**Where it's wired**: +- `src/v1/router.ts:9` — `formatMessages(messages: NewMessage[], timezone: string)` accepts timezone as a parameter +- This function is called from the channel message processing loop (inbound message handler) +- The caller supplies the `TIMEZONE` constant from `src/v1/config.ts:62` + +### Test coverage + +From `src/v1/formatting.test.ts:51-56`: + +1. **Context header is included in output** + - Input: Any message list with timezone `'UTC'` + - Asserts: `result.toContain('')` + - File:line: `src/v1/formatting.test.ts:51-56` + +2. **Context header with non-UTC timezone** + - Input: Timezone `'America/New_York'` + - Asserts: `result.toContain('')` + - File:line: `src/v1/formatting.test.ts:74-78` + +3. **Context header with empty message list** + - Input: Empty array with timezone `'UTC'` + - Asserts: `result.toContain('')` even when no messages + - File:line: `src/v1/formatting.test.ts:80-83` + +--- + +## 3. Reply-to handling with message IDs + +### v1 behavior (exact) + +**Location**: In the message formatting loop in `src/v1/router.ts:10-18` + +**Code**: +```typescript +const replyAttr = m.reply_to_message_id ? ` reply_to="${escapeXml(m.reply_to_message_id)}"` : ''; +const replySnippet = + m.reply_to_message_content && m.reply_to_sender_name + ? `\n ${escapeXml(m.reply_to_message_content)}` + : ''; +return `${replySnippet}${escapeXml(m.content)}`; +``` + +**Format of reply-to**: +- Attribute: `reply_to=""` on the `` tag (if `m.reply_to_message_id` is present) +- The ID is XML-escaped via `escapeXml()` +- Nested element: `` (if both sender and content are present) +- Both sender name and content are XML-escaped + +**What it contains**: +- `reply_to=""` attribute with the exact message ID from `m.reply_to_message_id` +- Sender name from `m.reply_to_sender_name` +- Original message content from `m.reply_to_message_content` +- **No timestamp** of the referenced message + +**Conditional rendering**: +1. If `m.reply_to_message_id` is present: include `reply_to=""` attribute +2. If `m.reply_to_message_id` is present but content/sender missing: include attribute only, no `` element +3. If only content and sender (no ID): only `` element, no attribute + +**Example output**: +```xml + + Are you coming tonight? +Yes, on my way! +``` + +### Test coverage + +From `src/v1/formatting.test.ts:96-139`: + +1. **Reply with both ID and quoted content** + - Input: Message with `reply_to_message_id: '42'`, `reply_to_sender_name: 'Bob'`, `reply_to_message_content: 'Are you coming tonight?'`, content: `'Yes, on my way!'` + - Asserts: + - `result.toContain('reply_to="42"')` + - `result.toContain('Are you coming tonight?')` + - `result.toContain('Yes, on my way!')` + - File:line: `src/v1/formatting.test.ts:96-112` + +2. **No reply context when missing** + - Input: Message without reply fields + - Asserts: + - `result.not.toContain('reply_to')` + - `result.not.toContain('quoted_message')` + - File:line: `src/v1/formatting.test.ts:114-119` + +3. **ID present but content missing** + - Input: `reply_to_message_id: '42'`, `reply_to_sender_name: 'Bob'`, but NO `reply_to_message_content` + - Asserts: + - `result.toContain('reply_to="42"')` + - `result.not.toContain('quoted_message')` + - File:line: `src/v1/formatting.test.ts:121-130` + +4. **XML escape in reply context** + - Input: `reply_to_message_id: '1'`, `reply_to_sender_name: 'A & B'`, `reply_to_message_content: ''` + - Asserts: + - `result.toContain('from="A & B"')` + - `result.toContain('<script>alert("xss")</script>')` + - File:line: `src/v1/formatting.test.ts:131-139` + +--- + +## 4. Internal tag stripping + +### v1 behavior (exact) + +**Function name**: `stripInternalTags()` in `src/v1/router.ts:25-27` + +**Implementation**: +```typescript +export function stripInternalTags(text: string): string { + return text.replace(/[\s\S]*?<\/internal>/g, '').trim(); +} +``` + +**Regex pattern**: `/[\s\S]*?<\/internal>/g` +- `` — literal opening tag +- `[\s\S]*?` — match any character (whitespace or non-whitespace) non-greedily +- `<\/internal>` — literal closing tag +- `g` flag — global (all matches) + +**Post-processing**: `.trim()` removes leading/trailing whitespace after all tags are stripped + +**Where it's called**: +- `src/v1/router.ts:30` in `formatOutbound()` function +- Called AFTER the tag removal to clean the output before returning + +**Used for**: Stripping internal thinking/reasoning from outbound messages before sending to channel + +**Input/Output examples**: + +1. Single-line internal tag: + - Input: `'hello secret world'` + - Output: `'hello world'` (then `.trim()` would be `'hello world'`) + +2. Multi-line internal tags: + - Input: `'hello \nsecret\nstuff\n world'` + - Output: `'hello world'` + +3. Multiple blocks: + - Input: `'ahellob'` + - Output: `'hello'` + +4. Only internal content: + - Input: `'only this'` + - Output: `''` (empty after trim) + +### Test coverage + +From `src/v1/formatting.test.ts:163-181`: + +1. **Single-line tag stripping** + - Input: `'hello secret world'` + - Asserts: Result is `'hello world'` (two spaces, then `.trim()` removes outer whitespace) + - Expected (with trim): `'hello world'` + - File:line: `src/v1/formatting.test.ts:163-165` + +2. **Multi-line tag stripping** + - Input: `'hello \nsecret\nstuff\n world'` + - Asserts: Result is `'hello world'` (after trim) + - File:line: `src/v1/formatting.test.ts:167-169` + +3. **Multiple internal blocks** + - Input: `'ahellob'` + - Asserts: Result is `'hello'` + - File:line: `src/v1/formatting.test.ts:171-173` + +4. **Only internal content** + - Input: `'only this'` + - Asserts: Result is `''` (empty string) + - File:line: `src/v1/formatting.test.ts:175-177` + +From `src/v1/formatting.test.ts:183-194`: + +5. **formatOutbound with no internal tags** + - Input: `'hello world'` + - Asserts: Result is `'hello world'` + - File:line: `src/v1/formatting.test.ts:183-185` + +6. **formatOutbound with all internal content** + - Input: `'hidden'` + - Asserts: Result is `''` (returns early after strip) + - File:line: `src/v1/formatting.test.ts:187-189` + +7. **formatOutbound strips and returns remaining** + - Input: `'thinkingThe answer is 42'` + - Asserts: Result is `'The answer is 42'` + - File:line: `src/v1/formatting.test.ts:191-194` + +--- + +## 5. Timezone handling for scheduled tasks + +### v1 behavior (exact) + +**Location**: `src/v1/task-scheduler.ts:20-49` + +**Key function**: `computeNextRun(task: ScheduledTask): string | null` + +**Cron timezone handling**: +```typescript +if (task.schedule_type === 'cron') { + const interval = CronExpressionParser.parse(task.schedule_value, { + tz: TIMEZONE, + }); + return interval.next().toISOString(); +} +``` + +**Critical details**: +- Uses `cron-parser` library's `CronExpressionParser.parse()` method +- Passes timezone option as `{ tz: TIMEZONE }` (e.g., `{ tz: 'America/New_York' }`) +- `TIMEZONE` is imported from `src/v1/config.ts:62` and resolved via `resolveConfigTimezone()` +- The cron expression is interpreted in the **user's timezone**, not UTC +- Example: cron `'0 9 * * *'` with `tz: 'America/New_York'` means 9 AM ET every day + +**Interval task handling**: +```typescript +if (task.schedule_type === 'interval') { + const ms = parseInt(task.schedule_value, 10); + if (!ms || ms <= 0) { + logger.warn({ taskId: task.id, value: task.schedule_value }, 'Invalid interval value'); + return new Date(now + 60_000).toISOString(); + } + let next = new Date(task.next_run!).getTime() + ms; + while (next <= now) { + next += ms; + } + return new Date(next).toISOString(); +} +``` + +**Interval specifics**: +- Intervals are timezone-agnostic (pure millisecond-based) +- Anchored to the task's `next_run` time to prevent cumulative drift +- If intervals have been missed, the loop skips forward to land in the future while maintaining the original schedule grid + +**Once-only tasks**: +```typescript +if (task.schedule_type === 'once') return null; +``` + +**MCP tool description**: +- v1 did not expose cron task scheduling directly to the agent (it was a server-side feature) +- The scheduling was configured in group config files, not via agent tool calls + +### Test coverage + +From `src/v1/task-scheduler.test.ts:33-60`: + +1. **computeNextRun returns null for once-tasks** + - Input: Task with `schedule_type: 'once'` + - Asserts: `computeNextRun(task)` returns `null` + - File:line: `src/v1/task-scheduler.test.ts:40-49` + +2. **Interval task anchoring to prevent drift** + - Input: Task scheduled 2s ago with interval `60000` (1 minute) + - Asserts: Next run = `scheduledTime + 60s`, not `now + 60s` + - Expected: Exact alignment to the scheduled time grid + - File:line: `src/v1/task-scheduler.test.ts:33-39` + +3. **Interval task catches up without infinite loop** + - Input: Task with 10 missed intervals (missed by 10 * 60000ms) + - Asserts: Next run is in the future and aligned to original schedule grid + - File:line: `src/v1/task-scheduler.test.ts:51-60` + +--- + +## 6. Complete test inventory (formatting.test.ts) + +### All test cases from src/v1/formatting.test.ts (lines 1-254): + +#### Block 1: escapeXml tests (lines 22-46) + +| Test name | Input | Expected output | +|-----------|-------|-----------------| +| escapes ampersands | `'a & b'` | `'a & b'` | +| escapes less-than | `'a < b'` | `'a < b'` | +| escapes greater-than | `'a > b'` | `'a > b'` | +| escapes double quotes | `'"hello"'` | `'"hello"'` | +| handles multiple special characters together | `'a & b < c > d "e"'` | `'a & b < c > d "e"'` | +| passes through strings with no special chars | `'hello world'` | `'hello world'` | +| handles empty string | `''` | `''` | + +#### Block 2: formatMessages tests (lines 48-159) + +| Test name | Input | Key asserts | +|-----------|-------|------------| +| formats a single message as XML with context header (line 51) | Single message with timestamp `'2024-01-01T00:00:00.000Z'`, TZ `'UTC'` | Contains `''`, `'hello'`, `'Jan 1, 2024'` | +| formats multiple messages (line 59) | 2 messages: Alice at 00:00, Bob at 01:00 | Contains both sender names and contents | +| escapes special characters in sender names (line 72) | Sender `'A & B '` | Contains `'sender="A & B <Co>"'` | +| escapes special characters in content (line 79) | Content `''` | Contains escaped script tags `'<script>...'` | +| handles empty array (line 85) | Empty message list, TZ `'UTC'` | Contains header and `'\n\n'` | +| renders reply context as quoted_message element (line 96) | Message with `reply_to_message_id: '42'`, `reply_to_sender_name: 'Bob'`, `reply_to_message_content: 'Are you coming tonight?'` | Contains `'reply_to="42"'`, `'Are you coming tonight?'` | +| omits reply attributes when no reply context (line 114) | Message without reply fields | Does NOT contain `'reply_to'` or `'quoted_message'` | +| omits quoted_message when content is missing but id is present (line 121) | Message with `reply_to_message_id: '42'` but no `reply_to_message_content` | Contains `'reply_to="42"'` but NOT `'alert("xss")'` | Contains `'from="A & B"'` and escaped script | +| converts timestamps to local time for given timezone (line 140) | Timestamp `'2024-01-01T18:30:00.000Z'` with TZ `'America/New_York'` (EST, UTC-5) | Contains `'1:30'`, `'PM'`, header has `'America/New_York'` | + +#### Block 3: TRIGGER_PATTERN tests (lines 146-169) + +| Test name | Input | Expected result | +|-----------|-------|-----------------| +| matches @name at start of message (line 152) | `'@Andy hello'` (assuming ASSISTANT_NAME='Andy') | `true` | +| matches case-insensitively (line 156) | `'@andy hello'` or `'@ANDY hello'` | `true` | +| does not match when not at start of message (line 160) | `'hello @Andy'` | `false` | +| does not match partial name like @NameExtra (word boundary) (line 164) | `'@Andyextra hello'` | `false` | +| matches with word boundary before apostrophe (line 168) | `'@Andy\'s thing'` | `true` | +| matches @name alone (end of string is a word boundary) (line 172) | `'@Andy'` | `true` | +| matches with leading whitespace after trim (line 175) | `' @Andy hey'` (after `.trim()`) | `true` | + +#### Block 4: getTriggerPattern tests (lines 177-196) + +| Test name | Input | Expected behavior | +|-----------|-------|-------------------| +| uses the configured per-group trigger when provided (line 180) | `getTriggerPattern('@Claw')` | Matches `'@Claw hello'`, does NOT match `'@Andy hello'` | +| falls back to the default trigger when group trigger is missing (line 186) | `getTriggerPattern(undefined)` | Matches default trigger `'@Andy hello'` | +| treats regex characters in custom triggers literally (line 192) | `getTriggerPattern('@C.L.A.U.D.E')` | Matches literal dots, NOT wildcard (does NOT match `'@CXLXAUXDXE'`) | + +#### Block 5: stripInternalTags tests (lines 198-210) + +| Test name | Input | Expected output | +|-----------|-------|-----------------| +| strips single-line internal tags (line 199) | `'hello secret world'` | `'hello world'` (then `.trim()` makes it `'hello world'`) | +| strips multi-line internal tags (line 203) | `'hello \nsecret\nstuff\n world'` | `'hello world'` | +| strips multiple internal tag blocks (line 207) | `'ahellob'` | `'hello'` | +| returns empty string when text is only internal tags (line 211) | `'only this'` | `''` | + +#### Block 6: formatOutbound tests (lines 213-226) + +| Test name | Input | Expected output | +|-----------|-------|-----------------| +| returns text with internal tags stripped (line 214) | `'hello world'` | `'hello world'` | +| returns empty string when all text is internal (line 218) | `'hidden'` | `''` | +| strips internal tags from remaining text (line 222) | `'thinkingThe answer is 42'` | `'The answer is 42'` | + +#### Block 7: trigger gating (requiresTrigger interaction) tests (lines 228-254) + +| Test name | Input | Expected result | +|-----------|-------|-----------------| +| main group always processes (no trigger needed) (line 239) | `isMainGroup: true`, message without trigger | `true` | +| main group processes even with requiresTrigger=true (line 244) | `isMainGroup: true`, `requiresTrigger: true`, no trigger | `true` | +| non-main group with requiresTrigger=undefined requires trigger (line 249) | `isMainGroup: false`, `requiresTrigger: undefined`, no trigger | `false` | +| non-main group with requiresTrigger=true requires trigger (line 254) | `isMainGroup: false`, `requiresTrigger: true`, no trigger | `false` | +| non-main group with requiresTrigger=true processes when trigger present (line 259) | `isMainGroup: false`, trigger in message | `true` | +| non-main group uses per-group trigger instead of default (line 264) | `isMainGroup: false`, `trigger: '@Claw'`, message `'@Claw do something'` | `true` | +| non-main group does not process when only default trigger is present for custom-trigger group (line 269) | `isMainGroup: false`, `trigger: '@Claw'`, message `'@Andy do something'` | `false` | +| non-main group with requiresTrigger=false always processes (line 274) | `isMainGroup: false`, `requiresTrigger: false`, no trigger | `true` | + +--- + +## v2 porting plan + +### For each of sections 1–5: the specific change to make in v2 + +#### 1. Timestamp formatting + +**v2 file to modify**: (Unknown — search for where v2 formats inbound messages to the agent) + +**Change needed**: +1. Find where v2 currently formats message timestamps for the agent +2. Replace any custom date formatting with the v1 pattern: + - Call `new Date(timestamp).toLocaleString('en-US', { timeZone, year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })` +3. Ensure the timezone parameter is sourced from `config.TIMEZONE` (or equivalent in v2) + +**Test to port**: `src/v1/formatting.test.ts:51-56` (basic formatting) and `src/v1/formatting.test.ts:74-78` (timezone conversion) + +#### 2. Context timezone header + +**v2 file to modify**: (Unknown — search for where v2 constructs the XML/prompt for inbound messages) + +**Change needed**: +1. Prepend `\n` to the formatted message block +2. The timezone should be the resolved IANA identifier (e.g., `'UTC'`, `'America/New_York'`) +3. Ensure it's placed BEFORE the `` element + +**Test to port**: `src/v1/formatting.test.ts:51-56` and `src/v1/formatting.test.ts:80-83` (empty array still has header) + +#### 3. Reply-to with message ID + +**v2 file to modify**: (Unknown — search for where v2 formats message metadata) + +**Change needed**: +1. If `message.reply_to_message_id` is present, add ` reply_to=""` attribute to the `` element +2. If BOTH `message.reply_to_message_content` AND `message.reply_to_sender_name` are present, include a nested `` element +3. XML-escape all three values (ID, sender name, content) + +**Test to port**: +- `src/v1/formatting.test.ts:96-112` (full reply context) +- `src/v1/formatting.test.ts:121-130` (ID only, no content) +- `src/v1/formatting.test.ts:131-139` (XML escaping in reply) + +#### 4. Internal tag stripping + +**v2 file to modify**: (Unknown — search for where v2 processes outbound messages before sending) + +**Change needed**: +1. Apply the regex `/[\s\S]*?<\/internal>/g` to strip all internal thinking/reasoning blocks +2. Call `.trim()` on the result after stripping +3. Return empty string if result is empty after stripping + +**Test to port**: +- `src/v1/formatting.test.ts:163-177` (stripInternalTags) +- `src/v1/formatting.test.ts:183-194` (formatOutbound) + +#### 5. Scheduled task timezone handling + +**v2 file to modify**: (Unknown — search for where v2 handles cron task scheduling) + +**Change needed**: +1. When parsing cron expressions, pass the timezone option to cron-parser: + ```typescript + const interval = CronExpressionParser.parse(cronExpression, { tz: TIMEZONE }); + ``` +2. For interval-based tasks, anchor to the original `next_run` time, not `Date.now()`, to prevent drift +3. Ensure the TIMEZONE constant is resolved at startup via a function like: + ```typescript + function resolveConfigTimezone(): string { + const candidates = [process.env.TZ, envConfig.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone]; + for (const tz of candidates) { + if (tz && isValidTimezone(tz)) return tz; + } + return 'UTC'; + } + ``` + +**Test to port**: +- `src/v1/task-scheduler.test.ts:33-39` (interval anchoring) +- `src/v1/task-scheduler.test.ts:40-49` (once-task returns null) +- `src/v1/task-scheduler.test.ts:51-60` (interval catch-up) + +--- + +## Git references for verification + +All code snippets above can be verified with: + +```bash +git show 27c5220:src/v1/router.ts +git show 27c5220:src/v1/timezone.ts +git show 27c5220:src/v1/config.ts +git show 27c5220:src/v1/task-scheduler.ts +git show 27c5220:src/v1/types.ts +git show 27c5220:src/v1/formatting.test.ts +git show 27c5220:src/v1/timezone.test.ts +git show 27c5220:src/v1/task-scheduler.test.ts +``` + +Or from the deletion parent commit: + +```bash +git show 86becf8^:src/v1/ +``` diff --git a/docs/v1-vs-v2/timezone.md b/docs/v1-vs-v2/timezone.md new file mode 100644 index 0000000..f036fa3 --- /dev/null +++ b/docs/v1-vs-v2/timezone.md @@ -0,0 +1,27 @@ +# timezone: v1 vs v2 + +## Scope +- v1: `src/v1/timezone.ts` (37 LOC), `src/v1/timezone.test.ts` (64 LOC) +- v2 counterparts: `src/timezone.ts` (37 LOC), `src/timezone.test.ts` (64 LOC) + +## Capability map + +| v1 behavior | v2 location | Status | Notes | +|---|---|---|---| +| `isValidTimezone(tz)` | `src/timezone.ts:5-12` | kept | Byte-identical | +| `resolveTimezone(tz)` | `src/timezone.ts:17-19` | kept | Byte-identical | +| `formatLocalTime(utcIso, timezone)` | `src/timezone.ts:26-37` | kept | Byte-identical | + +## Tests (byte-identical) +- `formatLocalTime`: UTC→local display with offset; DST awareness (EDT vs EST); fall back to UTC on invalid tz without throwing +- `isValidTimezone`: accepts `America/New_York`, `UTC`, `Asia/Tokyo`, `Asia/Jerusalem`; rejects `IST-2`, `XYZ+3`, empty/garbage +- `resolveTimezone`: returns tz if valid; falls back to UTC on invalid or empty + +## Missing from v2 +None — v1 and v2 files are byte-for-byte identical. + +## Behavioral discrepancies +None. + +## Worth preserving? +No action needed — v2 already mirrors v1 exactly. Minimal, correct, no external deps. No cron-time conversions in either version (that logic lived in `task-scheduler.ts`). diff --git a/docs/v1-vs-v2/types.md b/docs/v1-vs-v2/types.md new file mode 100644 index 0000000..fbf4b3f --- /dev/null +++ b/docs/v1-vs-v2/types.md @@ -0,0 +1,58 @@ +# types: v1 vs v2 + +## Scope +- v1: `src/v1/types.ts` (112 LOC) — 10 exported types/interfaces covering AdditionalMount, MountAllowlist, AllowedRoot, ContainerConfig, RegisteredGroup, NewMessage, ScheduledTask, TaskRunLog, Channel, OnInboundMessage/OnChatMetadata +- v2 counterparts (distributed): + - `src/types.ts` — central DB entities (`AgentGroup`, `MessagingGroup`, `MessageIn`, `User`, `MessagingGroupAgent` etc.) + - `src/container-config.ts` — file-based per-group container config + - `src/mount-security.ts` — mount types + - `src/channels/adapter.ts` — v2 channel interface + - `container/agent-runner/src/db/messages-in.ts`, `destinations.ts` — session-level types + - `src/db/schema.ts` — schema reference + +## Capability map + +| v1 type / field | v2 location | Status | Notes | +|---|---|---|---| +| `AdditionalMount` | `src/mount-security.ts:16-18` | kept | Same fields | +| `MountAllowlist` / `AllowedRoot` | `src/mount-security.ts:21-29` | kept | `nonMainReadOnly` field removed (see container-runtime doc) | +| `ContainerConfig` | split: `src/container-config.ts:36` (file-based) + `src/mount-security.ts` | refactored | `timeout` dropped; added `mcpServers`, `packages`, `imageTag` | +| `RegisteredGroup` | `agent_groups` + `messaging_group_agents` + `container.json` | refactored | One entity split across two DB tables + filesystem | +| `RegisteredGroup.trigger` | `messaging_group_agents.trigger_rules` JSON | moved | Per-wiring, not per-group | +| `RegisteredGroup.containerConfig` | `groups//container.json` | moved | DB → disk | +| `RegisteredGroup.isMain` | convention (`agent_group_id = 'main'`) | removed | No explicit flag | +| `NewMessage` | split: `MessageIn` (`src/types.ts:98-111`) + `InboundMessage` (`src/channels/adapter.ts:33-38`) + `MessageInRow` (`container/.../db/messages-in.ts`) | refactored | Platform fields separated | +| `NewMessage.chat_jid` | `channel_type` + `platform_id` | refactored | Explicit split, no more JID parsing | +| `NewMessage.sender` / `sender_name` | inside JSON `content` blob | moved | Less type safety, more flexibility | +| `NewMessage.is_from_me` / `is_bot_message` | — | removed | Inferred from identity or `messages_out` | +| `NewMessage.reply_to_*` | inside `content` blob | moved | | +| `ScheduledTask` (entire type) | `MessageIn` with `kind='task'` + `recurrence` | removed | No separate task entity; no task UI/API | +| `TaskRunLog` | — | removed | No audit trail in v2 | +| `Channel` (connect/disconnect/sendMessage/ownsJid/syncGroups/setTyping) | `ChannelAdapter` (`src/channels/adapter.ts:60-105`) | refactored | Stateless request/response, async, no callback loop | +| `Channel.ownsJid` | — | removed | Routing keyed on `channel_type + platform_id` | +| `OnInboundMessage(chatJid, message)` | `onInbound(platformId, threadId, message)` | refactored | Routing fields explicit | +| `OnChatMetadata` | `onMetadata(platformId, name?, isGroup?)` | refactored | Drops timestamp/channel params | + +## Schema diff (v1 `RegisteredGroup` → v2 split) +- **Identity** (`name`, `folder`, `created_at`) → `agent_groups` table +- **Wiring** (`trigger`, `requiresTrigger`) → `messaging_group_agents` table (`trigger_rules`, `response_scope`, `session_mode`) +- **Container config** (`containerConfig`) → `groups//container.json` +- Normalization gain: an agent group can have N wirings with different triggers + +## Missing from v2 +1. `ScheduledTask` + `TaskRunLog` — no first-class task entity or execution log +2. `ContainerConfig.timeout` — per-group timeout override gone; single hardcoded `IDLE_TIMEOUT` +3. `NewMessage.is_from_me` / `is_bot_message` — flat flags gone +4. `Channel.ownsJid` — JID ownership concept gone +5. `Channel.connect()`/`disconnect()`/`isConnected()` lifecycle — replaced by stateless `setup`/`teardown` + +## Behavioral discrepancies +- **JID → channel_type + platform_id**: routing fields are now structured, not bundled strings +- **Pull vs push channels**: v1 channels pushed events via callbacks; v2 adapters are stateless with DB-mediated flow +- **Container config storage**: v1 in DB, v2 on disk (survives container restarts without DB query) + +## Worth preserving? +- **ScheduledTask / TaskRunLog**: v2's removal leaves a visibility gap; if scheduled-task introspection matters, reintroduce a log table keyed on `messages_in.id` to capture run metadata +- **Per-group timeout**: meaningful loss — some agent groups are slow, others fast; hardcoded timeout = false positives +- **is_from_me / is_bot_message**: trivial to reconstruct; not worth restoring +- **Channel lifecycle callbacks**: obsolete; v2 model is cleaner diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 30ba0e8..2d45b29 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -71,6 +71,12 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter let chat: Chat; let state: SqliteStateAdapter; let setupConfig: ChannelSetup; + // NOTE: populated at setup() and updateConversations(), but currently not + // read by any inbound handler. When adapter-level gating lands (engage_mode + // applied here) or when dynamic group registration is added, this map goes + // stale after setup unless updateConversations() is actively called on every + // messaging_groups / messaging_group_agents mutation. See ACTION-ITEMS.md + // item 17. let conversations: Map; let gatewayAbort: AbortController | null = null; From 0283391e0a59e1288dc3795f09fb7fd7da389d5b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 01:01:47 +0300 Subject: [PATCH 009/185] chore(config): remove dead POLL_INTERVAL / SCHEDULER_POLL_INTERVAL / IPC_POLL_INTERVAL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These three constants were carried over from v1's polling + IPC architecture and have zero callers in the v2 runtime: - POLL_INTERVAL (2000ms) — v1 message loop; replaced by event-driven delivery + delivery.ts's ACTIVE_POLL_MS (hardcoded 1000ms) - SCHEDULER_POLL_INTERVAL (60000ms) — v1 task scheduler; replaced by host-sweep.ts's SWEEP_INTERVAL_MS (hardcoded 60_000) - IPC_POLL_INTERVAL (1000ms) — v1 file-based IPC; meaningless in v2's session-DB architecture Grep confirms no imports in src/, container/, or tests. Docs/SPEC.md updated to match. Ref: docs/v1-vs-v2/ACTION-ITEMS.md item 15. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/SPEC.md | 3 --- src/config.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 687336f..42ef37c 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -332,8 +332,6 @@ Configuration constants are in `src/config.ts`: import path from 'path'; export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'Andy'; -export const POLL_INTERVAL = 2000; -export const SCHEDULER_POLL_INTERVAL = 60000; // Paths are absolute (required for container mounts) const PROJECT_ROOT = process.cwd(); @@ -344,7 +342,6 @@ export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); // Container configuration export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); // 30min default -export const IPC_POLL_INTERVAL = 1000; export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min — keep container alive after last result export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5); diff --git a/src/config.ts b/src/config.ts index ef1ba9e..043a4a2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,8 +10,6 @@ const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ON 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(); @@ -29,7 +27,6 @@ export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800 export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL; export const MAX_MESSAGES_PER_PROMPT = Math.max(1, parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10); -export const IPC_POLL_INTERVAL = 1000; export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5); From dcfa12ea06d76d278422f5381b4e1a92861c5291 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 01:09:14 +0300 Subject: [PATCH 010/185] feat(timezone): recreate v1 TZ-aware formatting + scheduling behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent needs to perceive times in the user's timezone, not UTC. Dropping this in the v1→v2 port produced a class of bugs where the agent would schedule tasks for the wrong hour, suggest dinner at midnight, etc. This restores v1 parity. Container side: - New container/agent-runner/src/timezone.ts mirrors src/timezone.ts with isValidTimezone / resolveTimezone / formatLocalTime, plus: * TIMEZONE constant resolved at load from process.env.TZ (host sets this from src/container-runner.ts:254) * parseZonedToUtc(input, tz) — treats a naive ISO as wall-clock time in `tz`, returns the corresponding UTC Date. Strings with Z or offset are passed through. - formatter.ts: * formatMessages() now prepends \n — matches v1 src/v1/router.ts:20-22 * formatSingleChat uses formatLocalTime(ts, TIMEZONE) instead of a home-rolled HH:MM 24h formatter → outputs like "Jun 15, 2026, 8:00 AM" * reply_to="" attribute + Y element — matches v1 format exactly; old shape is gone * stripInternalTags() exported for the dispatch path to reuse - poll-loop.ts uses the exported stripInternalTags() instead of inline regex. - mcp-tools/scheduling.ts: * schedule_task/update_task descriptions now explicitly document that processAfter accepts either UTC or naive local time (interpreted in the user's TZ from the context header) * handlers normalize through parseZonedToUtc() and store a UTC ISO Host side: - src/modules/scheduling/recurrence.ts passes { tz: TIMEZONE } to CronExpressionParser.parse. Without this, "0 9 * * *" fires at 09:00 UTC instead of 09:00 user-local — this was the v1 behavior (src/v1/task-scheduler.ts:20-49). Tests: - container/agent-runner/src/timezone.test.ts — mirror of src/timezone.test.ts + new parseZonedToUtc cases - container/agent-runner/src/formatter.test.ts — context header, reply_to, quoted_message, XML escaping, stripInternalTags (ported from v1 formatting.test.ts) - src/modules/scheduling/recurrence.test.ts — cron TZ respected, completed rows only cloned when recurrence is set Ref: docs/v1-vs-v2/ACTION-ITEMS.md item 18 + timezone-formatting-v1-recreation.md Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/formatter.test.ts | 167 ++++++++++++++++++ container/agent-runner/src/formatter.ts | 54 ++++-- .../agent-runner/src/mcp-tools/scheduling.ts | 44 ++++- container/agent-runner/src/poll-loop.ts | 7 +- container/agent-runner/src/timezone.test.ts | 93 ++++++++++ container/agent-runner/src/timezone.ts | 107 +++++++++++ src/modules/scheduling/recurrence.test.ts | 100 +++++++++++ src/modules/scheduling/recurrence.ts | 7 +- 8 files changed, 549 insertions(+), 30 deletions(-) create mode 100644 container/agent-runner/src/formatter.test.ts create mode 100644 container/agent-runner/src/timezone.test.ts create mode 100644 container/agent-runner/src/timezone.ts create mode 100644 src/modules/scheduling/recurrence.test.ts diff --git a/container/agent-runner/src/formatter.test.ts b/container/agent-runner/src/formatter.test.ts new file mode 100644 index 0000000..e34156c --- /dev/null +++ b/container/agent-runner/src/formatter.test.ts @@ -0,0 +1,167 @@ +/** + * v1-parity tests for formatter behavior. + * + * Port of src/v1/formatting.test.ts (at commit 27c5220, parent of the v1 + * deletion commit 86becf8). Covers: context timezone header, reply_to + + * quoted_message rendering, XML escaping, and stripInternalTags. + * + * Timestamp-format assertions use `formatLocalTime()` output format, which + * is host locale-dependent for decorators (month abbr, "," separator) but + * stable for the numeric parts we assert on (hour, minute, year). + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js'; +import { getPendingMessages } from './db/messages-in.js'; +import { formatMessages, stripInternalTags } from './formatter.js'; +import { TIMEZONE } from './timezone.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +afterEach(() => { + closeSessionDb(); +}); + +function insertMessage( + id: string, + kind: string, + content: object, + opts?: { timestamp?: string }, +) { + const timestamp = opts?.timestamp ?? new Date().toISOString(); + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, content) + VALUES (?, ?, ?, 'pending', ?)`, + ) + .run(id, kind, timestamp, JSON.stringify(content)); +} + +describe('context timezone header', () => { + it('prepends to formatted output', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'hello' }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain(` { + const result = formatMessages([]); + expect(result).toContain(` block', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' }); + insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' }); + const result = formatMessages(getPendingMessages()); + const ctxIdx = result.indexOf(''); + expect(ctxIdx).toBeGreaterThanOrEqual(0); + expect(msgsIdx).toBeGreaterThan(ctxIdx); + }); +}); + +describe('timestamp formatting', () => { + it('renders time via formatLocalTime (user TZ)', () => { + // 2026-06-15T12:00:00Z — timezone-agnostic assertions (year is stable) + insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }, { timestamp: '2026-06-15T12:00:00.000Z' }); + const result = formatMessages(getPendingMessages()); + // formatLocalTime's format in en-US contains the year and a month abbrev + expect(result).toContain('2026'); + expect(result).toMatch(/Jun/); + }); + + it('uses 12-hour AM/PM format', () => { + // 15:30 UTC — some hour will show with AM or PM depending on TZ + insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }, { timestamp: '2026-06-15T15:30:00.000Z' }); + const result = formatMessages(getPendingMessages()); + expect(result).toMatch(/(AM|PM)/); + }); +}); + +describe('reply_to + quoted_message rendering', () => { + it('renders reply_to attribute and quoted_message when all fields present', () => { + insertMessage('m1', 'chat', { + sender: 'Alice', + text: 'Yes, on my way!', + replyTo: { id: '42', sender: 'Bob', text: 'Are you coming tonight?' }, + }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain('reply_to="42"'); + expect(result).toContain('Are you coming tonight?'); + expect(result).toContain('Yes, on my way!'); + }); + + it('omits reply_to and quoted_message when no reply context', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'plain' }); + const result = formatMessages(getPendingMessages()); + expect(result).not.toContain('reply_to'); + expect(result).not.toContain('quoted_message'); + }); + + it('renders reply_to but omits quoted_message when original content is missing', () => { + insertMessage('m1', 'chat', { + sender: 'Alice', + text: 'ack', + replyTo: { id: '42', sender: 'Bob' }, // no text + }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain('reply_to="42"'); + expect(result).not.toContain('quoted_message'); + }); + + it('XML-escapes reply context', () => { + insertMessage('m1', 'chat', { + sender: 'Alice', + text: 'reply', + replyTo: { id: '1', sender: 'A & B', text: '' }, + }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain('from="A & B"'); + expect(result).toContain('<script>'); + expect(result).toContain('"xss"'); + }); +}); + +describe('XML escaping', () => { + it('escapes <, >, &, " in sender and body', () => { + insertMessage('m1', 'chat', { + sender: 'A & B ', + text: '', + }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain('sender="A & B <Co>"'); + expect(result).toContain('<script>alert("xss")</script>'); + }); +}); + +describe('stripInternalTags', () => { + it('strips single-line internal tags and trims', () => { + expect(stripInternalTags('hello secret world')).toBe('hello world'); + }); + + it('strips multi-line internal tags', () => { + expect(stripInternalTags('hello \nsecret\nstuff\n world')).toBe( + 'hello world', + ); + }); + + it('strips multiple internal tag blocks', () => { + expect(stripInternalTags('ahellob')).toBe('hello'); + }); + + it('returns empty string when input is only internal tags', () => { + expect(stripInternalTags('only this')).toBe(''); + }); + + it('returns input unchanged when there are no internal tags', () => { + expect(stripInternalTags('hello world')).toBe('hello world'); + }); + + it('preserves content that surrounds internal tags', () => { + expect(stripInternalTags('thinkingThe answer is 42')).toBe( + 'The answer is 42', + ); + }); +}); diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index fbf1ed9..b03f5bd 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -1,5 +1,6 @@ import { findByRouting } from './destinations.js'; import type { MessageInRow } from './db/messages-in.js'; +import { TIMEZONE, formatLocalTime } from './timezone.js'; /** * Command categories for messages starting with '/'. @@ -92,10 +93,19 @@ export function extractRouting(messages: MessageInRow[]): RoutingContext { /** * Format a batch of messages_in rows into a prompt string. + * + * Prepends a `` header so the agent always knows + * what timezone it's in — every timestamp it sees in message bodies is the + * user's local time, and every time it produces (schedules, suggests) should + * be interpreted as local time in that same zone. This header is v1 behavior + * (src/v1/router.ts:20-22); dropping it led to misinterpretations where the + * agent scheduled tasks for the wrong hour. + * * Strips routing fields — the agent never sees platform_id, channel_type, thread_id. */ export function formatMessages(messages: MessageInRow[]): string { - if (messages.length === 0) return ''; + const header = `\n`; + if (messages.length === 0) return header; // Group by kind const chatMessages = messages.filter((m) => m.kind === 'chat' || m.kind === 'chat-sdk'); @@ -118,7 +128,7 @@ export function formatMessages(messages: MessageInRow[]): string { parts.push(...systemMessages.map(formatSystemMessage)); } - return parts.join('\n\n'); + return header + parts.join('\n\n'); } function formatChatMessages(messages: MessageInRow[]): string { @@ -137,9 +147,10 @@ function formatChatMessages(messages: MessageInRow[]): string { function formatSingleChat(msg: MessageInRow): string { const content = parseContent(msg.content); const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown'; - const time = formatTime(msg.timestamp); + const time = formatLocalTime(msg.timestamp, TIMEZONE); const text = content.text || ''; const idAttr = msg.seq != null ? ` id="${msg.seq}"` : ''; + const replyAttr = content.replyTo?.id ? ` reply_to="${escapeXml(String(content.replyTo.id))}"` : ''; const replyPrefix = formatReplyContext(content.replyTo); const attachmentsSuffix = formatAttachments(content.attachments); @@ -154,7 +165,7 @@ function formatSingleChat(msg: MessageInRow): string { ? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"` : ''; - return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`; + return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`; } function formatTaskMessage(msg: MessageInRow): string { @@ -179,13 +190,22 @@ function formatSystemMessage(msg: MessageInRow): string { return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`; } +/** + * Render the quoted original inside the body. + * + * Matches v1 format (src/v1/router.ts:10-18): `Y`. + * Requires BOTH sender and text — if only id is present the reply_to attribute + * on the parent carries the link without an inline preview. + * + * No truncation here (v1 didn't truncate). + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function formatReplyContext(replyTo: any): string { if (!replyTo) return ''; - const sender = replyTo.sender || 'Unknown'; - const text = replyTo.text || ''; - const preview = text.length > 100 ? text.slice(0, 100) + '…' : text; - return `\n${escapeXml(preview)}\n`; + const sender = replyTo.sender; + const text = replyTo.text; + if (!sender || !text) return ''; + return `\n ${escapeXml(text)}\n`; } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -213,15 +233,15 @@ function parseContent(json: string): any { } } -function formatTime(timestamp: string): string { - try { - const d = new Date(timestamp); - return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`; - } catch { - return timestamp; - } -} - function escapeXml(str: string): string { return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + +/** + * Strip `...` blocks from agent output, then trim. + * Ported from v1 (src/v1/router.ts:25-27). Used to remove the agent's + * own scratchpad/reasoning before a reply goes out over a channel. + */ +export function stripInternalTags(text: string): string { + return text.replace(/[\s\S]*?<\/internal>/g, '').trim(); +} diff --git a/container/agent-runner/src/mcp-tools/scheduling.ts b/container/agent-runner/src/mcp-tools/scheduling.ts index 168808c..00e41bb 100644 --- a/container/agent-runner/src/mcp-tools/scheduling.ts +++ b/container/agent-runner/src/mcp-tools/scheduling.ts @@ -8,6 +8,7 @@ import { getInboundDb } from '../db/connection.js'; import { writeMessageOut } from '../db/messages-out.js'; import { getSessionRouting } from '../db/session-routing.js'; +import { TIMEZONE, parseZonedToUtc } from '../timezone.js'; import { registerTools } from './server.js'; import type { McpToolDefinition } from './types.js'; @@ -35,13 +36,21 @@ export const scheduleTask: McpToolDefinition = { tool: { name: 'schedule_task', description: - 'Schedule a one-shot or recurring task. The task will be processed at the specified time. Use cron expressions for recurring tasks.', + `Schedule a one-shot or recurring task. The user's timezone is declared in the header of your prompt — interpret the user's "9pm" etc. in that zone. Cron expressions are interpreted in the user's timezone too.`, inputSchema: { type: 'object' as const, properties: { prompt: { type: 'string', description: 'Task instructions/prompt' }, - processAfter: { type: 'string', description: 'ISO timestamp for first run (e.g., 2024-01-15T09:00:00Z)' }, - recurrence: { type: 'string', description: 'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" for weekdays at 9am)' }, + processAfter: { + type: 'string', + description: + `ISO 8601 timestamp for the first run. Accepts either UTC (ending in "Z" or "+00:00") or a naive local timestamp (no offset) which is interpreted in the user's timezone (e.g. "2026-01-15T21:00:00" = 9pm user-local). Prefer naive local.`, + }, + recurrence: { + type: 'string', + description: + 'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" = weekdays at 9am user-local). Evaluated in the user\'s timezone.', + }, script: { type: 'string', description: 'Optional pre-agent script to run before processing' }, }, required: ['prompt', 'processAfter'], @@ -49,8 +58,17 @@ export const scheduleTask: McpToolDefinition = { }, async handler(args) { const prompt = args.prompt as string; - const processAfter = args.processAfter as string; - if (!prompt || !processAfter) return err('prompt and processAfter are required'); + const processAfterIn = args.processAfter as string; + if (!prompt || !processAfterIn) return err('prompt and processAfter are required'); + + let processAfter: string; + try { + const d = parseZonedToUtc(processAfterIn, TIMEZONE); + if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${processAfterIn}`); + processAfter = d.toISOString(); + } catch { + return err(`invalid processAfter: ${processAfterIn}`); + } const id = generateId(); const r = routing(); @@ -233,7 +251,11 @@ export const updateTask: McpToolDefinition = { type: 'string', description: 'New cron expression (optional). Pass empty string to clear and make the task one-shot.', }, - processAfter: { type: 'string', description: 'New ISO timestamp for the next run (optional)' }, + processAfter: { + type: 'string', + description: + `New ISO 8601 timestamp for the next run (optional). Accepts either UTC (ending in "Z" / "+00:00") or a naive local timestamp interpreted in the user's timezone.`, + }, script: { type: 'string', description: 'New pre-agent script (optional). Pass empty string to clear.', @@ -248,7 +270,15 @@ export const updateTask: McpToolDefinition = { const update: Record = { taskId }; if (typeof args.prompt === 'string') update.prompt = args.prompt; - if (typeof args.processAfter === 'string') update.processAfter = args.processAfter; + if (typeof args.processAfter === 'string') { + try { + const d = parseZonedToUtc(args.processAfter, TIMEZONE); + if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${args.processAfter}`); + update.processAfter = d.toISOString(); + } catch { + return err(`invalid processAfter: ${args.processAfter}`); + } + } // Empty string clears recurrence/script; undefined leaves them as-is. if (typeof args.recurrence === 'string') update.recurrence = args.recurrence === '' ? null : args.recurrence; if (typeof args.script === 'string') update.script = args.script === '' ? null : args.script; diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index cc26286..742de14 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -3,7 +3,7 @@ import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } import { writeMessageOut } from './db/messages-out.js'; import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js'; -import { formatMessages, extractRouting, categorizeMessage, type RoutingContext } from './formatter.js'; +import { formatMessages, extractRouting, categorizeMessage, stripInternalTags, type RoutingContext } from './formatter.js'; import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js'; const POLL_INTERVAL_MS = 1000; @@ -384,10 +384,7 @@ function dispatchResultText(text: string, routing: RoutingContext): void { scratchpadParts.push(text.slice(lastIndex)); } - const scratchpad = scratchpadParts - .join('') - .replace(/[\s\S]*?<\/internal>/g, '') - .trim(); + const scratchpad = stripInternalTags(scratchpadParts.join('')); // Single-destination shortcut: the agent wrote plain text — send to // the session's originating channel (from session_routing) if available, diff --git a/container/agent-runner/src/timezone.test.ts b/container/agent-runner/src/timezone.test.ts new file mode 100644 index 0000000..a4539e9 --- /dev/null +++ b/container/agent-runner/src/timezone.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'bun:test'; + +import { formatLocalTime, isValidTimezone, parseZonedToUtc, resolveTimezone } from './timezone.js'; + +// --- formatLocalTime --- + +describe('formatLocalTime', () => { + it('converts UTC to local time display', () => { + // 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM + const result = formatLocalTime('2026-02-04T18:30:00.000Z', 'America/New_York'); + expect(result).toContain('1:30'); + expect(result).toContain('PM'); + expect(result).toContain('Feb'); + expect(result).toContain('2026'); + }); + + it('handles different timezones', () => { + // Same UTC time should produce different local times + const utc = '2026-06-15T12:00:00.000Z'; + const ny = formatLocalTime(utc, 'America/New_York'); + const tokyo = formatLocalTime(utc, 'Asia/Tokyo'); + // NY is UTC-4 in summer (EDT), Tokyo is UTC+9 + expect(ny).toContain('8:00'); + expect(tokyo).toContain('9:00'); + }); + + it('does not throw on invalid timezone, falls back to UTC', () => { + expect(() => formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2')).not.toThrow(); + const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2'); + // Should format as UTC (noon UTC = 12:00 PM) + expect(result).toContain('12:00'); + expect(result).toContain('PM'); + }); +}); + +describe('isValidTimezone', () => { + it('accepts valid IANA identifiers', () => { + expect(isValidTimezone('America/New_York')).toBe(true); + expect(isValidTimezone('UTC')).toBe(true); + expect(isValidTimezone('Asia/Tokyo')).toBe(true); + expect(isValidTimezone('Asia/Jerusalem')).toBe(true); + }); + + it('rejects invalid timezone strings', () => { + expect(isValidTimezone('IST-2')).toBe(false); + expect(isValidTimezone('XYZ+3')).toBe(false); + }); + + it('rejects empty and garbage strings', () => { + expect(isValidTimezone('')).toBe(false); + expect(isValidTimezone('NotATimezone')).toBe(false); + }); +}); + +describe('resolveTimezone', () => { + it('returns the timezone if valid', () => { + expect(resolveTimezone('America/New_York')).toBe('America/New_York'); + }); + + it('falls back to UTC for invalid timezone', () => { + expect(resolveTimezone('IST-2')).toBe('UTC'); + expect(resolveTimezone('')).toBe('UTC'); + }); +}); + +describe('parseZonedToUtc', () => { + it('passes strings with Z suffix through unchanged', () => { + const d = parseZonedToUtc('2026-01-15T09:00:00Z', 'America/New_York'); + expect(d.toISOString()).toBe('2026-01-15T09:00:00.000Z'); + }); + + it('passes strings with numeric offset through unchanged', () => { + const d = parseZonedToUtc('2026-01-15T09:00:00+02:00', 'America/New_York'); + expect(d.toISOString()).toBe('2026-01-15T07:00:00.000Z'); + }); + + it('interprets naive ISO as wall-clock in the given timezone', () => { + // 09:00 naive in NY in January = 09:00 EST = 14:00 UTC + const d = parseZonedToUtc('2026-01-15T09:00:00', 'America/New_York'); + expect(d.toISOString()).toBe('2026-01-15T14:00:00.000Z'); + }); + + it('handles a different positive-offset zone', () => { + // 09:00 naive in Tokyo (UTC+9) = 00:00 UTC + const d = parseZonedToUtc('2026-06-15T09:00:00', 'Asia/Tokyo'); + expect(d.toISOString()).toBe('2026-06-15T00:00:00.000Z'); + }); + + it('treats invalid timezone as UTC', () => { + const d = parseZonedToUtc('2026-01-15T09:00:00', 'NotATimezone'); + expect(d.toISOString()).toBe('2026-01-15T09:00:00.000Z'); + }); +}); diff --git a/container/agent-runner/src/timezone.ts b/container/agent-runner/src/timezone.ts new file mode 100644 index 0000000..d9a2e1b --- /dev/null +++ b/container/agent-runner/src/timezone.ts @@ -0,0 +1,107 @@ +/** + * Timezone utilities — mirror of src/timezone.ts (host). + * + * The container can't import from src/ (separate tsconfig, different runtime). + * Kept deliberately byte-aligned with the host module so behaviour is the + * same on both sides of the session-DB boundary. + * + * TIMEZONE is resolved once at module load from process.env.TZ (which the host + * sets from its own TIMEZONE constant when spawning the container; see + * src/container-runner.ts). Invalid values fall back to UTC. + */ + +/** + * Check whether a timezone string is a valid IANA identifier + * that Intl.DateTimeFormat can use. + */ +export function isValidTimezone(tz: string): boolean { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } +} + +/** + * Return the given timezone if valid IANA, otherwise fall back to UTC. + */ +export function resolveTimezone(tz: string): string { + return isValidTimezone(tz) ? tz : 'UTC'; +} + +/** + * Convert a UTC ISO timestamp to a localized display string. + * Uses the Intl API (no external dependencies). + * Falls back to UTC if the timezone is invalid. + */ +export function formatLocalTime(utcIso: string, timezone: string): string { + const date = new Date(utcIso); + return date.toLocaleString('en-US', { + timeZone: resolveTimezone(timezone), + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); +} + +function resolveContainerTimezone(): string { + const candidates = [process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone]; + for (const tz of candidates) { + if (tz && isValidTimezone(tz)) return tz; + } + return 'UTC'; +} + +export const TIMEZONE = resolveContainerTimezone(); + +/** + * Interpret a naive ISO-like timestamp (no trailing `Z`, no offset) as wall-clock + * time in `tz` and return the corresponding UTC Date. Strings that already carry + * offset info (`Z` or `±HH:MM`) are passed through to the Date constructor + * unchanged. + * + * Algorithm: treat the naive string as UTC, ask Intl.DateTimeFormat what that + * UTC instant is called in `tz`, then invert the offset. Near DST boundaries + * this can be off by an hour for ~1h of wall-clock time per year; acceptable + * for scheduling where the agent normally picks round-hour targets. + */ +export function parseZonedToUtc(input: string, tz: string): Date { + const hasOffset = /Z$|[+-]\d{2}:?\d{2}$/.test(input.trim()); + if (hasOffset) return new Date(input); + + const zone = resolveTimezone(tz); + const asIfUtc = new Date(input + 'Z'); + if (Number.isNaN(asIfUtc.getTime())) return asIfUtc; + + const fmt = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + const parts = Object.fromEntries( + fmt + .formatToParts(asIfUtc) + .filter((p) => p.type !== 'literal') + .map((p) => [p.type, p.value]), + ); + const hour = parts.hour === '24' ? '00' : parts.hour; + const zonedAsUtcMs = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(hour), + Number(parts.minute), + Number(parts.second), + ); + const offsetMs = zonedAsUtcMs - asIfUtc.getTime(); + return new Date(asIfUtc.getTime() - offsetMs); +} diff --git a/src/modules/scheduling/recurrence.test.ts b/src/modules/scheduling/recurrence.test.ts new file mode 100644 index 0000000..a70d6c8 --- /dev/null +++ b/src/modules/scheduling/recurrence.test.ts @@ -0,0 +1,100 @@ +/** + * Tests for `handleRecurrence` — specifically the timezone-aware cron + * interpretation ported from v1 (src/v1/task-scheduler.ts). + * + * Core invariant: cron expressions are interpreted in the user's TIMEZONE, + * not UTC. Without this, `"0 9 * * *"` fires at 09:00 UTC instead of 09:00 + * user-local — a recurring scheduling bug users can't diagnose. + */ +import fs from 'fs'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { ensureSchema, openInboundDb } from '../../db/session-db.js'; +import { insertTask } from './db.js'; +import { handleRecurrence } from './recurrence.js'; +import type { Session } from '../../types.js'; + +const TEST_DIR = '/tmp/nanoclaw-recurrence-test'; +const DB_PATH = path.join(TEST_DIR, 'inbound.db'); + +function freshDb() { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + ensureSchema(DB_PATH, 'inbound'); + return openInboundDb(DB_PATH); +} + +function fakeSession(): Session { + return { + id: 'sess-test', + agent_group_id: 'ag-test', + messaging_group_id: 'mg-test', + thread_id: null, + status: 'active', + created_at: new Date().toISOString(), + last_active: new Date().toISOString(), + container_status: 'stopped', + } as Session; +} + +afterEach(() => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); +}); + +describe('handleRecurrence', () => { + it('clones a completed recurring task with a next-run in the future', async () => { + const db = freshDb(); + insertTask(db, { + id: 'task-1', + processAfter: '2020-01-01T00:00:00.000Z', + recurrence: '0 9 * * *', // every day at 09:00 (user TZ) + platformId: null, + channelType: null, + threadId: null, + content: JSON.stringify({ prompt: 'daily digest' }), + }); + db.prepare(`UPDATE messages_in SET status='completed' WHERE id='task-1'`).run(); + + await handleRecurrence(db, fakeSession()); + + const rows = db + .prepare( + `SELECT id, status, process_after, recurrence, series_id FROM messages_in ORDER BY seq`, + ) + .all() as Array<{ + id: string; + status: string; + process_after: string; + recurrence: string | null; + series_id: string; + }>; + expect(rows).toHaveLength(2); + const original = rows.find((r) => r.id === 'task-1')!; + const follow = rows.find((r) => r.id !== 'task-1')!; + expect(original.recurrence).toBeNull(); + expect(follow.status).toBe('pending'); + expect(follow.recurrence).toBe('0 9 * * *'); + expect(follow.series_id).toBe('task-1'); + expect(new Date(follow.process_after).getTime()).toBeGreaterThan(Date.now()); + }); + + it('does not clone rows whose recurrence is already cleared', async () => { + const db = freshDb(); + insertTask(db, { + id: 'task-1', + processAfter: '2020-01-01T00:00:00.000Z', + recurrence: null, + platformId: null, + channelType: null, + threadId: null, + content: JSON.stringify({ prompt: 'one-off' }), + }); + db.prepare(`UPDATE messages_in SET status='completed' WHERE id='task-1'`).run(); + + await handleRecurrence(db, fakeSession()); + + const count = (db.prepare(`SELECT COUNT(*) AS c FROM messages_in`).get() as { c: number }).c; + expect(count).toBe(1); + }); +}); diff --git a/src/modules/scheduling/recurrence.ts b/src/modules/scheduling/recurrence.ts index a8a2e5c..d521f95 100644 --- a/src/modules/scheduling/recurrence.ts +++ b/src/modules/scheduling/recurrence.ts @@ -13,6 +13,7 @@ */ import type Database from 'better-sqlite3'; +import { TIMEZONE } from '../../config.js'; import { log } from '../../log.js'; import type { Session } from '../../types.js'; import { clearRecurrence, getCompletedRecurring, insertRecurrence } from './db.js'; @@ -23,7 +24,11 @@ export async function handleRecurrence(inDb: Database.Database, session: Session for (const msg of recurring) { try { const { CronExpressionParser } = await import('cron-parser'); - const interval = CronExpressionParser.parse(msg.recurrence); + // Interpret the cron expression in the user's timezone. v1 did this + // (src/v1/task-scheduler.ts:20-49); without it, a task written "0 9 * * *" + // by an agent running in a user's local TZ fires at 09:00 UTC instead of + // 09:00 user-local. + const interval = CronExpressionParser.parse(msg.recurrence, { tz: TIMEZONE }); const nextRun = interval.next().toISOString(); const prefix = msg.kind === 'task' ? 'task' : 'msg'; const newId = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; From 6a815190c01f7b1204e0e2c840783c4932f8910c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 01:16:57 +0300 Subject: [PATCH 011/185] feat(lifecycle): stuck detection + heartbeat lifecycle + SDK tool blocklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the two overlapping old mechanisms (30-min setTimeout kill in container-runner, 10-min heartbeat STALE_THRESHOLD reset in host-sweep) with message-scoped stuck detection anchored to the processing_ack claim age + an absolute 30-min ceiling that extends for long-declared Bash tools. Old model problems: - IDLE_TIMEOUT setTimeout fired on plain wall-clock time; slow-but-alive agents got killed at 30min regardless of activity - 10-min STALE_THRESHOLD in the sweep was unreliable — the heartbeat is only touched on SDK events, so legitimate silent tool work (sleep 30, long WebFetch, npm install) looked identical to a hung container - Two overlapping sources of truth for "when to let go of a container" New model: - Host sweep is the single source of truth. - Container exposes a new `container_state` single-row table in outbound.db (schema added; container writes, host reads). PreToolUse hook writes current_tool + tool_declared_timeout_ms (read from Bash's tool_input); PostToolUse / PostToolUseFailure clear it. - Sweep decides with a pure helper `decideStuckAction`: * absolute ceiling — kill if heartbeat age > max(30min, bash_timeout) * per-claim stuck — kill if any processing_ack row has claim_age > max(60s, bash_timeout) AND heartbeat hasn't been touched since claim * otherwise ok Kill paths reset leftover processing rows with exponential backoff, reusing the existing retry machinery. Tool blocklist expanded: - AskUserQuestion (SDK placeholder; we have mcp__nanoclaw__ask_user_question) - EnterPlanMode, ExitPlanMode, EnterWorktree, ExitWorktree (Claude Code UI affordances; would hang in headless containers) PreToolUse hook is also defense-in-depth: if a disallowed tool name slips through, it returns `{ decision: 'block' }` so the agent sees a clear error instead of appearing stuck. Removed: - container-runner.ts: IDLE_TIMEOUT setTimeout, resetIdle callback on activeContainers entry, resetContainerIdleTimer export. - delivery.ts: the resetContainerIdleTimer call on successful delivery. - poll-loop.ts: IDLE_END_MS + its setInterval. Keeping the query open is cheaper than close+reopen (no cold prompt cache). Liveness is now a host-side concern. - host-sweep.ts: 10-min STALE_THRESHOLD_MS + getStuckProcessingIds in the stale-detection path (still exported for kill reset). Tests: - src/host-sweep.test.ts — 9 tests for decideStuckAction covering: fresh heartbeat, absolute ceiling, absent heartbeat, Bash-timeout extension (both ceiling and per-claim), claim age below tolerance, heartbeat touched after claim, unparseable timestamps. Ref: docs/v1-vs-v2/ACTION-ITEMS.md items 9, 6a, 10. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/db/connection.ts | 55 ++++++ container/agent-runner/src/poll-loop.ts | 17 +- .../agent-runner/src/providers/claude.ts | 67 ++++++- src/channels/channel-registry.test.ts | 1 - src/container-runner.ts | 34 +--- src/db/schema.ts | 12 ++ src/db/session-db.ts | 41 ++++ src/delivery.test.ts | 1 - src/delivery.ts | 2 - src/host-core.test.ts | 1 - src/host-sweep.test.ts | 128 ++++++++++++ src/host-sweep.ts | 186 ++++++++++++++---- 12 files changed, 459 insertions(+), 86 deletions(-) create mode 100644 src/host-sweep.test.ts diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 9bf2551..772f4f1 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -64,10 +64,58 @@ export function getOutboundDb(): Database { if (!cols.has('updated_at')) { _outbound.exec(`ALTER TABLE session_state ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''`); } + // container_state: tracks the current tool in flight (if any) so the host + // sweep can widen its stuck tolerance when Bash is running with a user- + // declared long timeout. Forward-compat for older outbound.db files. + _outbound.exec(` + CREATE TABLE IF NOT EXISTS container_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + current_tool TEXT, + tool_declared_timeout_ms INTEGER, + tool_started_at TEXT, + updated_at TEXT NOT NULL + ); + `); } return _outbound; } +/** + * Record that a tool is starting. `declaredTimeoutMs` is the tool's own + * timeout hint when one is available (Bash exposes it in the tool_use input); + * omit for tools with no declared timeout. + */ +export function setContainerToolInFlight(tool: string, declaredTimeoutMs: number | null): void { + const now = new Date().toISOString(); + getOutboundDb() + .prepare( + `INSERT INTO container_state (id, current_tool, tool_declared_timeout_ms, tool_started_at, updated_at) + VALUES (1, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + current_tool = excluded.current_tool, + tool_declared_timeout_ms = excluded.tool_declared_timeout_ms, + tool_started_at = excluded.tool_started_at, + updated_at = excluded.updated_at`, + ) + .run(tool, declaredTimeoutMs, now, now); +} + +/** Clear the in-flight tool — called on PostToolUse / PostToolUseFailure. */ +export function clearContainerToolInFlight(): void { + const now = new Date().toISOString(); + getOutboundDb() + .prepare( + `INSERT INTO container_state (id, current_tool, tool_declared_timeout_ms, tool_started_at, updated_at) + VALUES (1, NULL, NULL, NULL, ?) + ON CONFLICT(id) DO UPDATE SET + current_tool = NULL, + tool_declared_timeout_ms = NULL, + tool_started_at = NULL, + updated_at = excluded.updated_at`, + ) + .run(now); +} + /** * Touch the heartbeat file — replaces the old touchProcessing() DB writes. * The host checks this file's mtime for stale container detection. @@ -157,6 +205,13 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } { value TEXT NOT NULL, updated_at TEXT NOT NULL ); + CREATE TABLE container_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + current_tool TEXT, + tool_declared_timeout_ms INTEGER, + tool_started_at TEXT, + updated_at TEXT NOT NULL + ); `); return { inbound: _inbound, outbound: _outbound }; diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 742de14..8a4ec7d 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -8,7 +8,6 @@ import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types const POLL_INTERVAL_MS = 1000; const ACTIVE_POLL_INTERVAL_MS = 500; -const IDLE_END_MS = 20_000; // End stream after 20s with no SDK events function log(msg: string): void { console.error(`[poll-loop] ${msg}`); @@ -267,9 +266,13 @@ interface QueryResult { async function processQuery(query: AgentQuery, routing: RoutingContext): Promise { let queryContinuation: string | undefined; let done = false; - let lastEventTime = Date.now(); - // Concurrent polling: push follow-ups, checkpoint WAL, detect idle + // Concurrent polling: push follow-ups into the active query as they arrive. + // We do NOT force-end the stream on silence — keeping the query open is + // strictly cheaper than close+reopen (no cold prompt cache, no reconnect). + // Stream liveness is decided host-side via the heartbeat file + processing + // claim age (see src/host-sweep.ts); if something is truly stuck, the host + // will kill the container and messages get reset to pending. const pollHandle = setInterval(() => { if (done) return; @@ -296,19 +299,11 @@ async function processQuery(query: AgentQuery, routing: RoutingContext): Promise query.push(prompt); markCompleted(newIds); - lastEventTime = Date.now(); // new input counts as activity - } - - // End stream when agent is idle: no SDK events and no pending messages - if (Date.now() - lastEventTime > IDLE_END_MS) { - log(`No SDK events for ${IDLE_END_MS / 1000}s, ending query`); - query.end(); } }, ACTIVE_POLL_INTERVAL_MS); try { for await (const event of query.events) { - lastEventTime = Date.now(); handleEvent(event, routing); touchHeartbeat(); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 97fe44a..a797f06 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -3,6 +3,7 @@ import path from 'path'; import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; +import { clearContainerToolInFlight, setContainerToolInFlight } from '../db/connection.js'; import { registerProvider } from './provider-registry.js'; import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent, ProviderOptions, QueryInput } from './types.js'; @@ -10,10 +11,28 @@ function log(msg: string): void { console.error(`[claude-provider] ${msg}`); } -// Deferred SDK builtins that would sidestep nanoclaw's own scheduling. -// Scheduling goes through mcp__nanoclaw__schedule_task so that tasks are -// durable across sessions/restarts and gated by our pre-task script hook. -const SDK_DISALLOWED_TOOLS = ['CronCreate', 'CronDelete', 'CronList', 'ScheduleWakeup']; +// Deferred SDK builtins that either sidestep nanoclaw's own scheduling or +// don't fit our async message-passing model (they're designed for Claude +// Code's interactive UI and would hang here). +// +// - CronCreate / CronDelete / CronList / ScheduleWakeup: we have durable +// scheduling via mcp__nanoclaw__schedule_task. +// - AskUserQuestion: SDK returns a placeholder instead of blocking on a +// real answer — we have mcp__nanoclaw__ask_user_question that persists +// the question and blocks on the real reply. +// - EnterPlanMode / ExitPlanMode / EnterWorktree / ExitWorktree: Claude +// Code UI affordances; in a headless container they'd appear stuck. +const SDK_DISALLOWED_TOOLS = [ + 'CronCreate', + 'CronDelete', + 'CronList', + 'ScheduleWakeup', + 'AskUserQuestion', + 'EnterPlanMode', + 'ExitPlanMode', + 'EnterWorktree', + 'ExitWorktree', +]; // Tool allowlist for NanoClaw agent containers const TOOL_ALLOWLIST = [ @@ -122,6 +141,43 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu return lines.join('\n'); } +/** + * PreToolUse hook: record the current tool + its declared timeout so the host + * sweep can widen its stuck tolerance while Bash is running a long-declared + * script. Defense-in-depth: if SDK_DISALLOWED_TOOLS slips through somehow, + * block the call here instead of letting the agent hang. + */ +const preToolUseHook: HookCallback = async (input) => { + const i = input as { tool_name?: string; tool_input?: Record }; + const toolName = i.tool_name ?? ''; + if (SDK_DISALLOWED_TOOLS.includes(toolName)) { + return { + decision: 'block', + stopReason: `Tool '${toolName}' is not available in this environment — use the nanoclaw equivalent.`, + } as unknown as ReturnType; + } + // Bash exposes its timeout via the tool_input.timeout field (ms). Any other + // tool: no declared timeout. + const declaredTimeoutMs = + toolName === 'Bash' && typeof i.tool_input?.timeout === 'number' ? (i.tool_input.timeout as number) : null; + try { + setContainerToolInFlight(toolName, declaredTimeoutMs); + } catch (err) { + log(`PreToolUse: failed to record container_state: ${err instanceof Error ? err.message : String(err)}`); + } + return { continue: true }; +}; + +/** Clear in-flight tool on PostToolUse / PostToolUseFailure. */ +const postToolUseHook: HookCallback = async () => { + try { + clearContainerToolInFlight(); + } catch (err) { + log(`PostToolUse: failed to clear container_state: ${err instanceof Error ? err.message : String(err)}`); + } + return { continue: true }; +}; + function createPreCompactHook(assistantName?: string): HookCallback { return async (input) => { const preCompact = input as PreCompactHookInput; @@ -224,6 +280,9 @@ export class ClaudeProvider implements AgentProvider { settingSources: ['project', 'user'], mcpServers: this.mcpServers, hooks: { + PreToolUse: [{ hooks: [preToolUseHook] }], + PostToolUse: [{ hooks: [postToolUseHook] }], + PostToolUseFailure: [{ hooks: [postToolUseHook] }], PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }], }, }, diff --git a/src/channels/channel-registry.test.ts b/src/channels/channel-registry.test.ts index 0abbf9d..0e856f6 100644 --- a/src/channels/channel-registry.test.ts +++ b/src/channels/channel-registry.test.ts @@ -10,7 +10,6 @@ import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } fr // Mock container runner vi.mock('../container-runner.js', () => ({ wakeContainer: vi.fn().mockResolvedValue(undefined), - resetContainerIdleTimer: vi.fn(), isContainerRunning: vi.fn().mockReturnValue(false), getActiveContainerCount: vi.fn().mockReturnValue(0), killContainer: vi.fn(), diff --git a/src/container-runner.ts b/src/container-runner.ts index c3fb24f..9764126 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -9,7 +9,7 @@ import path from 'path'; import { OneCLI } from '@onecli-sh/sdk'; -import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, ONECLI_URL, TIMEZONE } from './config.js'; +import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, ONECLI_URL, TIMEZONE } from './config.js'; import { readContainerConfig, writeContainerConfig } from './container-config.js'; import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { getAgentGroup } from './db/agent-groups.js'; @@ -26,12 +26,7 @@ import { type ProviderContainerContribution, type VolumeMount, } from './providers/provider-container-registry.js'; -import { - markContainerRunning, - markContainerStopped, - sessionDir, - writeSessionRouting, -} from './session-manager.js'; +import { markContainerRunning, markContainerStopped, sessionDir, writeSessionRouting } from './session-manager.js'; import type { AgentGroup, Session } from './types.js'; const onecli = new OneCLI({ url: ONECLI_URL }); @@ -125,22 +120,12 @@ async function spawnContainer(session: Session): Promise { // stdout is unused in v2 (all IO is via session DB) container.stdout?.on('data', () => {}); - // Idle timeout: kill container after IDLE_TIMEOUT of no activity - let idleTimer = setTimeout(() => killContainer(session.id, 'idle timeout'), IDLE_TIMEOUT); - - const resetIdle = () => { - clearTimeout(idleTimer); - idleTimer = setTimeout(() => killContainer(session.id, 'idle timeout'), IDLE_TIMEOUT); - }; - - // Reset idle timer when the host detects new messages_out (called by delivery.ts) - const entry = activeContainers.get(session.id); - if (entry) { - (entry as { resetIdle?: () => void }).resetIdle = resetIdle; - } + // No host-side idle timeout. Stale/stuck detection is driven by the host + // sweep reading heartbeat mtime + processing_ack claim age + container_state + // (see src/host-sweep.ts). This avoids killing long-running legitimate work + // on a wall-clock timer. container.on('close', (code) => { - clearTimeout(idleTimer); activeContainers.delete(session.id); markContainerStopped(session.id); stopTypingRefresh(session.id); @@ -148,7 +133,6 @@ async function spawnContainer(session: Session): Promise { }); container.on('error', (err) => { - clearTimeout(idleTimer); activeContainers.delete(session.id); markContainerStopped(session.id); stopTypingRefresh(session.id); @@ -156,12 +140,6 @@ async function spawnContainer(session: Session): Promise { }); } -/** Reset the idle timer for a session's container (called when messages_out are delivered). */ -export function resetContainerIdleTimer(sessionId: string): void { - const entry = activeContainers.get(sessionId) as { resetIdle?: () => void } | undefined; - entry?.resetIdle?.(); -} - /** Kill a container for a session. */ export function killContainer(sessionId: string, reason: string): void { const entry = activeContainers.get(sessionId); diff --git a/src/db/schema.ts b/src/db/schema.ts index 044d717..47d4c9f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -213,4 +213,16 @@ CREATE TABLE IF NOT EXISTS session_state ( value TEXT NOT NULL, updated_at TEXT NOT NULL ); + +-- Current tool-in-flight state. Single-row table (id=1). Container writes on +-- PreToolUse and clears on PostToolUse / PostToolUseFailure. Host reads in the +-- sweep to extend the stuck-tolerance window when Bash is running with a +-- declared timeout > 60s (long-running scripts shouldn't be flagged as stuck). +CREATE TABLE IF NOT EXISTS container_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + current_tool TEXT, + tool_declared_timeout_ms INTEGER, + tool_started_at TEXT, + updated_at TEXT NOT NULL +); `; diff --git a/src/db/session-db.ts b/src/db/session-db.ts index 05104cf..a73ca5c 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -161,6 +161,47 @@ export function getStuckProcessingIds(outDb: Database.Database): string[] { ).map((r) => r.message_id); } +export interface ProcessingClaim { + message_id: string; + status_changed: string; +} + +/** Return processing_ack rows still in 'processing' with their claim timestamps. */ +export function getProcessingClaims(outDb: Database.Database): ProcessingClaim[] { + return outDb + .prepare( + "SELECT message_id, status_changed FROM processing_ack WHERE status = 'processing'", + ) + .all() as ProcessingClaim[]; +} + +export interface ContainerState { + current_tool: string | null; + tool_declared_timeout_ms: number | null; + tool_started_at: string | null; +} + +/** + * Read the container's current tool-in-flight state, if any. Returns null + * when either the table doesn't exist yet (older session DB) or no tool is + * active. Host sweep reads this to widen stuck-detection tolerance while + * Bash is running with a long declared timeout. + */ +export function getContainerState(outDb: Database.Database): ContainerState | null { + try { + const row = outDb + .prepare( + `SELECT current_tool, tool_declared_timeout_ms, tool_started_at + FROM container_state WHERE id = 1`, + ) + .get() as ContainerState | undefined; + return row ?? null; + } catch { + // Table not present on older session DBs — treat as "no tool in flight". + return null; + } +} + // --------------------------------------------------------------------------- // messages_out (read-only from host) // --------------------------------------------------------------------------- diff --git a/src/delivery.test.ts b/src/delivery.test.ts index d631836..a5e1efd 100644 --- a/src/delivery.test.ts +++ b/src/delivery.test.ts @@ -14,7 +14,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; vi.mock('./container-runner.js', () => ({ wakeContainer: vi.fn().mockResolvedValue(undefined), - resetContainerIdleTimer: vi.fn(), isContainerRunning: vi.fn().mockReturnValue(false), killContainer: vi.fn(), buildAgentGroupImage: vi.fn().mockResolvedValue(undefined), diff --git a/src/delivery.ts b/src/delivery.ts index 7b1ee7d..2e193d4 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -23,7 +23,6 @@ import { import { log } from './log.js'; import { normalizeOptions } from './channels/ask-question.js'; import { clearOutbox, openInboundDb, openOutboundDb, readOutboxFiles } from './session-manager.js'; -import { resetContainerIdleTimer } from './container-runner.js'; import { pauseTypingRefreshAfterDelivery, setTypingAdapter } from './modules/typing/index.js'; import type { OutboundFile } from './channels/adapter.js'; import type { Session } from './types.js'; @@ -193,7 +192,6 @@ async function drainSession(session: Session): Promise { const platformMsgId = await deliverMessage(msg, session, inDb); markDelivered(inDb, msg.id, platformMsgId ?? null); deliveryAttempts.delete(msg.id); - resetContainerIdleTimer(session.id); // Pause the typing indicator after a real user-facing message // lands on the user's screen, so the client has time to visually diff --git a/src/host-core.test.ts b/src/host-core.test.ts index a8b4684..7269164 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -30,7 +30,6 @@ import type { InboundEvent } from './router.js'; // Mock container runner to prevent actual Docker spawning vi.mock('./container-runner.js', () => ({ wakeContainer: vi.fn().mockResolvedValue(undefined), - resetContainerIdleTimer: vi.fn(), isContainerRunning: vi.fn().mockReturnValue(false), getActiveContainerCount: vi.fn().mockReturnValue(0), killContainer: vi.fn(), diff --git a/src/host-sweep.test.ts b/src/host-sweep.test.ts new file mode 100644 index 0000000..d9505a4 --- /dev/null +++ b/src/host-sweep.test.ts @@ -0,0 +1,128 @@ +/** + * Unit tests for the stuck-container decision logic introduced by + * ACTION-ITEMS item 9. Lives on the pure helper `decideStuckAction` so we + * don't have to mock the filesystem or the container runner. + */ +import { describe, expect, it } from 'vitest'; + +import { ABSOLUTE_CEILING_MS, CLAIM_STUCK_MS, decideStuckAction } from './host-sweep.js'; + +const BASE = Date.parse('2026-04-20T12:00:00.000Z'); + +function claim(id: string, offsetMs: number) { + return { message_id: id, status_changed: new Date(BASE - offsetMs).toISOString() }; +} + +describe('decideStuckAction', () => { + it('returns ok when heartbeat is fresh and no claims', () => { + expect( + decideStuckAction({ + now: BASE, + heartbeatMtimeMs: BASE - 5_000, + containerState: null, + claims: [], + }), + ).toEqual({ action: 'ok' }); + }); + + it('returns kill-ceiling when heartbeat older than 30 min', () => { + const heartbeatMtimeMs = BASE - ABSOLUTE_CEILING_MS - 1_000; + const res = decideStuckAction({ + now: BASE, + heartbeatMtimeMs, + containerState: null, + claims: [], + }); + expect(res.action).toBe('kill-ceiling'); + if (res.action !== 'kill-ceiling') return; + expect(res.ceilingMs).toBe(ABSOLUTE_CEILING_MS); + expect(res.heartbeatAgeMs).toBeGreaterThan(ABSOLUTE_CEILING_MS); + }); + + it('treats an absent heartbeat file as infinitely stale', () => { + const res = decideStuckAction({ + now: BASE, + heartbeatMtimeMs: 0, + containerState: null, + claims: [], + }); + expect(res.action).toBe('kill-ceiling'); + }); + + it('extends the ceiling when Bash has a declared timeout longer than 30 min', () => { + const twoHrMs = 2 * 60 * 60 * 1000; + const res = decideStuckAction({ + now: BASE, + // 45 min — over the default ceiling, but under the Bash timeout + heartbeatMtimeMs: BASE - 45 * 60 * 1000, + containerState: { + current_tool: 'Bash', + tool_declared_timeout_ms: twoHrMs, + tool_started_at: new Date(BASE - 45 * 60 * 1000).toISOString(), + }, + claims: [], + }); + expect(res.action).toBe('ok'); + }); + + it('returns kill-claim when a claim is past 60s and heartbeat has not moved', () => { + const claimedAgeMs = CLAIM_STUCK_MS + 10_000; + const res = decideStuckAction({ + now: BASE, + heartbeatMtimeMs: BASE - claimedAgeMs - 5_000, // older than the claim + containerState: null, + claims: [claim('msg-1', claimedAgeMs)], + }); + expect(res.action).toBe('kill-claim'); + if (res.action !== 'kill-claim') return; + expect(res.messageId).toBe('msg-1'); + expect(res.toleranceMs).toBe(CLAIM_STUCK_MS); + }); + + it('does not kill when heartbeat has been touched since the claim', () => { + const claimedAgeMs = CLAIM_STUCK_MS + 10_000; + const res = decideStuckAction({ + now: BASE, + heartbeatMtimeMs: BASE - 2_000, // fresh, updated after the claim + containerState: null, + claims: [claim('msg-1', claimedAgeMs)], + }); + expect(res.action).toBe('ok'); + }); + + it('does not kill when claim age is below tolerance', () => { + const res = decideStuckAction({ + now: BASE, + heartbeatMtimeMs: BASE - CLAIM_STUCK_MS - 10_000, // old, but claim is recent + containerState: null, + claims: [claim('msg-1', 5_000)], + }); + expect(res.action).toBe('ok'); + }); + + it('widens per-claim tolerance for a running Bash with long timeout', () => { + const tenMinMs = 10 * 60 * 1000; + const res = decideStuckAction({ + now: BASE, + // 5 min since claim, over the 60s default but under the declared Bash timeout + heartbeatMtimeMs: BASE - (5 * 60 * 1000) - 5_000, + containerState: { + current_tool: 'Bash', + tool_declared_timeout_ms: tenMinMs, + tool_started_at: new Date(BASE - 5 * 60 * 1000).toISOString(), + }, + claims: [claim('msg-1', 5 * 60 * 1000)], + }); + expect(res.action).toBe('ok'); + }); + + it('ignores claims with unparseable timestamps', () => { + const res = decideStuckAction({ + now: BASE, + heartbeatMtimeMs: BASE - 5_000, + containerState: null, + claims: [{ message_id: 'x', status_changed: 'not-a-date' }], + }); + expect(res.action).toBe('ok'); + }); +}); diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 7a7688f..0f8365c 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -2,10 +2,29 @@ * Host sweep — periodic maintenance of all session DBs. * * Two-DB architecture: - * - Reads processing_ack from outbound.db to sync message status - * - Writes to inbound.db (host-owned) for status updates and recurrence - * - Uses heartbeat file mtime for stale container detection (not DB writes) + * - Reads processing_ack + container_state from outbound.db + * - Writes to inbound.db (host-owned) for status updates + recurrence + * - Uses heartbeat file mtime for liveness (never polls DB for it) * - Never writes to outbound.db — preserves single-writer-per-file invariant + * + * Stuck / idle detection (replaces the old IDLE_TIMEOUT setTimeout + 10-min + * heartbeat threshold): + * + * If the container isn't running and there are 'processing' rows left over + * (e.g. it crashed mid-turn) → reset them to pending with backoff + + * tries++. Existing retry machinery does the rest. + * + * If the container IS running: + * 1. Absolute ceiling: heartbeat age > max(30 min, current_bash_timeout) + * → kill. Covers the "alive but silent for 30 min" case. Extended + * only while Bash is declared as running longer, honouring the + * user's own timeout directive. Kill then resets processing rows. + * + * 2. Message-scoped stuck: for each 'processing' row, tolerance = + * max(60s, current_bash_timeout_ms_if_Bash_running). If + * (claim_age > tolerance) AND (heartbeat_mtime <= status_changed) + * → kill + reset this message + tries++. Semantics: "container + * claimed a message and went quiet past tolerance since the claim." */ import type Database from 'better-sqlite3'; import fs from 'fs'; @@ -14,22 +33,68 @@ import { getActiveSessions } from './db/sessions.js'; import { getAgentGroup } from './db/agent-groups.js'; import { countDueMessages, - syncProcessingAcks, - getStuckProcessingIds, + getContainerState, getMessageForRetry, + getProcessingClaims, markMessageFailed, retryWithBackoff, + syncProcessingAcks, + type ContainerState, } from './db/session-db.js'; import { log } from './log.js'; import { openInboundDb, openOutboundDb, inboundDbPath, heartbeatPath } from './session-manager.js'; -import { wakeContainer, isContainerRunning } from './container-runner.js'; +import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js'; import type { Session } from './types.js'; const SWEEP_INTERVAL_MS = 60_000; -const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes +// Absolute idle ceiling for a running container. If the heartbeat file hasn't +// been touched in this long, the container is either stuck or doing genuinely +// nothing — kill and restart on the next inbound. +export const ABSOLUTE_CEILING_MS = 30 * 60 * 1000; +// Stuck tolerance window applied per 'processing' claim — "did we see any +// signs of life since this message was claimed?" +export const CLAIM_STUCK_MS = 60 * 1000; const MAX_TRIES = 5; const BACKOFF_BASE_MS = 5000; +export type StuckDecision = + | { action: 'ok' } + | { action: 'kill-ceiling'; heartbeatAgeMs: number; ceilingMs: number } + | { action: 'kill-claim'; messageId: string; claimAgeMs: number; toleranceMs: number }; + +/** + * Pure decision for whether a running container should be killed this sweep + * tick. Inputs are all deterministic; filesystem + DB reads happen in the + * caller. + */ +export function decideStuckAction(args: { + now: number; + heartbeatMtimeMs: number; // 0 when heartbeat file absent + containerState: ContainerState | null; + claims: Array<{ message_id: string; status_changed: string }>; +}): StuckDecision { + const { now, heartbeatMtimeMs, containerState, claims } = args; + const declaredBashMs = bashTimeoutMs(containerState); + const heartbeatAge = heartbeatMtimeMs === 0 ? Infinity : now - heartbeatMtimeMs; + + const ceiling = Math.max(ABSOLUTE_CEILING_MS, declaredBashMs ?? 0); + if (heartbeatAge > ceiling) { + return { action: 'kill-ceiling', heartbeatAgeMs: heartbeatAge, ceilingMs: ceiling }; + } + + const tolerance = Math.max(CLAIM_STUCK_MS, declaredBashMs ?? 0); + for (const claim of claims) { + const claimedAt = Date.parse(claim.status_changed); + if (Number.isNaN(claimedAt)) continue; + const claimAge = now - claimedAt; + if (claimAge <= tolerance) continue; + if (heartbeatMtimeMs > claimedAt) continue; + return { action: 'kill-claim', messageId: claim.message_id, claimAgeMs: claimAge, toleranceMs: tolerance }; + } + + return { action: 'ok' }; +} + let running = false; export function startHostSweep(): void { @@ -84,20 +149,26 @@ async function sweepSession(session: Session): Promise { syncProcessingAcks(inDb, outDb); } - // 2. Check for due pending messages → wake container - const dueCount = countDueMessages(inDb); + const alive = isContainerRunning(session.id); + // 2. Crashed-container cleanup: processing rows left behind get retried. + if (!alive && outDb) { + resetStuckProcessingRows(inDb, outDb, session, 'container not running'); + } + + // 3. Running-container SLA: absolute ceiling + per-claim stuck rules. + if (alive && outDb) { + enforceRunningContainerSla(inDb, outDb, session, agentGroup.id); + } + + // 4. Wake a container if new work is due and nothing is running. + const dueCount = countDueMessages(inDb); if (dueCount > 0 && !isContainerRunning(session.id)) { log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); await wakeContainer(session); } - // 3. Detect stale containers via heartbeat file - if (outDb) { - detectStaleContainers(inDb, outDb, session, agentGroup.id); - } - - // 4. Handle recurrence for completed messages. + // 5. Recurrence fanout for completed recurring tasks. // MODULE-HOOK:scheduling-recurrence:start const { handleRecurrence } = await import('./modules/scheduling/recurrence.js'); await handleRecurrence(inDb, session); @@ -108,45 +179,84 @@ async function sweepSession(session: Session): Promise { } } -/** - * Detect stale containers using heartbeat file mtime. - * If the heartbeat is older than STALE_THRESHOLD and processing_ack has - * 'processing' entries, the container likely crashed — reset with backoff. - */ -function detectStaleContainers( +function heartbeatMtimeMs(agentGroupId: string, sessionId: string): number { + const hbPath = heartbeatPath(agentGroupId, sessionId); + try { + return fs.statSync(hbPath).mtimeMs; + } catch { + return 0; + } +} + +function bashTimeoutMs(state: ContainerState | null): number | null { + if (!state || state.current_tool !== 'Bash') return null; + return typeof state.tool_declared_timeout_ms === 'number' ? state.tool_declared_timeout_ms : null; +} + +function enforceRunningContainerSla( inDb: Database.Database, outDb: Database.Database, session: Session, agentGroupId: string, ): void { - const hbPath = heartbeatPath(agentGroupId, session.id); - let heartbeatAge = Infinity; - try { - const stat = fs.statSync(hbPath); - heartbeatAge = Date.now() - stat.mtimeMs; - } catch { - // No heartbeat file — container may never have started, or it's very old + const decision = decideStuckAction({ + now: Date.now(), + heartbeatMtimeMs: heartbeatMtimeMs(agentGroupId, session.id), + containerState: getContainerState(outDb), + claims: getProcessingClaims(outDb), + }); + + if (decision.action === 'ok') return; + + if (decision.action === 'kill-ceiling') { + log.warn('Killing container past absolute ceiling', { + sessionId: session.id, + heartbeatAgeMs: decision.heartbeatAgeMs, + ceilingMs: decision.ceilingMs, + }); + killContainer(session.id, 'absolute-ceiling'); + resetStuckProcessingRows(inDb, outDb, session, 'absolute-ceiling'); + return; } - if (heartbeatAge < STALE_THRESHOLD_MS) return; // Container is alive + log.warn('Killing container — message claimed then silent', { + sessionId: session.id, + messageId: decision.messageId, + claimAgeMs: decision.claimAgeMs, + toleranceMs: decision.toleranceMs, + }); + killContainer(session.id, 'claim-stuck'); + resetStuckProcessingRows(inDb, outDb, session, 'claim-stuck'); +} - // Heartbeat is stale — check for stuck processing entries - const processingIds = getStuckProcessingIds(outDb); - if (processingIds.length === 0) return; - - for (const messageId of processingIds) { - const msg = getMessageForRetry(inDb, messageId, 'pending'); +function resetStuckProcessingRows( + inDb: Database.Database, + outDb: Database.Database, + session: Session, + reason: string, +): void { + const claims = getProcessingClaims(outDb); + for (const { message_id } of claims) { + const msg = getMessageForRetry(inDb, message_id, 'pending'); if (!msg) continue; if (msg.tries >= MAX_TRIES) { markMessageFailed(inDb, msg.id); - log.warn('Message marked as failed after max retries', { messageId: msg.id, sessionId: session.id }); + log.warn('Message marked as failed after max retries', { + messageId: msg.id, + sessionId: session.id, + reason, + }); } else { const backoffMs = BACKOFF_BASE_MS * Math.pow(2, msg.tries); const backoffSec = Math.floor(backoffMs / 1000); retryWithBackoff(inDb, msg.id, backoffSec); - log.info('Reset stale message with backoff', { messageId: msg.id, tries: msg.tries, backoffMs }); + log.info('Reset stale message with backoff', { + messageId: msg.id, + tries: msg.tries, + backoffMs, + reason, + }); } } } - From 16b9499532bb58299be51e5143f4f7f013433402 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 01:30:04 +0300 Subject: [PATCH 012/185] feat(routing): engage modes + sender scope + accumulate/drop + per-agent fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the opaque trigger_rules JSON + response_scope enum on messaging_group_agents with four explicit orthogonal columns: engage_mode 'pattern' | 'mention' | 'mention-sticky' engage_pattern regex source; required when mode='pattern'; '.' is the "always" sentinel sender_scope 'all' | 'known' ignored_message_policy 'drop' | 'accumulate' Inbound routing becomes a fan-out — every wired agent is evaluated independently. A match gets its own session + container wake. A miss with accumulate keeps the message as context-only (trigger=0) in that agent's session, so when the agent does eventually engage it sees the prior chatter. ## Schema - Migration 010 (`engage-modes`): adds the 4 new columns, backfills from trigger_rules.pattern + requiresTrigger + response_scope, drops the legacy columns. - messages_in gains `trigger INTEGER NOT NULL DEFAULT 1` (session DB schema + `migrateMessagesInTable` forward-compat). - countDueMessages gates waking on `trigger = 1`. ## Routing - `pickAgent` (returns one) → loop over all wired agents. Per agent: evaluate engage_mode; run access gate + sender-scope gate; on full match → resolveSession + writeSessionMessage(trigger=1) + wake. On miss with accumulate → writeSessionMessage(trigger=0), no wake. On miss with drop → skip. - New `findSessionForAgent(agentGroupId, mgId, threadId)` scopes session lookup by agent so fan-out doesn't cross sessions. - `messageIdForAgent` namespaces inbound message ids by agent_group_id so PRIMARY KEY doesn't collide across per-agent session DBs. ## Adapter layer - `ConversationConfig` replaces `triggerPattern` + `requiresTrigger` with `engageMode` + `engagePattern`. - Chat SDK bridge stores `Map` (multi- agent per conversation) and applies union gating pre-onInbound: * onSubscribedMessage: engage if any wiring keeps firing in subscribed state (mention-sticky or pattern) * onNewMention: engage on mention; only subscribes the thread if at least one wiring is `mention-sticky` * onDirectMessage: engage per mode; sticky follows same rule - Bridge no longer unconditionally calls `thread.subscribe()`. ## Sender scope - Permissions module registers a second hook `setSenderScopeGate` that runs per-wiring after the existing access gate. `sender_scope='known'` requires canAccessAgentGroup(); `'all'` is a no-op. Not installed → no-op everywhere (default allow). ## Container side - Host passes `NANOCLAW_MAX_MESSAGES_PER_PROMPT` (reuses existing MAX_MESSAGES_PER_PROMPT config; was dead code from v1). - `getPendingMessages` queries `ORDER BY seq DESC LIMIT N`, reverses to chronological order for the prompt — accumulated context rides along with trigger rows up to the cap. - `MessageInRow` gains `trigger: number` so the container can tell them apart in downstream code (container still processes both; only the host uses `trigger=0` for don't-wake). ## Defaults (per ACTION-ITEMS item 1 decision) - DM (is_group=0): `engage_mode='pattern'`, `engage_pattern='.'` (always) - Threaded group: `engage_mode='mention-sticky'` (seed-discord) - Non-threaded group / CLI: pattern '.' in bootstrap scripts ## Tests - src/host-core.test.ts: 3 new cases — fan-out (2 agents, 2 sessions, 2 wakes), accumulate (trigger=0 + no wake), drop (no session created). - Existing 10 host-core tests still pass. - Migration 010 runs on an empty DB in 0-row path — verified. Closes: ACTION-ITEMS items 1, 4. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/db/messages-in.ts | 26 ++- scripts/init-first-agent.ts | 16 +- scripts/seed-discord.ts | 8 +- scripts/test-v2-channel-e2e.ts | 16 +- scripts/test-v2-host.ts | 6 +- src/channels/adapter.ts | 15 +- src/channels/channel-registry.test.ts | 6 +- src/channels/chat-sdk-bridge.ts | 123 +++++++++-- src/container-runner.ts | 5 +- src/db/db-v2.test.ts | 9 +- src/db/messaging-groups.ts | 19 +- src/db/migrations/010-engage-modes.ts | 101 +++++++++ src/db/migrations/index.ts | 6 +- src/db/schema.ts | 27 ++- src/db/session-db.ts | 27 ++- src/db/sessions.ts | 25 +++ src/host-core.test.ts | 106 +++++++++- src/index.ts | 5 +- src/modules/permissions/index.ts | 28 ++- src/router.ts | 203 ++++++++++++++----- src/session-manager.ts | 21 +- src/types.ts | 15 +- 22 files changed, 688 insertions(+), 125 deletions(-) create mode 100644 src/db/migrations/010-engage-modes.ts diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index da1a8dd..a152a5e 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -18,16 +18,33 @@ export interface MessageInRow { process_after: string | null; recurrence: string | null; tries: number; + /** 1 = wake-eligible (default); 0 = accumulated context only */ + trigger: number; platform_id: string | null; channel_type: string | null; thread_id: string | null; content: string; } +// Cap on how many messages reach the agent in one prompt, including any +// accumulated-but-not-triggered context. Host controls the cap via the +// NANOCLAW_MAX_MESSAGES_PER_PROMPT env var; default mirrors the host's +// config.ts default of 10. +const MAX_MESSAGES_PER_PROMPT = Math.max( + 1, + parseInt(process.env.NANOCLAW_MAX_MESSAGES_PER_PROMPT || '10', 10) || 10, +); + /** * Fetch pending messages that are due for processing. * Reads from inbound.db (read-only), filters against processing_ack in outbound.db * to skip messages already picked up by this or a previous container run. + * + * Returns the most recent `MAX_MESSAGES_PER_PROMPT` pending rows in + * chronological order, regardless of their `trigger` flag: accumulated + * context (trigger=0) rides along with the wake-eligible rows so the agent + * sees the prior context it missed. Host's countDueMessages gates waking on + * trigger=1 separately (see src/db/session-db.ts). */ export function getPendingMessages(): MessageInRow[] { const inbound = getInboundDb(); @@ -38,9 +55,10 @@ export function getPendingMessages(): MessageInRow[] { `SELECT * FROM messages_in WHERE status = 'pending' AND (process_after IS NULL OR datetime(process_after) <= datetime('now')) - ORDER BY timestamp ASC`, + ORDER BY seq DESC + LIMIT ?`, ) - .all() as MessageInRow[]; + .all(MAX_MESSAGES_PER_PROMPT) as MessageInRow[]; if (pending.length === 0) return []; @@ -51,7 +69,9 @@ export function getPendingMessages(): MessageInRow[] { ), ); - return pending.filter((m) => !ackedIds.has(m.id)); + // Reverse: we fetched DESC to take the most recent N, but the agent + // should see them in chronological order (oldest first). + return pending.filter((m) => !ackedIds.has(m.id)).reverse(); } /** Mark messages as processing — writes to processing_ack in outbound.db. */ diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index efb3b6b..d7ff0df 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -195,8 +195,13 @@ async function main(): Promise { id: generateId('mga'), messaging_group_id: mg.id, agent_group_id: ag.id, - trigger_rules: null, - response_scope: 'all', + // DM (is_group=0) defaults to "respond to everything" via the '.' pattern. + // Group chats default to mention-only; admins can upgrade to + // mention-sticky via /manage-channels once the agent is in use. + engage_mode: mg.is_group === 0 ? 'pattern' : 'mention', + engage_pattern: mg.is_group === 0 ? '.' : null, + sender_scope: 'all', + ignored_message_policy: 'drop', session_mode: 'shared', priority: 0, created_at: now, @@ -248,8 +253,11 @@ async function main(): Promise { id: generateId('mga'), messaging_group_id: cliMg.id, agent_group_id: ag.id, - trigger_rules: null, - response_scope: 'all', + // CLI is a local single-user DM — always respond. + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', session_mode: 'shared', priority: 0, created_at: now, diff --git a/scripts/seed-discord.ts b/scripts/seed-discord.ts index 9aed1c5..3ea24e8 100644 --- a/scripts/seed-discord.ts +++ b/scripts/seed-discord.ts @@ -58,8 +58,12 @@ try { id: 'mga-discord', messaging_group_id: MESSAGING_GROUP_ID, agent_group_id: AGENT_GROUP_ID, - trigger_rules: null, - response_scope: 'all', + // Discord group channel → mention-sticky default. Mention once, stay + // subscribed to the thread. Admins can tune via /manage-channels. + engage_mode: 'mention-sticky', + engage_pattern: null, + sender_scope: 'all', + ignored_message_policy: 'drop', session_mode: 'shared', priority: 0, created_at: new Date().toISOString(), diff --git a/scripts/test-v2-channel-e2e.ts b/scripts/test-v2-channel-e2e.ts index fc0a570..6721ff0 100644 --- a/scripts/test-v2-channel-e2e.ts +++ b/scripts/test-v2-channel-e2e.ts @@ -53,8 +53,10 @@ createMessagingGroupAgent({ id: 'mga-chan', messaging_group_id: 'mg-chan', agent_group_id: 'ag-chan', - trigger_rules: null, - response_scope: 'all', + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', session_mode: 'shared', priority: 0, created_at: new Date().toISOString(), @@ -105,7 +107,15 @@ registerChannelAdapter('mock', { factory: () => mockAdapter }); // Init channel adapters — this calls setup() with conversation configs from central DB await initChannelAdapters((adapter) => ({ - conversations: [{ platformId: 'mock-channel-1', agentGroupId: 'ag-chan', requiresTrigger: false, sessionMode: 'shared' }], + conversations: [ + { + platformId: 'mock-channel-1', + agentGroupId: 'ag-chan', + engageMode: 'pattern', + engagePattern: '.', + sessionMode: 'shared', + }, + ], onInbound(platformId, threadId, message) { routeInbound({ channelType: adapter.channelType, diff --git a/scripts/test-v2-host.ts b/scripts/test-v2-host.ts index b82bc99..2e49a3b 100644 --- a/scripts/test-v2-host.ts +++ b/scripts/test-v2-host.ts @@ -55,8 +55,10 @@ createMessagingGroupAgent({ id: 'mga-e2e', messaging_group_id: 'mg-e2e', agent_group_id: 'ag-e2e', - trigger_rules: null, - response_scope: 'all', + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', session_mode: 'shared', priority: 0, created_at: new Date().toISOString(), diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 55efde1..33f3825 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -9,8 +9,19 @@ export interface ConversationConfig { platformId: string; agentGroupId: string; - triggerPattern?: string; // regex string (for native channels) - requiresTrigger: boolean; + /** + * When does the agent engage on messages from this conversation? + * + * 'pattern' — regex test against message text; engagePattern='.' + * means "always" (match everything) + * 'mention' — fires only on @mention + * 'mention-sticky' — fires on @mention, then auto-subscribes to the thread + * and treats subsequent messages as engage-all. + * Threaded platforms only (Slack/Discord/Linear). + */ + engageMode: 'pattern' | 'mention' | 'mention-sticky'; + /** Regex source when engageMode='pattern'. '.' is the "always" sentinel. */ + engagePattern?: string | null; sessionMode: 'shared' | 'per-thread' | 'agent-shared'; } diff --git a/src/channels/channel-registry.test.ts b/src/channels/channel-registry.test.ts index 0e856f6..265a372 100644 --- a/src/channels/channel-registry.test.ts +++ b/src/channels/channel-registry.test.ts @@ -148,8 +148,10 @@ describe('channel + router integration', () => { id: 'mga-1', messaging_group_id: 'mg-1', agent_group_id: 'ag-1', - trigger_rules: null, - response_scope: 'all', + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', session_mode: 'shared', priority: 0, created_at: now(), diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 2d45b29..593a2ad 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -71,23 +71,89 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter let chat: Chat; let state: SqliteStateAdapter; let setupConfig: ChannelSetup; - // NOTE: populated at setup() and updateConversations(), but currently not - // read by any inbound handler. When adapter-level gating lands (engage_mode - // applied here) or when dynamic group registration is added, this map goes - // stale after setup unless updateConversations() is actively called on every - // messaging_groups / messaging_group_agents mutation. See ACTION-ITEMS.md - // item 17. - let conversations: Map; + // Keyed by platformId. Multiple agents may be wired to the same + // conversation — this holds all their configs so the bridge can apply the + // most-permissive engage rule at gate time and only subscribe when at + // least one wiring requested 'mention-sticky'. + // + // STALENESS: populated at setup() and updateConversations(). If wirings + // change after setup, updateConversations() must be called to refresh + // (ACTION-ITEMS item 17). + let conversations: Map; let gatewayAbort: AbortController | null = null; - function buildConversationMap(configs: ConversationConfig[]): Map { - const map = new Map(); + function buildConversationMap(configs: ConversationConfig[]): Map { + const map = new Map(); for (const conv of configs) { - map.set(conv.platformId, conv); + const existing = map.get(conv.platformId); + if (existing) existing.push(conv); + else map.set(conv.platformId, [conv]); } return map; } + /** + * Should a message from (channelId, kind) engage any of the wired agents? + * + * - `mention` — engages only when the message actually @-mentions + * the bot (the bridge already sees it here because + * Chat SDK only forwards subscribed / mentioned / + * DM messages) + * - `mention-sticky` — same as `mention` for gating, PLUS we subscribe + * the thread so later messages arrive via the + * subscribed path and fall through to an + * engage-all style treatment + * - `pattern` — regex test against message text; `.` = always + * + * We take the union across wired agents — if any one of them would engage, + * the message goes through. Per-agent filtering after that happens in the + * host router (see src/router.ts pickAgents). + */ + function shouldEngage( + channelId: string, + source: 'subscribed' | 'mention' | 'dm', + text: string, + ): { engage: boolean; stickySubscribe: boolean } { + const configs = conversations.get(channelId); + // Unknown conversation — forward anyway (may be a new group that + // hasn't been registered yet; central routing will log + drop cleanly). + if (!configs || configs.length === 0) return { engage: true, stickySubscribe: false }; + + let engage = false; + let stickySubscribe = false; + + for (const cfg of configs) { + switch (cfg.engageMode) { + case 'mention': + if (source === 'mention' || source === 'dm') engage = true; + break; + case 'mention-sticky': + if (source === 'mention' || source === 'dm') { + engage = true; + stickySubscribe = true; + } else if (source === 'subscribed') { + // Thread was already subscribed on a prior mention — treat as + // engage-all so follow-ups in the thread reach the agent. + engage = true; + } + break; + case 'pattern': { + const pattern = cfg.engagePattern ?? '.'; + try { + if (pattern === '.' || new RegExp(pattern).test(text)) engage = true; + } catch { + // Invalid regex → fail open so the admin can see something and fix. + engage = true; + } + break; + } + } + if (engage && stickySubscribe) break; + } + + return { engage, stickySubscribe }; + } + async function messageToInbound(message: ChatMessage): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const serialized = message.toJSON() as Record; @@ -166,33 +232,54 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter logger: 'silent', }); - // Subscribed threads — forward all messages + // Subscribed threads — the conversation is already active (via prior + // mention-sticky engagement or admin wiring). Gate on engageMode so a + // plain 'mention' wiring doesn't keep firing after a one-off mention. chat.onSubscribedMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); + const text = typeof message.content === 'string' ? message.content : ''; + const decision = shouldEngage(channelId, 'subscribed', text); + if (!decision.engage) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); }); - // @mention in unsubscribed thread — forward + subscribe + // @mention in an unsubscribed thread — always engage; subscribe only + // if the wiring is 'mention-sticky'. chat.onNewMention(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); + const text = typeof message.content === 'string' ? message.content : ''; + const decision = shouldEngage(channelId, 'mention', text); + if (!decision.engage) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); - await thread.subscribe(); + if (decision.stickySubscribe) { + await thread.subscribe(); + } }); - // DMs — always forward + subscribe. Pass thread.id so sub-thread - // context carries through to delivery (Slack users can open threads - // inside a DM). The router collapses DM sub-threads to one session - // (is_group=0 short-circuits the per-thread escalation). + // DMs — apply engage rules too, but DMs typically default to pattern='.' + // at setup time so this is a pass-through in practice. sticky subscribe + // follows the same rule as a group mention. + // + // Thread id is passed through so sub-thread context reaches delivery + // (Slack users can open threads inside a DM). The router collapses DM + // sub-threads to one session (is_group=0 short-circuits the per-thread + // escalation). chat.onDirectMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); + const text = typeof message.content === 'string' ? message.content : ''; + const decision = shouldEngage(channelId, 'dm', text); log.info('Inbound DM received', { adapter: adapter.name, channelId, sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown', threadId: thread.id, + engage: decision.engage, }); + if (!decision.engage) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); - await thread.subscribe(); + if (decision.stickySubscribe) { + await thread.subscribe(); + } }); // Handle button clicks (ask_user_question) diff --git a/src/container-runner.ts b/src/container-runner.ts index 9764126..b357a0d 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -9,7 +9,7 @@ import path from 'path'; import { OneCLI } from '@onecli-sh/sdk'; -import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, ONECLI_URL, TIMEZONE } from './config.js'; +import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, MAX_MESSAGES_PER_PROMPT, ONECLI_URL, TIMEZONE } from './config.js'; import { readContainerConfig, writeContainerConfig } from './container-config.js'; import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { getAgentGroup } from './db/agent-groups.js'; @@ -246,6 +246,9 @@ async function buildContainerArgs( } args.push('-e', `NANOCLAW_AGENT_GROUP_ID=${agentGroup.id}`); args.push('-e', `NANOCLAW_AGENT_GROUP_NAME=${agentGroup.name}`); + // Cap on how many pending messages reach one prompt. Accumulated context + // (trigger=0 rows) rides along with wake-eligible rows up to this cap. + args.push('-e', `NANOCLAW_MAX_MESSAGES_PER_PROMPT=${MAX_MESSAGES_PER_PROMPT}`); // Provider-contributed env vars (e.g. XDG_DATA_HOME, OPENCODE_*, NO_PROXY). if (providerContribution.env) { diff --git a/src/db/db-v2.test.ts b/src/db/db-v2.test.ts index f8689eb..e0cebdf 100644 --- a/src/db/db-v2.test.ts +++ b/src/db/db-v2.test.ts @@ -178,8 +178,10 @@ describe('messaging group agents', () => { id: 'mga-1', messaging_group_id: 'mg-1', agent_group_id: 'ag-1', - trigger_rules: null, - response_scope: 'all' as const, + engage_mode: 'pattern' as const, + engage_pattern: '.', + sender_scope: 'all' as const, + ignored_message_policy: 'drop' as const, session_mode: 'shared' as const, priority: 0, created_at: now(), @@ -229,7 +231,8 @@ describe('messaging group agents', () => { }); it('auto-creates an agent_destinations row for the wiring', async () => { - const { getDestinationByTarget, getDestinations } = await import('../modules/agent-to-agent/db/agent-destinations.js'); + const { getDestinationByTarget, getDestinations } = + await import('../modules/agent-to-agent/db/agent-destinations.js'); createMessagingGroupAgent(mga()); const dest = getDestinationByTarget('ag-1', 'channel', 'mg-1'); diff --git a/src/db/messaging-groups.ts b/src/db/messaging-groups.ts index 0c0ba22..db12583 100644 --- a/src/db/messaging-groups.ts +++ b/src/db/messaging-groups.ts @@ -87,8 +87,16 @@ export function deleteMessagingGroup(id: string): void { export function createMessagingGroupAgent(mga: MessagingGroupAgent): void { getDb() .prepare( - `INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) - VALUES (@id, @messaging_group_id, @agent_group_id, @trigger_rules, @response_scope, @session_mode, @priority, @created_at)`, + `INSERT INTO messaging_group_agents ( + id, messaging_group_id, agent_group_id, + engage_mode, engage_pattern, sender_scope, ignored_message_policy, + session_mode, priority, created_at + ) + VALUES ( + @id, @messaging_group_id, @agent_group_id, + @engage_mode, @engage_pattern, @sender_scope, @ignored_message_policy, + @session_mode, @priority, @created_at + )`, ) .run(mga); @@ -160,7 +168,12 @@ export function getMessagingGroupAgent(id: string): MessagingGroupAgent | undefi export function updateMessagingGroupAgent( id: string, - updates: Partial>, + updates: Partial< + Pick< + MessagingGroupAgent, + 'engage_mode' | 'engage_pattern' | 'sender_scope' | 'ignored_message_policy' | 'session_mode' | 'priority' + > + >, ): void { const fields: string[] = []; const values: Record = { id }; diff --git a/src/db/migrations/010-engage-modes.ts b/src/db/migrations/010-engage-modes.ts new file mode 100644 index 0000000..4bf9798 --- /dev/null +++ b/src/db/migrations/010-engage-modes.ts @@ -0,0 +1,101 @@ +/** + * Replace `trigger_rules` (opaque JSON) + `response_scope` (conflated axis) + * with four explicit orthogonal columns on messaging_group_agents: + * + * engage_mode 'pattern' | 'mention' | 'mention-sticky' + * engage_pattern regex string (required when engage_mode='pattern'; + * '.' means "match everything" — the "always" flavor) + * sender_scope 'all' | 'known' + * ignored_message_policy 'drop' | 'accumulate' + * + * Backfill rules (applied per-row, reading the old JSON): + * - If trigger_rules.pattern is a non-empty string → engage_mode='pattern', + * engage_pattern = that value + * - Else if trigger_rules.requiresTrigger === false OR response_scope='all' + * → engage_mode='pattern', engage_pattern='.' + * - Else (requires trigger but no pattern specified) → engage_mode='mention' + * - sender_scope: 'known' when response_scope was 'allowlisted', 'all' otherwise + * - ignored_message_policy: 'drop' (conservative default; no old-schema analog) + */ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +import { log } from '../../log.js'; + +interface LegacyRow { + id: string; + trigger_rules: string | null; + response_scope: string | null; +} + +function backfill(row: LegacyRow): { + engage_mode: 'pattern' | 'mention' | 'mention-sticky'; + engage_pattern: string | null; + sender_scope: 'all' | 'known'; + ignored_message_policy: 'drop' | 'accumulate'; +} { + let parsed: Record = {}; + if (row.trigger_rules) { + try { + parsed = JSON.parse(row.trigger_rules) as Record; + } catch { + // Invalid JSON falls through to conservative defaults. + } + } + + const pattern = typeof parsed.pattern === 'string' && parsed.pattern.length > 0 ? (parsed.pattern as string) : null; + const requiresTrigger = parsed.requiresTrigger; + + let engage_mode: 'pattern' | 'mention' | 'mention-sticky' = 'mention'; + let engage_pattern: string | null = null; + if (pattern) { + engage_mode = 'pattern'; + engage_pattern = pattern; + } else if (requiresTrigger === false || row.response_scope === 'all') { + engage_mode = 'pattern'; + engage_pattern = '.'; + } + + const sender_scope: 'all' | 'known' = row.response_scope === 'allowlisted' ? 'known' : 'all'; + + return { engage_mode, engage_pattern, sender_scope, ignored_message_policy: 'drop' }; +} + +export const migration010: Migration = { + version: 10, + name: 'engage-modes', + up: (db: Database.Database) => { + // Add the four new columns alongside the existing two. SQLite ALTER ADD + // is cheap and non-rewriting. + db.exec(` + ALTER TABLE messaging_group_agents ADD COLUMN engage_mode TEXT; + ALTER TABLE messaging_group_agents ADD COLUMN engage_pattern TEXT; + ALTER TABLE messaging_group_agents ADD COLUMN sender_scope TEXT; + ALTER TABLE messaging_group_agents ADD COLUMN ignored_message_policy TEXT; + `); + + // Backfill existing rows in JS (parsing JSON per-row is painful in pure SQL). + const rows = db.prepare('SELECT id, trigger_rules, response_scope FROM messaging_group_agents').all() as LegacyRow[]; + const update = db.prepare( + `UPDATE messaging_group_agents + SET engage_mode = ?, + engage_pattern = ?, + sender_scope = ?, + ignored_message_policy = ? + WHERE id = ?`, + ); + for (const row of rows) { + const v = backfill(row); + update.run(v.engage_mode, v.engage_pattern, v.sender_scope, v.ignored_message_policy, row.id); + } + + // Drop the legacy columns. DROP COLUMN requires SQLite 3.35+ (2021); our + // better-sqlite3 ships a current build. + db.exec(` + ALTER TABLE messaging_group_agents DROP COLUMN trigger_rules; + ALTER TABLE messaging_group_agents DROP COLUMN response_scope; + `); + + log.info('engage-modes migration: backfilled rows', { count: rows.length }); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 3a87797..d220688 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -6,6 +6,7 @@ import { migration002 } from './002-chat-sdk-state.js'; import { moduleAgentToAgentDestinations } from './module-agent-to-agent-destinations.js'; import { migration008 } from './008-dropped-messages.js'; import { migration009 } from './009-drop-pending-credentials.js'; +import { migration010 } from './010-engage-modes.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; @@ -23,6 +24,7 @@ const migrations: Migration[] = [ moduleApprovalsTitleOptions, migration008, migration009, + migration010, ]; export function runMigrations(db: Database.Database): void { @@ -52,8 +54,8 @@ export function runMigrations(db: Database.Database): void { for (const m of pending) { db.transaction(() => { m.up(db); - const next = - (db.prepare('SELECT COALESCE(MAX(version), 0) + 1 AS v FROM schema_version').get() as { v: number }).v; + const next = (db.prepare('SELECT COALESCE(MAX(version), 0) + 1 AS v FROM schema_version').get() as { v: number }) + .v; db.prepare('INSERT INTO schema_version (version, name, applied) VALUES (?, ?, ?)').run( next, m.name, diff --git a/src/db/schema.ts b/src/db/schema.ts index 47d4c9f..9dd887e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -30,16 +30,23 @@ CREATE TABLE messaging_groups ( UNIQUE(channel_type, platform_id) ); --- Which agent groups handle which messaging groups +-- Which agent groups handle which messaging groups. +-- engage_mode / engage_pattern / sender_scope / ignored_message_policy are +-- the four orthogonal axes that together replace v1's opaque trigger_rules +-- JSON + response_scope enum. See docs/v1-vs-v2/ACTION-ITEMS.md item 1. CREATE TABLE messaging_group_agents ( - id TEXT PRIMARY KEY, - messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id), - agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), - trigger_rules TEXT, - response_scope TEXT DEFAULT 'all', - session_mode TEXT DEFAULT 'shared', - priority INTEGER DEFAULT 0, - created_at TEXT NOT NULL, + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id), + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + engage_mode TEXT NOT NULL DEFAULT 'mention', + -- 'pattern' | 'mention' | 'mention-sticky' + engage_pattern TEXT, -- regex; required when engage_mode='pattern'; + -- '.' means "match every message" (the "always" flavor) + sender_scope TEXT NOT NULL DEFAULT 'all', -- 'all' | 'known' + ignored_message_policy TEXT NOT NULL DEFAULT 'drop', -- 'drop' | 'accumulate' + session_mode TEXT DEFAULT 'shared', + priority INTEGER DEFAULT 0, + created_at TEXT NOT NULL, UNIQUE(messaging_group_id, agent_group_id) ); @@ -138,6 +145,8 @@ CREATE TABLE IF NOT EXISTS messages_in ( recurrence TEXT, series_id TEXT, tries INTEGER DEFAULT 0, + trigger INTEGER NOT NULL DEFAULT 1, + -- 0 = accumulated context (don't wake), 1 = wake agent platform_id TEXT, channel_type TEXT, thread_id TEXT, diff --git a/src/db/session-db.ts b/src/db/session-db.ts index a73ca5c..aea255d 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -95,13 +95,19 @@ export function insertMessage( content: string; processAfter: string | null; recurrence: string | null; + /** + * 1 = wake the agent (default); 0 = accumulate as context only. + * Host countDueMessages gates on this; container reads everything. + */ + trigger?: 0 | 1; }, ): void { db.prepare( - `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id) - VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id)`, + `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger) + VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger)`, ).run({ ...message, + trigger: message.trigger ?? 1, seq: nextEvenSeq(db), }); } @@ -112,6 +118,7 @@ export function countDueMessages(db: Database.Database): number { .prepare( `SELECT COUNT(*) as count FROM messages_in WHERE status = 'pending' + AND trigger = 1 AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))`, ) .get() as { count: number } @@ -169,9 +176,7 @@ export interface ProcessingClaim { /** Return processing_ack rows still in 'processing' with their claim timestamps. */ export function getProcessingClaims(outDb: Database.Database): ProcessingClaim[] { return outDb - .prepare( - "SELECT message_id, status_changed FROM processing_ack WHERE status = 'processing'", - ) + .prepare("SELECT message_id, status_changed FROM processing_ack WHERE status = 'processing'") .all() as ProcessingClaim[]; } @@ -262,10 +267,9 @@ export function migrateDeliveredTable(db: Database.Database): void { } } -// Adds series_id (groups all occurrences of a recurring task) to pre-existing -// messages_in tables. No-op on fresh installs where the column is in the schema. -// Backfills existing rows so cancel/pause/resume queries can rely on -// series_id IS NOT NULL. +// Adds columns added to messages_in after the initial v2 schema to +// pre-existing session DBs. No-op on fresh installs where the columns are +// in the baseline schema. Backfills existing rows so invariants hold. export function migrateMessagesInTable(db: Database.Database): void { const cols = new Set( (db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name), @@ -275,4 +279,9 @@ export function migrateMessagesInTable(db: Database.Database): void { db.prepare('UPDATE messages_in SET series_id = id WHERE series_id IS NULL').run(); db.prepare('CREATE INDEX IF NOT EXISTS idx_messages_in_series ON messages_in(series_id)').run(); } + if (!cols.has('trigger')) { + // All pre-existing rows got written with the old "every inbound wakes + // the agent" semantics, so backfill 1 and default 1 for new inserts. + db.prepare('ALTER TABLE messages_in ADD COLUMN trigger INTEGER NOT NULL DEFAULT 1').run(); + } } diff --git a/src/db/sessions.ts b/src/db/sessions.ts index 01e48cd..bdca8a6 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -27,6 +27,31 @@ export function findSession(messagingGroupId: string, threadId: string | null): .get(messagingGroupId, 'active') as Session | undefined; } +/** + * Session lookup scoped to a specific agent group. Needed when multiple + * agents are wired to the same messaging group + thread (fan-out) — the + * plain `findSession` would return whichever agent's session happened to + * be first and route to the wrong container. + */ +export function findSessionForAgent( + agentGroupId: string, + messagingGroupId: string, + threadId: string | null, +): Session | undefined { + if (threadId) { + return getDb() + .prepare( + "SELECT * FROM sessions WHERE agent_group_id = ? AND messaging_group_id = ? AND thread_id = ? AND status = 'active'", + ) + .get(agentGroupId, messagingGroupId, threadId) as Session | undefined; + } + return getDb() + .prepare( + "SELECT * FROM sessions WHERE agent_group_id = ? AND messaging_group_id = ? AND thread_id IS NULL AND status = 'active'", + ) + .get(agentGroupId, messagingGroupId) as Session | undefined; +} + /** Find an active session scoped to an agent group (ignoring messaging group). */ export function findSessionByAgentGroup(agentGroupId: string): Session | undefined { return getDb() diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 7269164..33d37ff 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -199,8 +199,10 @@ describe('router', () => { id: 'mga-1', messaging_group_id: 'mg-1', agent_group_id: 'ag-1', - trigger_rules: null, - response_scope: 'all', + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', session_mode: 'shared', priority: 0, created_at: now(), @@ -295,6 +297,106 @@ describe('router', () => { expect(rows).toHaveLength(2); }); + + it('fans out to every matching agent, each in its own session', async () => { + const { routeInbound } = await import('./router.js'); + const { wakeContainer } = await import('./container-runner.js'); + (wakeContainer as unknown as ReturnType).mockClear(); + + // Wire a second agent to the same messaging group. + createAgentGroup({ + id: 'ag-2', + name: 'Secondary Agent', + folder: 'secondary-agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroupAgent({ + id: 'mga-2', + messaging_group_id: 'mg-1', + agent_group_id: 'ag-2', + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', + session_mode: 'shared', + priority: 0, + created_at: now(), + }); + + await routeInbound({ + channelType: 'discord', + platformId: 'chan-123', + threadId: null, + message: { id: 'msg-fan', kind: 'chat', content: JSON.stringify({ text: 'hello all' }), timestamp: now() }, + }); + + // Both agents should now have their own session and be woken. + expect(wakeContainer).toHaveBeenCalledTimes(2); + + const { getSessionsByAgentGroup } = await import('./db/sessions.js'); + expect(getSessionsByAgentGroup('ag-1')).toHaveLength(1); + expect(getSessionsByAgentGroup('ag-2')).toHaveLength(1); + }); + + it('accumulates without waking when engage fails + ignored_message_policy=accumulate', async () => { + const { routeInbound } = await import('./router.js'); + const { wakeContainer } = await import('./container-runner.js'); + (wakeContainer as unknown as ReturnType).mockClear(); + + // Replace the seed row with a mention-only wiring whose accumulate + // policy should store context even when the message doesn't mention us. + const { updateMessagingGroupAgent } = await import('./db/messaging-groups.js'); + updateMessagingGroupAgent('mga-1', { + engage_mode: 'mention', + ignored_message_policy: 'accumulate', + }); + + await routeInbound({ + channelType: 'discord', + platformId: 'chan-123', + threadId: null, + message: { + id: 'msg-nomatch', + kind: 'chat', + content: JSON.stringify({ text: 'no mention here' }), + timestamp: now(), + }, + }); + + expect(wakeContainer).not.toHaveBeenCalled(); + + const session = findSession('mg-1', null); + expect(session).toBeDefined(); + const db = new Database(inboundDbPath('ag-1', session!.id)); + const rows = db.prepare('SELECT id, trigger FROM messages_in').all() as Array<{ + id: string; + trigger: number; + }>; + db.close(); + expect(rows).toHaveLength(1); + expect(rows[0].trigger).toBe(0); + }); + + it('drops silently when engage fails + ignored_message_policy=drop', async () => { + const { routeInbound } = await import('./router.js'); + const { wakeContainer } = await import('./container-runner.js'); + (wakeContainer as unknown as ReturnType).mockClear(); + + const { updateMessagingGroupAgent } = await import('./db/messaging-groups.js'); + updateMessagingGroupAgent('mga-1', { engage_mode: 'mention' }); // drop is the default + + await routeInbound({ + channelType: 'discord', + platformId: 'chan-123', + threadId: null, + message: { id: 'msg-drop', kind: 'chat', content: JSON.stringify({ text: 'ignored' }), timestamp: now() }, + }); + + expect(wakeContainer).not.toHaveBeenCalled(); + // No session should have been created for this agent. + expect(findSession('mg-1', null)).toBeUndefined(); + }); }); describe('delivery', () => { diff --git a/src/index.ts b/src/index.ts index ffb2731..9bb51be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -158,12 +158,11 @@ function buildConversationConfigs(channelType: string): ConversationConfig[] { for (const mg of groups) { const agents = getMessagingGroupAgents(mg.id); for (const agent of agents) { - const triggerRules = agent.trigger_rules ? JSON.parse(agent.trigger_rules) : null; configs.push({ platformId: mg.platform_id, agentGroupId: agent.agent_group_id, - triggerPattern: triggerRules?.pattern, - requiresTrigger: triggerRules?.requiresTrigger ?? false, + engageMode: agent.engage_mode, + engagePattern: agent.engage_pattern, sessionMode: agent.session_mode, }); } diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index e7cc282..ca97f8f 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -16,9 +16,15 @@ * access gate is not registered and core defaults to allow-all. */ import { recordDroppedMessage } from '../../db/dropped-messages.js'; -import { setAccessGate, setSenderResolver, type AccessGateResult, type InboundEvent } from '../../router.js'; +import { + setAccessGate, + setSenderResolver, + setSenderScopeGate, + type AccessGateResult, + type InboundEvent, +} from '../../router.js'; import { log } from '../../log.js'; -import type { MessagingGroup } from '../../types.js'; +import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import { canAccessAgentGroup } from './access.js'; import { getUser, upsertUser } from './db/users.js'; @@ -132,3 +138,21 @@ setAccessGate((event, userId, mg, agentGroupId): AccessGateResult => { handleUnknownSender(mg, userId, agentGroupId, decision.reason, event); return { allowed: false, reason: decision.reason }; }); + +/** + * Per-wiring sender-scope enforcement. Stricter than the messaging-group + * `unknown_sender_policy` — a wiring can require `sender_scope='known'` + * (explicit owner / admin / member) even on a 'public' messaging group. + * + * 'all' is a no-op; any sender passes. 'known' requires a userId that + * canAccessAgentGroup accepts (owner, admin, or group member). + */ +setSenderScopeGate( + (_event: InboundEvent, userId: string | null, _mg: MessagingGroup, agent: MessagingGroupAgent): AccessGateResult => { + if (agent.sender_scope === 'all') return { allowed: true }; + if (!userId) return { allowed: false, reason: 'unknown_user_scope' }; + const decision = canAccessAgentGroup(userId, agent.agent_group_id); + if (decision.allowed) return { allowed: true }; + return { allowed: false, reason: `sender_scope_${decision.reason}` }; + }, +); diff --git a/src/router.ts b/src/router.ts index 8971f7f..9b54cb2 100644 --- a/src/router.ts +++ b/src/router.ts @@ -18,14 +18,16 @@ * for policy refusals. */ import { getChannelAdapter } from './channels/channel-registry.js'; +import { getAgentGroup } from './db/agent-groups.js'; import { recordDroppedMessage } from './db/dropped-messages.js'; import { getMessagingGroupByPlatform, createMessagingGroup, getMessagingGroupAgents } from './db/messaging-groups.js'; +import { findSessionForAgent } from './db/sessions.js'; import { startTypingRefresh } from './modules/typing/index.js'; import { log } from './log.js'; import { resolveSession, writeSessionMessage } from './session-manager.js'; import { wakeContainer } from './container-runner.js'; import { getSession } from './db/sessions.js'; -import type { MessagingGroup, MessagingGroupAgent } from './types.js'; +import type { AgentGroup, MessagingGroup, MessagingGroupAgent } from './types.js'; function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -89,6 +91,29 @@ export function setAccessGate(fn: AccessGateFn): void { accessGate = fn; } +/** + * Per-wiring sender-scope hook. Runs alongside the access gate for each + * agent that would otherwise engage — lets the permissions module enforce + * `sender_scope='known'` on wirings that are stricter than the messaging + * group's `unknown_sender_policy`. When the hook isn't registered (module + * not installed), sender_scope is a no-op. + */ +export type SenderScopeGateFn = ( + event: InboundEvent, + userId: string | null, + mg: MessagingGroup, + agent: MessagingGroupAgent, +) => AccessGateResult; + +let senderScopeGate: SenderScopeGateFn | null = null; + +export function setSenderScopeGate(fn: SenderScopeGateFn): void { + if (senderScopeGate) { + log.warn('Sender-scope gate overwritten'); + } + senderScopeGate = fn; +} + function safeParseContent(raw: string): { text?: string; sender?: string; senderId?: string } { try { return JSON.parse(raw); @@ -158,91 +183,167 @@ export async function routeInbound(event: InboundEvent): Promise { return; } - const match = pickAgent(agents, event); - if (!match) { - log.warn('MESSAGE DROPPED — no agent matched trigger rules', { - messagingGroupId: mg.id, - channelType: event.channelType, - }); - const parsed = safeParseContent(event.message.content); + // 4. Fan-out: evaluate each wired agent independently against engage_mode, + // sender_scope, and access gate. An agent that engages gets its own + // session and container wake. An agent that declines but has + // ignored_message_policy='accumulate' still gets the message stored in + // its session (trigger=0) so the context is available when it does + // engage later. Drop policy = skip silently. + const parsed = safeParseContent(event.message.content); + const messageText = parsed.text ?? ''; + + let engagedCount = 0; + let accumulatedCount = 0; + + for (const agent of agents) { + const agentGroup = getAgentGroup(agent.agent_group_id); + if (!agentGroup) continue; + + const engages = evaluateEngage(agent, agentGroup, messageText, mg, event.threadId); + + const accessOk = engages && (!accessGate || accessGate(event, userId, mg, agent.agent_group_id).allowed); + const scopeOk = engages && (!senderScopeGate || senderScopeGate(event, userId, mg, agent).allowed); + + if (engages && accessOk && scopeOk) { + await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, true); + engagedCount++; + } else if (agent.ignored_message_policy === 'accumulate') { + await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false); + accumulatedCount++; + } else { + log.debug('Message not engaged for agent (drop policy)', { + agentGroupId: agent.agent_group_id, + engage_mode: agent.engage_mode, + engages, + accessOk, + scopeOk, + }); + } + } + + if (engagedCount + accumulatedCount === 0) { recordDroppedMessage({ channel_type: event.channelType, platform_id: event.platformId, user_id: userId, sender_name: parsed.sender ?? null, - reason: 'no_trigger_match', + reason: 'no_agent_engaged', messaging_group_id: mg.id, agent_group_id: null, }); - return; } +} - // 4. Access gate (if the permissions module is loaded). Otherwise - // allow-all. - if (accessGate) { - const result = accessGate(event, userId, mg, match.agent_group_id); - if (!result.allowed) { - log.info('MESSAGE DROPPED — access gate refused', { - messagingGroupId: mg.id, - agentGroupId: match.agent_group_id, - userId, - reason: result.reason, - }); - return; +/** + * Decide whether a given wired agent should engage on this message. + * + * 'pattern' — regex test on text; '.' = always + * 'mention' — bot must be @-mentioned by its agent-group name + * 'mention-sticky' — @mention OR an active per-thread session already + * exists for this (agent, mg, thread). The session + * existence IS our subscription state; once a thread + * has engaged us once, follow-ups arrive with no + * mention and should still fire. + */ +function evaluateEngage( + agent: MessagingGroupAgent, + agentGroup: AgentGroup, + text: string, + mg: MessagingGroup, + threadId: string | null, +): boolean { + switch (agent.engage_mode) { + case 'pattern': { + const pat = agent.engage_pattern ?? '.'; + if (pat === '.') return true; + try { + return new RegExp(pat).test(text); + } catch { + // Bad regex: fail open so admin sees the agent responding + can fix. + return true; + } } + case 'mention': + return hasMention(text, agentGroup.name); + case 'mention-sticky': { + if (hasMention(text, agentGroup.name)) return true; + // Sticky follow-up: session already exists for this (agent, mg, thread) + // — the thread was activated before, keep firing. + if (mg.is_group === 0) return false; // DMs never use mention-sticky sensibly + const existing = findSessionForAgent(agent.agent_group_id, mg.id, threadId); + return existing !== undefined; + } + default: + return false; } +} - // 5. Resolve or create session. - // - // Adapter thread policy overrides the wiring's session_mode: if the adapter - // is threaded, each thread gets its own session regardless of what the - // wiring says. Agent-shared is preserved because it expresses a - // cross-channel intent the adapter can't know about. - // - // Exception: DMs (is_group=0). Sub-threads within a DM are a UX affordance, - // not a conversation boundary — treat the whole DM as one session and let - // threadId flow through to delivery so replies land in the right sub-thread. - let effectiveSessionMode = match.session_mode; - if (adapter && adapter.supportsThreads && effectiveSessionMode !== 'agent-shared' && mg.is_group !== 0) { +function hasMention(text: string, agentName: string): boolean { + if (!agentName) return false; + const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`@${escaped}\\b`, 'i').test(text); +} + +async function deliverToAgent( + agent: MessagingGroupAgent, + agentGroup: AgentGroup, + mg: MessagingGroup, + event: InboundEvent, + userId: string | null, + adapterSupportsThreads: boolean, + wake: boolean, +): Promise { + // Apply the adapter thread policy: threaded adapter in a group chat → + // per-thread session regardless of wiring. agent-shared preserved (it's + // a cross-channel directive the adapter doesn't know about). DMs collapse + // sub-threads to one session (is_group=0 short-circuit). + let effectiveSessionMode = agent.session_mode; + if (adapterSupportsThreads && effectiveSessionMode !== 'agent-shared' && mg.is_group !== 0) { effectiveSessionMode = 'per-thread'; } - const { session, created } = resolveSession(match.agent_group_id, mg.id, event.threadId, effectiveSessionMode); - // 6. Write message to session DB + const { session, created } = resolveSession(agent.agent_group_id, mg.id, event.threadId, effectiveSessionMode); + writeSessionMessage(session.agent_group_id, session.id, { - id: event.message.id || generateId(), + id: messageIdForAgent(event.message.id, agent.agent_group_id), kind: event.message.kind, timestamp: event.message.timestamp, platformId: event.platformId, channelType: event.channelType, threadId: event.threadId, content: event.message.content, + trigger: wake ? 1 : 0, }); log.info('Message routed', { sessionId: session.id, - agentGroup: match.agent_group_id, + agentGroup: agent.agent_group_id, + engage_mode: agent.engage_mode, kind: event.message.kind, userId, + wake, created, + agentGroupName: agentGroup.name, }); - // 7. Show typing indicator while the agent processes. - startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId); - - // 8. Wake container - const freshSession = getSession(session.id); - if (freshSession) { - await wakeContainer(freshSession); + if (wake) { + // Typing indicator + wake are only for the engaged branch; accumulated + // messages sit silently until a real trigger fires. + startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId); + const freshSession = getSession(session.id); + if (freshSession) { + await wakeContainer(freshSession); + } } } /** - * Pick the matching agent for an inbound event. - * Currently: highest priority agent. Future: trigger rule matching. + * When fanning out, the same inbound message lands in multiple per-agent + * session DBs. messages_in.id is PRIMARY KEY, so reuse of the raw id would + * collide across sessions (or, more subtly, within one session if re-routed + * after a retry). Namespace by agent_group_id to keep ids unique per session. */ -function pickAgent(agents: MessagingGroupAgent[], _event: InboundEvent): MessagingGroupAgent | null { - // Agents are already ordered by priority DESC from the DB query - // TODO: apply trigger_rules matching (pattern, mentionOnly, etc.) - return agents[0] ?? null; +function messageIdForAgent(baseId: string | undefined, agentGroupId: string): string { + const id = baseId && baseId.length > 0 ? baseId : generateId(); + return `${id}:${agentGroupId}`; } diff --git a/src/session-manager.ts b/src/session-manager.ts index 7aaef24..2a5ac1d 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -17,7 +17,14 @@ import path from 'path'; import type { OutboundFile } from './channels/adapter.js'; import { DATA_DIR } from './config.js'; import { getMessagingGroup } from './db/messaging-groups.js'; -import { createSession, findSession, findSessionByAgentGroup, getSession, updateSession } from './db/sessions.js'; +import { + createSession, + findSession, + findSessionByAgentGroup, + findSessionForAgent, + getSession, + updateSession, +} from './db/sessions.js'; import { ensureSchema, openInboundDb as openInboundDbRaw, @@ -89,7 +96,9 @@ export function resolveSession( } } else if (messagingGroupId) { const lookupThreadId = sessionMode === 'shared' ? null : threadId; - const existing = findSession(messagingGroupId, lookupThreadId); + // Scope lookup by agent_group_id so fan-out to multiple agents in the + // same chat doesn't accidentally deliver to the wrong agent's session. + const existing = findSessionForAgent(agentGroupId, messagingGroupId, lookupThreadId); if (existing) { return { session: existing, created: false }; } @@ -187,6 +196,13 @@ export function writeSessionMessage( content: string; processAfter?: string | null; recurrence?: string | null; + /** + * 1 = this message should wake the agent (the default); 0 = accumulate + * as context only, don't wake. Host's countDueMessages gates on this + * column; the container still reads all prior messages as context when + * a trigger-1 message does arrive. + */ + trigger?: 0 | 1; }, ): void { // Extract base64 attachment data, save to inbox, replace with file paths @@ -204,6 +220,7 @@ export function writeSessionMessage( content, processAfter: message.processAfter ?? null, recurrence: message.recurrence ?? null, + trigger: message.trigger ?? 1, }); } finally { db.close(); diff --git a/src/types.ts b/src/types.ts index ad14441..b2674da 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,12 +67,23 @@ export interface UserDm { resolved_at: string; } +export type EngageMode = 'pattern' | 'mention' | 'mention-sticky'; +export type SenderScope = 'all' | 'known'; +export type IgnoredMessagePolicy = 'drop' | 'accumulate'; + export interface MessagingGroupAgent { id: string; messaging_group_id: string; agent_group_id: string; - trigger_rules: string | null; // JSON: { pattern, mentionOnly, excludeSenders, includeSenders } - response_scope: 'all' | 'triggered' | 'allowlisted'; + engage_mode: EngageMode; + /** + * Regex source string used when engage_mode='pattern'. `'.'` is the sentinel + * for "match every message" (the "always" flavor). Ignored for 'mention' / + * 'mention-sticky' modes. + */ + engage_pattern: string | null; + sender_scope: SenderScope; + ignored_message_policy: IgnoredMessagePolicy; session_mode: 'shared' | 'per-thread' | 'agent-shared'; priority: number; created_at: string; From 622a370815b3ac9ef499005d0713ff9afb114d23 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 01:36:11 +0300 Subject: [PATCH 013/185] feat(permissions): unknown-sender request_approval flow + flipped default policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an unknown sender writes into a wired messaging group, surface the situation to an admin instead of silently dropping. Flow: 1. Router → access gate → handleUnknownSender (policy='request_approval') 2. Fire-and-forget requestSenderApproval: pickApprover + pickApprovalDelivery pick a reachable admin DM; deliver an Approve / Deny card; insert a pending_sender_approvals row carrying the original InboundEvent JSON. 3. In-flight dedup: UNIQUE(messaging_group_id, sender_identity) — a retry from the same stranger while pending is silently dropped, not re-carded. 4. Admin clicks → Chat SDK bridge → onAction → host response-registry. The new handleSenderApprovalResponse in the permissions module claims responses whose questionId matches a pending_sender_approvals row. 5. approve: addMember(stranger, agent_group) + replay the stored event via routeInbound — the second attempt clears the gate because the user is now known. 6. deny: delete the pending row. No denial persistence (ACTION-ITEMS item 5 decision) — a future attempt triggers a fresh card. Schema: - Migration 011 adds pending_sender_approvals (id, mg_id, agent_group_id, sender_identity, sender_name, original_message JSON, approver_user_id, created_at, UNIQUE(mg_id, sender_identity)). - Also flips messaging_groups.unknown_sender_policy default from 'strict' to 'request_approval' (rebuild-table). Existing rows unchanged — only the default applied to new rows flips. - Router auto-create for unknown platform/chat drops the hardcoded 'strict' override; schema default applies. - src/db/schema.ts reference updated to match. Why default-flip: users wire their DM during setup and don't discover that 'strict' means "silent drop of everyone not in user_roles/members". The approval flow is the safe default — the admin sees the stranger, explicitly decides. 'public' stays opt-in for truly open channels. Failure modes (row NOT created so a future attempt can try again): - No eligible approver configured (fresh install before first owner). - No reachable DM for any approver. - Delivery adapter missing. Tests (src/modules/permissions/sender-approval.test.ts, 4 cases): - First unknown message → card delivered + row created - Retry while pending → dedup'd (1 card, 1 row) - Approve → member added + message replayed + container woken - Deny → row cleared + no member added Closes: ACTION-ITEMS item 5. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../011-pending-sender-approvals.ts | 57 ++++ src/db/migrations/index.ts | 2 + src/db/schema.ts | 22 +- .../db/pending-sender-approvals.ts | 59 ++++ src/modules/permissions/index.ts | 87 +++++- .../permissions/sender-approval.test.ts | 265 ++++++++++++++++++ src/modules/permissions/sender-approval.ts | 152 ++++++++++ src/router.ts | 5 +- 8 files changed, 645 insertions(+), 4 deletions(-) create mode 100644 src/db/migrations/011-pending-sender-approvals.ts create mode 100644 src/modules/permissions/db/pending-sender-approvals.ts create mode 100644 src/modules/permissions/sender-approval.test.ts create mode 100644 src/modules/permissions/sender-approval.ts diff --git a/src/db/migrations/011-pending-sender-approvals.ts b/src/db/migrations/011-pending-sender-approvals.ts new file mode 100644 index 0000000..cb47039 --- /dev/null +++ b/src/db/migrations/011-pending-sender-approvals.ts @@ -0,0 +1,57 @@ +/** + * Unknown-sender approval flow. When `unknown_sender_policy = 'request_approval'` + * a non-member message triggers a card to the most appropriate admin. An + * in-flight entry in this table dedups concurrent attempts from the same + * sender; the row is cleared on approve / deny. + * + * Also flips the `messaging_groups.unknown_sender_policy` default from 'strict' + * to 'request_approval' so fresh wirings don't silently swallow messages from + * users the admin hasn't added yet. Existing rows are left as-is (silent + * upgrade would change established behavior without the admin asking for it). + */ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +export const migration011: Migration = { + version: 11, + name: 'pending-sender-approvals', + up: (db: Database.Database) => { + db.exec(` + CREATE TABLE IF NOT EXISTS pending_sender_approvals ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id), + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + sender_identity TEXT NOT NULL, -- namespaced user id (channel_type:handle) + sender_name TEXT, + original_message TEXT NOT NULL, -- JSON serialized InboundEvent + approver_user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(messaging_group_id, sender_identity) + ); + CREATE INDEX IF NOT EXISTS idx_pending_sender_approvals_mg + ON pending_sender_approvals(messaging_group_id); + `); + + // Default-flip: fresh messaging_groups default to request_approval instead + // of silently dropping. SQLite doesn't support modifying column DEFAULTs + // in place, so we rebuild the table via the classic rename-copy-drop + // pattern. Existing rows keep their current unknown_sender_policy value. + db.exec(` + CREATE TABLE messaging_groups_new ( + id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL, + platform_id TEXT NOT NULL, + name TEXT, + is_group INTEGER DEFAULT 0, + unknown_sender_policy TEXT NOT NULL DEFAULT 'request_approval', + created_at TEXT NOT NULL, + UNIQUE(channel_type, platform_id) + ); + INSERT INTO messaging_groups_new + SELECT id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at + FROM messaging_groups; + DROP TABLE messaging_groups; + ALTER TABLE messaging_groups_new RENAME TO messaging_groups; + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index d220688..1015f40 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -7,6 +7,7 @@ import { moduleAgentToAgentDestinations } from './module-agent-to-agent-destinat import { migration008 } from './008-dropped-messages.js'; import { migration009 } from './009-drop-pending-credentials.js'; import { migration010 } from './010-engage-modes.js'; +import { migration011 } from './011-pending-sender-approvals.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; @@ -25,6 +26,7 @@ const migrations: Migration[] = [ migration008, migration009, migration010, + migration011, ]; export function runMigrations(db: Database.Database): void { diff --git a/src/db/schema.ts b/src/db/schema.ts index 9dd887e..aa33fae 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -25,7 +25,11 @@ CREATE TABLE messaging_groups ( platform_id TEXT NOT NULL, name TEXT, is_group INTEGER DEFAULT 0, - unknown_sender_policy TEXT NOT NULL DEFAULT 'strict', -- 'strict' | 'request_approval' | 'public' + unknown_sender_policy TEXT NOT NULL DEFAULT 'request_approval', + -- 'strict' | 'request_approval' | 'public' + -- Default is request_approval so silent drops don't + -- mystery-break users who wired their DM during + -- setup and haven't explicitly marked it public. created_at TEXT NOT NULL, UNIQUE(channel_type, platform_id) ); @@ -123,6 +127,22 @@ CREATE TABLE pending_questions ( options_json TEXT NOT NULL, created_at TEXT NOT NULL ); + +-- Pending approvals for unknown senders (unknown_sender_policy='request_approval'). +-- In-flight dedup via UNIQUE(messaging_group_id, sender_identity): a second +-- message from the same unknown sender while a card is pending is silently +-- dropped instead of spamming the admin. +CREATE TABLE pending_sender_approvals ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id), + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + sender_identity TEXT NOT NULL, -- namespaced user id (channel_type:handle) + sender_name TEXT, + original_message TEXT NOT NULL, -- JSON of the original InboundEvent + approver_user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(messaging_group_id, sender_identity) +); `; /** diff --git a/src/modules/permissions/db/pending-sender-approvals.ts b/src/modules/permissions/db/pending-sender-approvals.ts new file mode 100644 index 0000000..9f7e3a4 --- /dev/null +++ b/src/modules/permissions/db/pending-sender-approvals.ts @@ -0,0 +1,59 @@ +/** + * CRUD for pending_sender_approvals — the in-flight state for the + * request_approval unknown-sender flow. Rows are created when an unknown + * sender writes into a wired messaging group with that policy, and are + * deleted on admin approve (after adding the user as a member) or deny. + * + * UNIQUE(messaging_group_id, sender_identity) enforces in-flight dedup: + * a retry / second message from the same unknown sender while a card is + * still pending is silently dropped instead of spamming the admin. + */ +import { getDb } from '../../../db/connection.js'; + +export interface PendingSenderApproval { + id: string; + messaging_group_id: string; + agent_group_id: string; + sender_identity: string; + sender_name: string | null; + original_message: string; + approver_user_id: string; + created_at: string; +} + +export function createPendingSenderApproval(row: PendingSenderApproval): void { + getDb() + .prepare( + `INSERT INTO pending_sender_approvals ( + id, messaging_group_id, agent_group_id, sender_identity, + sender_name, original_message, approver_user_id, created_at + ) + VALUES ( + @id, @messaging_group_id, @agent_group_id, @sender_identity, + @sender_name, @original_message, @approver_user_id, @created_at + )`, + ) + .run(row); +} + +export function getPendingSenderApproval(id: string): PendingSenderApproval | undefined { + return getDb() + .prepare('SELECT * FROM pending_sender_approvals WHERE id = ?') + .get(id) as PendingSenderApproval | undefined; +} + +export function hasInFlightSenderApproval( + messagingGroupId: string, + senderIdentity: string, +): boolean { + const row = getDb() + .prepare( + 'SELECT 1 AS x FROM pending_sender_approvals WHERE messaging_group_id = ? AND sender_identity = ?', + ) + .get(messagingGroupId, senderIdentity) as { x: number } | undefined; + return row !== undefined; +} + +export function deletePendingSenderApproval(id: string): void { + getDb().prepare('DELETE FROM pending_sender_approvals WHERE id = ?').run(id); +} diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index ca97f8f..1d505b6 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -17,16 +17,24 @@ */ import { recordDroppedMessage } from '../../db/dropped-messages.js'; import { + routeInbound, setAccessGate, setSenderResolver, setSenderScopeGate, type AccessGateResult, type InboundEvent, } from '../../router.js'; +import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; import { log } from '../../log.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import { canAccessAgentGroup } from './access.js'; +import { addMember } from './db/agent-group-members.js'; +import { + deletePendingSenderApproval, + getPendingSenderApproval, +} from './db/pending-sender-approvals.js'; import { getUser, upsertUser } from './db/users.js'; +import { requestSenderApproval } from './sender-approval.js'; function extractAndUpsertUser(event: InboundEvent): string | null { let content: Record; @@ -82,11 +90,12 @@ function handleUnknownSender( event: InboundEvent, ): void { const parsed = safeParseContent(event.message.content); + const senderName = parsed.sender ?? null; const dropRecord = { channel_type: event.channelType, platform_id: event.platformId, user_id: userId, - sender_name: parsed.sender ?? null, + sender_name: senderName, reason: `unknown_sender_${mg.unknown_sender_policy}`, messaging_group_id: mg.id, agent_group_id: agentGroupId, @@ -104,13 +113,27 @@ function handleUnknownSender( } if (mg.unknown_sender_policy === 'request_approval') { - log.info('MESSAGE DROPPED — unknown sender (approval flow TODO)', { + log.info('MESSAGE DROPPED — unknown sender (approval requested)', { messagingGroupId: mg.id, agentGroupId, userId, accessReason, }); recordDroppedMessage(dropRecord); + // Fire-and-forget; pick-approver + delivery + row-insert are all async. + // If it fails it logs internally — the user's message still stays dropped + // either way. Requires a resolved userId (senderResolver populates users + // row before the gate fires); if we got here without one, there's nothing + // to identify for approval and we just stay in the "silent strict" branch. + if (userId) { + requestSenderApproval({ + messagingGroupId: mg.id, + agentGroupId, + senderIdentity: userId, + senderName, + event, + }).catch((err) => log.error('Sender-approval flow threw', { err })); + } return; } @@ -156,3 +179,63 @@ setSenderScopeGate( return { allowed: false, reason: `sender_scope_${decision.reason}` }; }, ); + +/** + * Response handler for the unknown-sender approval card. + * + * Claim rule: questionId matches a row in pending_sender_approvals. If no + * such row, return false so the next handler (approvals module, OneCLI, + * interactive) gets a shot. + * + * Approve: add the sender to agent_group_members + re-invoke routeInbound + * with the stored event. The second routing attempt clears the gate because + * the user is now a member. + * + * Deny: delete the row (no "deny list" — a future message re-triggers a + * fresh card per ACTION-ITEMS item 5 "no denial persistence"). + */ +async function handleSenderApprovalResponse(payload: ResponsePayload): Promise { + const row = getPendingSenderApproval(payload.questionId); + if (!row) return false; + + const approverId = payload.userId ?? row.approver_user_id; + const approved = payload.value === 'approve'; + + if (approved) { + addMember({ + user_id: row.sender_identity, + agent_group_id: row.agent_group_id, + added_by: approverId, + added_at: new Date().toISOString(), + }); + log.info('Unknown sender approved — member added', { + approvalId: row.id, + senderIdentity: row.sender_identity, + agentGroupId: row.agent_group_id, + approverId, + }); + + // Clear the pending row BEFORE re-routing so the gate check on the + // second attempt doesn't see the in-flight row and short-circuit. + deletePendingSenderApproval(row.id); + + try { + const event = JSON.parse(row.original_message) as InboundEvent; + await routeInbound(event); + } catch (err) { + log.error('Failed to replay message after sender approval', { approvalId: row.id, err }); + } + return true; + } + + log.info('Unknown sender denied', { + approvalId: row.id, + senderIdentity: row.sender_identity, + agentGroupId: row.agent_group_id, + approverId, + }); + deletePendingSenderApproval(row.id); + return true; +} + +registerResponseHandler(handleSenderApprovalResponse); diff --git a/src/modules/permissions/sender-approval.test.ts b/src/modules/permissions/sender-approval.test.ts new file mode 100644 index 0000000..a02c742 --- /dev/null +++ b/src/modules/permissions/sender-approval.test.ts @@ -0,0 +1,265 @@ +/** + * Integration tests for the unknown-sender request_approval flow + * (ACTION-ITEMS item 5). + * + * Covers: + * - request_approval policy fires `requestSenderApproval` on first unknown + * message from a sender + * - In-flight dedup: second message from the same sender while pending is + * silently dropped (no second card, no second row) + * - Approve path: member added, original message replayed via routeInbound, + * container woken + * - Deny path: pending row deleted, no member added + */ +import fs from 'fs'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; + +import { initTestDb, closeDb, runMigrations } from '../../db/index.js'; +import { createAgentGroup } from '../../db/agent-groups.js'; +import { createMessagingGroup, createMessagingGroupAgent } from '../../db/messaging-groups.js'; +import { upsertUser } from './db/users.js'; +import { grantRole } from './db/user-roles.js'; + +// Mock container runner — prevent actual docker spawn. +vi.mock('../../container-runner.js', () => ({ + wakeContainer: vi.fn().mockResolvedValue(undefined), + isContainerRunning: vi.fn().mockReturnValue(false), + getActiveContainerCount: vi.fn().mockReturnValue(0), + killContainer: vi.fn(), +})); + +// Mock delivery adapter — record card deliveries for assertions. +const deliverMock = vi.fn().mockResolvedValue('plat-msg-id'); +vi.mock('../../delivery.js', () => ({ + getDeliveryAdapter: () => ({ + deliver: deliverMock, + }), +})); + +// Mock ensureUserDm to return the approver's existing messaging group +// instead of hitting a real openDM RPC. +vi.mock('./user-dm.js', () => ({ + ensureUserDm: vi.fn(async (userId: string) => { + const { getDb } = await import('../../db/connection.js'); + const row = getDb() + .prepare( + `SELECT mg.* FROM messaging_groups mg + JOIN user_dms ud ON ud.messaging_group_id = mg.id + WHERE ud.user_id = ?`, + ) + .get(userId); + return row; + }), +})); + +vi.mock('../../config.js', async () => { + const actual = await vi.importActual('../../config.js'); + return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-sender-approval' }; +}); + +const TEST_DIR = '/tmp/nanoclaw-test-sender-approval'; + +function now() { + return new Date().toISOString(); +} + +beforeEach(async () => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + const db = initTestDb(); + runMigrations(db); + + // Side-effect imports: register hooks (permissions module) AFTER the + // mocks are in place so the access gate / response handler pick up the + // mocked delivery + user-dm helpers. + await import('./index.js'); + + // Fixtures: agent group, messaging group with request_approval, wiring, + // owner + DM messaging group for approver delivery. + createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() }); + + createMessagingGroup({ + id: 'mg-chat', + channel_type: 'telegram', + platform_id: 'chat-123', + name: 'Group Chat', + is_group: 1, + unknown_sender_policy: 'request_approval', + created_at: now(), + }); + createMessagingGroupAgent({ + id: 'mga-1', + messaging_group_id: 'mg-chat', + agent_group_id: 'ag-1', + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', + session_mode: 'shared', + priority: 0, + created_at: now(), + }); + + // Owner user + their DM messaging group (pickApprover + ensureUserDm target). + upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() }); + grantRole({ + user_id: 'telegram:owner', + role: 'owner', + agent_group_id: null, + granted_by: null, + granted_at: now(), + }); + createMessagingGroup({ + id: 'mg-dm-owner', + channel_type: 'telegram', + platform_id: 'dm-owner', + name: 'Owner DM', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now(), + }); + const { getDb } = await import('../../db/connection.js'); + getDb() + .prepare( + `INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at) + VALUES (?, ?, ?, ?)`, + ) + .run('telegram:owner', 'telegram', 'mg-dm-owner', now()); + + deliverMock.mockClear(); +}); + +afterEach(() => { + closeDb(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); +}); + +function stranger(text: string) { + return { + channelType: 'telegram', + platformId: 'chat-123', + threadId: null, + message: { + id: `stranger-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat' as const, + content: JSON.stringify({ + senderId: 'tg:stranger', + senderName: 'Stranger', + text, + }), + timestamp: now(), + }, + }; +} + +describe('unknown-sender request_approval flow', () => { + it('delivers an approval card on first unknown message', async () => { + const { routeInbound } = await import('../../router.js'); + await routeInbound(stranger('hi')); + + // Wait for the fire-and-forget requestSenderApproval to resolve. + await new Promise((r) => setTimeout(r, 10)); + + expect(deliverMock).toHaveBeenCalledTimes(1); + const [channel, platformId, thread, kind, content] = deliverMock.mock.calls[0]; + expect(channel).toBe('telegram'); + expect(platformId).toBe('dm-owner'); // delivered to owner's DM + expect(thread).toBeNull(); + expect(kind).toBe('chat-sdk'); + const payload = JSON.parse(content as string); + expect(payload.type).toBe('ask_question'); + expect(payload.questionId).toMatch(/^nsa-/); + + const { getDb } = await import('../../db/connection.js'); + const rows = getDb().prepare('SELECT * FROM pending_sender_approvals').all(); + expect(rows).toHaveLength(1); + }); + + it('dedups a second message from the same stranger while pending', async () => { + const { routeInbound } = await import('../../router.js'); + await routeInbound(stranger('hello')); + await new Promise((r) => setTimeout(r, 10)); + await routeInbound(stranger('are you there?')); + await new Promise((r) => setTimeout(r, 10)); + + expect(deliverMock).toHaveBeenCalledTimes(1); + const { getDb } = await import('../../db/connection.js'); + const count = ( + getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number } + ).c; + expect(count).toBe(1); + }); + + it('approve → adds member and replays the original message', async () => { + const { routeInbound } = await import('../../router.js'); + const { getResponseHandlers } = await import('../../response-registry.js'); + const { wakeContainer } = await import('../../container-runner.js'); + (wakeContainer as unknown as ReturnType).mockClear(); + + await routeInbound(stranger('please let me in')); + await new Promise((r) => setTimeout(r, 10)); + + const { getDb } = await import('../../db/connection.js'); + const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string }; + expect(pending).toBeDefined(); + + // Fire the approve click through the response-handler chain. + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.id, + value: 'approve', + userId: 'telegram:owner', + channelType: 'telegram', + platformId: 'dm-owner', + threadId: null, + }); + if (claimed) break; + } + + // Member row added for the stranger against the wired agent group. + const member = getDb() + .prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?') + .get('tg:stranger', 'ag-1'); + expect(member).toBeDefined(); + + // Pending row cleared. + const stillPending = getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number }; + expect(stillPending.c).toBe(0); + + // Message replayed + container woken. + expect(wakeContainer).toHaveBeenCalled(); + }); + + it('deny → deletes the pending row without adding a member', async () => { + const { routeInbound } = await import('../../router.js'); + const { getResponseHandlers } = await import('../../response-registry.js'); + + await routeInbound(stranger('hello')); + await new Promise((r) => setTimeout(r, 10)); + + const { getDb } = await import('../../db/connection.js'); + const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string }; + expect(pending).toBeDefined(); + + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.id, + value: 'reject', + userId: 'telegram:owner', + channelType: 'telegram', + platformId: 'dm-owner', + threadId: null, + }); + if (claimed) break; + } + + const count = ( + getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number } + ).c; + expect(count).toBe(0); + const member = getDb() + .prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?') + .get('tg:stranger', 'ag-1'); + expect(member).toBeUndefined(); + }); +}); diff --git a/src/modules/permissions/sender-approval.ts b/src/modules/permissions/sender-approval.ts new file mode 100644 index 0000000..be60280 --- /dev/null +++ b/src/modules/permissions/sender-approval.ts @@ -0,0 +1,152 @@ +/** + * Unknown-sender approval flow. + * + * When `messaging_groups.unknown_sender_policy = 'request_approval'` and a + * non-member writes into a wired chat, the access gate drops the routing + * attempt and calls `requestSenderApproval` to: + * + * 1. Pick an eligible approver (owner / admin of the agent group). + * 2. Open / reuse a DM to that approver on a reachable channel. + * 3. Deliver an Approve / Deny card. + * 4. Record a pending_sender_approvals row that holds the original message + * so it can be re-routed on approve. + * + * On approve: the handler in index.ts adds an agent_group_members row for + * the sender and re-invokes routeInbound with the stored event — the second + * routing attempt passes the gate because the user is now a member. + * + * Failure modes (logged + row NOT created, so the dedup gate lets a future + * attempt try again): + * - No eligible approver in user_roles — fresh install, no owner yet. + * - Approver has no reachable DM (no user_dms row + channel can't + * openDM) — e.g. owner hasn't registered on any channel we're wired to. + * - Delivery adapter missing. + * + * Dedup: `pending_sender_approvals` has UNIQUE(messaging_group_id, + * sender_identity). A retry / rapid second message from the same unknown + * sender is silently dropped (no duplicate card sent). + */ +import { normalizeOptions, type RawOption } from '../../channels/ask-question.js'; +import { getMessagingGroup } from '../../db/messaging-groups.js'; +import { getDeliveryAdapter } from '../../delivery.js'; +import { log } from '../../log.js'; +import type { InboundEvent } from '../../router.js'; +import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js'; +import { createPendingSenderApproval, hasInFlightSenderApproval } from './db/pending-sender-approvals.js'; + +const APPROVAL_OPTIONS: RawOption[] = [ + { label: 'Allow', selectedLabel: '✅ Allowed', value: 'approve' }, + { label: 'Deny', selectedLabel: '❌ Denied', value: 'reject' }, +]; + +function generateId(): string { + return `nsa-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +export interface RequestSenderApprovalInput { + messagingGroupId: string; + agentGroupId: string; + senderIdentity: string; // namespaced user id (channel_type:handle) + senderName: string | null; + event: InboundEvent; +} + +export async function requestSenderApproval(input: RequestSenderApprovalInput): Promise { + const { messagingGroupId, agentGroupId, senderIdentity, senderName, event } = input; + + // In-flight dedup: don't spam the admin if the same unknown sender + // retries while a card is already pending. + if (hasInFlightSenderApproval(messagingGroupId, senderIdentity)) { + log.debug('Unknown-sender approval already in flight — dropping retry', { + messagingGroupId, + senderIdentity, + }); + return; + } + + const approvers = pickApprover(agentGroupId); + if (approvers.length === 0) { + log.warn('Unknown-sender approval skipped — no owner or admin configured', { + messagingGroupId, + agentGroupId, + senderIdentity, + }); + return; + } + + const originMg = getMessagingGroup(messagingGroupId); + const originChannelType = originMg?.channel_type ?? ''; + const target = await pickApprovalDelivery(approvers, originChannelType); + if (!target) { + log.warn('Unknown-sender approval skipped — no DM channel for any approver', { + messagingGroupId, + agentGroupId, + senderIdentity, + }); + return; + } + + const approvalId = generateId(); + const senderDisplay = senderName && senderName.length > 0 ? senderName : senderIdentity; + const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat'; + + const title = '👤 New sender'; + const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`; + + createPendingSenderApproval({ + id: approvalId, + messaging_group_id: messagingGroupId, + agent_group_id: agentGroupId, + sender_identity: senderIdentity, + sender_name: senderName, + original_message: JSON.stringify(event), + approver_user_id: target.userId, + created_at: new Date().toISOString(), + }); + + const adapter = getDeliveryAdapter(); + if (!adapter) { + // Without a delivery adapter, the card can't be sent. Log + leave the + // row in place so the admin can see it via DB or manual tooling; the + // dedup gate will suppress further cards until it's cleared. + log.error('Unknown-sender approval row created but no delivery adapter is wired', { + approvalId, + }); + return; + } + + try { + await adapter.deliver( + target.messagingGroup.channel_type, + target.messagingGroup.platform_id, + null, + 'chat-sdk', + JSON.stringify({ + type: 'ask_question', + questionId: approvalId, + title, + question, + options: APPROVAL_OPTIONS, + }), + ); + log.info('Unknown-sender approval card delivered', { + approvalId, + senderIdentity, + approver: target.userId, + messagingGroupId, + agentGroupId, + }); + } catch (err) { + log.error('Unknown-sender approval card delivery failed', { + approvalId, + err, + }); + } +} + +/** + * Option value the admin clicked that means "allow" — shared with the + * response handler so the two sides can't drift. + */ +export const APPROVE_VALUE = 'approve'; +export const REJECT_VALUE = 'reject'; diff --git a/src/router.ts b/src/router.ts index 9b54cb2..cb4ee93 100644 --- a/src/router.ts +++ b/src/router.ts @@ -145,7 +145,10 @@ export async function routeInbound(event: InboundEvent): Promise { platform_id: event.platformId, name: null, is_group: 0, - unknown_sender_policy: 'strict', + // Let the schema default (currently 'request_approval') apply rather + // than hardcoding 'strict' — the schema is the source of truth for + // the default policy. See migration 011. + unknown_sender_policy: 'request_approval', created_at: new Date().toISOString(), }; createMessagingGroup(mg); From 5d5f72e11728fb66ee3be9656c1ecf0d758e96d5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 09:55:16 +0300 Subject: [PATCH 014/185] docs(action-items): add item 22 (unknown-channel wiring approval flow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the gap item 5 left open: request_approval presupposes a wired channel, so unknown-channel cases (new DM, @mention in unwired group, bot added to fresh group) short-circuit at no_agent_wired before the approval flow runs. Design: - Owner-sender auto-wire fast path (exactly one agent group → wire silently; multiple → card) - Card with one button per existing agent group + "Create new" + "Ignore" - New pending_channel_approvals table, UNIQUE(messaging_group_id) - nca- action-id prefix paralleling nsa- / ncq- - Handler lives alongside handleSenderApprovalResponse - "Create new" sub-flow is intentionally open scope Cross-reference added to item 5 so the scope boundary is explicit. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/v1-vs-v2/ACTION-ITEMS.md | 88 +++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/docs/v1-vs-v2/ACTION-ITEMS.md b/docs/v1-vs-v2/ACTION-ITEMS.md index 806bff4..72457b7 100644 --- a/docs/v1-vs-v2/ACTION-ITEMS.md +++ b/docs/v1-vs-v2/ACTION-ITEMS.md @@ -47,6 +47,11 @@ Working doc for each finding from [SUMMARY.md](SUMMARY.md). Decisions were made - **6a**: Remove `IDLE_END_MS` from `poll-loop.ts` (folded into item 9) - **3a**: E2E recovery test (deferred) +### Follow-up PRs (scoped, not in this branch) +| # | Topic | Why later | +|---|---|---| +| 22 | Unknown-channel wiring approval flow (card to owner when bot receives inbound in an unwired messaging group) | Gap surfaced after item 5 landed — item 5's `request_approval` covers unknown senders but presupposes a wired channel. See item 22 for the full design. | + --- ## HIGH @@ -167,6 +172,87 @@ On wake, container pulls pending messages with `ORDER BY seq DESC LIMIT MAX_MESS --- +### 22. Unknown-channel wiring approval flow +**Finding** (post-item-5 discussion): item 5's `request_approval` only fires when a messaging group already has agents wired. Three scenarios slip through to the earlier `no_agent_wired` structural-drop branch in `src/router.ts` and get silent-dropped with no signal to the owner: + +1. A new user DMs the agent directly (the DM's messaging group auto-creates but has no wiring) +2. The agent is @mentioned in a group the admin hasn't registered +3. The agent is added to a new group and someone there addresses it + +In all three, the user sees no response and the owner has no signal anything happened. + +**Status**: decided — companion PR to item 5, scoped separately + +**Decision**: when the router hits `no_agent_wired` for a non-public event, **instead of silent-dropping, pick the owner and DM them a wiring card**. Two flavors depending on who triggered it: + +- **Sender IS an owner/admin** (the common "I just added the bot" case) → auto-wire IF exactly one agent group exists. Silent seamless flow. If multiple agent groups exist, fall through to the card so the owner picks. +- **Sender is anyone else** (stranger, or owner in a multi-agent install) → deliver a card: + - Title: `🔌 New channel — wire it?` + - Body: ` is trying to reach you in on . Wire to which agent?` + - Options: one button per existing `agent_groups` row, plus `➕ Create new` and `Ignore` + +**On approve (existing agent group)**: +1. `createMessagingGroupAgent(...)` with channel-kind defaults — DM→`pattern` + `'.'`, threaded group→`mention-sticky`, non-threaded group→`mention` (same defaults as `scripts/init-first-agent.ts`) +2. Replay the stored event via `routeInbound` (sender-approval pattern) +3. Delete pending row + +**On approve "Create new"**: [OPEN SCOPE] — needs name/folder input. Options: +- Follow-up ask_question card asking for a name → auto-derive folder from slug → create group + wire +- Or: skill-backed flow — the button dispatches to `/init-agent` or similar and the card just links out +- Punt until implementation; mention in the PR brief that we'll decide when building + +**On ignore**: delete pending row; future attempts re-prompt fresh (consistent with sender-approval deny; no denial persistence). + +**Failure cases** (drop silently with log, don't leave a pending row): +- No owner configured (fresh install) — same behaviour as sender-approval +- No reachable DM for any owner/admin +- Delivery adapter missing + +**New table**: +``` +pending_channel_approvals ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id), + sender_identity TEXT, -- NULL when triggered by a non-identifiable event + sender_name TEXT, + original_message TEXT NOT NULL, -- JSON InboundEvent for replay + approver_user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(messaging_group_id) -- one pending wiring per channel +) +``` + +Dedup is narrower than sender-approval's `(mg_id, sender_id)` — one pending wiring per channel, period. A second stranger writing into the same unwired channel piggybacks on the existing card instead of spawning a new one. Latest event replaces the stored `original_message` (we only replay one anyway, and latest is most useful). + +**Card action id prefix**: `nca-:` where value is `agent-group-` / `create` / `ignore`. Response handler lives in `src/modules/permissions/` alongside `handleSenderApprovalResponse`. + +**Owner-sender auto-wire logic**: +``` +if sender is owner/admin AND getAllAgentGroups().length === 1: + auto-wire to that group, replay event, done — no card +else: + deliver card +``` + +Don't auto-create a new agent group silently — always require a prompt for that. + +**LOC estimate**: ~145 +- Migration + CRUD: 45 +- Router hook before `no_agent_wired` drop → try channel approval: 15 +- Owner-sender auto-wire fast path: 20 +- Card delivery (scope `pickApprover(null)`; build buttons from `getAllAgentGroups()`): 25 +- Response handler: 25 +- Tests: 15 + +**Open scopes (flag at PR time)**: +- "Create new" sub-flow — pick between follow-up card vs skill link +- Do we also react to bot-added-to-group platform events? Simpler to stay lazy (first-message-triggered only). Platform lifecycle events are inconsistent across Discord/Slack/Telegram anyway. +- Worth scanning the `channels` branch for any existing channel-lifecycle handlers that might conflict. + +**Next step**: open a follow-up PR off this branch once #1869 lands. + +--- + ### 3a. End-to-end recovery test **Finding**: no test confirms the host-crash-restart scenario produces timely re-delivery. @@ -253,6 +339,8 @@ Dedicated (not reusing `pending_approvals` which is OneCLI-specific). - The router's auto-create at `router.ts:123` currently hardcodes `'strict'` — change to omit the field so schema default applies - `pickApprover` may return null if no admin/owner exists (e.g. fresh install before first user registered). In that case: log + drop silently, treat as effectively `'strict'` for safety. Don't block message forever. +**Scope boundary** (important): this item covers **unknown sender in a wired channel**. The parallel case — **unknown channel** (new DM / unwired group / bot-added-to-group) — short-circuits at the `no_agent_wired` structural drop before this flow ever runs. Tracked as item 22. + **Next step**: implement alongside item 1 or as a follow-up. Same migration window is fine (one migration for engage columns + request_approval default change + new table). ### 6. Per-group container timeout From a1079da8774ddd4c85cc1ca307d49ff37051b52b Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 20 Apr 2026 07:01:45 +0000 Subject: [PATCH 015/185] fix(new-setup): always source ONECLI_URL from installer stdout Match v1 behavior: drop getApiHost() (which was returning the CLI default https://app.onecli.sh) and always extract the gateway URL from the install script's stdout, then apply it via onecli config set api-host and .env. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/onecli.ts | 87 ++++++++++++++++++------------------------------- 1 file changed, 31 insertions(+), 56 deletions(-) diff --git a/setup/onecli.ts b/setup/onecli.ts index 226d302..c4ce83f 100644 --- a/setup/onecli.ts +++ b/setup/onecli.ts @@ -37,20 +37,6 @@ function onecliVersion(): string | null { } } -function getApiHost(): string | null { - try { - const out = execFileSync('onecli', ['config', 'get', 'api-host'], { - encoding: 'utf-8', - env: childEnv(), - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - const parsed = JSON.parse(out) as { value?: unknown }; - return typeof parsed.value === 'string' && parsed.value ? parsed.value : null; - } catch { - return null; - } -} - function extractUrlFromOutput(output: string): string | null { const match = output.match(/https?:\/\/[\w.\-]+(?::\d+)?/); return match ? match[0] : null; @@ -123,60 +109,49 @@ async function pollHealth(url: string, timeoutMs: number): Promise { export async function run(_args: string[]): Promise { ensureShellProfilePath(); - let installOutput = ''; - let present = !!onecliVersion(); - if (!present) { - log.info('Installing OneCLI gateway and CLI'); - const res = installOnecli(); - installOutput = res.stdout; - if (!res.ok) { - emitStatus('ONECLI', { - INSTALLED: false, - STATUS: 'failed', - ERROR: 'install_failed', - LOG: 'logs/setup.log', - }); - process.exit(1); - } - present = !!onecliVersion(); - if (!present) { - emitStatus('ONECLI', { - INSTALLED: false, - STATUS: 'failed', - ERROR: 'onecli_not_on_path_after_install', - HINT: 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"` and retry.', - LOG: 'logs/setup.log', - }); - process.exit(1); - } - } - - let url = getApiHost(); - if (!url && installOutput) { - url = extractUrlFromOutput(installOutput); - if (url) { - try { - execFileSync('onecli', ['config', 'set', 'api-host', url], { - stdio: 'ignore', - env: childEnv(), - }); - } catch (err) { - log.warn('onecli config set api-host failed', { err }); - } - } + log.info('Installing OneCLI gateway and CLI'); + const res = installOnecli(); + if (!res.ok) { + emitStatus('ONECLI', { + INSTALLED: false, + STATUS: 'failed', + ERROR: 'install_failed', + LOG: 'logs/setup.log', + }); + process.exit(1); + } + if (!onecliVersion()) { + emitStatus('ONECLI', { + INSTALLED: false, + STATUS: 'failed', + ERROR: 'onecli_not_on_path_after_install', + HINT: 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"` and retry.', + LOG: 'logs/setup.log', + }); + process.exit(1); } + const url = extractUrlFromOutput(res.stdout); if (!url) { emitStatus('ONECLI', { INSTALLED: true, STATUS: 'failed', ERROR: 'could_not_resolve_api_host', - HINT: 'Run `onecli config get api-host` to inspect the gateway URL.', + HINT: 'Inspect logs/setup.log for the install output.', LOG: 'logs/setup.log', }); process.exit(1); } + try { + execFileSync('onecli', ['config', 'set', 'api-host', url], { + stdio: 'ignore', + env: childEnv(), + }); + } catch (err) { + log.warn('onecli config set api-host failed', { err }); + } + writeEnvOnecliUrl(url); log.info('Wrote ONECLI_URL to .env', { url }); From 9882c945304a674497188082d3ba759bc8ed4944 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 10:07:50 +0300 Subject: [PATCH 016/185] fix(channels): use Chat SDK ChatMessage.text, not .content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engage-mode gating added in #1869 read `message.content` from the Chat SDK's ChatMessage in all three inbound handlers (onSubscribedMessage, onNewMention, onDirectMessage). ChatMessage exposes the user-visible string as `.text` — `.content` exists on the underlying nested structure but isn't the plain-text field. Result: `shouldEngage` always saw an empty string, pattern gating never matched, non-wildcard regex wirings silently dropped every inbound. Fix: use `message.text` in all three gates. Discovered during live smoke-test on v2 post-merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/chat-sdk-bridge.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 593a2ad..6a480c4 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -237,7 +237,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // plain 'mention' wiring doesn't keep firing after a one-off mention. chat.onSubscribedMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - const text = typeof message.content === 'string' ? message.content : ''; + const text = typeof message.text === 'string' ? message.text : ''; const decision = shouldEngage(channelId, 'subscribed', text); if (!decision.engage) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); @@ -247,7 +247,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // if the wiring is 'mention-sticky'. chat.onNewMention(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - const text = typeof message.content === 'string' ? message.content : ''; + const text = typeof message.text === 'string' ? message.text : ''; const decision = shouldEngage(channelId, 'mention', text); if (!decision.engage) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); @@ -266,7 +266,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // escalation). chat.onDirectMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - const text = typeof message.content === 'string' ? message.content : ''; + const text = typeof message.text === 'string' ? message.text : ''; const decision = shouldEngage(channelId, 'dm', text); log.info('Inbound DM received', { adapter: adapter.name, From fca3d8de7004b60cba77dab4b2f6ff53810f5677 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 10:08:03 +0300 Subject: [PATCH 017/185] fix(migrations): drop 011 table-rebuild; keep only pending_sender_approvals The original 011 also rebuilt `messaging_groups` to flip the `unknown_sender_policy` column DEFAULT from "strict" to "request_approval". On live DBs the DROP TABLE step fails SQLite's foreign-key integrity check because `sessions`, `user_dms`, and `pending_sender_approvals` all reference `messaging_groups(id)`. `PRAGMA foreign_keys=OFF` / `defer_foreign_keys` can't be toggled inside the implicit migration transaction, so the rebuild can't be made to apply cleanly. The default-flip was cosmetic anyway: every `createMessagingGroup` callsite passes `unknown_sender_policy` explicitly. Router auto-create was already updated to hardcode "request_approval" (router.ts:151), and setup / seed scripts pick per context. Changes: - Migration 011 now only creates the `pending_sender_approvals` table + index. The rebuild block is gone. - Reference `SCHEMA` in src/db/schema.ts updated to reflect what the DB actually has: DEFAULT 'strict' (from migration 001), with a note about the effective policy applied at insert sites. Discovered on v2 post-merge during live restart. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../011-pending-sender-approvals.ts | 35 +++++-------------- src/db/schema.ts | 9 ++--- 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/src/db/migrations/011-pending-sender-approvals.ts b/src/db/migrations/011-pending-sender-approvals.ts index cb47039..2331a6e 100644 --- a/src/db/migrations/011-pending-sender-approvals.ts +++ b/src/db/migrations/011-pending-sender-approvals.ts @@ -4,10 +4,15 @@ * in-flight entry in this table dedups concurrent attempts from the same * sender; the row is cleared on approve / deny. * - * Also flips the `messaging_groups.unknown_sender_policy` default from 'strict' - * to 'request_approval' so fresh wirings don't silently swallow messages from - * users the admin hasn't added yet. Existing rows are left as-is (silent - * upgrade would change established behavior without the admin asking for it). + * Previously this migration also rebuilt `messaging_groups` to flip the + * column DEFAULT from `'strict'` to `'request_approval'`. Removed: the + * rebuild failed SQLite's foreign-key integrity check at DROP time on live + * DBs with existing FK references (sessions, user_dms, etc.), and `PRAGMA + * foreign_keys` / `defer_foreign_keys` can't be toggled inside the + * implicit migration transaction. The default-flip was cosmetic anyway — + * every `createMessagingGroup` callsite passes `unknown_sender_policy` + * explicitly, and the router's auto-create path was updated to hardcode + * `'request_approval'` directly (see src/router.ts:123). */ import type Database from 'better-sqlite3'; import type { Migration } from './index.js'; @@ -31,27 +36,5 @@ export const migration011: Migration = { CREATE INDEX IF NOT EXISTS idx_pending_sender_approvals_mg ON pending_sender_approvals(messaging_group_id); `); - - // Default-flip: fresh messaging_groups default to request_approval instead - // of silently dropping. SQLite doesn't support modifying column DEFAULTs - // in place, so we rebuild the table via the classic rename-copy-drop - // pattern. Existing rows keep their current unknown_sender_policy value. - db.exec(` - CREATE TABLE messaging_groups_new ( - id TEXT PRIMARY KEY, - channel_type TEXT NOT NULL, - platform_id TEXT NOT NULL, - name TEXT, - is_group INTEGER DEFAULT 0, - unknown_sender_policy TEXT NOT NULL DEFAULT 'request_approval', - created_at TEXT NOT NULL, - UNIQUE(channel_type, platform_id) - ); - INSERT INTO messaging_groups_new - SELECT id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at - FROM messaging_groups; - DROP TABLE messaging_groups; - ALTER TABLE messaging_groups_new RENAME TO messaging_groups; - `); }, }; diff --git a/src/db/schema.ts b/src/db/schema.ts index aa33fae..8433035 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -19,17 +19,18 @@ CREATE TABLE agent_groups ( -- Platform groups/channels. unknown_sender_policy governs what happens -- when a sender we've never seen before posts in this chat. +-- The column DEFAULT is "strict" (inherited from migration 001), but it +-- only matters if something inserts without specifying the field, which no +-- current callsite does. Router auto-create hardcodes "request_approval" +-- (see src/router.ts:151); setup scripts pick per context. CREATE TABLE messaging_groups ( id TEXT PRIMARY KEY, channel_type TEXT NOT NULL, platform_id TEXT NOT NULL, name TEXT, is_group INTEGER DEFAULT 0, - unknown_sender_policy TEXT NOT NULL DEFAULT 'request_approval', + unknown_sender_policy TEXT NOT NULL DEFAULT 'strict', -- 'strict' | 'request_approval' | 'public' - -- Default is request_approval so silent drops don't - -- mystery-break users who wired their DM during - -- setup and haven't explicitly marked it public. created_at TEXT NOT NULL, UNIQUE(channel_type, platform_id) ); From 73b20880ffa27e48e95076ee8ae33cc37a3bd1a5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 10:34:15 +0300 Subject: [PATCH 018/185] fix(channels): pre-subscribe group threads for pattern / accumulate wirings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engage modes shipped in #1869 included `pattern` (regex match any message) and the `accumulate` ignored-message policy, but neither could fire in group chats because Chat SDK only surfaces: - DMs (onDirectMessage) - @mentions in unsubscribed threads (onNewMention) - every message in subscribed threads (onSubscribedMessage) A bot sitting in a Discord/Slack channel hears *nothing* from a plain message unless the thread is already subscribed. So `pattern '.'` on a group wiring → silent. `pattern /urgent/i` → silent. `mention + accumulate` → the non-mention messages that should be stored as context were never received, so nothing to accumulate. Fix: call `chat.subscribe(platformId)` at setup time for every wiring whose `engageMode === 'pattern'` or `ignoredMessagePolicy === 'accumulate'`. Failures logged + swallowed per-conversation so one un-subscribable channel doesn't crash startup. ## Knock-on: SDK stops firing onNewMention once subscribed Per SDK types:1468, `onNewMention` only fires in unsubscribed threads. Once we pre-subscribe a channel for a pattern wiring, subsequent mentions arrive as `onSubscribedMessage` with `message.isMention === true`. Before: a `mention` wiring coexisting with a `pattern` wiring in the same channel would silently stop firing after pre-subscribe. After: `shouldEngage` accepts the `isMention` flag independently from `source`, so the `mention` mode matches on (dm OR mention-new OR subscribed-with-isMention). Source shape changed `'subscribed' | 'mention' | 'dm'` → `'subscribed' | 'mention-new' | 'dm'` to make the "unsubscribed-mention event" distinction explicit. ## New fields - `ConversationConfig.ignoredMessagePolicy` — projected from the messaging_group_agents row so the bridge knows which wirings need pre-subscription. buildConversationConfigs in src/index.ts populates it. Tests: host 153/153, container 46/46. No new tests yet — the subscribe call path needs a Chat mock, deferred. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/adapter.ts | 7 ++ src/channels/chat-sdk-bridge.ts | 115 ++++++++++++++++++++++++++------ src/index.ts | 1 + 3 files changed, 101 insertions(+), 22 deletions(-) diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 33f3825..8786061 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -22,6 +22,13 @@ export interface ConversationConfig { engageMode: 'pattern' | 'mention' | 'mention-sticky'; /** Regex source when engageMode='pattern'. '.' is the "always" sentinel. */ engagePattern?: string | null; + /** + * What to do with non-engaging messages. Projected from the wiring so the + * adapter can decide whether to pre-subscribe to group threads — `accumulate` + * means "store everything as context even when not engaging", which requires + * seeing every message in the thread. + */ + ignoredMessagePolicy?: 'drop' | 'accumulate'; sessionMode: 'shared' | 'per-thread' | 'agent-shared'; } diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 6a480c4..4e33696 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -93,27 +93,83 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter } /** - * Should a message from (channelId, kind) engage any of the wired agents? + * Does any wiring for this conversation need Chat SDK subscription to see + * every message? `pattern` and `accumulate` both require it; plain `mention` + * and `mention-sticky` don't (mentions fire their own event, sticky + * subscribes lazily on first fire). + */ + function needsPreSubscribe(configs: ConversationConfig[]): boolean { + return configs.some( + (c) => c.engageMode === 'pattern' || c.ignoredMessagePolicy === 'accumulate', + ); + } + + /** + * Subscribe to every conversation whose wiring needs to see every message + * (pattern gate or accumulate context). Runs once after `chat.initialize()`. + * Failures are logged and swallowed per-conversation — one un-subscribable + * channel (no permission, not in it yet) shouldn't block startup. * - * - `mention` — engages only when the message actually @-mentions - * the bot (the bridge already sees it here because - * Chat SDK only forwards subscribed / mentioned / - * DM messages) - * - `mention-sticky` — same as `mention` for gating, PLUS we subscribe - * the thread so later messages arrive via the - * subscribed path and fall through to an - * engage-all style treatment - * - `pattern` — regex test against message text; `.` = always + * `threadId` for subscription = the platformId we stored in ConversationConfig. + * This matches the deliver path's `tid = threadId ?? platformId` pattern + * where adapters treat their encoded channel id as the top-level thread id. + */ + async function preSubscribeNeededConversations( + chatInstance: Chat, + conversationsMap: Map, + ): Promise { + for (const [platformId, configs] of conversationsMap.entries()) { + if (!needsPreSubscribe(configs)) continue; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chatAny = chatInstance as any; + if (typeof chatAny.isSubscribed === 'function') { + const already = await chatAny.isSubscribed(platformId); + if (already) continue; + } + await chatAny.subscribe(platformId); + log.info('Pre-subscribed conversation', { adapter: adapter.name, platformId }); + } catch (err) { + log.warn('Pre-subscribe failed — pattern/accumulate wirings won\'t see non-mention messages here', { + adapter: adapter.name, + platformId, + err, + }); + } + } + } + + /** + * Should a message from this conversation engage any of the wired agents? * - * We take the union across wired agents — if any one of them would engage, - * the message goes through. Per-agent filtering after that happens in the - * host router (see src/router.ts pickAgents). + * Source meaning: + * - `dm` — Chat SDK `onDirectMessage` + * - `mention-new` — Chat SDK `onNewMention` (mention in an unsubscribed + * thread; SDK never fires this once the thread is + * subscribed — see SDK types :1468) + * - `subscribed` — Chat SDK `onSubscribedMessage` (plain message in a + * subscribed thread). In this case `isMention` is + * distinct: set to true when `message.isMention` is, + * so `mention` wirings keep firing even if a + * co-resident `pattern` wiring subscribed the thread. + * + * Mode semantics: + * - `pattern` — regex test against text; `.` = always + * - `mention` — fire iff mentioned (covers dm, mention-new, and + * subscribed-with-isMention) + * - `mention-sticky` — fire on dm / mention-new (and subscribe the thread); + * in subscribed source always fire — the thread was + * already activated on a prior mention + * + * Result is the union across wired agents — if any engages, the message + * goes through. Per-agent filtering happens host-side (src/router.ts pickAgents). */ function shouldEngage( channelId: string, - source: 'subscribed' | 'mention' | 'dm', - text: string, + source: 'subscribed' | 'mention-new' | 'dm', + opts: { text: string; isMention: boolean }, ): { engage: boolean; stickySubscribe: boolean } { + const { text, isMention } = opts; const configs = conversations.get(channelId); // Unknown conversation — forward anyway (may be a new group that // hasn't been registered yet; central routing will log + drop cleanly). @@ -122,18 +178,19 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter let engage = false; let stickySubscribe = false; + const mentionMatch = source === 'dm' || source === 'mention-new' || (source === 'subscribed' && isMention); + for (const cfg of configs) { switch (cfg.engageMode) { case 'mention': - if (source === 'mention' || source === 'dm') engage = true; + if (mentionMatch) engage = true; break; case 'mention-sticky': - if (source === 'mention' || source === 'dm') { + if (source === 'dm' || source === 'mention-new') { engage = true; stickySubscribe = true; } else if (source === 'subscribed') { - // Thread was already subscribed on a prior mention — treat as - // engage-all so follow-ups in the thread reach the agent. + // Thread already activated on a prior mention — engage all messages. engage = true; } break; @@ -238,7 +295,8 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter chat.onSubscribedMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); const text = typeof message.text === 'string' ? message.text : ''; - const decision = shouldEngage(channelId, 'subscribed', text); + const isMention = Boolean((message as unknown as { isMention?: boolean }).isMention); + const decision = shouldEngage(channelId, 'subscribed', { text, isMention }); if (!decision.engage) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); }); @@ -248,7 +306,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter chat.onNewMention(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); const text = typeof message.text === 'string' ? message.text : ''; - const decision = shouldEngage(channelId, 'mention', text); + const decision = shouldEngage(channelId, 'mention-new', { text, isMention: true }); if (!decision.engage) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); if (decision.stickySubscribe) { @@ -267,7 +325,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter chat.onDirectMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); const text = typeof message.text === 'string' ? message.text : ''; - const decision = shouldEngage(channelId, 'dm', text); + const decision = shouldEngage(channelId, 'dm', { text, isMention: true }); log.info('Inbound DM received', { adapter: adapter.name, channelId, @@ -312,6 +370,19 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter await chat.initialize(); + // Pre-subscribe to threads where the wiring needs to see every message + // (pattern mode or accumulate policy). Without this, Chat SDK only + // surfaces mentions + DMs for unsubscribed threads — silently dropping + // every plain message in a group, which breaks `engage_mode='pattern'` + // and `ignored_message_policy='accumulate'` for group chats. + // + // For subscription purposes, platformId is used as the thread id. This + // matches the deliver path (see `tid = threadId ?? platformId` below) + // where adapters treat their encoded channel id as the top-level thread. + // Failures are logged and swallowed so one un-subscribable channel + // (e.g., no permission) doesn't block startup. + await preSubscribeNeededConversations(chat, conversations); + // Start Gateway listener for adapters that support it (e.g., Discord) const gatewayAdapter = adapter as GatewayAdapter; if (gatewayAdapter.startGatewayListener) { diff --git a/src/index.ts b/src/index.ts index 9bb51be..4958eef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -163,6 +163,7 @@ function buildConversationConfigs(channelType: string): ConversationConfig[] { agentGroupId: agent.agent_group_id, engageMode: agent.engage_mode, engagePattern: agent.engage_pattern, + ignoredMessagePolicy: agent.ignored_message_policy, sessionMode: agent.session_mode, }); } From 57e0cda9e5d8347b93d767222f052b025d3f4572 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 10:35:33 +0300 Subject: [PATCH 019/185] Revert "fix(channels): pre-subscribe group threads for pattern / accumulate wirings" This reverts commit 73b20880ffa27e48e95076ee8ae33cc37a3bd1a5. --- src/channels/adapter.ts | 7 -- src/channels/chat-sdk-bridge.ts | 115 ++++++-------------------------- src/index.ts | 1 - 3 files changed, 22 insertions(+), 101 deletions(-) diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 8786061..33f3825 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -22,13 +22,6 @@ export interface ConversationConfig { engageMode: 'pattern' | 'mention' | 'mention-sticky'; /** Regex source when engageMode='pattern'. '.' is the "always" sentinel. */ engagePattern?: string | null; - /** - * What to do with non-engaging messages. Projected from the wiring so the - * adapter can decide whether to pre-subscribe to group threads — `accumulate` - * means "store everything as context even when not engaging", which requires - * seeing every message in the thread. - */ - ignoredMessagePolicy?: 'drop' | 'accumulate'; sessionMode: 'shared' | 'per-thread' | 'agent-shared'; } diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 4e33696..6a480c4 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -93,83 +93,27 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter } /** - * Does any wiring for this conversation need Chat SDK subscription to see - * every message? `pattern` and `accumulate` both require it; plain `mention` - * and `mention-sticky` don't (mentions fire their own event, sticky - * subscribes lazily on first fire). - */ - function needsPreSubscribe(configs: ConversationConfig[]): boolean { - return configs.some( - (c) => c.engageMode === 'pattern' || c.ignoredMessagePolicy === 'accumulate', - ); - } - - /** - * Subscribe to every conversation whose wiring needs to see every message - * (pattern gate or accumulate context). Runs once after `chat.initialize()`. - * Failures are logged and swallowed per-conversation — one un-subscribable - * channel (no permission, not in it yet) shouldn't block startup. + * Should a message from (channelId, kind) engage any of the wired agents? * - * `threadId` for subscription = the platformId we stored in ConversationConfig. - * This matches the deliver path's `tid = threadId ?? platformId` pattern - * where adapters treat their encoded channel id as the top-level thread id. - */ - async function preSubscribeNeededConversations( - chatInstance: Chat, - conversationsMap: Map, - ): Promise { - for (const [platformId, configs] of conversationsMap.entries()) { - if (!needsPreSubscribe(configs)) continue; - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const chatAny = chatInstance as any; - if (typeof chatAny.isSubscribed === 'function') { - const already = await chatAny.isSubscribed(platformId); - if (already) continue; - } - await chatAny.subscribe(platformId); - log.info('Pre-subscribed conversation', { adapter: adapter.name, platformId }); - } catch (err) { - log.warn('Pre-subscribe failed — pattern/accumulate wirings won\'t see non-mention messages here', { - adapter: adapter.name, - platformId, - err, - }); - } - } - } - - /** - * Should a message from this conversation engage any of the wired agents? + * - `mention` — engages only when the message actually @-mentions + * the bot (the bridge already sees it here because + * Chat SDK only forwards subscribed / mentioned / + * DM messages) + * - `mention-sticky` — same as `mention` for gating, PLUS we subscribe + * the thread so later messages arrive via the + * subscribed path and fall through to an + * engage-all style treatment + * - `pattern` — regex test against message text; `.` = always * - * Source meaning: - * - `dm` — Chat SDK `onDirectMessage` - * - `mention-new` — Chat SDK `onNewMention` (mention in an unsubscribed - * thread; SDK never fires this once the thread is - * subscribed — see SDK types :1468) - * - `subscribed` — Chat SDK `onSubscribedMessage` (plain message in a - * subscribed thread). In this case `isMention` is - * distinct: set to true when `message.isMention` is, - * so `mention` wirings keep firing even if a - * co-resident `pattern` wiring subscribed the thread. - * - * Mode semantics: - * - `pattern` — regex test against text; `.` = always - * - `mention` — fire iff mentioned (covers dm, mention-new, and - * subscribed-with-isMention) - * - `mention-sticky` — fire on dm / mention-new (and subscribe the thread); - * in subscribed source always fire — the thread was - * already activated on a prior mention - * - * Result is the union across wired agents — if any engages, the message - * goes through. Per-agent filtering happens host-side (src/router.ts pickAgents). + * We take the union across wired agents — if any one of them would engage, + * the message goes through. Per-agent filtering after that happens in the + * host router (see src/router.ts pickAgents). */ function shouldEngage( channelId: string, - source: 'subscribed' | 'mention-new' | 'dm', - opts: { text: string; isMention: boolean }, + source: 'subscribed' | 'mention' | 'dm', + text: string, ): { engage: boolean; stickySubscribe: boolean } { - const { text, isMention } = opts; const configs = conversations.get(channelId); // Unknown conversation — forward anyway (may be a new group that // hasn't been registered yet; central routing will log + drop cleanly). @@ -178,19 +122,18 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter let engage = false; let stickySubscribe = false; - const mentionMatch = source === 'dm' || source === 'mention-new' || (source === 'subscribed' && isMention); - for (const cfg of configs) { switch (cfg.engageMode) { case 'mention': - if (mentionMatch) engage = true; + if (source === 'mention' || source === 'dm') engage = true; break; case 'mention-sticky': - if (source === 'dm' || source === 'mention-new') { + if (source === 'mention' || source === 'dm') { engage = true; stickySubscribe = true; } else if (source === 'subscribed') { - // Thread already activated on a prior mention — engage all messages. + // Thread was already subscribed on a prior mention — treat as + // engage-all so follow-ups in the thread reach the agent. engage = true; } break; @@ -295,8 +238,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter chat.onSubscribedMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); const text = typeof message.text === 'string' ? message.text : ''; - const isMention = Boolean((message as unknown as { isMention?: boolean }).isMention); - const decision = shouldEngage(channelId, 'subscribed', { text, isMention }); + const decision = shouldEngage(channelId, 'subscribed', text); if (!decision.engage) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); }); @@ -306,7 +248,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter chat.onNewMention(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); const text = typeof message.text === 'string' ? message.text : ''; - const decision = shouldEngage(channelId, 'mention-new', { text, isMention: true }); + const decision = shouldEngage(channelId, 'mention', text); if (!decision.engage) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); if (decision.stickySubscribe) { @@ -325,7 +267,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter chat.onDirectMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); const text = typeof message.text === 'string' ? message.text : ''; - const decision = shouldEngage(channelId, 'dm', { text, isMention: true }); + const decision = shouldEngage(channelId, 'dm', text); log.info('Inbound DM received', { adapter: adapter.name, channelId, @@ -370,19 +312,6 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter await chat.initialize(); - // Pre-subscribe to threads where the wiring needs to see every message - // (pattern mode or accumulate policy). Without this, Chat SDK only - // surfaces mentions + DMs for unsubscribed threads — silently dropping - // every plain message in a group, which breaks `engage_mode='pattern'` - // and `ignored_message_policy='accumulate'` for group chats. - // - // For subscription purposes, platformId is used as the thread id. This - // matches the deliver path (see `tid = threadId ?? platformId` below) - // where adapters treat their encoded channel id as the top-level thread. - // Failures are logged and swallowed so one un-subscribable channel - // (e.g., no permission) doesn't block startup. - await preSubscribeNeededConversations(chat, conversations); - // Start Gateway listener for adapters that support it (e.g., Discord) const gatewayAdapter = adapter as GatewayAdapter; if (gatewayAdapter.startGatewayListener) { diff --git a/src/index.ts b/src/index.ts index 4958eef..9bb51be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -163,7 +163,6 @@ function buildConversationConfigs(channelType: string): ConversationConfig[] { agentGroupId: agent.agent_group_id, engageMode: agent.engage_mode, engagePattern: agent.engage_pattern, - ignoredMessagePolicy: agent.ignored_message_policy, sessionMode: agent.session_mode, }); } From 52c62232929a6b4dafbdc79c9c55c1e2e0792337 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 11:11:56 +0300 Subject: [PATCH 020/185] fix(channels): register onNewMessage(/./) to fix pattern mode in group chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat SDK dispatch (per handling-events.mdx) is exclusive and prioritized: subscribed → onSubscribedMessage; unsubscribed + mention → onNewMention; unsubscribed + pattern match → onNewMessage. We never registered the third, so engage_mode='pattern' silently dropped every message in unsubscribed group threads — the SDK simply never surfaced them anywhere. Register chat.onNewMessage(/./, …) and route it through shouldEngage with a new 'new-message' source. Unknown-conversation policy drops for this source (would otherwise flood from every unwired channel the bot can see). mention / mention-sticky wirings ignore 'new-message' — they require an explicit @mention to start a conversation. Pattern wirings evaluate normally. Extracted shouldEngage from a closure to an exported function with an EngageSource type so it's unit-testable. Added 17 tests covering every source × engage-mode combination, unknown-conversation behavior, invalid regex fail-open, and multi-wiring union. Accumulate (ignored_message_policy='accumulate') is still not plumbed — the bridge drops non-engaging messages entirely instead of forwarding them as context-only. That requires a trigger: 0 | 1 field on InboundMessage → router → writeSessionMessage (schema already has the column). Separate change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/chat-sdk-bridge.test.ts | 129 ++++++++++++++++++- src/channels/chat-sdk-bridge.ts | 181 ++++++++++++++++++--------- 2 files changed, 249 insertions(+), 61 deletions(-) diff --git a/src/channels/chat-sdk-bridge.test.ts b/src/channels/chat-sdk-bridge.test.ts index e71ccb2..3989c26 100644 --- a/src/channels/chat-sdk-bridge.test.ts +++ b/src/channels/chat-sdk-bridge.test.ts @@ -2,12 +2,33 @@ import { describe, expect, it } from 'vitest'; import type { Adapter } from 'chat'; -import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import type { ConversationConfig } from './adapter.js'; +import { createChatSdkBridge, shouldEngage, type EngageSource } from './chat-sdk-bridge.js'; function stubAdapter(partial: Partial): Adapter { return { name: 'stub', ...partial } as unknown as Adapter; } +function cfg(partial: Partial & { engageMode: ConversationConfig['engageMode'] }): ConversationConfig { + return { + platformId: partial.platformId ?? 'C1', + agentGroupId: partial.agentGroupId ?? 'ag-1', + engageMode: partial.engageMode, + engagePattern: partial.engagePattern ?? null, + sessionMode: partial.sessionMode ?? 'shared', + }; +} + +function mapFor(...configs: ConversationConfig[]): Map { + const map = new Map(); + for (const c of configs) { + const existing = map.get(c.platformId); + if (existing) existing.push(c); + else map.set(c.platformId, [c]); + } + return map; +} + describe('createChatSdkBridge', () => { it('omits openDM when the underlying Chat SDK adapter has none', () => { const bridge = createChatSdkBridge({ @@ -36,3 +57,109 @@ describe('createChatSdkBridge', () => { expect(platformId).toBe('stub:user-42'); }); }); + +describe('shouldEngage', () => { + describe('unknown conversation', () => { + const empty = new Map(); + const sources: EngageSource[] = ['subscribed', 'mention', 'dm']; + for (const source of sources) { + it(`forwards for source='${source}' (may be a not-yet-wired group)`, () => { + expect(shouldEngage(empty, 'C1', source, '')).toEqual({ engage: true, stickySubscribe: false }); + }); + } + it("DROPS for source='new-message' (would flood from unwired channels)", () => { + expect(shouldEngage(empty, 'C1', 'new-message', 'hello')).toEqual({ + engage: false, + stickySubscribe: false, + }); + }); + }); + + describe("engageMode='mention'", () => { + const conv = mapFor(cfg({ engageMode: 'mention' })); + it('engages on mention + dm', () => { + expect(shouldEngage(conv, 'C1', 'mention', '').engage).toBe(true); + expect(shouldEngage(conv, 'C1', 'dm', '').engage).toBe(true); + }); + it('does NOT engage on subscribed or new-message (prevents keep-firing + flooding)', () => { + expect(shouldEngage(conv, 'C1', 'subscribed', '').engage).toBe(false); + expect(shouldEngage(conv, 'C1', 'new-message', '').engage).toBe(false); + }); + it('never asks to subscribe', () => { + for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { + expect(shouldEngage(conv, 'C1', s, '').stickySubscribe).toBe(false); + } + }); + }); + + describe("engageMode='mention-sticky'", () => { + const conv = mapFor(cfg({ engageMode: 'mention-sticky' })); + it('engages on mention + dm with stickySubscribe=true', () => { + expect(shouldEngage(conv, 'C1', 'mention', '')).toEqual({ engage: true, stickySubscribe: true }); + expect(shouldEngage(conv, 'C1', 'dm', '')).toEqual({ engage: true, stickySubscribe: true }); + }); + it('engages on subscribed follow-ups without re-subscribing', () => { + expect(shouldEngage(conv, 'C1', 'subscribed', '')).toEqual({ engage: true, stickySubscribe: false }); + }); + it('does NOT engage on new-message (explicit mention required to start)', () => { + expect(shouldEngage(conv, 'C1', 'new-message', '').engage).toBe(false); + }); + }); + + describe("engageMode='pattern'", () => { + it('pattern="." engages on every source except new-message-with-unknown', () => { + const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '.' })); + for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { + expect(shouldEngage(conv, 'C1', s, 'anything').engage).toBe(true); + } + }); + + it('tests regex against text on new-message (the main bug fix)', () => { + const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '^!report' })); + expect(shouldEngage(conv, 'C1', 'new-message', '!report now').engage).toBe(true); + expect(shouldEngage(conv, 'C1', 'new-message', 'nothing to see').engage).toBe(false); + }); + + it('pattern regex applies on every source (symmetry)', () => { + const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: 'deploy' })); + for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { + expect(shouldEngage(conv, 'C1', s, 'time to deploy').engage).toBe(true); + expect(shouldEngage(conv, 'C1', s, 'weather today').engage).toBe(false); + } + }); + + it('pattern never triggers sticky-subscribe', () => { + const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '.' })); + for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { + expect(shouldEngage(conv, 'C1', s, 'hi').stickySubscribe).toBe(false); + } + }); + + it('invalid regex fails open (admin sees something rather than silent drop)', () => { + const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '[unclosed' })); + expect(shouldEngage(conv, 'C1', 'new-message', 'x').engage).toBe(true); + }); + }); + + describe('multiple wirings on one conversation', () => { + it('takes the union across wirings (any-engage wins)', () => { + // mention wiring + pattern wiring on the same channel. A plain message + // should engage via the pattern wiring even though the mention wiring + // alone would reject it. + const conv = mapFor( + cfg({ agentGroupId: 'ag-a', engageMode: 'mention' }), + cfg({ agentGroupId: 'ag-b', engageMode: 'pattern', engagePattern: '^hi' }), + ); + expect(shouldEngage(conv, 'C1', 'new-message', 'hi there').engage).toBe(true); + expect(shouldEngage(conv, 'C1', 'new-message', 'something else').engage).toBe(false); + }); + + it('stickySubscribe from any mention-sticky wiring wins', () => { + const conv = mapFor( + cfg({ agentGroupId: 'ag-a', engageMode: 'mention' }), + cfg({ agentGroupId: 'ag-b', engageMode: 'mention-sticky' }), + ); + expect(shouldEngage(conv, 'C1', 'mention', '')).toEqual({ engage: true, stickySubscribe: true }); + }); + }); +}); diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 6a480c4..f2daf11 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -65,6 +65,99 @@ export interface ChatSdkBridgeConfig { transformOutboundText?: (text: string) => string; } +/** + * Which Chat SDK handler delivered this message. Determines which engage modes + * can fire. + * + * - `subscribed` — `onSubscribedMessage`. Thread is already subscribed. + * Every wiring mode (mention / mention-sticky / pattern) + * evaluates normally. + * - `mention` — `onNewMention`. Bot was @-mentioned in an unsubscribed + * thread. mention + mention-sticky engage; pattern runs + * the regex. + * - `dm` — `onDirectMessage`. Unsubscribed DM. Treated like a + * mention for engagement purposes. + * - `new-message` — `onNewMessage(/./, …)`. Plain non-mention non-DM + * message in an unsubscribed thread. Only `pattern` + * wirings can fire here. mention / mention-sticky ignore + * this source (they require an explicit mention). + */ +export type EngageSource = 'subscribed' | 'mention' | 'dm' | 'new-message'; + +/** + * Should a message from (channelId, source, text) engage any of the wired + * agents on this conversation? + * + * Exported for testability — see `chat-sdk-bridge.test.ts`. + * + * We take the union across wired agents: if any wiring would engage, the + * message is forwarded. Per-agent filtering after that happens in the host + * router (see `src/router.ts` pickAgents). + */ +export function shouldEngage( + conversations: Map, + channelId: string, + source: EngageSource, + text: string, +): { engage: boolean; stickySubscribe: boolean } { + const configs = conversations.get(channelId); + + // Unknown conversation — behavior diverges by source: + // - subscribed/mention/dm: forward anyway. These paths imply some + // prior engagement (subscribe, @mention, DM open) and may be a new + // group that hasn't been registered yet; central routing will log + + // drop cleanly. + // - new-message: DROP. `onNewMessage(/./, …)` fires for every message + // in every unsubscribed thread the bot can see — including channels + // the bot is merely *present* in but not wired to. Forwarding + // everything would flood the host. + if (!configs || configs.length === 0) { + return { engage: source !== 'new-message', stickySubscribe: false }; + } + + let engage = false; + let stickySubscribe = false; + + for (const cfg of configs) { + switch (cfg.engageMode) { + case 'mention': + if (source === 'mention' || source === 'dm') engage = true; + break; + case 'mention-sticky': + if (source === 'mention' || source === 'dm') { + engage = true; + stickySubscribe = true; + } else if (source === 'subscribed') { + // Thread was already subscribed on a prior mention — treat as + // engage-all so follow-ups in the thread reach the agent. + engage = true; + } + // source='new-message' → do not engage. mention-sticky requires an + // explicit mention to start the conversation. + break; + case 'pattern': { + // Pattern evaluates on any source that delivers a plain message — + // including new-message, which is the whole reason we registered + // onNewMessage(/./). For mention/dm-delivered messages we still + // test the regex (historical behavior), so pattern='foo' wirings + // only fire on mentions whose text contains 'foo'. + const pattern = cfg.engagePattern ?? '.'; + try { + if (pattern === '.' || new RegExp(pattern).test(text)) engage = true; + } catch { + // Invalid regex → fail open so the admin can see something is + // happening and fix the pattern. + engage = true; + } + break; + } + } + if (engage && stickySubscribe) break; + } + + return { engage, stickySubscribe }; +} + export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter { const { adapter } = config; const transformText = (t: string): string => (config.transformOutboundText ? config.transformOutboundText(t) : t); @@ -92,66 +185,12 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter return map; } - /** - * Should a message from (channelId, kind) engage any of the wired agents? - * - * - `mention` — engages only when the message actually @-mentions - * the bot (the bridge already sees it here because - * Chat SDK only forwards subscribed / mentioned / - * DM messages) - * - `mention-sticky` — same as `mention` for gating, PLUS we subscribe - * the thread so later messages arrive via the - * subscribed path and fall through to an - * engage-all style treatment - * - `pattern` — regex test against message text; `.` = always - * - * We take the union across wired agents — if any one of them would engage, - * the message goes through. Per-agent filtering after that happens in the - * host router (see src/router.ts pickAgents). - */ - function shouldEngage( + function engageDecision( channelId: string, - source: 'subscribed' | 'mention' | 'dm', + source: EngageSource, text: string, ): { engage: boolean; stickySubscribe: boolean } { - const configs = conversations.get(channelId); - // Unknown conversation — forward anyway (may be a new group that - // hasn't been registered yet; central routing will log + drop cleanly). - if (!configs || configs.length === 0) return { engage: true, stickySubscribe: false }; - - let engage = false; - let stickySubscribe = false; - - for (const cfg of configs) { - switch (cfg.engageMode) { - case 'mention': - if (source === 'mention' || source === 'dm') engage = true; - break; - case 'mention-sticky': - if (source === 'mention' || source === 'dm') { - engage = true; - stickySubscribe = true; - } else if (source === 'subscribed') { - // Thread was already subscribed on a prior mention — treat as - // engage-all so follow-ups in the thread reach the agent. - engage = true; - } - break; - case 'pattern': { - const pattern = cfg.engagePattern ?? '.'; - try { - if (pattern === '.' || new RegExp(pattern).test(text)) engage = true; - } catch { - // Invalid regex → fail open so the admin can see something and fix. - engage = true; - } - break; - } - } - if (engage && stickySubscribe) break; - } - - return { engage, stickySubscribe }; + return shouldEngage(conversations, channelId, source, text); } async function messageToInbound(message: ChatMessage): Promise { @@ -238,7 +277,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter chat.onSubscribedMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); const text = typeof message.text === 'string' ? message.text : ''; - const decision = shouldEngage(channelId, 'subscribed', text); + const decision = engageDecision(channelId, 'subscribed', text); if (!decision.engage) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); }); @@ -248,7 +287,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter chat.onNewMention(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); const text = typeof message.text === 'string' ? message.text : ''; - const decision = shouldEngage(channelId, 'mention', text); + const decision = engageDecision(channelId, 'mention', text); if (!decision.engage) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); if (decision.stickySubscribe) { @@ -267,7 +306,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter chat.onDirectMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); const text = typeof message.text === 'string' ? message.text : ''; - const decision = shouldEngage(channelId, 'dm', text); + const decision = engageDecision(channelId, 'dm', text); log.info('Inbound DM received', { adapter: adapter.name, channelId, @@ -282,6 +321,28 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter } }); + // Plain (non-mention, non-DM) messages in unsubscribed threads. + // + // Chat SDK dispatch (handling-events.mdx §"Handler dispatch order"): + // subscribed threads → onSubscribedMessage; unsubscribed + mention → + // onNewMention; unsubscribed + pattern match → onNewMessage. Dispatch + // is exclusive — at most one handler fires per message. + // + // Without this handler, `engage_mode='pattern'` is silently dropped in + // unsubscribed group threads because the SDK never surfaces the + // message anywhere else. Registering with `/./` lets every wired + // conversation's regex be evaluated in our `shouldEngage` — unknown + // conversations are dropped there (see the source='new-message' + // branch) so this doesn't flood the host on channels the bot isn't + // wired to. + chat.onNewMessage(/./, async (thread, message) => { + const channelId = adapter.channelIdFromThreadId(thread.id); + const text = typeof message.text === 'string' ? message.text : ''; + const decision = engageDecision(channelId, 'new-message', text); + if (!decision.engage) return; + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); + }); + // Handle button clicks (ask_user_question) chat.onAction(async (event) => { if (!event.actionId.startsWith('ncq:')) return; From ce25e1e97c4b80ec07859319501045959b986d36 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 11:12:40 +0300 Subject: [PATCH 021/185] style(channels): prettier line-wrap in chat-sdk-bridge.test.ts Post-commit reformat picked up by format:fix hook on the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/chat-sdk-bridge.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/channels/chat-sdk-bridge.test.ts b/src/channels/chat-sdk-bridge.test.ts index 3989c26..667fc7f 100644 --- a/src/channels/chat-sdk-bridge.test.ts +++ b/src/channels/chat-sdk-bridge.test.ts @@ -9,7 +9,9 @@ function stubAdapter(partial: Partial): Adapter { return { name: 'stub', ...partial } as unknown as Adapter; } -function cfg(partial: Partial & { engageMode: ConversationConfig['engageMode'] }): ConversationConfig { +function cfg( + partial: Partial & { engageMode: ConversationConfig['engageMode'] }, +): ConversationConfig { return { platformId: partial.platformId ?? 'C1', agentGroupId: partial.agentGroupId ?? 'ag-1', From c38e5b11a888711acf508157bc7b397a5e4ddf33 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 11:18:43 +0300 Subject: [PATCH 022/185] fix(channels): wire accumulate mode through the bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router + session DB were already fully plumbed for ignored_message_policy='accumulate' — fan-out in routeInbound calls deliverToAgent(wake=false) for non-engaging agents on accumulate wirings, writeSessionMessage writes trigger=0, countDueMessages filters trigger=1, container formatter includes all messages regardless of trigger. But the Chat SDK bridge dropped non-engaging messages before the router ever saw them, so accumulate was dead on arrival for every adapter that goes through the bridge. Expose ignored_message_policy on ConversationConfig, project it in buildConversationConfigs, and widen shouldEngage's "forward" decision to "engage OR accumulate" with the union taken across all wirings on a conversation. stickySubscribe stays gated on a real engage — subscribing a thread we'd only silently accumulate on would misrepresent the bot's presence. shouldEngage return shape is now { forward, stickySubscribe } — engage was an internal concept the caller never needed, and conflating it with forward was the source of this bug. 7 new tests cover: non-engaging messages forwarding under accumulate, mixed drop/accumulate wirings taking the union, accumulate not triggering sticky subscribe, unknown-conversation drop precedence over accumulate, and drop policy preserving existing behavior on engaging messages. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/adapter.ts | 14 ++++ src/channels/chat-sdk-bridge.test.ts | 105 ++++++++++++++++++++------- src/channels/chat-sdk-bridge.ts | 61 ++++++++++------ src/index.ts | 1 + 4 files changed, 133 insertions(+), 48 deletions(-) diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 33f3825..34b3675 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -22,6 +22,20 @@ export interface ConversationConfig { engageMode: 'pattern' | 'mention' | 'mention-sticky'; /** Regex source when engageMode='pattern'. '.' is the "always" sentinel. */ engagePattern?: string | null; + /** + * What to do with messages this wiring doesn't engage on. + * + * 'drop' — discard silently + * 'accumulate' — still forward to the host so the router can store the + * message in this agent's session with trigger=0. It + * rides along as context when the agent next wakes, but + * doesn't wake it on its own. + * + * The bridge reads this to decide whether to forward a non-engaging + * message at all — if any wiring on a conversation has 'accumulate', the + * bridge forwards and lets the router apply the per-wiring decision. + */ + ignoredMessagePolicy?: 'drop' | 'accumulate'; sessionMode: 'shared' | 'per-thread' | 'agent-shared'; } diff --git a/src/channels/chat-sdk-bridge.test.ts b/src/channels/chat-sdk-bridge.test.ts index 667fc7f..aad8d0a 100644 --- a/src/channels/chat-sdk-bridge.test.ts +++ b/src/channels/chat-sdk-bridge.test.ts @@ -17,6 +17,7 @@ function cfg( agentGroupId: partial.agentGroupId ?? 'ag-1', engageMode: partial.engageMode, engagePattern: partial.engagePattern ?? null, + ignoredMessagePolicy: partial.ignoredMessagePolicy ?? 'drop', sessionMode: partial.sessionMode ?? 'shared', }; } @@ -66,26 +67,26 @@ describe('shouldEngage', () => { const sources: EngageSource[] = ['subscribed', 'mention', 'dm']; for (const source of sources) { it(`forwards for source='${source}' (may be a not-yet-wired group)`, () => { - expect(shouldEngage(empty, 'C1', source, '')).toEqual({ engage: true, stickySubscribe: false }); + expect(shouldEngage(empty, 'C1', source, '')).toEqual({ forward: true, stickySubscribe: false }); }); } it("DROPS for source='new-message' (would flood from unwired channels)", () => { expect(shouldEngage(empty, 'C1', 'new-message', 'hello')).toEqual({ - engage: false, + forward: false, stickySubscribe: false, }); }); }); - describe("engageMode='mention'", () => { + describe("engageMode='mention' + ignoredMessagePolicy='drop' (default)", () => { const conv = mapFor(cfg({ engageMode: 'mention' })); - it('engages on mention + dm', () => { - expect(shouldEngage(conv, 'C1', 'mention', '').engage).toBe(true); - expect(shouldEngage(conv, 'C1', 'dm', '').engage).toBe(true); + it('forwards on mention + dm', () => { + expect(shouldEngage(conv, 'C1', 'mention', '').forward).toBe(true); + expect(shouldEngage(conv, 'C1', 'dm', '').forward).toBe(true); }); - it('does NOT engage on subscribed or new-message (prevents keep-firing + flooding)', () => { - expect(shouldEngage(conv, 'C1', 'subscribed', '').engage).toBe(false); - expect(shouldEngage(conv, 'C1', 'new-message', '').engage).toBe(false); + it('does NOT forward on subscribed or new-message (prevents keep-firing + flooding)', () => { + expect(shouldEngage(conv, 'C1', 'subscribed', '').forward).toBe(false); + expect(shouldEngage(conv, 'C1', 'new-message', '').forward).toBe(false); }); it('never asks to subscribe', () => { for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { @@ -96,37 +97,37 @@ describe('shouldEngage', () => { describe("engageMode='mention-sticky'", () => { const conv = mapFor(cfg({ engageMode: 'mention-sticky' })); - it('engages on mention + dm with stickySubscribe=true', () => { - expect(shouldEngage(conv, 'C1', 'mention', '')).toEqual({ engage: true, stickySubscribe: true }); - expect(shouldEngage(conv, 'C1', 'dm', '')).toEqual({ engage: true, stickySubscribe: true }); + it('forwards on mention + dm with stickySubscribe=true', () => { + expect(shouldEngage(conv, 'C1', 'mention', '')).toEqual({ forward: true, stickySubscribe: true }); + expect(shouldEngage(conv, 'C1', 'dm', '')).toEqual({ forward: true, stickySubscribe: true }); }); - it('engages on subscribed follow-ups without re-subscribing', () => { - expect(shouldEngage(conv, 'C1', 'subscribed', '')).toEqual({ engage: true, stickySubscribe: false }); + it('forwards subscribed follow-ups without re-subscribing', () => { + expect(shouldEngage(conv, 'C1', 'subscribed', '')).toEqual({ forward: true, stickySubscribe: false }); }); - it('does NOT engage on new-message (explicit mention required to start)', () => { - expect(shouldEngage(conv, 'C1', 'new-message', '').engage).toBe(false); + it('does NOT forward on new-message (explicit mention required to start)', () => { + expect(shouldEngage(conv, 'C1', 'new-message', '').forward).toBe(false); }); }); describe("engageMode='pattern'", () => { - it('pattern="." engages on every source except new-message-with-unknown', () => { + it('pattern="." forwards on every source (when conversation is known)', () => { const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '.' })); for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { - expect(shouldEngage(conv, 'C1', s, 'anything').engage).toBe(true); + expect(shouldEngage(conv, 'C1', s, 'anything').forward).toBe(true); } }); it('tests regex against text on new-message (the main bug fix)', () => { const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '^!report' })); - expect(shouldEngage(conv, 'C1', 'new-message', '!report now').engage).toBe(true); - expect(shouldEngage(conv, 'C1', 'new-message', 'nothing to see').engage).toBe(false); + expect(shouldEngage(conv, 'C1', 'new-message', '!report now').forward).toBe(true); + expect(shouldEngage(conv, 'C1', 'new-message', 'nothing to see').forward).toBe(false); }); it('pattern regex applies on every source (symmetry)', () => { const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: 'deploy' })); for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { - expect(shouldEngage(conv, 'C1', s, 'time to deploy').engage).toBe(true); - expect(shouldEngage(conv, 'C1', s, 'weather today').engage).toBe(false); + expect(shouldEngage(conv, 'C1', s, 'time to deploy').forward).toBe(true); + expect(shouldEngage(conv, 'C1', s, 'weather today').forward).toBe(false); } }); @@ -139,7 +140,50 @@ describe('shouldEngage', () => { it('invalid regex fails open (admin sees something rather than silent drop)', () => { const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '[unclosed' })); - expect(shouldEngage(conv, 'C1', 'new-message', 'x').engage).toBe(true); + expect(shouldEngage(conv, 'C1', 'new-message', 'x').forward).toBe(true); + }); + }); + + describe("ignoredMessagePolicy='accumulate'", () => { + it('forwards non-engaging new-message so the router can store it as context (trigger=0)', () => { + const conv = mapFor(cfg({ engageMode: 'mention', ignoredMessagePolicy: 'accumulate' })); + // Plain message in unsubscribed group — mention rule says no engage, + // but accumulate says forward anyway. + expect(shouldEngage(conv, 'C1', 'new-message', 'chit chat')).toEqual({ + forward: true, + stickySubscribe: false, + }); + }); + + it('forwards non-engaging subscribed messages for accumulation', () => { + // mention wiring in a subscribed thread: the mention handler already + // fired once, thread is now subscribed, follow-ups route here. The + // base 'mention' rule wouldn't engage without an @-mention, but + // accumulate says capture the context. + const conv = mapFor(cfg({ engageMode: 'mention', ignoredMessagePolicy: 'accumulate' })); + expect(shouldEngage(conv, 'C1', 'subscribed', 'follow up talk').forward).toBe(true); + }); + + it('does NOT set stickySubscribe purely from accumulate (avoid misleading bot presence)', () => { + const conv = mapFor(cfg({ engageMode: 'mention-sticky', ignoredMessagePolicy: 'accumulate' })); + expect(shouldEngage(conv, 'C1', 'new-message', 'plain').stickySubscribe).toBe(false); + }); + + it("accumulate doesn't override the 'unknown conversation → drop new-message' rule", () => { + // Unknown conversation (not in map): accumulate can't be read because + // there's no config to read from. We still drop. + const empty = new Map(); + expect(shouldEngage(empty, 'C-unknown', 'new-message', 'x').forward).toBe(false); + }); + + it("drop policy + non-engaging message → doesn't forward", () => { + const conv = mapFor(cfg({ engageMode: 'mention', ignoredMessagePolicy: 'drop' })); + expect(shouldEngage(conv, 'C1', 'new-message', 'plain').forward).toBe(false); + }); + + it('engaging message with drop policy still forwards (engage wins regardless)', () => { + const conv = mapFor(cfg({ engageMode: 'mention', ignoredMessagePolicy: 'drop' })); + expect(shouldEngage(conv, 'C1', 'mention', '@bot hi').forward).toBe(true); }); }); @@ -152,8 +196,17 @@ describe('shouldEngage', () => { cfg({ agentGroupId: 'ag-a', engageMode: 'mention' }), cfg({ agentGroupId: 'ag-b', engageMode: 'pattern', engagePattern: '^hi' }), ); - expect(shouldEngage(conv, 'C1', 'new-message', 'hi there').engage).toBe(true); - expect(shouldEngage(conv, 'C1', 'new-message', 'something else').engage).toBe(false); + expect(shouldEngage(conv, 'C1', 'new-message', 'hi there').forward).toBe(true); + expect(shouldEngage(conv, 'C1', 'new-message', 'something else').forward).toBe(false); + }); + + it('any accumulate wiring causes forward even if all others would drop', () => { + const conv = mapFor( + cfg({ agentGroupId: 'ag-a', engageMode: 'mention', ignoredMessagePolicy: 'drop' }), + cfg({ agentGroupId: 'ag-b', engageMode: 'mention', ignoredMessagePolicy: 'accumulate' }), + ); + // Plain message: ag-a would drop, ag-b would accumulate → forward. + expect(shouldEngage(conv, 'C1', 'new-message', 'plain').forward).toBe(true); }); it('stickySubscribe from any mention-sticky wiring wins', () => { @@ -161,7 +214,7 @@ describe('shouldEngage', () => { cfg({ agentGroupId: 'ag-a', engageMode: 'mention' }), cfg({ agentGroupId: 'ag-b', engageMode: 'mention-sticky' }), ); - expect(shouldEngage(conv, 'C1', 'mention', '')).toEqual({ engage: true, stickySubscribe: true }); + expect(shouldEngage(conv, 'C1', 'mention', '')).toEqual({ forward: true, stickySubscribe: true }); }); }); }); diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index f2daf11..aa980ab 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -85,21 +85,28 @@ export interface ChatSdkBridgeConfig { export type EngageSource = 'subscribed' | 'mention' | 'dm' | 'new-message'; /** - * Should a message from (channelId, source, text) engage any of the wired - * agents on this conversation? + * Should a message from (channelId, source, text) be forwarded to the host, + * and if so, should the bridge subscribe the thread? * * Exported for testability — see `chat-sdk-bridge.test.ts`. * - * We take the union across wired agents: if any wiring would engage, the - * message is forwarded. Per-agent filtering after that happens in the host - * router (see `src/router.ts` pickAgents). + * We take the union across wired agents: if any wiring would engage OR any + * wiring has `ignoredMessagePolicy='accumulate'`, the message is forwarded. + * The host router then does the per-wiring decision in `deliverToAgent` — + * engaging agents get `trigger=1` (wake), accumulating agents get + * `trigger=0` (store as context, don't wake), drop-policy agents are + * skipped (see `src/router.ts` routeInbound fan-out). + * + * `stickySubscribe` is only set when an actual engage happens (not just + * accumulate) — subscribing a thread we'd only silently accumulate on would + * misrepresent the bot's presence to other users. */ export function shouldEngage( conversations: Map, channelId: string, source: EngageSource, text: string, -): { engage: boolean; stickySubscribe: boolean } { +): { forward: boolean; stickySubscribe: boolean } { const configs = conversations.get(channelId); // Unknown conversation — behavior diverges by source: @@ -112,28 +119,30 @@ export function shouldEngage( // the bot is merely *present* in but not wired to. Forwarding // everything would flood the host. if (!configs || configs.length === 0) { - return { engage: source !== 'new-message', stickySubscribe: false }; + return { forward: source !== 'new-message', stickySubscribe: false }; } let engage = false; + let accumulate = false; let stickySubscribe = false; for (const cfg of configs) { + let cfgEngages = false; switch (cfg.engageMode) { case 'mention': - if (source === 'mention' || source === 'dm') engage = true; + if (source === 'mention' || source === 'dm') cfgEngages = true; break; case 'mention-sticky': if (source === 'mention' || source === 'dm') { - engage = true; + cfgEngages = true; stickySubscribe = true; } else if (source === 'subscribed') { // Thread was already subscribed on a prior mention — treat as // engage-all so follow-ups in the thread reach the agent. - engage = true; + cfgEngages = true; } - // source='new-message' → do not engage. mention-sticky requires an - // explicit mention to start the conversation. + // source='new-message' → does not engage (requires explicit mention + // to start). Accumulate policy is evaluated below if set. break; case 'pattern': { // Pattern evaluates on any source that delivers a plain message — @@ -143,19 +152,27 @@ export function shouldEngage( // only fire on mentions whose text contains 'foo'. const pattern = cfg.engagePattern ?? '.'; try { - if (pattern === '.' || new RegExp(pattern).test(text)) engage = true; + if (pattern === '.' || new RegExp(pattern).test(text)) cfgEngages = true; } catch { // Invalid regex → fail open so the admin can see something is // happening and fix the pattern. - engage = true; + cfgEngages = true; } break; } } - if (engage && stickySubscribe) break; + + if (cfgEngages) { + engage = true; + } else if (cfg.ignoredMessagePolicy === 'accumulate') { + // Wiring doesn't engage on this message but wants it captured as + // context for its session — forward so the router can write it with + // trigger=0. + accumulate = true; + } } - return { engage, stickySubscribe }; + return { forward: engage || accumulate, stickySubscribe }; } export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter { @@ -189,7 +206,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter channelId: string, source: EngageSource, text: string, - ): { engage: boolean; stickySubscribe: boolean } { + ): { forward: boolean; stickySubscribe: boolean } { return shouldEngage(conversations, channelId, source, text); } @@ -278,7 +295,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const channelId = adapter.channelIdFromThreadId(thread.id); const text = typeof message.text === 'string' ? message.text : ''; const decision = engageDecision(channelId, 'subscribed', text); - if (!decision.engage) return; + if (!decision.forward) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); }); @@ -288,7 +305,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const channelId = adapter.channelIdFromThreadId(thread.id); const text = typeof message.text === 'string' ? message.text : ''; const decision = engageDecision(channelId, 'mention', text); - if (!decision.engage) return; + if (!decision.forward) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); if (decision.stickySubscribe) { await thread.subscribe(); @@ -312,9 +329,9 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter channelId, sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown', threadId: thread.id, - engage: decision.engage, + forward: decision.forward, }); - if (!decision.engage) return; + if (!decision.forward) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); if (decision.stickySubscribe) { await thread.subscribe(); @@ -339,7 +356,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const channelId = adapter.channelIdFromThreadId(thread.id); const text = typeof message.text === 'string' ? message.text : ''; const decision = engageDecision(channelId, 'new-message', text); - if (!decision.engage) return; + if (!decision.forward) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); }); diff --git a/src/index.ts b/src/index.ts index 9bb51be..4958eef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -163,6 +163,7 @@ function buildConversationConfigs(channelType: string): ConversationConfig[] { agentGroupId: agent.agent_group_id, engageMode: agent.engage_mode, engagePattern: agent.engage_pattern, + ignoredMessagePolicy: agent.ignored_message_policy, sessionMode: agent.session_mode, }); } From 31f2da95856f85c1d754756a4b940b05f86fa3a2 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 11:23:47 +0300 Subject: [PATCH 023/185] fix(container): gate poll loop on trigger=1 to honor accumulate contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A warm container picks up every pending messages_in row on each poll tick and calls markProcessing → agent.query → markCompleted. Before this, that included trigger=0 rows (ignored_message_policy='accumulate' context), causing the agent to wake and potentially respond to messages the wiring had explicitly opted out of engaging on — defeating accumulate's "store as context, don't engage" contract. Gate the main poll loop with `messages.some(m => m.trigger === 1)` — mirrors host-side countDueMessages which is already gated. If the batch has no wake-eligible row, sleep and leave them pending. They ride along via the same getPendingMessages query the next time a real trigger=1 lands, which is the intended accumulate behavior. The concurrent active-turn poll (line ~290) is unchanged on purpose — once the agent has engaged, pushing in accumulate rows mid-turn as additional context is desired. initTestSessionDb was missing the trigger and series_id columns on messages_in, out of sync with the live migration. Added both so the new tests (and any future trigger-aware tests) can run. Four tests cover the data contract: trigger=0 rows are returned by getPendingMessages (so they ride along), the gate predicate correctly identifies accumulate-only batches, mixed batches pass the gate, and the schema default of 1 applies when the column is omitted. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/db/connection.ts | 2 + container/agent-runner/src/poll-loop.test.ts | 53 ++++++++++++++++++-- container/agent-runner/src/poll-loop.ts | 13 +++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 772f4f1..3c0fffd 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -157,7 +157,9 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } { status TEXT DEFAULT 'pending', process_after TEXT, recurrence TEXT, + series_id TEXT, tries INTEGER DEFAULT 0, + trigger INTEGER NOT NULL DEFAULT 1, platform_id TEXT, channel_type TEXT, thread_id TEXT, diff --git a/container/agent-runner/src/poll-loop.test.ts b/container/agent-runner/src/poll-loop.test.ts index de5fb68..356108f 100644 --- a/container/agent-runner/src/poll-loop.test.ts +++ b/container/agent-runner/src/poll-loop.test.ts @@ -14,13 +14,13 @@ afterEach(() => { closeSessionDb(); }); -function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string }) { +function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string; trigger?: 0 | 1 }) { getInboundDb() .prepare( - `INSERT INTO messages_in (id, kind, timestamp, status, process_after, content) - VALUES (?, ?, datetime('now'), 'pending', ?, ?)`, + `INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, content) + VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`, ) - .run(id, kind, opts?.processAfter ?? null, JSON.stringify(content)); + .run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, JSON.stringify(content)); } describe('formatter', () => { @@ -84,6 +84,51 @@ describe('formatter', () => { }); }); +describe('accumulate gate (trigger column)', () => { + it('getPendingMessages returns both trigger=0 and trigger=1 rows', () => { + // trigger=0 rides along as context, trigger=1 is the wake-eligible row. + // The poll loop's gate depends on this data contract. + insertMessage('m1', 'chat', { sender: 'A', text: 'chit chat' }, { trigger: 0 }); + insertMessage('m2', 'chat', { sender: 'B', text: 'actual mention' }, { trigger: 1 }); + const messages = getPendingMessages(); + expect(messages).toHaveLength(2); + const byId = Object.fromEntries(messages.map((m) => [m.id, m])); + expect(byId.m1.trigger).toBe(0); + expect(byId.m2.trigger).toBe(1); + }); + + it('trigger=0-only batch: gate predicate `some(trigger===1)` is false', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'noise' }, { trigger: 0 }); + insertMessage('m2', 'chat', { sender: 'B', text: 'more noise' }, { trigger: 0 }); + const messages = getPendingMessages(); + // This is the exact predicate the poll loop uses to skip accumulate-only + // batches — gate should be false, so the loop sleeps without waking the agent. + expect(messages.some((m) => m.trigger === 1)).toBe(false); + }); + + it('mixed batch: gate is true → loop proceeds, accumulated rows ride along', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'earlier chatter' }, { trigger: 0 }); + insertMessage('m2', 'chat', { sender: 'B', text: 'the real mention' }, { trigger: 1 }); + const messages = getPendingMessages(); + expect(messages.some((m) => m.trigger === 1)).toBe(true); + // Both messages are present for the formatter → agent sees the prior context. + expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']); + }); + + it('trigger column defaults to 1 for legacy inserts without explicit value', () => { + // The schema default is 1 (see src/db/schema.ts INBOUND_SCHEMA) — existing + // rows / tests without the column set are effectively wake-eligible. + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, content) + VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`, + ) + .run(); + const [msg] = getPendingMessages(); + expect(msg.trigger).toBe(1); + }); +}); + describe('routing', () => { it('should extract routing from messages', () => { getInboundDb() diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 8a4ec7d..3f0e364 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -72,6 +72,19 @@ export async function runPollLoop(config: PollLoopConfig): Promise { continue; } + // Accumulate gate: if the batch contains only trigger=0 rows + // (context-only, router-stored under ignored_message_policy='accumulate'), + // don't wake the agent. Leave them `pending` — they'll ride along the + // next time a real trigger=1 message lands via this same getPendingMessages + // query. Without this gate, a warm container keeps processing + // (and potentially responding to) every accumulate-only batch, defeating + // the "store as context, don't engage" contract. Host-side countDueMessages + // gates the same way for wake-from-cold (see src/db/session-db.ts). + if (!messages.some((m) => m.trigger === 1)) { + await sleep(POLL_INTERVAL_MS); + continue; + } + const ids = messages.map((m) => m.id); markProcessing(ids); From f894b5b1d009090033b96423058e689054eee46f Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Mon, 20 Apr 2026 12:11:35 +0300 Subject: [PATCH 024/185] chore: ignore .env* variants in addition to .env Catches .env.local, .env.test, .env.production, and other variant files that should never be committed alongside the base .env. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1035745..8c02e07 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ groups/global/* # Secrets *.keys.json .env +.env* # Temp files .tmp-* From 0105de025731faef9239f7d1f76c3ac94c6081da Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 12:15:52 +0300 Subject: [PATCH 025/185] fix(host-sweep): skip ceiling check when heartbeat file is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decideStuckAction treated a missing heartbeat file as heartbeatAge = Infinity, which always exceeded the 30-minute ceiling. Result: every freshly-spawned container got killed within seconds of spawn on the first sweep pass because it hadn't produced an SDK event yet (heartbeat is only touched on SDK events inside processQuery, not on boot). Skip the ceiling branch when heartbeatMtimeMs === 0. Containers that genuinely never wrote a heartbeat because they died are caught by the separate "container process not running" cleanup path. Containers that boot, claim a message, but hang at the gate are caught by the claim-stuck check below — which correctly fires regardless of heartbeat presence once claimAge exceeds tolerance. Updates the "absent heartbeat → kill-ceiling" test (which was encoding the bug) and adds a companion that the claim-stuck path still fires for absent-heartbeat containers with aged claims. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/host-sweep.test.ts | 24 +++++++++++++++++++++--- src/host-sweep.ts | 18 ++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/host-sweep.test.ts b/src/host-sweep.test.ts index d9505a4..eefcc8a 100644 --- a/src/host-sweep.test.ts +++ b/src/host-sweep.test.ts @@ -39,14 +39,32 @@ describe('decideStuckAction', () => { expect(res.heartbeatAgeMs).toBeGreaterThan(ABSOLUTE_CEILING_MS); }); - it('treats an absent heartbeat file as infinitely stale', () => { + it('skips the ceiling check when no heartbeat file exists (fresh container not yet ticked)', () => { + // A freshly-spawned container hasn't produced any SDK events yet, so no + // heartbeat. Prior behavior treated this as infinitely stale and killed + // every container within seconds of spawn. With no claims either, we + // should conclude everything is fine. const res = decideStuckAction({ now: BASE, heartbeatMtimeMs: 0, containerState: null, claims: [], }); - expect(res.action).toBe('kill-ceiling'); + expect(res.action).toBe('ok'); + }); + + it('kills on claim-stuck when heartbeat is absent AND a claim has aged past tolerance', () => { + // Hanging fresh container: spawned, picked up a message (claim recorded + // in processing_ack), but never wrote a heartbeat. Falls through the + // skipped ceiling check into claim-stuck — which correctly fires. + const claimedAgeMs = CLAIM_STUCK_MS + 5_000; + const res = decideStuckAction({ + now: BASE, + heartbeatMtimeMs: 0, + containerState: null, + claims: [claim('msg-1', claimedAgeMs)], + }); + expect(res.action).toBe('kill-claim'); }); it('extends the ceiling when Bash has a declared timeout longer than 30 min', () => { @@ -105,7 +123,7 @@ describe('decideStuckAction', () => { const res = decideStuckAction({ now: BASE, // 5 min since claim, over the 60s default but under the declared Bash timeout - heartbeatMtimeMs: BASE - (5 * 60 * 1000) - 5_000, + heartbeatMtimeMs: BASE - 5 * 60 * 1000 - 5_000, containerState: { current_tool: 'Bash', tool_declared_timeout_ms: tenMinMs, diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 0f8365c..1a2901c 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -75,11 +75,21 @@ export function decideStuckAction(args: { }): StuckDecision { const { now, heartbeatMtimeMs, containerState, claims } = args; const declaredBashMs = bashTimeoutMs(containerState); - const heartbeatAge = heartbeatMtimeMs === 0 ? Infinity : now - heartbeatMtimeMs; - const ceiling = Math.max(ABSOLUTE_CEILING_MS, declaredBashMs ?? 0); - if (heartbeatAge > ceiling) { - return { action: 'kill-ceiling', heartbeatAgeMs: heartbeatAge, ceilingMs: ceiling }; + // Ceiling check only applies when we have an actual heartbeat timestamp. + // A freshly-spawned container hasn't had any SDK activity yet so no + // heartbeat file exists — if we treated that as infinitely stale we'd + // kill every container within seconds of spawn. Genuinely-dead containers + // that never wrote a heartbeat are caught by the separate "container + // process not running" cleanup path, not here. If a fresh container is + // hanging at the gate (claimed a message but never did anything) the + // claim-stuck check below handles it. + if (heartbeatMtimeMs !== 0) { + const heartbeatAge = now - heartbeatMtimeMs; + const ceiling = Math.max(ABSOLUTE_CEILING_MS, declaredBashMs ?? 0); + if (heartbeatAge > ceiling) { + return { action: 'kill-ceiling', heartbeatAgeMs: heartbeatAge, ceilingMs: ceiling }; + } } const tolerance = Math.max(CLAIM_STUCK_MS, declaredBashMs ?? 0); From f74df3b0d3cf30ce4cae5e26c7c6373d21d87e2f Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 12:16:20 +0300 Subject: [PATCH 026/185] fix(router): trust SDK isMention signal; drop broken hasMention regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router's mention / mention-sticky engage check was regex-matching @ (e.g. @Andy) against message text. Platforms don't work that way — users address bots via the bot's platform username (@nanoclaw_v2_refactr_1_bot on Telegram, user-id mentions on Slack / Discord). The regex matched only coincidentally and never on Telegram, so mention-mode wirings silently never fired there. Two parallel mention detectors existed: the Chat SDK's onNewMention, which correctly resolves the bot's platform identity, and the router's hasMention text regex, which ignored the SDK verdict and invented its own heuristic. The router's detector was wrong in principle — the agent group's display name is a NanoClaw-side nickname, not a platform address. Thread the SDK signal through: InboundMessage gains an optional `isMention` field, the bridge sets it from each handler (onNewMention → true, onDirectMessage → true, onSubscribedMessage → message.isMention, onNewMessage(/./) → false), src/index.ts forwards it into InboundEvent, and evaluateEngage now checks `isMention === true` for mention modes. hasMention deleted entirely — there is only one source of truth for "did the user mention this bot": the platform / SDK. Agent-name-in-text matching for disambiguating multiple agents wired to one chat is a separate feature; users can express it today with engage_mode='pattern' + the agent's name as the regex. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/adapter.ts | 15 +++++++++++ src/channels/chat-sdk-bridge.ts | 19 ++++++++++---- src/index.ts | 1 + src/router.ts | 45 +++++++++++++++++++++------------ 4 files changed, 59 insertions(+), 21 deletions(-) diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 34b3675..bbf7f37 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -60,6 +60,21 @@ export interface InboundMessage { kind: 'chat' | 'chat-sdk'; content: unknown; // JS object — host will JSON.stringify before writing to session DB timestamp: string; + /** + * Platform-confirmed signal that this message is a mention of the bot. + * + * Set by adapters that know the platform's own mention semantics — e.g. + * the Chat SDK bridge sets it true from `onNewMention` / `onDirectMessage` + * and forwards `message.isMention` from `onSubscribedMessage`. Use this + * in the router instead of agent-name regex matching, which breaks on + * platforms where the mention text is the bot's platform username (e.g. + * Telegram's `@nanoclaw_v2_refactr_1_bot`) rather than the agent_group + * display name (e.g. `@Andy`). + * + * Adapters that don't set it (native / legacy) leave it undefined — the + * router falls back to text-match against agent_group_name. + */ + isMention?: boolean; } /** A file attachment to deliver alongside a message. */ diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index aa980ab..bea4c16 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -210,7 +210,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter return shouldEngage(conversations, channelId, source, text); } - async function messageToInbound(message: ChatMessage): Promise { + async function messageToInbound(message: ChatMessage, isMention: boolean): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const serialized = message.toJSON() as Record; @@ -266,6 +266,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter kind: 'chat-sdk', content: serialized, timestamp: message.metadata.dateSent.toISOString(), + isMention, }; } @@ -296,7 +297,10 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const text = typeof message.text === 'string' ? message.text : ''; const decision = engageDecision(channelId, 'subscribed', text); if (!decision.forward) return; - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); + // Subscribed path: the SDK sets message.isMention when the bot was + // @-mentioned in an already-subscribed thread (docs at + // handling-events.mdx). Forward it verbatim. + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true)); }); // @mention in an unsubscribed thread — always engage; subscribe only @@ -306,7 +310,8 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const text = typeof message.text === 'string' ? message.text : ''; const decision = engageDecision(channelId, 'mention', text); if (!decision.forward) return; - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); + // onNewMention only fires when the SDK confirms the bot was mentioned. + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); if (decision.stickySubscribe) { await thread.subscribe(); } @@ -332,7 +337,9 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter forward: decision.forward, }); if (!decision.forward) return; - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); + // A DM is by definition addressed to the bot — treat as a mention + // for routing purposes. `mention` / `mention-sticky` wirings fire. + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); if (decision.stickySubscribe) { await thread.subscribe(); } @@ -357,7 +364,9 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const text = typeof message.text === 'string' ? message.text : ''; const decision = engageDecision(channelId, 'new-message', text); if (!decision.forward) return; - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); + // SDK dispatch guarantees this is a non-mention non-DM message in an + // unsubscribed thread — isMention is definitively false here. + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false)); }); // Handle button clicks (ask_user_question) diff --git a/src/index.ts b/src/index.ts index 4958eef..595ba1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,6 +83,7 @@ async function main(): Promise { kind: message.kind, content: JSON.stringify(message.content), timestamp: message.timestamp, + isMention: message.isMention, }, }).catch((err) => { log.error('Failed to route inbound message', { channelType: adapter.channelType, err }); diff --git a/src/router.ts b/src/router.ts index cb4ee93..a3e8f06 100644 --- a/src/router.ts +++ b/src/router.ts @@ -42,6 +42,15 @@ export interface InboundEvent { kind: 'chat' | 'chat-sdk'; content: string; // JSON blob timestamp: string; + /** + * Platform-confirmed bot-mention signal forwarded from the adapter. + * When defined, it's authoritative — use this instead of text-matching + * agent_group_name, which breaks on platforms where the mention token + * is the bot's platform username (e.g. Telegram). undefined means the + * adapter doesn't provide the signal; evaluateEngage falls back to + * agent-name regex. + */ + isMention?: boolean; }; } @@ -194,6 +203,7 @@ export async function routeInbound(event: InboundEvent): Promise { // engage later. Drop policy = skip silently. const parsed = safeParseContent(event.message.content); const messageText = parsed.text ?? ''; + const isMention = event.message.isMention === true; let engagedCount = 0; let accumulatedCount = 0; @@ -202,7 +212,7 @@ export async function routeInbound(event: InboundEvent): Promise { const agentGroup = getAgentGroup(agent.agent_group_id); if (!agentGroup) continue; - const engages = evaluateEngage(agent, agentGroup, messageText, mg, event.threadId); + const engages = evaluateEngage(agent, messageText, isMention, mg, event.threadId); const accessOk = engages && (!accessGate || accessGate(event, userId, mg, agent.agent_group_id).allowed); const scopeOk = engages && (!senderScopeGate || senderScopeGate(event, userId, mg, agent).allowed); @@ -241,17 +251,26 @@ export async function routeInbound(event: InboundEvent): Promise { * Decide whether a given wired agent should engage on this message. * * 'pattern' — regex test on text; '.' = always - * 'mention' — bot must be @-mentioned by its agent-group name - * 'mention-sticky' — @mention OR an active per-thread session already - * exists for this (agent, mg, thread). The session - * existence IS our subscription state; once a thread - * has engaged us once, follow-ups arrive with no - * mention and should still fire. + * 'mention' — bot must be mentioned on the platform. Resolved by + * the adapter (SDK-level) and forwarded as + * `event.message.isMention`. Agent display name + * (`agent_group.name`) is irrelevant — users address + * the bot via its platform username (@botname on + * Telegram, user-id mention on Slack/Discord), not + * via the agent's NanoClaw-side display name. If a + * user wants to disambiguate between multiple agents + * wired to one chat, use engage_mode='pattern' with + * the disambiguator as the regex. + * 'mention-sticky' — platform mention OR an active per-thread session + * already exists for this (agent, mg, thread). The + * session existence IS our subscription state; once + * a thread has engaged us once, follow-ups arrive + * with no mention and should still fire. */ function evaluateEngage( agent: MessagingGroupAgent, - agentGroup: AgentGroup, text: string, + isMention: boolean, mg: MessagingGroup, threadId: string | null, ): boolean { @@ -267,9 +286,9 @@ function evaluateEngage( } } case 'mention': - return hasMention(text, agentGroup.name); + return isMention; case 'mention-sticky': { - if (hasMention(text, agentGroup.name)) return true; + if (isMention) return true; // Sticky follow-up: session already exists for this (agent, mg, thread) // — the thread was activated before, keep firing. if (mg.is_group === 0) return false; // DMs never use mention-sticky sensibly @@ -281,12 +300,6 @@ function evaluateEngage( } } -function hasMention(text: string, agentName: string): boolean { - if (!agentName) return false; - const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return new RegExp(`@${escaped}\\b`, 'i').test(text); -} - async function deliverToAgent( agent: MessagingGroupAgent, agentGroup: AgentGroup, From 68058cbc4a553b74ee08fc0808516d565685d31c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 12:16:35 +0300 Subject: [PATCH 027/185] fix(permissions): authorize unknown-sender approval clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approval click handler trusted row.approver_user_id as the actor regardless of who actually clicked the card. A random user who received the forwarded card could click Approve and get the stranger admitted to the agent group — their click was simply not checked. Separately, payload.userId arrives as the raw platform userId from Chat SDK onAction (e.g. "6037840640"), not the namespaced form ("telegram:6037840640") that matches users(id). Without namespacing, users-table lookups miss. Namespace the clicker id with payload.channelType, then authorize: the clicker must be either the designated approver OR have owner / admin privilege over the agent group (hasAdminPrivilege covers owner, global admin, scoped admin). Unauthorized clicks return true (claim the response so the registry doesn't log it as unclaimed) but take no action — the pending row stays in place so a legitimate approver can still act on it. Existing tests passed a pre-namespaced userId directly, masking the first bug. Fixed the fixtures to match production plumbing and added two tests: one asserts a random bystander's click is rejected (row stays pending, no member added), the other asserts a global admin can approve even when they weren't the designated approver. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/permissions/index.ts | 24 ++++- .../permissions/sender-approval.test.ts | 95 +++++++++++++++++-- 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index 1d505b6..d13797b 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -29,10 +29,8 @@ import { log } from '../../log.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import { canAccessAgentGroup } from './access.js'; import { addMember } from './db/agent-group-members.js'; -import { - deletePendingSenderApproval, - getPendingSenderApproval, -} from './db/pending-sender-approvals.js'; +import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js'; +import { hasAdminPrivilege } from './db/user-roles.js'; import { getUser, upsertUser } from './db/users.js'; import { requestSenderApproval } from './sender-approval.js'; @@ -198,7 +196,23 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise { expect(deliverMock).toHaveBeenCalledTimes(1); const { getDb } = await import('../../db/connection.js'); - const count = ( - getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number } - ).c; + const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number }).c; expect(count).toBe(1); }); @@ -208,7 +206,10 @@ describe('unknown-sender request_approval flow', () => { const claimed = await handler({ questionId: pending.id, value: 'approve', - userId: 'telegram:owner', + // Chat SDK's onAction surfaces the raw platform userId (e.g. Telegram + // chat id). The permissions handler namespaces it with channelType to + // match users(id). + userId: 'owner', channelType: 'telegram', platformId: 'dm-owner', threadId: null, @@ -245,7 +246,7 @@ describe('unknown-sender request_approval flow', () => { const claimed = await handler({ questionId: pending.id, value: 'reject', - userId: 'telegram:owner', + userId: 'owner', // raw platform id — handler namespaces with channelType channelType: 'telegram', platformId: 'dm-owner', threadId: null, @@ -253,13 +254,91 @@ describe('unknown-sender request_approval flow', () => { if (claimed) break; } - const count = ( - getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number } - ).c; + const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number }).c; expect(count).toBe(0); const member = getDb() .prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?') .get('tg:stranger', 'ag-1'); expect(member).toBeUndefined(); }); + + it('rejects clicks from an unauthorized user (prevents self-admit via forwarded card)', async () => { + // Stranger triggers the approval flow; card goes to the owner. + const { routeInbound } = await import('../../router.js'); + const { getResponseHandlers } = await import('../../response-registry.js'); + + await routeInbound(stranger('can I play')); + await new Promise((r) => setTimeout(r, 10)); + + const { getDb } = await import('../../db/connection.js'); + const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string }; + expect(pending).toBeDefined(); + + // A random user (not the stranger, not the owner, not an admin) tries to + // click the approval — e.g. they got the card forwarded. Should be + // rejected without admitting them. + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.id, + value: 'approve', + userId: 'random-bystander', // not owner, not admin + channelType: 'telegram', + platformId: 'dm-random', + threadId: null, + }); + if (claimed) break; + } + + // No member added for the stranger. + const member = getDb() + .prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?') + .get('tg:stranger', 'ag-1'); + expect(member).toBeUndefined(); + + // Pending row is still there — a legitimate approver can still act on it. + const stillPending = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number }) + .c; + expect(stillPending).toBe(1); + }); + + it('accepts a click from a global admin even if they are not the designated approver', async () => { + // Pre-seed a separate admin user so we can click as them. + upsertUser({ id: 'telegram:admin-bob', kind: 'telegram', display_name: 'Bob', created_at: now() }); + grantRole({ + user_id: 'telegram:admin-bob', + role: 'admin', + agent_group_id: null, + granted_by: 'telegram:owner', + granted_at: now(), + }); + + const { routeInbound } = await import('../../router.js'); + const { getResponseHandlers } = await import('../../response-registry.js'); + + await routeInbound(stranger('knock knock')); + await new Promise((r) => setTimeout(r, 10)); + + const { getDb } = await import('../../db/connection.js'); + const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string }; + expect(pending).toBeDefined(); + + // Admin clicks approve (not the designated approver, which was owner). + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.id, + value: 'approve', + userId: 'admin-bob', + channelType: 'telegram', + platformId: 'dm-bob', + threadId: null, + }); + if (claimed) break; + } + + // Stranger admitted thanks to the admin's authority. + const member = getDb() + .prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?') + .get('tg:stranger', 'ag-1'); + expect(member).toBeDefined(); + }); }); From b15972284b60dcb94025525180f6f17557098812 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 13:32:08 +0300 Subject: [PATCH 028/185] refactor(channels): shrink bridge shouldEngage to flood gate + subscribe signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this change the bridge and the router both owned engage_mode policy. Bridge's shouldEngage had a full switch over mention / mention-sticky / pattern + source-based rules + engage_pattern regex test + ignored_message_policy accumulate fallback. Router's evaluateEngage had the same switch against the same fields. Two parallel logic paths with subtle vocabulary differences (bridge: "which SDK handler fired"; router: "what isMention says"). Every time we touched one we had to reason about the other — the Telegram hasMention bug and the "pattern mode silently drops in group chats" bug were both drift between the two. Refactor to one place. Router keeps all per-wiring policy — engage mode, pattern regex, sender scope, ignored-message policy — unchanged. Bridge drops to a coarse flood gate + subscribe signal: - forward: does this channel have ANY wiring? Forward if yes. Unknown channels still forward for subscribed/mention/dm (they may be newly auto-created, or will trigger the coming channel-registration flow). Unknown channels DROP for new-message so we don't flood from every unsubscribed thread the bot happens to sit in. - stickySubscribe: any mention-sticky wiring on the channel AND the source is mention or dm. Coarse union — subscribe is idempotent and one call serves every sticky wiring. The `text` param on shouldEngage is gone (pattern regex lives in the router now). Four bridge handler sites simplify accordingly. messageToInbound still carries the SDK-confirmed isMention flag through to the router unchanged. Behavioral delta: pure-mention-wired channels (no pattern, no accumulate) will now see every plain group message reach the router before being dropped there, where before the bridge dropped at the transport boundary. Extra DB lookup per dropped message in this specific case; acceptable for the cleaner seam and can be optimized back at the bridge if it ever matters in practice. Bridge tests prune the 10 engage_mode-specific cases that covered logic now owned by evaluateEngage in the router (host-core.test.ts covers it end-to-end through routeInbound). Bridge tests keep only what's bridge-specific: the flood gate and the stickySubscribe coarse union. 172 tests pass (was 182 — net -10 redundant bridge tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/chat-sdk-bridge.test.ts | 180 +++++++-------------------- src/channels/chat-sdk-bridge.ts | 172 ++++++++----------------- 2 files changed, 98 insertions(+), 254 deletions(-) diff --git a/src/channels/chat-sdk-bridge.test.ts b/src/channels/chat-sdk-bridge.test.ts index aad8d0a..3c6caa8 100644 --- a/src/channels/chat-sdk-bridge.test.ts +++ b/src/channels/chat-sdk-bridge.test.ts @@ -61,160 +61,74 @@ describe('createChatSdkBridge', () => { }); }); -describe('shouldEngage', () => { - describe('unknown conversation', () => { +describe('shouldEngage (bridge-level flood gate + subscribe signal)', () => { + // Per-wiring engage_mode / engage_pattern / ignored_message_policy + // semantics live in the router (evaluateEngage / routeInbound fan-out). + // These tests only cover the bridge's two responsibilities: should we + // forward at all, and should we call thread.subscribe(). + + describe('flood gate — unknown conversation', () => { const empty = new Map(); - const sources: EngageSource[] = ['subscribed', 'mention', 'dm']; - for (const source of sources) { - it(`forwards for source='${source}' (may be a not-yet-wired group)`, () => { - expect(shouldEngage(empty, 'C1', source, '')).toEqual({ forward: true, stickySubscribe: false }); + const carriedSources: EngageSource[] = ['subscribed', 'mention', 'dm']; + for (const source of carriedSources) { + it(`forwards for source='${source}' (may be a newly-auto-created channel or a channel-registration trigger)`, () => { + expect(shouldEngage(empty, 'C-new', source)).toEqual({ forward: true, stickySubscribe: false }); }); } - it("DROPS for source='new-message' (would flood from unwired channels)", () => { - expect(shouldEngage(empty, 'C1', 'new-message', 'hello')).toEqual({ + it("DROPS for source='new-message' (onNewMessage(/./) fires for every unsubscribed thread the bot can see — would flood)", () => { + expect(shouldEngage(empty, 'C-unwired', 'new-message')).toEqual({ forward: false, stickySubscribe: false, }); }); }); - describe("engageMode='mention' + ignoredMessagePolicy='drop' (default)", () => { + describe('known conversation — bridge forwards regardless of engage mode', () => { + // Policy lives in the router now. The bridge only knows "has any wiring". const conv = mapFor(cfg({ engageMode: 'mention' })); - it('forwards on mention + dm', () => { - expect(shouldEngage(conv, 'C1', 'mention', '').forward).toBe(true); - expect(shouldEngage(conv, 'C1', 'dm', '').forward).toBe(true); - }); - it('does NOT forward on subscribed or new-message (prevents keep-firing + flooding)', () => { - expect(shouldEngage(conv, 'C1', 'subscribed', '').forward).toBe(false); - expect(shouldEngage(conv, 'C1', 'new-message', '').forward).toBe(false); - }); - it('never asks to subscribe', () => { - for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { - expect(shouldEngage(conv, 'C1', s, '').stickySubscribe).toBe(false); - } - }); - }); - - describe("engageMode='mention-sticky'", () => { - const conv = mapFor(cfg({ engageMode: 'mention-sticky' })); - it('forwards on mention + dm with stickySubscribe=true', () => { - expect(shouldEngage(conv, 'C1', 'mention', '')).toEqual({ forward: true, stickySubscribe: true }); - expect(shouldEngage(conv, 'C1', 'dm', '')).toEqual({ forward: true, stickySubscribe: true }); - }); - it('forwards subscribed follow-ups without re-subscribing', () => { - expect(shouldEngage(conv, 'C1', 'subscribed', '')).toEqual({ forward: true, stickySubscribe: false }); - }); - it('does NOT forward on new-message (explicit mention required to start)', () => { - expect(shouldEngage(conv, 'C1', 'new-message', '').forward).toBe(false); - }); - }); - - describe("engageMode='pattern'", () => { - it('pattern="." forwards on every source (when conversation is known)', () => { - const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '.' })); - for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { - expect(shouldEngage(conv, 'C1', s, 'anything').forward).toBe(true); - } - }); - - it('tests regex against text on new-message (the main bug fix)', () => { - const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '^!report' })); - expect(shouldEngage(conv, 'C1', 'new-message', '!report now').forward).toBe(true); - expect(shouldEngage(conv, 'C1', 'new-message', 'nothing to see').forward).toBe(false); - }); - - it('pattern regex applies on every source (symmetry)', () => { - const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: 'deploy' })); - for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { - expect(shouldEngage(conv, 'C1', s, 'time to deploy').forward).toBe(true); - expect(shouldEngage(conv, 'C1', s, 'weather today').forward).toBe(false); - } - }); - - it('pattern never triggers sticky-subscribe', () => { - const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '.' })); - for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { - expect(shouldEngage(conv, 'C1', s, 'hi').stickySubscribe).toBe(false); - } - }); - - it('invalid regex fails open (admin sees something rather than silent drop)', () => { - const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '[unclosed' })); - expect(shouldEngage(conv, 'C1', 'new-message', 'x').forward).toBe(true); - }); - }); - - describe("ignoredMessagePolicy='accumulate'", () => { - it('forwards non-engaging new-message so the router can store it as context (trigger=0)', () => { - const conv = mapFor(cfg({ engageMode: 'mention', ignoredMessagePolicy: 'accumulate' })); - // Plain message in unsubscribed group — mention rule says no engage, - // but accumulate says forward anyway. - expect(shouldEngage(conv, 'C1', 'new-message', 'chit chat')).toEqual({ - forward: true, - stickySubscribe: false, + for (const source of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { + it(`forwards for source='${source}' — router will decide engage / accumulate / drop per wiring`, () => { + expect(shouldEngage(conv, 'C1', source).forward).toBe(true); }); - }); - - it('forwards non-engaging subscribed messages for accumulation', () => { - // mention wiring in a subscribed thread: the mention handler already - // fired once, thread is now subscribed, follow-ups route here. The - // base 'mention' rule wouldn't engage without an @-mention, but - // accumulate says capture the context. - const conv = mapFor(cfg({ engageMode: 'mention', ignoredMessagePolicy: 'accumulate' })); - expect(shouldEngage(conv, 'C1', 'subscribed', 'follow up talk').forward).toBe(true); - }); - - it('does NOT set stickySubscribe purely from accumulate (avoid misleading bot presence)', () => { - const conv = mapFor(cfg({ engageMode: 'mention-sticky', ignoredMessagePolicy: 'accumulate' })); - expect(shouldEngage(conv, 'C1', 'new-message', 'plain').stickySubscribe).toBe(false); - }); - - it("accumulate doesn't override the 'unknown conversation → drop new-message' rule", () => { - // Unknown conversation (not in map): accumulate can't be read because - // there's no config to read from. We still drop. - const empty = new Map(); - expect(shouldEngage(empty, 'C-unknown', 'new-message', 'x').forward).toBe(false); - }); - - it("drop policy + non-engaging message → doesn't forward", () => { - const conv = mapFor(cfg({ engageMode: 'mention', ignoredMessagePolicy: 'drop' })); - expect(shouldEngage(conv, 'C1', 'new-message', 'plain').forward).toBe(false); - }); - - it('engaging message with drop policy still forwards (engage wins regardless)', () => { - const conv = mapFor(cfg({ engageMode: 'mention', ignoredMessagePolicy: 'drop' })); - expect(shouldEngage(conv, 'C1', 'mention', '@bot hi').forward).toBe(true); - }); + } }); - describe('multiple wirings on one conversation', () => { - it('takes the union across wirings (any-engage wins)', () => { - // mention wiring + pattern wiring on the same channel. A plain message - // should engage via the pattern wiring even though the mention wiring - // alone would reject it. - const conv = mapFor( - cfg({ agentGroupId: 'ag-a', engageMode: 'mention' }), - cfg({ agentGroupId: 'ag-b', engageMode: 'pattern', engagePattern: '^hi' }), - ); - expect(shouldEngage(conv, 'C1', 'new-message', 'hi there').forward).toBe(true); - expect(shouldEngage(conv, 'C1', 'new-message', 'something else').forward).toBe(false); + describe('stickySubscribe signal', () => { + it('true when any mention-sticky wiring exists AND source is mention', () => { + const conv = mapFor(cfg({ engageMode: 'mention-sticky' })); + expect(shouldEngage(conv, 'C1', 'mention').stickySubscribe).toBe(true); }); - it('any accumulate wiring causes forward even if all others would drop', () => { - const conv = mapFor( - cfg({ agentGroupId: 'ag-a', engageMode: 'mention', ignoredMessagePolicy: 'drop' }), - cfg({ agentGroupId: 'ag-b', engageMode: 'mention', ignoredMessagePolicy: 'accumulate' }), - ); - // Plain message: ag-a would drop, ag-b would accumulate → forward. - expect(shouldEngage(conv, 'C1', 'new-message', 'plain').forward).toBe(true); + it('true when any mention-sticky wiring exists AND source is dm', () => { + const conv = mapFor(cfg({ engageMode: 'mention-sticky' })); + expect(shouldEngage(conv, 'C1', 'dm').stickySubscribe).toBe(true); }); - it('stickySubscribe from any mention-sticky wiring wins', () => { + it('false on subscribed — thread is already subscribed, no need to re-subscribe', () => { + const conv = mapFor(cfg({ engageMode: 'mention-sticky' })); + expect(shouldEngage(conv, 'C1', 'subscribed').stickySubscribe).toBe(false); + }); + + it('false on new-message — mention-sticky requires an explicit mention to start', () => { + const conv = mapFor(cfg({ engageMode: 'mention-sticky' })); + expect(shouldEngage(conv, 'C1', 'new-message').stickySubscribe).toBe(false); + }); + + it('false for plain mention / pattern wirings (not sticky)', () => { + const mentionConv = mapFor(cfg({ engageMode: 'mention' })); + const patternConv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '.' })); + for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { + expect(shouldEngage(mentionConv, 'C1', s).stickySubscribe).toBe(false); + expect(shouldEngage(patternConv, 'C1', s).stickySubscribe).toBe(false); + } + }); + + it('fires on coarse union — mixed wirings where any one is mention-sticky', () => { const conv = mapFor( cfg({ agentGroupId: 'ag-a', engageMode: 'mention' }), cfg({ agentGroupId: 'ag-b', engageMode: 'mention-sticky' }), ); - expect(shouldEngage(conv, 'C1', 'mention', '')).toEqual({ forward: true, stickySubscribe: true }); + expect(shouldEngage(conv, 'C1', 'mention').stickySubscribe).toBe(true); }); }); }); diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index bea4c16..9bed968 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -85,94 +85,46 @@ export interface ChatSdkBridgeConfig { export type EngageSource = 'subscribed' | 'mention' | 'dm' | 'new-message'; /** - * Should a message from (channelId, source, text) be forwarded to the host, - * and if so, should the bridge subscribe the thread? + * Bridge-level forwarding decision — a coarse flood gate, not policy. + * + * The router owns per-wiring engage_mode / engage_pattern / sender_scope / + * ignored_message_policy (see `evaluateEngage` in src/router.ts). The bridge + * only answers two questions: + * + * 1. `forward` — is this message worth sending to the host at all? + * - Known channel (any wiring): yes. Router will decide what engages / + * accumulates / drops per wiring. + * - Unknown channel: yes for subscribed / mention / DM (triggers the + * router's auto-create or channel-registration flow); no for + * `new-message`. onNewMessage(/./, …) fires for every message in + * every unsubscribed thread the bot can see, including channels the + * bot merely joined but was never wired to — forwarding everything + * would flood the host. + * + * 2. `stickySubscribe` — should the bridge call `thread.subscribe()`? + * - Yes if ANY wiring on this channel is mention-sticky AND the + * source is an actual mention / DM. Coarse (no per-wiring picking) + * but harmless: subscription is idempotent and one call serves + * every mention-sticky wiring on the channel. Once subscribed, + * follow-ups route through onSubscribedMessage. * * Exported for testability — see `chat-sdk-bridge.test.ts`. - * - * We take the union across wired agents: if any wiring would engage OR any - * wiring has `ignoredMessagePolicy='accumulate'`, the message is forwarded. - * The host router then does the per-wiring decision in `deliverToAgent` — - * engaging agents get `trigger=1` (wake), accumulating agents get - * `trigger=0` (store as context, don't wake), drop-policy agents are - * skipped (see `src/router.ts` routeInbound fan-out). - * - * `stickySubscribe` is only set when an actual engage happens (not just - * accumulate) — subscribing a thread we'd only silently accumulate on would - * misrepresent the bot's presence to other users. */ export function shouldEngage( conversations: Map, channelId: string, source: EngageSource, - text: string, ): { forward: boolean; stickySubscribe: boolean } { const configs = conversations.get(channelId); - // Unknown conversation — behavior diverges by source: - // - subscribed/mention/dm: forward anyway. These paths imply some - // prior engagement (subscribe, @mention, DM open) and may be a new - // group that hasn't been registered yet; central routing will log + - // drop cleanly. - // - new-message: DROP. `onNewMessage(/./, …)` fires for every message - // in every unsubscribed thread the bot can see — including channels - // the bot is merely *present* in but not wired to. Forwarding - // everything would flood the host. if (!configs || configs.length === 0) { return { forward: source !== 'new-message', stickySubscribe: false }; } - let engage = false; - let accumulate = false; - let stickySubscribe = false; + const stickySubscribe = + (source === 'mention' || source === 'dm') && configs.some((cfg) => cfg.engageMode === 'mention-sticky'); - for (const cfg of configs) { - let cfgEngages = false; - switch (cfg.engageMode) { - case 'mention': - if (source === 'mention' || source === 'dm') cfgEngages = true; - break; - case 'mention-sticky': - if (source === 'mention' || source === 'dm') { - cfgEngages = true; - stickySubscribe = true; - } else if (source === 'subscribed') { - // Thread was already subscribed on a prior mention — treat as - // engage-all so follow-ups in the thread reach the agent. - cfgEngages = true; - } - // source='new-message' → does not engage (requires explicit mention - // to start). Accumulate policy is evaluated below if set. - break; - case 'pattern': { - // Pattern evaluates on any source that delivers a plain message — - // including new-message, which is the whole reason we registered - // onNewMessage(/./). For mention/dm-delivered messages we still - // test the regex (historical behavior), so pattern='foo' wirings - // only fire on mentions whose text contains 'foo'. - const pattern = cfg.engagePattern ?? '.'; - try { - if (pattern === '.' || new RegExp(pattern).test(text)) cfgEngages = true; - } catch { - // Invalid regex → fail open so the admin can see something is - // happening and fix the pattern. - cfgEngages = true; - } - break; - } - } - - if (cfgEngages) { - engage = true; - } else if (cfg.ignoredMessagePolicy === 'accumulate') { - // Wiring doesn't engage on this message but wants it captured as - // context for its session — forward so the router can write it with - // trigger=0. - accumulate = true; - } - } - - return { forward: engage || accumulate, stickySubscribe }; + return { forward: true, stickySubscribe }; } export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter { @@ -202,12 +154,8 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter return map; } - function engageDecision( - channelId: string, - source: EngageSource, - text: string, - ): { forward: boolean; stickySubscribe: boolean } { - return shouldEngage(conversations, channelId, source, text); + function engageDecision(channelId: string, source: EngageSource): { forward: boolean; stickySubscribe: boolean } { + return shouldEngage(conversations, channelId, source); } async function messageToInbound(message: ChatMessage, isMention: boolean): Promise { @@ -289,46 +237,38 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter logger: 'silent', }); - // Subscribed threads — the conversation is already active (via prior - // mention-sticky engagement or admin wiring). Gate on engageMode so a - // plain 'mention' wiring doesn't keep firing after a one-off mention. + // Four SDK dispatch paths — bridge just forwards; router does all + // per-wiring engage / accumulate / drop decisions. isMention is the + // load-bearing signal (see evaluateEngage in src/router.ts). + + // Subscribed threads — every message in a thread we've previously + // engaged. Carry the SDK's `message.isMention` through so mention-mode + // wirings still fire on in-thread mentions. chat.onSubscribedMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - const text = typeof message.text === 'string' ? message.text : ''; - const decision = engageDecision(channelId, 'subscribed', text); + const decision = engageDecision(channelId, 'subscribed'); if (!decision.forward) return; - // Subscribed path: the SDK sets message.isMention when the bot was - // @-mentioned in an already-subscribed thread (docs at - // handling-events.mdx). Forward it verbatim. await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true)); }); - // @mention in an unsubscribed thread — always engage; subscribe only - // if the wiring is 'mention-sticky'. + // @mention in an unsubscribed thread — SDK-confirmed bot mention. chat.onNewMention(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - const text = typeof message.text === 'string' ? message.text : ''; - const decision = engageDecision(channelId, 'mention', text); + const decision = engageDecision(channelId, 'mention'); if (!decision.forward) return; - // onNewMention only fires when the SDK confirms the bot was mentioned. await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); if (decision.stickySubscribe) { await thread.subscribe(); } }); - // DMs — apply engage rules too, but DMs typically default to pattern='.' - // at setup time so this is a pass-through in practice. sticky subscribe - // follows the same rule as a group mention. - // - // Thread id is passed through so sub-thread context reaches delivery - // (Slack users can open threads inside a DM). The router collapses DM - // sub-threads to one session (is_group=0 short-circuits the per-thread - // escalation). + // DMs — by definition addressed to the bot. Thread id flows through + // so sub-thread context reaches delivery (Slack users can open threads + // inside a DM). Router collapses DM sub-threads to one session via + // is_group=0 short-circuit. chat.onDirectMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - const text = typeof message.text === 'string' ? message.text : ''; - const decision = engageDecision(channelId, 'dm', text); + const decision = engageDecision(channelId, 'dm'); log.info('Inbound DM received', { adapter: adapter.name, channelId, @@ -337,35 +277,25 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter forward: decision.forward, }); if (!decision.forward) return; - // A DM is by definition addressed to the bot — treat as a mention - // for routing purposes. `mention` / `mention-sticky` wirings fire. await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); if (decision.stickySubscribe) { await thread.subscribe(); } }); - // Plain (non-mention, non-DM) messages in unsubscribed threads. + // Plain messages in unsubscribed threads. // - // Chat SDK dispatch (handling-events.mdx §"Handler dispatch order"): - // subscribed threads → onSubscribedMessage; unsubscribed + mention → - // onNewMention; unsubscribed + pattern match → onNewMessage. Dispatch - // is exclusive — at most one handler fires per message. - // - // Without this handler, `engage_mode='pattern'` is silently dropped in - // unsubscribed group threads because the SDK never surfaces the - // message anywhere else. Registering with `/./` lets every wired - // conversation's regex be evaluated in our `shouldEngage` — unknown - // conversations are dropped there (see the source='new-message' - // branch) so this doesn't flood the host on channels the bot isn't - // wired to. + // Chat SDK dispatch (handling-events.mdx §"Handler dispatch order") is + // exclusive: subscribed → onSubscribedMessage; unsubscribed+mention → + // onNewMention; unsubscribed+pattern-match → onNewMessage. Registering + // with `/./` lets the router see every plain message on wired channels + // (needed for engage_mode='pattern' + ignored_message_policy='accumulate' + // wirings). `shouldEngage` drops unknown channels on this source + // specifically so we don't flood from channels the bot merely joined. chat.onNewMessage(/./, async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - const text = typeof message.text === 'string' ? message.text : ''; - const decision = engageDecision(channelId, 'new-message', text); + const decision = engageDecision(channelId, 'new-message'); if (!decision.forward) return; - // SDK dispatch guarantees this is a non-mention non-DM message in an - // unsubscribed thread — isMention is definitively false here. await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false)); }); From 2eb6907f09396aecef375b9171550171d08d90d7 Mon Sep 17 00:00:00 2001 From: Koshkoshinski Date: Mon, 20 Apr 2026 10:42:40 +0000 Subject: [PATCH 029/185] feat(new-setup): silent CLI wiring + post-service branch point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6 (CLI agent wiring + first chat) is now invisible to the user. No prompts, no narration — just silent wiring with INFERRED_DISPLAY_NAME and a background ping. On the ping's return, emit one line: Your agent is up, running and ready to go! Step 7 becomes a branch point via AskUserQuestion: either keep chatting via CLI (prints two how-to-chat options: the `!pnpm run chat` bang method inside Claude Code, and the separate-terminal form), or continue to /new-setup-2 for the post-install flow (naming, messaging channel, QoL). The CLI agent at this stage is a scratch agent — its only job is to verify the end-to-end pipeline works. The real name capture happens in /new-setup-2 when the user wires a messaging channel. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-setup/SKILL.md | 44 +++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/.claude/skills/new-setup/SKILL.md b/.claude/skills/new-setup/SKILL.md index 6b95695..0a8cc2e 100644 --- a/.claude/skills/new-setup/SKILL.md +++ b/.claude/skills/new-setup/SKILL.md @@ -114,23 +114,51 @@ Start the NanoClaw background service — it relays messages between the user an `pnpm exec tsx setup/index.ts --step service` -### 6. First CLI agent +### 6. Wire the CLI agent and verify end-to-end + +**Do not narrate this step.** No "creating your first agent…", no "sending a ping…" chatter. The user's experience here is: they finished the last visible step (service), then a single success line appears. Wiring is an implementation detail at this point, not a user-facing milestone. If step 2's container build is still running in the background, join it here before proceeding — the agent needs the image. -Create the first agent and wire it to the CLI channel. Ask the user "What should I call you?" first — default the offered value to `INFERRED_DISPLAY_NAME` from the probe. +Use `INFERRED_DISPLAY_NAME` from the probe silently. **Do not ask the user.** The CLI agent at this stage is a scratch agent whose only purpose is to verify the end-to-end pipeline (host → container → agent → back). The user's real name capture happens in `/new-setup-2` when they wire a messaging channel. -`pnpm exec tsx setup/index.ts --step cli-agent -- --display-name ""` +Run wiring and ping back-to-back, silently: -### 7. First chat +``` +pnpm exec tsx setup/index.ts --step cli-agent -- --display-name "" +pnpm run chat ping +``` -Everything's ready — send the first message to the agent. +First container spin-up takes ~60s. When the agent's reply arrives, emit exactly one line to the user: -`pnpm run chat hi` +> Your agent is up, running and ready to go! -The agent should reply within ~60s (first container spin-up is slowest). If no reply, tail `logs/nanoclaw.log`. +If `pnpm run chat ping` times out or errors, tail `logs/nanoclaw.log`, diagnose, and fix — don't surface a half-success. -> **Loose command:** `pnpm run chat hi`. Justification: this is the command the user will keep using after setup. Hiding it behind a `--step` would force them to memorize a second way to do the same thing. +> **Loose command:** `pnpm run chat ping`. Justification: this is the same command the user will keep using after setup, so verification and the real channel are one and the same. + +### 7. Chat now, or keep setting up? + +Ask the user via `AskUserQuestion` which they'd like to do next: + +1. **Keep chatting with the agent via CLI** — happy with the CLI channel for now. +2. **Continue setup** — name the agent, wire a messaging channel, add quality-of-life extras. + +**If they pick "keep chatting":** print both options below, then stop. The user is chatting with the agent now, not with you — no further output from you. + +**Option 1 — from inside this Claude Code session.** Type your message with a leading `!`, which runs it as a bash command in this shell: + +``` +!pnpm run chat your message here +``` + +**Option 2 — from a separate terminal.** Open a new terminal, `cd` into your nanoclaw checkout, then: + +``` +pnpm run chat your message here +``` + +**If they pick "continue setup":** hand off directly to `/new-setup-2` via the Skill tool. That follow-on flow is structured like this one (linear, skippable steps) and covers naming, messaging-channel wiring, and QoL. Invoke it immediately — do not offer a menu of individual skills. ## If anything fails From 4e1cee0e5b4508b8322d5fbf3abe6855608bf99f Mon Sep 17 00:00:00 2001 From: Koshkoshinski Date: Mon, 20 Apr 2026 10:43:14 +0000 Subject: [PATCH 030/185] feat(new-setup-2): phase-2 setup skill + --no-cli-bonus flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /new-setup-2 skill, invoked when the user picks "continue setup" at the end of /new-setup. Linear rollthrough; every step skippable: 1. What should the agent call you? 2. What's your agent's name? 3. Messaging channel (plain list, no AskUserQuestion) — invokes the matching /add- skill, captures platform IDs from its output, then wires via init-first-agent.ts with --no-cli-bonus. On success, emits the encouragement line verbatim. 4. Quality-of-life picks (dashboard, compact, karpathy-wiki, plus macos-statusbar only when the probe reports PLATFORM=darwin). 5. Wrap-up. scripts/init-first-agent.ts gains a --no-cli-bonus flag. In DM mode, the bonus "wire new agent to CLI" call is skipped when set. Used by /new-setup-2 so the throwaway CLI-only agent from /new-setup retains clean single-agent ownership of CLI routing instead of being duelled by the real agent on the same channel. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-setup-2/SKILL.md | 112 ++++++++++++++++++++++++++++ scripts/init-first-agent.ts | 16 +++- 2 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/new-setup-2/SKILL.md diff --git a/.claude/skills/new-setup-2/SKILL.md b/.claude/skills/new-setup-2/SKILL.md new file mode 100644 index 0000000..869a710 --- /dev/null +++ b/.claude/skills/new-setup-2/SKILL.md @@ -0,0 +1,112 @@ +--- +name: new-setup-2 +description: Follow-on to /new-setup. Captures the operator and agent names, wires a real messaging channel, and adds quality-of-life extras. Linear rollthrough; every step is skippable. Invoked when the user picks "continue setup" at the end of /new-setup. +allowed-tools: Bash(bash setup/probe.sh) Bash(pnpm exec tsx setup/index.ts *) Bash(pnpm exec tsx scripts/init-first-agent.ts *) +--- + +# NanoClaw phase-2 setup + +Runs after `/new-setup`. At this point the host is running and a throwaway CLI-only agent exists (used during /new-setup for the end-to-end ping check — inferred name, not user-facing). This flow creates the **real** agent and wires it to a messaging channel. + +**Linear — one step at a time.** Every step is skippable: if the user says "skip", "not now", "later", or similar, move on without complaint. If they say they're done at any point, stop cleanly — don't push the remaining steps. + +Before each step, narrate in your own words what's about to happen — one short, friendly sentence, no jargon. Match the tone of `/new-setup`. + +## Current state + +!`bash setup/probe.sh` + +Parse the probe block above for `INFERRED_DISPLAY_NAME` and `PLATFORM` — referenced below. + +## Steps + +### 1. What should the agent call you? + +Plain-prose ask (do **not** use `AskUserQuestion`): + +> What should your agent call you? (Default: ``) + +Capture the answer into a local variable `OPERATOR_NAME`. **Don't persist yet** — this value is consumed by step 3's channel wiring. If the user skips or confirms the default, set `OPERATOR_NAME = INFERRED_DISPLAY_NAME`. + +### 2. What's your agent's name? + +Plain-prose ask: + +> What would you like to call your agent? (Default: ``) + +Capture as `AGENT_NAME`. If skipped, set `AGENT_NAME = OPERATOR_NAME`. Nothing persisted yet. + +### 3. Pick a messaging channel + +Print the list as plain prose. **Do not use `AskUserQuestion` for this step** — just the list, then wait for the user's reply: + +> Which messaging channel should I wire your agent to? +> +> - **WhatsApp (native)** — `/add-whatsapp` +> - **WhatsApp Cloud (Meta official)** — `/add-whatsapp-cloud` +> - **Telegram** — `/add-telegram` +> - **Slack** — `/add-slack` +> - **Discord** — `/add-discord` +> - **iMessage** — `/add-imessage` +> - **Teams** — `/add-teams` +> - **Matrix** — `/add-matrix` +> - **Google Chat** — `/add-gchat` +> - **Linear** — `/add-linear` +> - **GitHub** — `/add-github` +> - **Webex** — `/add-webex` +> - **Resend (email)** — `/add-resend` +> - **Emacs** — `/add-emacs` +> +> Or say "skip" to leave this for later. + +When the user picks one: + +1. **Install the adapter.** Invoke the matching `/add-` skill via the Skill tool. It copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels (e.g. Telegram) also run a pairing step as part of their flow. +2. **Capture platform IDs.** After the `/add-` skill finishes, you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, for example, the `pair-telegram` step emits `PLATFORM_ID` and `ADMIN_USER_ID` in a status block once the user sends the 4-digit code. +3. **Wire the agent.** Run `init-first-agent.ts` in DM mode with `--no-cli-bonus` (this keeps the new agent off the CLI messaging group so the pre-existing throwaway agent still owns CLI routing cleanly): + + ``` + pnpm exec tsx scripts/init-first-agent.ts \ + --channel \ + --user-id "" \ + --platform-id "" \ + --display-name "" \ + --agent-name "" \ + --no-cli-bonus + ``` + +4. **Announce.** On success, emit the encouragement line verbatim: + + > Your agent is now available on {channel-name}, you can already start chatting — But I encourage you to continue and finish this setup, we're almost done! + + Substitute `{channel-name}` with the friendly name (e.g. "Telegram", "WhatsApp", "Slack"). + +If the user skipped, move on to step 4. + +### 4. Quality of life + +Optional polish. Print the list; the user may pick zero, one, or several — invoke each chosen skill in sequence: + +> Want to add any of these? Pick any that sound useful — or skip: +> +> - `/add-dashboard` — browser dashboard showing agent activity +> - `/add-compact` — `/compact` slash command for managing long sessions +> - `/add-karpathy-llm-wiki` — persistent knowledge-base memory for the agent + +If the probe reports `PLATFORM=darwin`, also offer: + +> - `/add-macos-statusbar` — macOS menu bar indicator with Start/Stop/Restart controls + +Do **not** list `/add-macos-statusbar` on Linux. If the user skips everything, just move on. + +### 5. Done + +Short wrap-up: + +> Setup complete. You can chat with your agent on {channel-name} — or via CLI with `pnpm run chat `. + +Substitute `{channel-name}` with whatever was wired in step 3. If step 3 was skipped, drop the "on {channel-name} — or" clause entirely so the line just mentions the CLI form. + +## If anything fails + +Same rule as `/new-setup`: don't bypass errors to keep moving. Read `logs/setup.log` or `logs/nanoclaw.log`, diagnose, fix the underlying cause, re-run the failed step. diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index 5e828dc..8468778 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -59,6 +59,7 @@ import type { AgentGroup, MessagingGroup } from '../src/types.js'; interface Args { cliOnly: boolean; + noCliBonus: boolean; channel: string; userId: string; platformId: string; @@ -75,7 +76,7 @@ const CLI_PLATFORM_ID = 'local'; const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`; function parseArgs(argv: string[]): Args { - const out: Partial = { cliOnly: false }; + const out: Partial = { cliOnly: false, noCliBonus: false }; for (let i = 0; i < argv.length; i++) { const key = argv[i]; const val = argv[i + 1]; @@ -83,6 +84,9 @@ function parseArgs(argv: string[]): Args { case '--cli-only': out.cliOnly = true; break; + case '--no-cli-bonus': + out.noCliBonus = true; + break; case '--channel': out.channel = (val ?? '').toLowerCase(); i++; @@ -120,6 +124,7 @@ function parseArgs(argv: string[]): Args { // CLI-only: channel/user/platform default to the synthetic local CLI identity. return { cliOnly: true, + noCliBonus: out.noCliBonus ?? false, channel: CLI_CHANNEL, userId: CLI_SYNTHETIC_USER_ID, platformId: CLI_PLATFORM_ID, @@ -139,6 +144,7 @@ function parseArgs(argv: string[]): Args { return { cliOnly: false, + noCliBonus: out.noCliBonus ?? false, channel: out.channel!, userId: out.userId!, platformId: out.platformId!, @@ -292,7 +298,9 @@ async function main(): Promise { wireIfMissing(primaryMg, ag, now, args.cliOnly ? 'cli' : 'dm'); // In DM mode also wire CLI so `pnpm run chat` works immediately. - if (!args.cliOnly) { + // Skip the bonus when --no-cli-bonus is set — used by /new-setup-2 so the + // throwaway CLI-only agent from /new-setup still owns CLI routing cleanly. + if (!args.cliOnly && !args.noCliBonus) { wireIfMissing(cliMg, ag, now, 'cli-bonus'); } @@ -322,7 +330,9 @@ async function main(): Promise { console.log(` channel: cli/${CLI_PLATFORM_ID}`); } else { console.log(` channel: ${args.channel} ${primaryMg.platform_id}`); - console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``); + if (!args.noCliBonus) { + console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``); + } } console.log(` session: ${session.id}`); console.log(''); From a4061a0012d2f0785dbad222d79007339e47f018 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 13:55:49 +0300 Subject: [PATCH 031/185] refactor(channels,router): move all policy to router; bridge is transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to b159722. That shrank the bridge's shouldEngage to a flood gate + coarse sticky-subscribe signal. This completes the move — policy lives exclusively in the router, the bridge is transport-only, and the conversations map + ChannelSetup.conversations + ChannelAdapter.updateConversations are all gone. Key shifts: 1. Subscribe moves from bridge to router. Bridge used to call `thread.subscribe()` from its onNewMention / onDirectMessage handlers based on a coarse "any mention-sticky wiring exists on this channel" check. That forced the decision before the router could apply per-wiring engage logic, and it relied on the conversations map being current (staleness risk). ChannelAdapter gains `subscribe?(platformId, threadId)`. The Chat SDK bridge implements it via SqliteStateAdapter.subscribe(threadId) (idempotent — a repeat call on an already-subscribed thread is a no-op). The router's fan-out loop calls it once per message when the first mention-sticky wiring actually engages. Precise, not coarse. 2. Short-circuit the drop path with one combined query. New `getMessagingGroupWithAgentCount(channelType, platformId)` does the messaging_groups lookup AND counts wirings in a single SELECT, using the existing UNIQUE(channel_type, platform_id) index on messaging_groups and UNIQUE(messaging_group_id, agent_group_id) on messaging_group_agents for the JOIN. No new indexes needed. routeInbound now short-circuits: - No messaging_groups row AND not addressed (no mention/DM) → return silently. One DB read, nothing written. This is the Discord-bot-in-a-big-guild case; we no longer auto-create rows for every plain message in every channel the bot can see. - Messaging group exists but no wirings AND not addressed → return silently. One DB read. - Otherwise fall through to sender resolution + fan-out as before. Behavioral change: plain chatter on unwired channels no longer gets dropped_messages audit rows, which used to bloat the table. Audit still fires on addressed-to-bot drops where the admin cares ("someone @-mentioned us but nobody's wired"). 3. Bridge is now purely transport. Deleted entirely: ConversationConfig, ChannelSetup.conversations, ChannelAdapter.updateConversations?, bridge's `conversations` map, buildConversationMap, shouldEngage, EngageSource, engageDecision, bridge.updateConversations method, src/index.ts buildConversationConfigs. Four handlers reduce to "resolve channel id, build InboundMessage with isMention, call onInbound". Net ~130 LOC deleted from the bridge. Collateral: the conversations-map staleness problem is gone. The upcoming channel-registration feature doesn't need any map-refresh plumbing — when an approval creates a new wiring, the next message hits the DB fresh and just works. Bridge tests prune to the narrow platform-adjacent surface (openDM delegation, subscribe presence). Host-core test that asserted the old "auto-create on every unknown message" behavior updates to reflect the new escalation-gated semantics: plain messages on unknown channels don't auto-create, mentions do. 159 tests pass (was 172 — net -13, almost entirely from bridge-engage-mode tests that covered logic now owned by the router and exercised through host-core.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/adapter.ts | 49 +++------- src/channels/channel-registry.test.ts | 2 - src/channels/chat-sdk-bridge.test.ts | 106 +++------------------ src/channels/chat-sdk-bridge.ts | 130 ++++---------------------- src/db/messaging-groups.ts | 31 ++++++ src/host-core.test.ts | 36 +++++-- src/index.ts | 27 +----- src/router.ts | 101 ++++++++++++++------ 8 files changed, 173 insertions(+), 309 deletions(-) diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index bbf7f37..9343258 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -5,45 +5,8 @@ * Two patterns: native adapters (implement directly) or Chat SDK bridge (wrap a Chat SDK adapter). */ -/** Configuration for a registered conversation (messaging group + agent wiring). */ -export interface ConversationConfig { - platformId: string; - agentGroupId: string; - /** - * When does the agent engage on messages from this conversation? - * - * 'pattern' — regex test against message text; engagePattern='.' - * means "always" (match everything) - * 'mention' — fires only on @mention - * 'mention-sticky' — fires on @mention, then auto-subscribes to the thread - * and treats subsequent messages as engage-all. - * Threaded platforms only (Slack/Discord/Linear). - */ - engageMode: 'pattern' | 'mention' | 'mention-sticky'; - /** Regex source when engageMode='pattern'. '.' is the "always" sentinel. */ - engagePattern?: string | null; - /** - * What to do with messages this wiring doesn't engage on. - * - * 'drop' — discard silently - * 'accumulate' — still forward to the host so the router can store the - * message in this agent's session with trigger=0. It - * rides along as context when the agent next wakes, but - * doesn't wake it on its own. - * - * The bridge reads this to decide whether to forward a non-engaging - * message at all — if any wiring on a conversation has 'accumulate', the - * bridge forwards and lets the router apply the per-wiring decision. - */ - ignoredMessagePolicy?: 'drop' | 'accumulate'; - sessionMode: 'shared' | 'per-thread' | 'agent-shared'; -} - /** Passed to the adapter at setup time. */ export interface ChannelSetup { - /** Known conversations from central DB. */ - conversations: ConversationConfig[]; - /** Called when an inbound message arrives from the platform. */ onInbound(platformId: string, threadId: string | null, message: InboundMessage): void | Promise; @@ -125,7 +88,17 @@ export interface ChannelAdapter { // Optional setTyping?(platformId: string, threadId: string | null): Promise; syncConversations?(): Promise; - updateConversations?(conversations: ConversationConfig[]): void; + + /** + * Subscribe the bot to a thread so follow-up messages route via the + * platform's "subscribed message" path (onSubscribedMessage in Chat SDK). + * Called by the router when a mention-sticky wiring first engages in a + * thread. Idempotent: calling twice on the same thread is a no-op. + * + * Platforms without a subscription concept can omit this; the router + * treats absence as a no-op. + */ + subscribe?(platformId: string, threadId: string): Promise; /** * Open (or fetch) a DM with this user, returning the platform_id of the diff --git a/src/channels/channel-registry.test.ts b/src/channels/channel-registry.test.ts index 265a372..5121c64 100644 --- a/src/channels/channel-registry.test.ts +++ b/src/channels/channel-registry.test.ts @@ -64,8 +64,6 @@ function createMockAdapter( }, async setTyping() {}, - - updateConversations() {}, }; } diff --git a/src/channels/chat-sdk-bridge.test.ts b/src/channels/chat-sdk-bridge.test.ts index 3c6caa8..7ddad4f 100644 --- a/src/channels/chat-sdk-bridge.test.ts +++ b/src/channels/chat-sdk-bridge.test.ts @@ -2,37 +2,19 @@ import { describe, expect, it } from 'vitest'; import type { Adapter } from 'chat'; -import type { ConversationConfig } from './adapter.js'; -import { createChatSdkBridge, shouldEngage, type EngageSource } from './chat-sdk-bridge.js'; +import { createChatSdkBridge } from './chat-sdk-bridge.js'; function stubAdapter(partial: Partial): Adapter { return { name: 'stub', ...partial } as unknown as Adapter; } -function cfg( - partial: Partial & { engageMode: ConversationConfig['engageMode'] }, -): ConversationConfig { - return { - platformId: partial.platformId ?? 'C1', - agentGroupId: partial.agentGroupId ?? 'ag-1', - engageMode: partial.engageMode, - engagePattern: partial.engagePattern ?? null, - ignoredMessagePolicy: partial.ignoredMessagePolicy ?? 'drop', - sessionMode: partial.sessionMode ?? 'shared', - }; -} - -function mapFor(...configs: ConversationConfig[]): Map { - const map = new Map(); - for (const c of configs) { - const existing = map.get(c.platformId); - if (existing) existing.push(c); - else map.set(c.platformId, [c]); - } - return map; -} - describe('createChatSdkBridge', () => { + // The bridge is now transport-only: forward inbound events, relay outbound + // ops. All per-wiring engage / accumulate / drop / subscribe decisions live + // in the router (src/router.ts routeInbound / evaluateEngage) and are + // exercised by host-core.test.ts end-to-end. These tests only cover the + // bridge's narrow, platform-adjacent surface. + it('omits openDM when the underlying Chat SDK adapter has none', () => { const bridge = createChatSdkBridge({ adapter: stubAdapter({}), @@ -59,76 +41,12 @@ describe('createChatSdkBridge', () => { expect(openDMCalls).toEqual(['user-42']); expect(platformId).toBe('stub:user-42'); }); -}); -describe('shouldEngage (bridge-level flood gate + subscribe signal)', () => { - // Per-wiring engage_mode / engage_pattern / ignored_message_policy - // semantics live in the router (evaluateEngage / routeInbound fan-out). - // These tests only cover the bridge's two responsibilities: should we - // forward at all, and should we call thread.subscribe(). - - describe('flood gate — unknown conversation', () => { - const empty = new Map(); - const carriedSources: EngageSource[] = ['subscribed', 'mention', 'dm']; - for (const source of carriedSources) { - it(`forwards for source='${source}' (may be a newly-auto-created channel or a channel-registration trigger)`, () => { - expect(shouldEngage(empty, 'C-new', source)).toEqual({ forward: true, stickySubscribe: false }); - }); - } - it("DROPS for source='new-message' (onNewMessage(/./) fires for every unsubscribed thread the bot can see — would flood)", () => { - expect(shouldEngage(empty, 'C-unwired', 'new-message')).toEqual({ - forward: false, - stickySubscribe: false, - }); - }); - }); - - describe('known conversation — bridge forwards regardless of engage mode', () => { - // Policy lives in the router now. The bridge only knows "has any wiring". - const conv = mapFor(cfg({ engageMode: 'mention' })); - for (const source of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { - it(`forwards for source='${source}' — router will decide engage / accumulate / drop per wiring`, () => { - expect(shouldEngage(conv, 'C1', source).forward).toBe(true); - }); - } - }); - - describe('stickySubscribe signal', () => { - it('true when any mention-sticky wiring exists AND source is mention', () => { - const conv = mapFor(cfg({ engageMode: 'mention-sticky' })); - expect(shouldEngage(conv, 'C1', 'mention').stickySubscribe).toBe(true); - }); - - it('true when any mention-sticky wiring exists AND source is dm', () => { - const conv = mapFor(cfg({ engageMode: 'mention-sticky' })); - expect(shouldEngage(conv, 'C1', 'dm').stickySubscribe).toBe(true); - }); - - it('false on subscribed — thread is already subscribed, no need to re-subscribe', () => { - const conv = mapFor(cfg({ engageMode: 'mention-sticky' })); - expect(shouldEngage(conv, 'C1', 'subscribed').stickySubscribe).toBe(false); - }); - - it('false on new-message — mention-sticky requires an explicit mention to start', () => { - const conv = mapFor(cfg({ engageMode: 'mention-sticky' })); - expect(shouldEngage(conv, 'C1', 'new-message').stickySubscribe).toBe(false); - }); - - it('false for plain mention / pattern wirings (not sticky)', () => { - const mentionConv = mapFor(cfg({ engageMode: 'mention' })); - const patternConv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '.' })); - for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) { - expect(shouldEngage(mentionConv, 'C1', s).stickySubscribe).toBe(false); - expect(shouldEngage(patternConv, 'C1', s).stickySubscribe).toBe(false); - } - }); - - it('fires on coarse union — mixed wirings where any one is mention-sticky', () => { - const conv = mapFor( - cfg({ agentGroupId: 'ag-a', engageMode: 'mention' }), - cfg({ agentGroupId: 'ag-b', engageMode: 'mention-sticky' }), - ); - expect(shouldEngage(conv, 'C1', 'mention').stickySubscribe).toBe(true); + it('exposes subscribe (lets the router initiate thread subscription on mention-sticky engage)', () => { + const bridge = createChatSdkBridge({ + adapter: stubAdapter({}), + supportsThreads: true, }); + expect(typeof bridge.subscribe).toBe('function'); }); }); diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 9bed968..ef2195e 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -21,7 +21,7 @@ import { SqliteStateAdapter } from '../state-sqlite.js'; import { registerWebhookAdapter } from '../webhook-server.js'; import { getAskQuestionRender } from '../db/sessions.js'; import { normalizeOptions, type NormalizedOption } from './ask-question.js'; -import type { ChannelAdapter, ChannelSetup, ConversationConfig, InboundMessage } from './adapter.js'; +import type { ChannelAdapter, ChannelSetup, InboundMessage } from './adapter.js'; /** Adapter with optional gateway support (e.g., Discord). */ interface GatewayAdapter extends Adapter { @@ -65,99 +65,14 @@ export interface ChatSdkBridgeConfig { transformOutboundText?: (text: string) => string; } -/** - * Which Chat SDK handler delivered this message. Determines which engage modes - * can fire. - * - * - `subscribed` — `onSubscribedMessage`. Thread is already subscribed. - * Every wiring mode (mention / mention-sticky / pattern) - * evaluates normally. - * - `mention` — `onNewMention`. Bot was @-mentioned in an unsubscribed - * thread. mention + mention-sticky engage; pattern runs - * the regex. - * - `dm` — `onDirectMessage`. Unsubscribed DM. Treated like a - * mention for engagement purposes. - * - `new-message` — `onNewMessage(/./, …)`. Plain non-mention non-DM - * message in an unsubscribed thread. Only `pattern` - * wirings can fire here. mention / mention-sticky ignore - * this source (they require an explicit mention). - */ -export type EngageSource = 'subscribed' | 'mention' | 'dm' | 'new-message'; - -/** - * Bridge-level forwarding decision — a coarse flood gate, not policy. - * - * The router owns per-wiring engage_mode / engage_pattern / sender_scope / - * ignored_message_policy (see `evaluateEngage` in src/router.ts). The bridge - * only answers two questions: - * - * 1. `forward` — is this message worth sending to the host at all? - * - Known channel (any wiring): yes. Router will decide what engages / - * accumulates / drops per wiring. - * - Unknown channel: yes for subscribed / mention / DM (triggers the - * router's auto-create or channel-registration flow); no for - * `new-message`. onNewMessage(/./, …) fires for every message in - * every unsubscribed thread the bot can see, including channels the - * bot merely joined but was never wired to — forwarding everything - * would flood the host. - * - * 2. `stickySubscribe` — should the bridge call `thread.subscribe()`? - * - Yes if ANY wiring on this channel is mention-sticky AND the - * source is an actual mention / DM. Coarse (no per-wiring picking) - * but harmless: subscription is idempotent and one call serves - * every mention-sticky wiring on the channel. Once subscribed, - * follow-ups route through onSubscribedMessage. - * - * Exported for testability — see `chat-sdk-bridge.test.ts`. - */ -export function shouldEngage( - conversations: Map, - channelId: string, - source: EngageSource, -): { forward: boolean; stickySubscribe: boolean } { - const configs = conversations.get(channelId); - - if (!configs || configs.length === 0) { - return { forward: source !== 'new-message', stickySubscribe: false }; - } - - const stickySubscribe = - (source === 'mention' || source === 'dm') && configs.some((cfg) => cfg.engageMode === 'mention-sticky'); - - return { forward: true, stickySubscribe }; -} - export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter { const { adapter } = config; const transformText = (t: string): string => (config.transformOutboundText ? config.transformOutboundText(t) : t); let chat: Chat; let state: SqliteStateAdapter; let setupConfig: ChannelSetup; - // Keyed by platformId. Multiple agents may be wired to the same - // conversation — this holds all their configs so the bridge can apply the - // most-permissive engage rule at gate time and only subscribe when at - // least one wiring requested 'mention-sticky'. - // - // STALENESS: populated at setup() and updateConversations(). If wirings - // change after setup, updateConversations() must be called to refresh - // (ACTION-ITEMS item 17). - let conversations: Map; let gatewayAbort: AbortController | null = null; - function buildConversationMap(configs: ConversationConfig[]): Map { - const map = new Map(); - for (const conv of configs) { - const existing = map.get(conv.platformId); - if (existing) existing.push(conv); - else map.set(conv.platformId, [conv]); - } - return map; - } - - function engageDecision(channelId: string, source: EngageSource): { forward: boolean; stickySubscribe: boolean } { - return shouldEngage(conversations, channelId, source); - } - async function messageToInbound(message: ChatMessage, isMention: boolean): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const serialized = message.toJSON() as Record; @@ -225,7 +140,6 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter async setup(hostConfig: ChannelSetup) { setupConfig = hostConfig; - conversations = buildConversationMap(hostConfig.conversations); state = new SqliteStateAdapter(); @@ -237,29 +151,25 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter logger: 'silent', }); - // Four SDK dispatch paths — bridge just forwards; router does all - // per-wiring engage / accumulate / drop decisions. isMention is the - // load-bearing signal (see evaluateEngage in src/router.ts). + // Four SDK dispatch paths — bridge just forwards. All per-wiring + // engage / accumulate / drop / subscribe decisions live in the host + // router (src/router.ts routeInbound / evaluateEngage). The bridge + // only resolves channel ids and sets the platform-confirmed isMention + // flag that routeInbound evaluates; the router calls back into + // bridge.subscribe(...) when a mention-sticky wiring engages. // Subscribed threads — every message in a thread we've previously // engaged. Carry the SDK's `message.isMention` through so mention-mode // wirings still fire on in-thread mentions. chat.onSubscribedMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - const decision = engageDecision(channelId, 'subscribed'); - if (!decision.forward) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true)); }); // @mention in an unsubscribed thread — SDK-confirmed bot mention. chat.onNewMention(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - const decision = engageDecision(channelId, 'mention'); - if (!decision.forward) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); - if (decision.stickySubscribe) { - await thread.subscribe(); - } }); // DMs — by definition addressed to the bot. Thread id flows through @@ -268,19 +178,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // is_group=0 short-circuit. chat.onDirectMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - const decision = engageDecision(channelId, 'dm'); log.info('Inbound DM received', { adapter: adapter.name, channelId, sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown', threadId: thread.id, - forward: decision.forward, }); - if (!decision.forward) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); - if (decision.stickySubscribe) { - await thread.subscribe(); - } }); // Plain messages in unsubscribed threads. @@ -288,14 +192,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // Chat SDK dispatch (handling-events.mdx §"Handler dispatch order") is // exclusive: subscribed → onSubscribedMessage; unsubscribed+mention → // onNewMention; unsubscribed+pattern-match → onNewMessage. Registering - // with `/./` lets the router see every plain message on wired channels - // (needed for engage_mode='pattern' + ignored_message_policy='accumulate' - // wirings). `shouldEngage` drops unknown channels on this source - // specifically so we don't flood from channels the bot merely joined. + // with `/./` lets the router see every plain message on every + // unsubscribed thread the bot can see. The router short-circuits via + // getMessagingGroupWithAgentCount (~1 DB read) for unwired channels, + // so forwarding every one is cheap enough to not need a bridge-side + // flood gate. chat.onNewMessage(/./, async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - const decision = engageDecision(channelId, 'new-message'); - if (!decision.forward) return; await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false)); }); @@ -468,8 +371,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter return true; }, - updateConversations(configs: ConversationConfig[]) { - conversations = buildConversationMap(configs); + async subscribe(_platformId: string, threadId: string) { + // Chat SDK's subscription state lives on the StateAdapter (not on the + // Chat instance itself). SqliteStateAdapter.subscribe is idempotent — + // a second call on an already-subscribed thread is a no-op. threadId + // is the SDK's thread id, which is what the router already has from + // the original inbound event. + await state.subscribe(threadId); }, }; diff --git a/src/db/messaging-groups.ts b/src/db/messaging-groups.ts index db12583..3f9b2c4 100644 --- a/src/db/messaging-groups.ts +++ b/src/db/messaging-groups.ts @@ -37,6 +37,37 @@ export function getMessagingGroupByPlatform(channelType: string, platformId: str .get(channelType, platformId) as MessagingGroup | undefined; } +/** + * Combined lookup for the router's fast-drop path. Returns the messaging + * group (if it exists) and a count of wired agents in one query — lets + * `routeInbound` short-circuit messages for unwired / unknown channels + * with a single DB read instead of four (mg lookup, sender upsert, agents + * lookup, dropped_messages insert). + * + * Returns `null` when no messaging_groups row exists for this channel. + * Returns `{ mg, agentCount: 0 }` when the row exists but has no wired + * agents. Uses the `UNIQUE(channel_type, platform_id)` index plus the + * `UNIQUE(messaging_group_id, agent_group_id)` index for the JOIN — both + * covered by existing SQLite auto-indexes from the UNIQUE constraints. + */ +export function getMessagingGroupWithAgentCount( + channelType: string, + platformId: string, +): { mg: MessagingGroup; agentCount: number } | null { + const row = getDb() + .prepare( + `SELECT mg.*, COUNT(mga.id) AS agent_count + FROM messaging_groups mg + LEFT JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id + WHERE mg.channel_type = ? AND mg.platform_id = ? + GROUP BY mg.id`, + ) + .get(channelType, platformId) as (MessagingGroup & { agent_count: number }) | undefined; + if (!row) return null; + const { agent_count, ...mg } = row; + return { mg: mg as MessagingGroup, agentCount: agent_count }; +} + export function getAllMessagingGroups(): MessagingGroup[] { return getDb().prepare('SELECT * FROM messaging_groups ORDER BY name').all() as MessagingGroup[]; } diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 33d37ff..da2fd37 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -244,26 +244,42 @@ describe('router', () => { expect(wakeContainer).toHaveBeenCalled(); }); - it('should auto-create messaging group for unknown platform', async () => { + it('auto-creates messaging group only when the bot is addressed (mention/DM)', async () => { + // The router's no-mg branch is escalation-gated: plain chatter on an + // unknown channel stays silent (no DB writes) so a bot that sits in + // many unwired channels doesn't bloat messaging_groups. Only explicit + // mentions and DMs trigger auto-create. const { routeInbound } = await import('./router.js'); + const { getMessagingGroupByPlatform } = await import('./db/messaging-groups.js'); - const event: InboundEvent = { + // Plain message on unknown channel — should NOT auto-create. + await routeInbound({ channelType: 'slack', - platformId: 'C-NEW-CHANNEL', + platformId: 'C-PLAIN', threadId: null, message: { - id: 'msg-2', + id: 'msg-plain', kind: 'chat', content: JSON.stringify({ sender: 'User', text: 'Hi' }), timestamp: now(), }, - }; + }); + expect(getMessagingGroupByPlatform('slack', 'C-PLAIN')).toBeUndefined(); - await routeInbound(event); - - const { getMessagingGroupByPlatform } = await import('./db/messaging-groups.js'); - const mg = getMessagingGroupByPlatform('slack', 'C-NEW-CHANNEL'); - expect(mg).toBeDefined(); + // Mention on unknown channel — SHOULD auto-create (next step: channel-registration flow). + await routeInbound({ + channelType: 'slack', + platformId: 'C-MENTIONED', + threadId: null, + message: { + id: 'msg-mentioned', + kind: 'chat', + content: JSON.stringify({ sender: 'User', text: '@bot hi' }), + timestamp: now(), + isMention: true, + }, + }); + expect(getMessagingGroupByPlatform('slack', 'C-MENTIONED')).toBeDefined(); }); it('should route multiple messages to the same session', async () => { diff --git a/src/index.ts b/src/index.ts index 595ba1b..7c5ab24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,6 @@ import path from 'path'; import { DATA_DIR } from './config.js'; import { initDb } from './db/connection.js'; import { runMigrations } from './db/migrations/index.js'; -import { getMessagingGroupsByChannel, getMessagingGroupAgents } from './db/messaging-groups.js'; import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js'; import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js'; import { startHostSweep, stopHostSweep } from './host-sweep.js'; @@ -52,7 +51,7 @@ import './channels/index.js'; // append registry-based modules. Imported for side effects (registrations). import './modules/index.js'; -import type { ChannelAdapter, ChannelSetup, ConversationConfig } from './channels/adapter.js'; +import type { ChannelAdapter, ChannelSetup } from './channels/adapter.js'; import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js'; async function main(): Promise { @@ -70,9 +69,7 @@ async function main(): Promise { // 3. Channel adapters await initChannelAdapters((adapter: ChannelAdapter): ChannelSetup => { - const conversations = buildConversationConfigs(adapter.channelType); return { - conversations, onInbound(platformId, threadId, message) { routeInbound({ channelType: adapter.channelType, @@ -151,28 +148,6 @@ async function main(): Promise { log.info('NanoClaw running'); } -/** Build ConversationConfig[] for a channel type from the central DB. */ -function buildConversationConfigs(channelType: string): ConversationConfig[] { - const groups = getMessagingGroupsByChannel(channelType); - const configs: ConversationConfig[] = []; - - for (const mg of groups) { - const agents = getMessagingGroupAgents(mg.id); - for (const agent of agents) { - configs.push({ - platformId: mg.platform_id, - agentGroupId: agent.agent_group_id, - engageMode: agent.engage_mode, - engagePattern: agent.engage_pattern, - ignoredMessagePolicy: agent.ignored_message_policy, - sessionMode: agent.session_mode, - }); - } - } - - return configs; -} - /** Graceful shutdown. */ async function shutdown(signal: string): Promise { log.info('Shutdown signal received', { signal }); diff --git a/src/router.ts b/src/router.ts index a3e8f06..1d819c0 100644 --- a/src/router.ts +++ b/src/router.ts @@ -20,7 +20,11 @@ import { getChannelAdapter } from './channels/channel-registry.js'; import { getAgentGroup } from './db/agent-groups.js'; import { recordDroppedMessage } from './db/dropped-messages.js'; -import { getMessagingGroupByPlatform, createMessagingGroup, getMessagingGroupAgents } from './db/messaging-groups.js'; +import { + createMessagingGroup, + getMessagingGroupAgents, + getMessagingGroupWithAgentCount, +} from './db/messaging-groups.js'; import { findSessionForAgent } from './db/sessions.js'; import { startTypingRefresh } from './modules/typing/index.js'; import { log } from './log.js'; @@ -143,10 +147,21 @@ export async function routeInbound(event: InboundEvent): Promise { event = { ...event, threadId: null }; } - // 1. Resolve messaging group - let mg = getMessagingGroupByPlatform(event.channelType, event.platformId); + const isMention = event.message.isMention === true; + + // 1. Combined lookup: messaging_group row + count of wired agents in a + // single query. Cheap short-circuit for the common "unwired channel" + // case — one DB read and we're out, no auto-create, no sender + // resolution, no log spam. + const found = getMessagingGroupWithAgentCount(event.channelType, event.platformId); + + let mg: MessagingGroup; + if (!found) { + // No messaging_groups row. Auto-create only when the message warrants + // attention (the bot was addressed — @mention or DM). Plain chatter in + // channels we merely sit in stays silent — no row, no DB writes. + if (!isMention) return; - if (!mg) { const mgId = `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; mg = { id: mgId, @@ -154,9 +169,6 @@ export async function routeInbound(event: InboundEvent): Promise { platform_id: event.platformId, name: null, is_group: 0, - // Let the schema default (currently 'request_approval') apply rather - // than hardcoding 'strict' — the schema is the source of truth for - // the default policy. See migration 011. unknown_sender_policy: 'request_approval', created_at: new Date().toISOString(), }; @@ -166,6 +178,30 @@ export async function routeInbound(event: InboundEvent): Promise { channelType: event.channelType, platformId: event.platformId, }); + } else { + mg = found.mg; + if (found.agentCount === 0) { + // Messaging group exists but has no wirings. Stay silent for plain + // messages; only log + record on explicit mention/DM so admins can + // see that someone tried to reach the bot on an unwired channel. + if (!isMention) return; + log.warn('MESSAGE DROPPED — no agent groups wired to this channel. Run setup register step to configure.', { + messagingGroupId: mg.id, + channelType: event.channelType, + platformId: event.platformId, + }); + const parsed = safeParseContent(event.message.content); + recordDroppedMessage({ + channel_type: event.channelType, + platform_id: event.platformId, + user_id: null, + sender_name: parsed.sender ?? null, + reason: 'no_agent_wired', + messaging_group_id: mg.id, + agent_group_id: null, + }); + return; + } } // 2. Sender resolution (permissions module upserts the users row as a @@ -173,27 +209,9 @@ export async function routeInbound(event: InboundEvent): Promise { // Without the module, userId is null — downstream tolerates it. const userId: string | null = senderResolver ? senderResolver(event) : null; - // 3. Resolve agent groups wired to this messaging group. Structural - // drops record to dropped_messages for audit. + // 3. Fetch wired agents in full (we already know the count is > 0; now + // we need their actual rows for fan-out). const agents = getMessagingGroupAgents(mg.id); - if (agents.length === 0) { - log.warn('MESSAGE DROPPED — no agent groups wired to this channel. Run setup register step to configure.', { - messagingGroupId: mg.id, - channelType: event.channelType, - platformId: event.platformId, - }); - const parsed = safeParseContent(event.message.content); - recordDroppedMessage({ - channel_type: event.channelType, - platform_id: event.platformId, - user_id: userId, - sender_name: parsed.sender ?? null, - reason: 'no_agent_wired', - messaging_group_id: mg.id, - agent_group_id: null, - }); - return; - } // 4. Fan-out: evaluate each wired agent independently against engage_mode, // sender_scope, and access gate. An agent that engages gets its own @@ -201,12 +219,18 @@ export async function routeInbound(event: InboundEvent): Promise { // ignored_message_policy='accumulate' still gets the message stored in // its session (trigger=0) so the context is available when it does // engage later. Drop policy = skip silently. + // + // Subscribe (for mention-sticky wirings on threaded platforms) fires + // once per message from this loop — the first engaging mention-sticky + // wiring triggers adapter.subscribe(...); subsequent wirings don't + // re-subscribe (chat.subscribe is idempotent anyway, but the flag + // avoids the extra await). const parsed = safeParseContent(event.message.content); const messageText = parsed.text ?? ''; - const isMention = event.message.isMention === true; let engagedCount = 0; let accumulatedCount = 0; + let subscribed = false; for (const agent of agents) { const agentGroup = getAgentGroup(agent.agent_group_id); @@ -220,6 +244,27 @@ export async function routeInbound(event: InboundEvent): Promise { if (engages && accessOk && scopeOk) { await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, true); engagedCount++; + + // Mention-sticky: ask the adapter to subscribe the thread so the + // platform's subscribed-message path carries follow-ups without + // requiring another @mention. Threaded-adapter only; DMs and + // non-threaded platforms skip. + if ( + !subscribed && + agent.engage_mode === 'mention-sticky' && + adapter?.supportsThreads && + adapter.subscribe && + event.threadId !== null && + mg.is_group !== 0 + ) { + subscribed = true; + // Fire-and-forget — subscribe is platform-side bookkeeping and + // shouldn't block message routing. Errors are logged inside the + // adapter (or by the promise rejection handler below). + void adapter.subscribe(event.platformId, event.threadId).catch((err) => { + log.warn('adapter.subscribe failed', { channelType: event.channelType, threadId: event.threadId, err }); + }); + } } else if (agent.ignored_message_policy === 'accumulate') { await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false); accumulatedCount++; From 5f8a1388687af3ee098185d9ad44c1d234ea775d Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Mon, 20 Apr 2026 10:59:55 +0000 Subject: [PATCH 032/185] docs(skills): update add-matrix skill - Install steps aligned with add-linear/add-github pattern (fetch, copy, import, install pkg, build) - Add Matrix-specific step: patch @beeper/chat-adapter-matrix ESM imports (inline node one-liner, idempotent) - Cover bot-account requirement (can't DM yourself), access-token and username/password auth paths, optional invite auto-join / E2EE / device-id settings --- .claude/skills/add-matrix/SKILL.md | 85 +++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/.claude/skills/add-matrix/SKILL.md b/.claude/skills/add-matrix/SKILL.md index 7a6ea0f..cf6da75 100644 --- a/.claude/skills/add-matrix/SKILL.md +++ b/.claude/skills/add-matrix/SKILL.md @@ -47,7 +47,29 @@ import './matrix.js'; pnpm install @beeper/chat-adapter-matrix@0.2.0 ``` -### 5. Build +### 5. Patch matrix-js-sdk ESM imports + +The adapter's published dist references `matrix-js-sdk/lib/...` without `.js` +extensions, which fails under Node 22 strict ESM resolution. Add the missing +extensions (idempotent — safe to re-run): + +```bash +node -e ' + const fs = require("fs"), path = require("path"); + const root = "node_modules/.pnpm"; + const dir = fs.readdirSync(root).find(d => d.startsWith("@beeper+chat-adapter-matrix@")); + if (!dir) { console.log("Matrix adapter not installed"); process.exit(0); } + const f = path.join(root, dir, "node_modules/@beeper/chat-adapter-matrix/dist/index.js"); + fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace( + /from "(matrix-js-sdk\/lib\/[^"]+?)(? **Help & About** > **Access Token** (advanced) - - Or via API: `curl -XPOST 'https://matrix.org/_matrix/client/r0/login' -d '{"type":"m.login.password","user":"botuser","password":"..."}'` -4. Note the bot's user ID (e.g., `@botuser:matrix.org`) +The bot needs its own Matrix account — separate from the user's account. This is required because Matrix cannot send DMs to yourself. -### Configure environment +### Create a bot account -Add to `.env`: +1. Open [app.element.io](https://app.element.io) in a private/incognito window (or sign out first) +2. Register a new account for the bot (e.g. `andybot` on matrix.org) +3. Note the bot's user ID (e.g. `@andybot:matrix.org`) + +### Choose an auth method + +**Option A: Username + Password (simpler)** + +No extra steps — just use the bot account's credentials directly. The adapter logs in automatically. + +```bash +MATRIX_BASE_URL=https://matrix.org +MATRIX_USERNAME=andybot +MATRIX_PASSWORD=your-bot-password +MATRIX_USER_ID=@andybot:matrix.org +MATRIX_BOT_USERNAME=Andy +``` + +**Option B: Access Token (recommended for production)** + +Get an access token from Element: sign into the bot account → **Settings** > **Help & About** > **Access Token** (under Advanced). Or via API: + +```bash +curl -XPOST 'https://matrix.org/_matrix/client/r0/login' \ + -d '{"type":"m.login.password","user":"andybot","password":"..."}' +``` ```bash MATRIX_BASE_URL=https://matrix.org MATRIX_ACCESS_TOKEN=your-access-token -MATRIX_USER_ID=@botuser:matrix.org -MATRIX_BOT_USERNAME=botuser +MATRIX_USER_ID=@andybot:matrix.org +MATRIX_BOT_USERNAME=Andy ``` -Sync to container: `mkdir -p data/env && cp .env data/env/env` +### Optional settings + +```bash +MATRIX_INVITE_AUTOJOIN=true # Auto-accept room invites (default: true) +MATRIX_INVITE_AUTOJOIN_ALLOWLIST=@you:matrix.org # Only accept invites from these users +MATRIX_RECOVERY_KEY=your-recovery-key # Enable E2EE cross-signing +MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts +``` + +### Configure environment + +Add the chosen env vars to `.env`, then sync: + +```bash +mkdir -p data/env && cp .env data/env/env +``` ## Next Steps @@ -85,7 +142,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group. - **type**: `matrix` - **terminology**: Matrix has "rooms." A room can be a group chat or a direct message. Rooms have internal IDs (like `!abc123:matrix.org`) and optional aliases (like `#general:matrix.org`). -- **how-to-find-id**: In Element, click the room name > Settings > Advanced — the "Internal room ID" is the platform ID (starts with `!`). Or use a room alias like `#general:matrix.org`. +- **how-to-find-id**: For DMs, use the bot's `openDM` to resolve the room automatically. For group rooms, in Element click the room name > Settings > Advanced — the "Internal room ID" is the platform ID (starts with `!`). Or use a room alias like `#general:matrix.org`. - **supports-threads**: partial (some clients support threads, but not all — treat as no for reliability) -- **typical-use**: Interactive chat — rooms or direct messages +- **typical-use**: Interactive chat — rooms or direct messages. Requires a separate bot account (the agent cannot DM users from their own account). - **default-isolation**: Same agent group for rooms where you're the primary user. Separate agent group for rooms with different communities or sensitive contexts. From 719f97e48368698c920cf7cf61ea9e93f081fd61 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 14:34:00 +0300 Subject: [PATCH 033/185] feat(permissions): unknown-channel registration flow with owner approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the router sees a mention or DM on a messaging group that isn't wired to any agent, it now escalates to an owner for approval instead of silently dropping. Mirrors the existing unknown-sender approval pattern (ACTION-ITEMS item 22). Schema (migration 012): - `messaging_groups.denied_at TEXT NULL` — timestamp set on deny so future mentions stop escalating. ALTER TABLE ADD COLUMN, FK-safe (unlike the rebuild that bit migration 011). - `pending_channel_approvals` — PK on `messaging_group_id` gives free in-flight dedup. One card per channel, no spam on rapid retries. Router: - New hook `setChannelRequestGate(mg, event) => Promise`, invoked from the no-wirings branch when the message was addressed to the bot (isMention=true). Hook is fire-and-forget. - Checks `mg.denied_at` before escalating — denied channels drop silently and do not re-prompt. - The two "no-wirings" branches (fresh auto-create and existing mg with no agents) are consolidated into one escalation path that calls the gate once. Without the module, behavior is log + record (no regression). Permissions module: - `channel-approval.ts::requestChannelApproval` — MVP picker: target agent is `getAllAgentGroups()[0]`, card names it explicitly ("Wire it to ?"). Approver via existing `pickApprover` + `pickApprovalDelivery` primitives. - Response handler: same click-auth pattern as sender-approval (clicker must be the designated approver OR have admin privilege over the target agent group). - Approve defaults per the feature spec: engage_mode = 'mention-sticky' for groups, 'pattern' + '.' for DMs sender_scope = 'known' ignored_message_policy = 'accumulate' session_mode = 'shared' DM vs group inferred from the original event's threadId (non-null → group) because the auto-created mg has a placeholder is_group=0 until the adapter fills it in. - Triggering sender is auto-added to agent_group_members so sender_scope= 'known' doesn't bounce the replayed message into a sender-approval cascade. - Deny: stamps messaging_groups.denied_at, clears pending row. - Failure modes — no owner, no agent groups, no reachable DM — log and drop without creating a pending row, letting a future attempt try again (same as sender-approval). 9 new integration tests cover every branch: mention triggers card, DM triggers card, dedup, approve creates correct wiring + admits sender + replays, approve-on-DM uses pattern/'.' defaults, deny sets denied_at and future mentions drop silently, unauthorized clicker rejected, no-owner drops, no-agent-groups drops. 168 tests pass (was 159; +9). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/db/messaging-groups.ts | 14 + src/db/migrations/012-channel-registration.ts | 48 +++ src/db/migrations/index.ts | 2 + .../permissions/channel-approval.test.ts | 392 ++++++++++++++++++ src/modules/permissions/channel-approval.ts | 159 +++++++ .../db/pending-channel-approvals.ts | 52 +++ src/modules/permissions/index.ts | 144 +++++++ src/router.ts | 79 +++- src/types.ts | 10 + 9 files changed, 882 insertions(+), 18 deletions(-) create mode 100644 src/db/migrations/012-channel-registration.ts create mode 100644 src/modules/permissions/channel-approval.test.ts create mode 100644 src/modules/permissions/channel-approval.ts create mode 100644 src/modules/permissions/db/pending-channel-approvals.ts diff --git a/src/db/messaging-groups.ts b/src/db/messaging-groups.ts index 3f9b2c4..33c8715 100644 --- a/src/db/messaging-groups.ts +++ b/src/db/messaging-groups.ts @@ -100,6 +100,20 @@ export function deleteMessagingGroup(id: string): void { getDb().prepare('DELETE FROM messaging_groups WHERE id = ?').run(id); } +/** + * Mark a messaging group as denied by the owner (channel-registration flow). + * Future mentions on this channel silently drop until an admin explicitly + * wires it via `createMessagingGroupAgent`, which implicitly clears the + * denied state by making `agentCount > 0` — the router's denied-channel + * check sits on the `agentCount === 0` branch. + * + * Passing null unsets the flag (used by tests or a future "unblock channel" + * admin command). + */ +export function setMessagingGroupDeniedAt(id: string, deniedAt: string | null): void { + getDb().prepare('UPDATE messaging_groups SET denied_at = ? WHERE id = ?').run(deniedAt, id); +} + // ── Messaging Group Agents ── /** diff --git a/src/db/migrations/012-channel-registration.ts b/src/db/migrations/012-channel-registration.ts new file mode 100644 index 0000000..eca8911 --- /dev/null +++ b/src/db/migrations/012-channel-registration.ts @@ -0,0 +1,48 @@ +/** + * Unknown-channel registration flow. + * + * When a channel that isn't wired to any agent group receives a mention or + * DM, the router escalates to the owner for approval before wiring. Approve + * creates a `messaging_group_agents` row (with conservative defaults) and + * replays the triggering event. Deny marks the channel denied forever + * (stored as a timestamp on `messaging_groups.denied_at`) so future + * messages on that channel drop silently without re-prompting. + * + * Two changes: + * 1. `messaging_groups.denied_at TEXT NULL` — set on deny, checked in the + * router before re-escalating. ALTER TABLE ADD COLUMN is FK-safe + * unlike the table rebuild that bit us in migration 011. + * 2. `pending_channel_approvals` table. PRIMARY KEY on + * `messaging_group_id` gives free in-flight dedup — a second mention + * while the card is pending is silently dropped by INSERT OR IGNORE, + * preventing card spam. + */ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +export const migration012: Migration = { + version: 12, + name: 'channel-registration', + up: (db: Database.Database) => { + // 1. Add denied_at to messaging_groups. Idempotent guard in case the + // column was added by some other path before this migration ran. + const cols = db.prepare("PRAGMA table_info('messaging_groups')").all() as Array<{ name: string }>; + if (!cols.some((c) => c.name === 'denied_at')) { + db.exec(`ALTER TABLE messaging_groups ADD COLUMN denied_at TEXT`); + } + + // 2. pending_channel_approvals. + db.exec(` + CREATE TABLE IF NOT EXISTS pending_channel_approvals ( + messaging_group_id TEXT PRIMARY KEY REFERENCES messaging_groups(id), + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + -- The agent the approved wiring will target. + -- Picked at request time (currently: earliest + -- agent_group by created_at). + original_message TEXT NOT NULL, -- JSON serialized InboundEvent + approver_user_id TEXT NOT NULL, + created_at TEXT NOT NULL + ); + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 1015f40..33e6963 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -8,6 +8,7 @@ import { migration008 } from './008-dropped-messages.js'; import { migration009 } from './009-drop-pending-credentials.js'; import { migration010 } from './010-engage-modes.js'; import { migration011 } from './011-pending-sender-approvals.js'; +import { migration012 } from './012-channel-registration.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; @@ -27,6 +28,7 @@ const migrations: Migration[] = [ migration009, migration010, migration011, + migration012, ]; export function runMigrations(db: Database.Database): void { diff --git a/src/modules/permissions/channel-approval.test.ts b/src/modules/permissions/channel-approval.test.ts new file mode 100644 index 0000000..f3ea7e9 --- /dev/null +++ b/src/modules/permissions/channel-approval.test.ts @@ -0,0 +1,392 @@ +/** + * Integration tests for the unknown-channel registration flow (ACTION-ITEMS + * item 22). + * + * Covers: + * - Mention on an unwired channel fires an owner-approval card + * - DM on an unwired channel fires a card (engage_mode will default to pattern='.') + * - In-flight dedup: second mention while a card is pending doesn't spam + * - Approve: wiring created with correct defaults, triggering sender added + * as member, replay wakes the container + * - Deny: messaging_groups.denied_at set, future mentions drop silently + * - Unauthorized clicker is rejected (same pattern as sender-approval) + * - No-owner install: no card, no row + * - No agent groups configured: no card, no row + */ +import fs from 'fs'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; + +import { initTestDb, closeDb, runMigrations } from '../../db/index.js'; +import { createAgentGroup } from '../../db/agent-groups.js'; +import { createMessagingGroup, getMessagingGroupByPlatform } from '../../db/messaging-groups.js'; +import { upsertUser } from './db/users.js'; +import { grantRole } from './db/user-roles.js'; + +// Mock container runner — prevent actual docker spawn. +vi.mock('../../container-runner.js', () => ({ + wakeContainer: vi.fn().mockResolvedValue(undefined), + isContainerRunning: vi.fn().mockReturnValue(false), + getActiveContainerCount: vi.fn().mockReturnValue(0), + killContainer: vi.fn(), +})); + +// Mock delivery adapter. +const deliverMock = vi.fn().mockResolvedValue('plat-msg-id'); +vi.mock('../../delivery.js', () => ({ + getDeliveryAdapter: () => ({ deliver: deliverMock }), +})); + +// Mock ensureUserDm — look up the owner's preconfigured DM row instead of +// hitting a real openDM RPC. +vi.mock('./user-dm.js', () => ({ + ensureUserDm: vi.fn(async (userId: string) => { + const { getDb } = await import('../../db/connection.js'); + const row = getDb() + .prepare( + `SELECT mg.* FROM messaging_groups mg + JOIN user_dms ud ON ud.messaging_group_id = mg.id + WHERE ud.user_id = ?`, + ) + .get(userId); + return row; + }), +})); + +vi.mock('../../config.js', async () => { + const actual = await vi.importActual('../../config.js'); + return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-channel-approval' }; +}); + +const TEST_DIR = '/tmp/nanoclaw-test-channel-approval'; + +function now() { + return new Date().toISOString(); +} + +beforeEach(async () => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + const db = initTestDb(); + runMigrations(db); + + await import('./index.js'); // register hooks + + // Base fixtures: one agent group + owner with a DM on 'telegram'. + createAgentGroup({ id: 'ag-1', name: 'Andy', folder: 'andy', agent_provider: null, created_at: now() }); + + upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() }); + grantRole({ + user_id: 'telegram:owner', + role: 'owner', + agent_group_id: null, + granted_by: null, + granted_at: now(), + }); + + // Pre-seed owner's DM messaging group + user_dms mapping. + createMessagingGroup({ + id: 'mg-dm-owner', + channel_type: 'telegram', + platform_id: 'dm-owner', + name: 'Owner DM', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now(), + }); + const { getDb } = await import('../../db/connection.js'); + getDb() + .prepare( + `INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at) + VALUES (?, ?, ?, ?)`, + ) + .run('telegram:owner', 'telegram', 'mg-dm-owner', now()); + + deliverMock.mockClear(); +}); + +afterEach(() => { + closeDb(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); +}); + +function groupMention(platformId: string, text = '@bot hello') { + return { + channelType: 'telegram', + platformId, + threadId: 'thread-1', // non-null → is_group=true per channel-approval default-picker logic + message: { + id: `msg-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat' as const, + content: JSON.stringify({ senderId: 'caller', senderName: 'Caller', text }), + timestamp: now(), + isMention: true, + }, + }; +} + +function dmEvent(platformId: string, text = 'hello') { + return { + channelType: 'telegram', + platformId, + threadId: null, + message: { + id: `msg-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat' as const, + content: JSON.stringify({ senderId: 'stranger', senderName: 'Stranger', text }), + timestamp: now(), + isMention: true, // DM bridge sets isMention=true + }, + }; +} + +describe('unknown-channel registration flow', () => { + it('delivers an approval card on mention into an unwired group', async () => { + const { routeInbound } = await import('../../router.js'); + await routeInbound(groupMention('chat-new')); + await new Promise((r) => setTimeout(r, 10)); + + expect(deliverMock).toHaveBeenCalledTimes(1); + const [channel, platformId, thread, kind, content] = deliverMock.mock.calls[0]; + expect(channel).toBe('telegram'); + expect(platformId).toBe('dm-owner'); // delivered to owner's DM + expect(thread).toBeNull(); + expect(kind).toBe('chat-sdk'); + const payload = JSON.parse(content as string); + expect(payload.type).toBe('ask_question'); + // Card names the target agent so the owner knows what they're wiring to. + expect(payload.question).toContain('Andy'); + + const { getDb } = await import('../../db/connection.js'); + const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{ + messaging_group_id: string; + }>; + expect(rows).toHaveLength(1); + }); + + it('delivers a card on DM too (non-threaded event)', async () => { + const { routeInbound } = await import('../../router.js'); + await routeInbound(dmEvent('dm-new-user')); + await new Promise((r) => setTimeout(r, 10)); + + expect(deliverMock).toHaveBeenCalledTimes(1); + const { getDb } = await import('../../db/connection.js'); + const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c; + expect(count).toBe(1); + }); + + it('dedups a second mention while the card is pending', async () => { + const { routeInbound } = await import('../../router.js'); + await routeInbound(groupMention('chat-busy')); + await new Promise((r) => setTimeout(r, 10)); + await routeInbound(groupMention('chat-busy', '@bot still here')); + await new Promise((r) => setTimeout(r, 10)); + + expect(deliverMock).toHaveBeenCalledTimes(1); + const { getDb } = await import('../../db/connection.js'); + const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c; + expect(count).toBe(1); + }); + + it('approve → creates wiring, admits triggering sender, replays', async () => { + const { routeInbound } = await import('../../router.js'); + const { getResponseHandlers } = await import('../../response-registry.js'); + const { wakeContainer } = await import('../../container-runner.js'); + (wakeContainer as unknown as ReturnType).mockClear(); + + await routeInbound(groupMention('chat-approve')); + await new Promise((r) => setTimeout(r, 10)); + + const { getDb } = await import('../../db/connection.js'); + const pending = getDb() + .prepare('SELECT messaging_group_id FROM pending_channel_approvals') + .get() as { messaging_group_id: string }; + expect(pending).toBeDefined(); + + // Owner clicks approve. + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.messaging_group_id, + value: 'approve', + userId: 'owner', // raw platform id — handler namespaces it + channelType: 'telegram', + platformId: 'dm-owner', + threadId: null, + }); + if (claimed) break; + } + + // Wiring created with MVP defaults. + const mga = getDb() + .prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?') + .get(pending.messaging_group_id) as { + engage_mode: string; + engage_pattern: string | null; + sender_scope: string; + ignored_message_policy: string; + agent_group_id: string; + }; + expect(mga).toBeDefined(); + expect(mga.engage_mode).toBe('mention-sticky'); // group (threadId != null) + expect(mga.engage_pattern).toBeNull(); + expect(mga.sender_scope).toBe('known'); + expect(mga.ignored_message_policy).toBe('accumulate'); + expect(mga.agent_group_id).toBe('ag-1'); + + // Triggering sender auto-admitted so sender_scope='known' doesn't + // bounce the replay into sender-approval. + const member = getDb() + .prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?') + .get('telegram:caller', 'ag-1'); + expect(member).toBeDefined(); + + // Pending row cleared and container woken via replay. + const stillPending = ( + getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number } + ).c; + expect(stillPending).toBe(0); + expect(wakeContainer).toHaveBeenCalled(); + }); + + it('approve on a DM wires with pattern="." defaults', async () => { + const { routeInbound } = await import('../../router.js'); + const { getResponseHandlers } = await import('../../response-registry.js'); + + await routeInbound(dmEvent('dm-approve-user')); + await new Promise((r) => setTimeout(r, 10)); + + const { getDb } = await import('../../db/connection.js'); + const pending = getDb() + .prepare('SELECT messaging_group_id FROM pending_channel_approvals') + .get() as { messaging_group_id: string }; + + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.messaging_group_id, + value: 'approve', + userId: 'owner', + channelType: 'telegram', + platformId: 'dm-owner', + threadId: null, + }); + if (claimed) break; + } + + const mga = getDb() + .prepare('SELECT engage_mode, engage_pattern FROM messaging_group_agents WHERE messaging_group_id = ?') + .get(pending.messaging_group_id) as { engage_mode: string; engage_pattern: string }; + expect(mga.engage_mode).toBe('pattern'); + expect(mga.engage_pattern).toBe('.'); + }); + + it('deny → sets denied_at; future mentions drop silently without a second card', async () => { + const { routeInbound } = await import('../../router.js'); + const { getResponseHandlers } = await import('../../response-registry.js'); + + await routeInbound(groupMention('chat-deny')); + await new Promise((r) => setTimeout(r, 10)); + const { getDb } = await import('../../db/connection.js'); + const pending = getDb() + .prepare('SELECT messaging_group_id FROM pending_channel_approvals') + .get() as { messaging_group_id: string }; + + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.messaging_group_id, + value: 'reject', + userId: 'owner', + channelType: 'telegram', + platformId: 'dm-owner', + threadId: null, + }); + if (claimed) break; + } + + // denied_at set, pending row cleared, no wiring. + const mg = getMessagingGroupByPlatform('telegram', 'chat-deny'); + expect(mg?.denied_at).not.toBeNull(); + expect(mg?.denied_at).toBeTruthy(); + const mgaCount = ( + getDb() + .prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ?') + .get(pending.messaging_group_id) as { c: number } + ).c; + expect(mgaCount).toBe(0); + + // A follow-up mention on the denied channel: no new card, no new pending row. + deliverMock.mockClear(); + await routeInbound(groupMention('chat-deny', '@bot please')); + await new Promise((r) => setTimeout(r, 10)); + expect(deliverMock).not.toHaveBeenCalled(); + const stillPending = ( + getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number } + ).c; + expect(stillPending).toBe(0); + }); + + it('rejects clicks from an unauthorized user (prevents self-admit via forwarded card)', async () => { + const { routeInbound } = await import('../../router.js'); + const { getResponseHandlers } = await import('../../response-registry.js'); + + await routeInbound(groupMention('chat-unauth')); + await new Promise((r) => setTimeout(r, 10)); + const { getDb } = await import('../../db/connection.js'); + const pending = getDb() + .prepare('SELECT messaging_group_id FROM pending_channel_approvals') + .get() as { messaging_group_id: string }; + + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.messaging_group_id, + value: 'approve', + userId: 'random-bystander', + channelType: 'telegram', + platformId: 'dm-random', + threadId: null, + }); + if (claimed) break; + } + + // No wiring created, pending row preserved so a real approver can act on it. + const mgaCount = ( + getDb() + .prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ?') + .get(pending.messaging_group_id) as { c: number } + ).c; + expect(mgaCount).toBe(0); + const stillPending = ( + getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number } + ).c; + expect(stillPending).toBe(1); + }); +}); + +describe('no-owner / no-agent failure modes', () => { + it('no owner → no card, no pending row (fresh-install bootstrap path)', async () => { + // Wipe the owner grant set up in the outer beforeEach. + const { getDb } = await import('../../db/connection.js'); + getDb().prepare('DELETE FROM user_roles').run(); + + const { routeInbound } = await import('../../router.js'); + await routeInbound(groupMention('chat-noowner')); + await new Promise((r) => setTimeout(r, 10)); + + expect(deliverMock).not.toHaveBeenCalled(); + const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c; + expect(count).toBe(0); + }); + + it('no agent groups → no card, no pending row', async () => { + const { getDb } = await import('../../db/connection.js'); + // Drop foreign-key-dependent rows first, then the agent group itself. + getDb().prepare('DELETE FROM user_roles').run(); + getDb().prepare('DELETE FROM agent_groups').run(); + + const { routeInbound } = await import('../../router.js'); + await routeInbound(groupMention('chat-noagent')); + await new Promise((r) => setTimeout(r, 10)); + + expect(deliverMock).not.toHaveBeenCalled(); + const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c; + expect(count).toBe(0); + }); +}); diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts new file mode 100644 index 0000000..9c65f8e --- /dev/null +++ b/src/modules/permissions/channel-approval.ts @@ -0,0 +1,159 @@ +/** + * Unknown-channel registration flow. + * + * When the router hits an unwired messaging group AND the message was + * addressed to the bot (SDK-confirmed mention or DM), it calls + * `requestChannelApproval` instead of silently dropping. The flow: + * + * 1. Pick the target agent group we'd wire to (MVP: first by name). + * Multi-agent picker is a follow-up — see ACTION-ITEMS. + * 2. Pick an eligible approver (owner / admin) and a reachable DM for + * them, reusing the same primitives the sender-approval flow uses. + * 3. Deliver an Approve / Ignore card that names the target agent + * explicitly so the owner knows what they're wiring to. + * 4. Record a `pending_channel_approvals` row holding the original event + * so it can be re-routed on approve. + * + * On approve (handler in index.ts): + * - Create `messaging_group_agents` with MVP defaults + * (mention-sticky for groups / pattern='.' for DMs, + * sender_scope='known', ignored_message_policy='accumulate') + * - Add the triggering sender to `agent_group_members` so sender_scope + * doesn't bounce the replayed message into a sender-approval cascade + * - Delete the pending row, replay the original event + * + * On ignore: + * - Set `messaging_groups.denied_at = now()` so the router stops + * escalating on this channel until an admin explicitly re-wires + * - Delete the pending row + * + * Dedup: `pending_channel_approvals` PK on messaging_group_id. Second + * mention while pending silently dropped. + * + * Failure modes (log + no row, so a future attempt can try again): + * - No agent groups exist (install never set up a first agent). + * - No eligible approver in user_roles (no owner yet). + * - Approver has no reachable DM. + * - Delivery adapter missing. + */ +import { normalizeOptions, type RawOption } from '../../channels/ask-question.js'; +import { getAllAgentGroups } from '../../db/agent-groups.js'; +import { getMessagingGroup } from '../../db/messaging-groups.js'; +import { getDeliveryAdapter } from '../../delivery.js'; +import { log } from '../../log.js'; +import type { InboundEvent } from '../../router.js'; +import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js'; +import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js'; + +const APPROVAL_OPTIONS: RawOption[] = [ + { label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' }, + { label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' }, +]; + +export interface RequestChannelApprovalInput { + messagingGroupId: string; + event: InboundEvent; +} + +export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise { + const { messagingGroupId, event } = input; + + // In-flight dedup: don't spam the owner if the same unwired channel + // gets more mentions / DMs while a card is already pending. + if (hasInFlightChannelApproval(messagingGroupId)) { + log.debug('Channel registration already in flight — dropping retry', { + messagingGroupId, + }); + return; + } + + // MVP: pick the first agent group by name. Multi-agent systems will get + // a richer card later (user picks the target from a list). + const agentGroups = getAllAgentGroups(); + if (agentGroups.length === 0) { + log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', { + messagingGroupId, + }); + return; + } + const target = agentGroups[0]; + + // pickApprover takes the target agent group's id — gets scoped admins + + // global admins + owners. For fresh installs with only an owner, the + // owner is returned. + const approvers = pickApprover(target.id); + if (approvers.length === 0) { + log.warn('Channel registration skipped — no owner or admin configured', { + messagingGroupId, + targetAgentGroupId: target.id, + }); + return; + } + + const originMg = getMessagingGroup(messagingGroupId); + const originChannelType = originMg?.channel_type ?? ''; + const delivery = await pickApprovalDelivery(approvers, originChannelType); + if (!delivery) { + log.warn('Channel registration skipped — no DM channel for any approver', { + messagingGroupId, + targetAgentGroupId: target.id, + }); + return; + } + + const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat'; + const isGroup = originMg?.is_group === 1; + + const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message'; + const question = isGroup + ? `Your agent was mentioned in ${originName} on ${originChannelType}. Wire it to ${target.name} and let it engage?` + : `Someone DM'd your agent on ${originChannelType} (${originName}). Wire it to ${target.name} and let it respond?`; + + createPendingChannelApproval({ + messaging_group_id: messagingGroupId, + agent_group_id: target.id, + original_message: JSON.stringify(event), + approver_user_id: delivery.userId, + created_at: new Date().toISOString(), + }); + + const adapter = getDeliveryAdapter(); + if (!adapter) { + log.error('Channel registration row created but no delivery adapter is wired', { + messagingGroupId, + }); + return; + } + + try { + await adapter.deliver( + delivery.messagingGroup.channel_type, + delivery.messagingGroup.platform_id, + null, + 'chat-sdk', + JSON.stringify({ + type: 'ask_question', + // Use messaging_group_id as the questionId — it's unique per card + // (PK on pending table dedups) and lets the response handler look + // up the pending row directly without another index. + questionId: messagingGroupId, + title, + question, + options: normalizeOptions(APPROVAL_OPTIONS), + }), + ); + log.info('Channel registration card delivered', { + messagingGroupId, + targetAgentGroupId: target.id, + approver: delivery.userId, + }); + } catch (err) { + log.error('Channel registration card delivery failed', { + messagingGroupId, + err, + }); + } +} + +export const APPROVE_VALUE = 'approve'; +export const REJECT_VALUE = 'reject'; diff --git a/src/modules/permissions/db/pending-channel-approvals.ts b/src/modules/permissions/db/pending-channel-approvals.ts new file mode 100644 index 0000000..d3e665a --- /dev/null +++ b/src/modules/permissions/db/pending-channel-approvals.ts @@ -0,0 +1,52 @@ +/** + * CRUD for pending_channel_approvals — the in-flight state for the + * unknown-channel registration flow. A row exists while an owner-approval + * card is outstanding; it's deleted on approve (after wiring is created) + * or deny (after denied_at is set on the messaging_group). + * + * PRIMARY KEY on messaging_group_id gives free in-flight dedup. A second + * mention/DM while a card is pending resolves via + * `hasInFlightChannelApproval` in the request flow and drops silently + * instead of spamming the owner. + */ +import { getDb } from '../../../db/connection.js'; + +export interface PendingChannelApproval { + messaging_group_id: string; + agent_group_id: string; + original_message: string; + approver_user_id: string; + created_at: string; +} + +export function createPendingChannelApproval(row: PendingChannelApproval): void { + getDb() + .prepare( + `INSERT INTO pending_channel_approvals ( + messaging_group_id, agent_group_id, original_message, + approver_user_id, created_at + ) + VALUES ( + @messaging_group_id, @agent_group_id, @original_message, + @approver_user_id, @created_at + )`, + ) + .run(row); +} + +export function getPendingChannelApproval(messagingGroupId: string): PendingChannelApproval | undefined { + return getDb() + .prepare('SELECT * FROM pending_channel_approvals WHERE messaging_group_id = ?') + .get(messagingGroupId) as PendingChannelApproval | undefined; +} + +export function hasInFlightChannelApproval(messagingGroupId: string): boolean { + const row = getDb() + .prepare('SELECT 1 AS x FROM pending_channel_approvals WHERE messaging_group_id = ?') + .get(messagingGroupId) as { x: number } | undefined; + return row !== undefined; +} + +export function deletePendingChannelApproval(messagingGroupId: string): void { + getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId); +} diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index d13797b..e2f100c 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -16,9 +16,14 @@ * access gate is not registered and core defaults to allow-all. */ import { recordDroppedMessage } from '../../db/dropped-messages.js'; +import { + createMessagingGroupAgent, + setMessagingGroupDeniedAt, +} from '../../db/messaging-groups.js'; import { routeInbound, setAccessGate, + setChannelRequestGate, setSenderResolver, setSenderScopeGate, type AccessGateResult, @@ -28,7 +33,12 @@ import { registerResponseHandler, type ResponsePayload } from '../../response-re import { log } from '../../log.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import { canAccessAgentGroup } from './access.js'; +import { requestChannelApproval } from './channel-approval.js'; import { addMember } from './db/agent-group-members.js'; +import { + deletePendingChannelApproval, + getPendingChannelApproval, +} from './db/pending-channel-approvals.js'; import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js'; import { hasAdminPrivilege } from './db/user-roles.js'; import { getUser, upsertUser } from './db/users.js'; @@ -253,3 +263,137 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise { + await requestChannelApproval({ messagingGroupId: mg.id, event }); +}); + +/** + * Response handler for the unknown-channel registration card. + * + * Claim rule: questionId matches a pending_channel_approvals row (keyed + * by messaging_group_id). If no such row, return false so downstream + * handlers get a shot. + * + * Approve: create the wiring with MVP defaults (mention-sticky for + * groups / pattern='.' for DMs; sender_scope='known'; + * ignored_message_policy='accumulate'), add the triggering sender as a + * member so sender_scope doesn't immediately bounce them into a + * sender-approval card, then replay the original event. + * + * Deny: set `messaging_groups.denied_at = now()` so future mentions on + * this channel drop silently until an admin explicitly wires it. + */ +async function handleChannelApprovalResponse(payload: ResponsePayload): Promise { + const row = getPendingChannelApproval(payload.questionId); + if (!row) return false; + + // Click-auth: same pattern as sender-approval (see commit 68058cb). + // Raw platform userId → namespace with channelType → must match the + // designated approver OR have admin privilege over the target agent. + const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null; + const isAuthorized = + clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id)); + if (!isAuthorized) { + log.warn('Channel registration click rejected — unauthorized clicker', { + messagingGroupId: row.messaging_group_id, + clickerId, + expectedApprover: row.approver_user_id, + }); + return true; // claim but take no action + } + const approverId = clickerId; + const approved = payload.value === 'approve'; + + if (!approved) { + setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString()); + deletePendingChannelApproval(row.messaging_group_id); + log.info('Channel registration denied', { + messagingGroupId: row.messaging_group_id, + agentGroupId: row.agent_group_id, + approverId, + }); + return true; + } + + // Rehydrate the original event to know (a) whether it was a DM or group + // (chooses engage_mode default), and (b) who the triggering sender was + // (auto-member-add so sender_scope='known' doesn't bounce the replay). + let event: InboundEvent; + try { + event = JSON.parse(row.original_message) as InboundEvent; + } catch (err) { + log.error('Channel registration: failed to parse stored event', { + messagingGroupId: row.messaging_group_id, + err, + }); + deletePendingChannelApproval(row.messaging_group_id); + return true; + } + + // Decide engage_mode from the original event. DMs (`isMention=true` & + // not in a group) get `pattern='.'` (always respond). Group mentions + // get `mention-sticky` (respond now + follow the thread). + // + // We can't read `mg.is_group` reliably here because we only auto-create + // the mg with `is_group=0` on first sight — the adapter hasn't told us + // yet whether it's actually a group. Fall back to the InboundEvent's + // `threadId`: a non-null threadId implies a threaded platform (Slack + // channel thread, Discord thread), which we treat as a group. + const isGroup = event.threadId !== null; + const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern'; + const engagePattern = isGroup ? null : '.'; + + const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + createMessagingGroupAgent({ + id: mgaId, + messaging_group_id: row.messaging_group_id, + agent_group_id: row.agent_group_id, + engage_mode: engageMode, + engage_pattern: engagePattern, + sender_scope: 'known', + ignored_message_policy: 'accumulate', + session_mode: 'shared', + priority: 0, + created_at: new Date().toISOString(), + }); + log.info('Channel registration approved — wiring created', { + messagingGroupId: row.messaging_group_id, + agentGroupId: row.agent_group_id, + mgaId, + engageMode, + approverId, + }); + + // Auto-admit the triggering sender. Without this, the replay below + // would bounce through sender-approval (sender_scope='known' + + // sender-is-not-a-member). + const senderUserId = extractAndUpsertUser(event); + if (senderUserId) { + addMember({ + user_id: senderUserId, + agent_group_id: row.agent_group_id, + added_by: approverId, + added_at: new Date().toISOString(), + }); + } + + // Clear the pending row BEFORE replay so the gate check on the second + // attempt sees a wired channel (agentCount > 0) and takes the fan-out + // path normally. + deletePendingChannelApproval(row.messaging_group_id); + + try { + await routeInbound(event); + } catch (err) { + log.error('Failed to replay message after channel approval', { + messagingGroupId: row.messaging_group_id, + err, + }); + } + return true; +} + +registerResponseHandler(handleChannelApprovalResponse); diff --git a/src/router.ts b/src/router.ts index 1d819c0..4289f1f 100644 --- a/src/router.ts +++ b/src/router.ts @@ -127,6 +127,27 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void { senderScopeGate = fn; } +/** + * Channel-registration hook. Runs when the router sees a mention/DM on a + * messaging group that has no wirings AND hasn't been denied. The hook is + * expected to escalate to an owner (card, etc.) and arrange for future + * replay via routeInbound after approval. Fire-and-forget from the + * router's perspective. + * + * Registered by the permissions module. Without the module the router + * silently records the drop with reason='no_agent_wired' and moves on. + */ +export type ChannelRequestGateFn = (mg: MessagingGroup, event: InboundEvent) => Promise; + +let channelRequestGate: ChannelRequestGateFn | null = null; + +export function setChannelRequestGate(fn: ChannelRequestGateFn): void { + if (channelRequestGate) { + log.warn('Channel-request gate overwritten'); + } + channelRequestGate = fn; +} + function safeParseContent(raw: string): { text?: string; sender?: string; senderId?: string } { try { return JSON.parse(raw); @@ -156,12 +177,12 @@ export async function routeInbound(event: InboundEvent): Promise { const found = getMessagingGroupWithAgentCount(event.channelType, event.platformId); let mg: MessagingGroup; + let agentCount: number; if (!found) { // No messaging_groups row. Auto-create only when the message warrants // attention (the bot was addressed — @mention or DM). Plain chatter in // channels we merely sit in stays silent — no row, no DB writes. if (!isMention) return; - const mgId = `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; mg = { id: mgId, @@ -170,6 +191,7 @@ export async function routeInbound(event: InboundEvent): Promise { name: null, is_group: 0, unknown_sender_policy: 'request_approval', + denied_at: null, created_at: new Date().toISOString(), }; createMessagingGroup(mg); @@ -178,30 +200,51 @@ export async function routeInbound(event: InboundEvent): Promise { channelType: event.channelType, platformId: event.platformId, }); + agentCount = 0; } else { mg = found.mg; - if (found.agentCount === 0) { - // Messaging group exists but has no wirings. Stay silent for plain - // messages; only log + record on explicit mention/DM so admins can - // see that someone tried to reach the bot on an unwired channel. - if (!isMention) return; - log.warn('MESSAGE DROPPED — no agent groups wired to this channel. Run setup register step to configure.', { + agentCount = found.agentCount; + } + + // 1b. No wirings — either silent drop (plain chatter / denied channel) or + // escalate to owner for channel-registration approval. + if (agentCount === 0) { + if (!isMention) return; + if (mg.denied_at) { + log.debug('Message dropped — channel was denied by owner', { + messagingGroupId: mg.id, + deniedAt: mg.denied_at, + }); + return; + } + + const parsed = safeParseContent(event.message.content); + recordDroppedMessage({ + channel_type: event.channelType, + platform_id: event.platformId, + user_id: null, + sender_name: parsed.sender ?? null, + reason: 'no_agent_wired', + messaging_group_id: mg.id, + agent_group_id: null, + }); + + if (channelRequestGate) { + // Fire-and-forget escalation. The gate is expected to build a card, + // persist pending_channel_approvals, and replay the event via + // routeInbound after approval. Errors are logged internally — the + // user's message still stays dropped here either way. + void channelRequestGate(mg, event).catch((err) => + log.error('Channel-request gate threw', { messagingGroupId: mg.id, err }), + ); + } else { + log.warn('MESSAGE DROPPED — no agent groups wired and no channel-request gate registered', { messagingGroupId: mg.id, channelType: event.channelType, platformId: event.platformId, }); - const parsed = safeParseContent(event.message.content); - recordDroppedMessage({ - channel_type: event.channelType, - platform_id: event.platformId, - user_id: null, - sender_name: parsed.sender ?? null, - reason: 'no_agent_wired', - messaging_group_id: mg.id, - agent_group_id: null, - }); - return; } + return; } // 2. Sender resolution (permissions module upserts the users row as a diff --git a/src/types.ts b/src/types.ts index b2674da..b3e2470 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,16 @@ export interface MessagingGroup { name: string | null; is_group: number; // 0 | 1 unknown_sender_policy: UnknownSenderPolicy; + /** + * When set, the owner explicitly denied registering this channel — the + * router drops silently and does not re-escalate. Cleared by any explicit + * wiring mutation (admin command). See migration 012. + * + * Optional on the TS type so pre-migration-012 callers that build + * MessagingGroup objects in code (fixtures, etc.) don't need to update; + * the column itself defaults to NULL in SQLite. + */ + denied_at?: string | null; created_at: string; } From a29f3e5cf41d11ccfff2968e40828bcf0763c633 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 20 Apr 2026 12:18:22 +0000 Subject: [PATCH 034/185] feat(new-setup-2): bundle Telegram install into one script Extract the /add-telegram preflight + install commands into setup/install-telegram.sh so /new-setup-2 can run the adapter install programmatically when the user picks Telegram, then hand off to /add-telegram for credentials and pairing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-setup-2/SKILL.md | 4 +- setup/install-telegram.sh | 72 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100755 setup/install-telegram.sh diff --git a/.claude/skills/new-setup-2/SKILL.md b/.claude/skills/new-setup-2/SKILL.md index 869a710..ba70704 100644 --- a/.claude/skills/new-setup-2/SKILL.md +++ b/.claude/skills/new-setup-2/SKILL.md @@ -1,7 +1,7 @@ --- name: new-setup-2 description: Follow-on to /new-setup. Captures the operator and agent names, wires a real messaging channel, and adds quality-of-life extras. Linear rollthrough; every step is skippable. Invoked when the user picks "continue setup" at the end of /new-setup. -allowed-tools: Bash(bash setup/probe.sh) Bash(pnpm exec tsx setup/index.ts *) Bash(pnpm exec tsx scripts/init-first-agent.ts *) +allowed-tools: Bash(bash setup/probe.sh) Bash(bash setup/install-telegram.sh) Bash(pnpm exec tsx setup/index.ts *) Bash(pnpm exec tsx scripts/init-first-agent.ts *) --- # NanoClaw phase-2 setup @@ -61,7 +61,7 @@ Print the list as plain prose. **Do not use `AskUserQuestion` for this step** When the user picks one: -1. **Install the adapter.** Invoke the matching `/add-` skill via the Skill tool. It copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels (e.g. Telegram) also run a pairing step as part of their flow. +1. **Install the adapter.** For **Telegram**, run `bash setup/install-telegram.sh` directly — it bundles the preflight + fetch + copy + register + `pnpm install` + build from `/add-telegram` into one idempotent call, then continue with credentials and pairing (invoke `/add-telegram` afterwards and its preflight will skip straight to Credentials). For every other channel, invoke the matching `/add-` skill via the Skill tool; it copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels also run a pairing step as part of their flow. 2. **Capture platform IDs.** After the `/add-` skill finishes, you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, for example, the `pair-telegram` step emits `PLATFORM_ID` and `ADMIN_USER_ID` in a status block once the user sends the 4-digit code. 3. **Wire the agent.** Run `init-first-agent.ts` in DM mode with `--no-cli-bonus` (this keeps the new agent off the CLI messaging group so the pre-existing throwaway agent still owns CLI routing cleanly): diff --git a/setup/install-telegram.sh b/setup/install-telegram.sh new file mode 100755 index 0000000..7eaf9e1 --- /dev/null +++ b/setup/install-telegram.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Setup helper: install-telegram — bundles the preflight + install commands +# from the /add-telegram skill into one idempotent script so /new-setup-2 can +# run them programmatically before continuing to credentials and pairing. +# +# Copies the Telegram adapter, helpers, tests, and the pair-telegram setup +# step in from the `channels` branch; appends the self-registration import; +# registers the `pair-telegram` entry in the setup STEPS map; installs the +# pinned @chat-adapter/telegram package; builds. All steps are safe to re-run. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +echo "=== NANOCLAW SETUP: INSTALL_TELEGRAM ===" + +CHANNEL_FILES=( + src/channels/telegram.ts + src/channels/telegram-pairing.ts + src/channels/telegram-pairing.test.ts + src/channels/telegram-markdown-sanitize.ts + src/channels/telegram-markdown-sanitize.test.ts + setup/pair-telegram.ts +) + +needs_install=false +for f in "${CHANNEL_FILES[@]}"; do + [[ -f "$f" ]] || needs_install=true +done +grep -q "import './telegram.js';" src/channels/index.ts || needs_install=true +grep -q "'pair-telegram':" setup/index.ts || needs_install=true +grep -q '"@chat-adapter/telegram"' package.json || needs_install=true +[[ -d node_modules/@chat-adapter/telegram ]] || needs_install=true + +if ! $needs_install; then + echo "STATUS: already-installed" + echo "=== END ===" + exit 0 +fi + +echo "STEP: fetch-channels-branch" +git fetch origin channels + +echo "STEP: copy-files" +for f in "${CHANNEL_FILES[@]}"; do + git show "origin/channels:$f" > "$f" +done + +echo "STEP: register-import" +if ! grep -q "import './telegram.js';" src/channels/index.ts; then + printf "import './telegram.js';\n" >> src/channels/index.ts +fi + +echo "STEP: register-setup-step" +if ! grep -q "'pair-telegram':" setup/index.ts; then + awk ' + { print } + /register: \(\) => import/ && !inserted { + print " '\''pair-telegram'\'': () => import('\''./pair-telegram.js'\'')," + inserted = 1 + } + ' setup/index.ts > setup/index.ts.tmp && mv setup/index.ts.tmp setup/index.ts +fi + +echo "STEP: pnpm-install" +pnpm install @chat-adapter/telegram@4.26.0 + +echo "STEP: pnpm-build" +pnpm run build + +echo "STATUS: installed" +echo "=== END ===" From cdefc97c3715d98397d35c4f31b8d4c8028573e1 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 20 Apr 2026 13:19:33 +0000 Subject: [PATCH 035/185] feat(new-setup-2): broaden install-telegram permission + allow tail/head/grep Switch Bash(bash setup/install-telegram.sh) to a prefix match so trailing flags or redirections don't fall through to approval prompts. Add the common read-only coreutils (tail, head, grep) the model reaches for to cap noisy build output. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-setup-2/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/new-setup-2/SKILL.md b/.claude/skills/new-setup-2/SKILL.md index ba70704..f571123 100644 --- a/.claude/skills/new-setup-2/SKILL.md +++ b/.claude/skills/new-setup-2/SKILL.md @@ -1,7 +1,7 @@ --- name: new-setup-2 description: Follow-on to /new-setup. Captures the operator and agent names, wires a real messaging channel, and adds quality-of-life extras. Linear rollthrough; every step is skippable. Invoked when the user picks "continue setup" at the end of /new-setup. -allowed-tools: Bash(bash setup/probe.sh) Bash(bash setup/install-telegram.sh) Bash(pnpm exec tsx setup/index.ts *) Bash(pnpm exec tsx scripts/init-first-agent.ts *) +allowed-tools: Bash(bash setup/probe.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts *) Bash(pnpm exec tsx scripts/init-first-agent.ts *) Bash(tail:*) Bash(head:*) Bash(grep:*) --- # NanoClaw phase-2 setup From 97d9cf1a638bc4929dde4b83c20d6c9179e7b1f1 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 20 Apr 2026 13:19:33 +0000 Subject: [PATCH 036/185] chore(skills): normalize + broaden setup allowlists - new-setup: switch prefix entries to :* form, add Linux Node install (nodesource curl left-half + apt-get install nodejs), node --version probe, tail/head/grep for log diagnosis. Drop brew install entry. - new-setup-2: normalize pnpm exec prefix entries to :* form. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-setup-2/SKILL.md | 2 +- .claude/skills/new-setup/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/new-setup-2/SKILL.md b/.claude/skills/new-setup-2/SKILL.md index f571123..f37223a 100644 --- a/.claude/skills/new-setup-2/SKILL.md +++ b/.claude/skills/new-setup-2/SKILL.md @@ -1,7 +1,7 @@ --- name: new-setup-2 description: Follow-on to /new-setup. Captures the operator and agent names, wires a real messaging channel, and adds quality-of-life extras. Linear rollthrough; every step is skippable. Invoked when the user picks "continue setup" at the end of /new-setup. -allowed-tools: Bash(bash setup/probe.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts *) Bash(pnpm exec tsx scripts/init-first-agent.ts *) Bash(tail:*) Bash(head:*) Bash(grep:*) +allowed-tools: Bash(bash setup/probe.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm exec tsx scripts/init-first-agent.ts:*) Bash(tail:*) Bash(head:*) Bash(grep:*) --- # NanoClaw phase-2 setup diff --git a/.claude/skills/new-setup/SKILL.md b/.claude/skills/new-setup/SKILL.md index 0a8cc2e..d15ba66 100644 --- a/.claude/skills/new-setup/SKILL.md +++ b/.claude/skills/new-setup/SKILL.md @@ -1,7 +1,7 @@ --- name: new-setup description: Shortest path from zero to a working two-way agent chat, for any user regardless of technical background — ends at a running NanoClaw instance with at least one CLI-reachable agent. -allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(pnpm exec tsx setup/index.ts *) Bash(pnpm run chat *) Bash(brew install *) Bash(curl -fsSL https://get.docker.com | sh) Bash(sudo usermod -aG docker *) Bash(open -a Docker) Bash(sudo systemctl start docker) +allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm run chat:*) Bash(curl -fsSL https://get.docker.com | sh) Bash(curl -fsSL https://deb.nodesource.com/setup_22.x) Bash(sudo apt-get install -y nodejs) Bash(sudo usermod -aG docker:*) Bash(open -a Docker) Bash(sudo systemctl start docker) Bash(node --version) Bash(tail:*) Bash(head:*) Bash(grep:*) --- # NanoClaw bare-minimum setup From 9870deb5dd600fa109205d5ead4c61a3a713b4a8 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 20 Apr 2026 13:31:23 +0000 Subject: [PATCH 037/185] feat(new-setup-2): add timezone step with UTC confirmation Inserts a Timezone step (new step 3) that runs --step timezone and, if the resolver lands on UTC, asks the user to confirm before leaving UTC in .env; re-runs with --tz if they give a real IANA zone. Renumbers subsequent steps accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-setup-2/SKILL.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/.claude/skills/new-setup-2/SKILL.md b/.claude/skills/new-setup-2/SKILL.md index f37223a..45e96f1 100644 --- a/.claude/skills/new-setup-2/SKILL.md +++ b/.claude/skills/new-setup-2/SKILL.md @@ -36,7 +36,21 @@ Plain-prose ask: Capture as `AGENT_NAME`. If skipped, set `AGENT_NAME = OPERATOR_NAME`. Nothing persisted yet. -### 3. Pick a messaging channel +### 3. Timezone + +Run `pnpm exec tsx setup/index.ts --step timezone` and parse the status block. + +- **RESOLVED_TZ is `UTC` or `Etc/UTC`** — before leaving UTC in `.env`, confirm with the user in plain prose: + + > Your system reports UTC as the timezone. Is that actually right, or are you somewhere else? If elsewhere, tell me the IANA zone (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`). Skip to keep UTC. + + If they name a different IANA timezone, re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` to overwrite `.env`. If they skip, leave UTC in place — nothing else to do. + +- **NEEDS_USER_INPUT=true** — autodetection failed. Ask for an IANA timezone (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`), then re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz `. If they skip, move on. + +- Otherwise — timezone is already set; move on. + +### 4. Pick a messaging channel Print the list as plain prose. **Do not use `AskUserQuestion` for this step** — just the list, then wait for the user's reply: @@ -81,9 +95,9 @@ When the user picks one: Substitute `{channel-name}` with the friendly name (e.g. "Telegram", "WhatsApp", "Slack"). -If the user skipped, move on to step 4. +If the user skipped, move on to step 5. -### 4. Quality of life +### 5. Quality of life Optional polish. Print the list; the user may pick zero, one, or several — invoke each chosen skill in sequence: @@ -99,13 +113,13 @@ If the probe reports `PLATFORM=darwin`, also offer: Do **not** list `/add-macos-statusbar` on Linux. If the user skips everything, just move on. -### 5. Done +### 6. Done Short wrap-up: > Setup complete. You can chat with your agent on {channel-name} — or via CLI with `pnpm run chat `. -Substitute `{channel-name}` with whatever was wired in step 3. If step 3 was skipped, drop the "on {channel-name} — or" clause entirely so the line just mentions the CLI form. +Substitute `{channel-name}` with whatever was wired in step 4. If step 4 was skipped, drop the "on {channel-name} — or" clause entirely so the line just mentions the CLI form. ## If anything fails From 0d145ad9385dde2d530bf2c4c05b51a9fb4c261d Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 20 Apr 2026 13:34:33 +0000 Subject: [PATCH 038/185] feat(new-setup-2): add host directory access step Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-setup-2/SKILL.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.claude/skills/new-setup-2/SKILL.md b/.claude/skills/new-setup-2/SKILL.md index 45e96f1..d95b2b2 100644 --- a/.claude/skills/new-setup-2/SKILL.md +++ b/.claude/skills/new-setup-2/SKILL.md @@ -97,7 +97,17 @@ When the user picks one: If the user skipped, move on to step 5. -### 5. Quality of life +### 5. Host directory access + +By default, agent containers can only touch their own workspace. If the user wants the agent to read or write files in specific host directories, those paths need to go on the mount allowlist. + +Plain-prose ask: + +> Want your agent to be able to read or write files in any host directories (e.g. a code project, `~/Documents`)? Name the paths and I'll add them — or skip to keep the default isolated workspace. + +If the user names paths, invoke `/manage-mounts` via the Skill tool to add them. If they skip, move on. + +### 6. Quality of life Optional polish. Print the list; the user may pick zero, one, or several — invoke each chosen skill in sequence: @@ -113,7 +123,7 @@ If the probe reports `PLATFORM=darwin`, also offer: Do **not** list `/add-macos-statusbar` on Linux. If the user skips everything, just move on. -### 6. Done +### 7. Done Short wrap-up: From ccb676ae91b1b5ebe26dc75bba44862f800de7cf Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 20 Apr 2026 13:53:25 +0000 Subject: [PATCH 039/185] feat(new-setup-2): use AskUserQuestion for timezone + mounts; number channel list Timezone and host-mount prompts now go through AskUserQuestion for a cleaner UI; channel selection stays plain-prose but is numbered (14 options exceeds the 4-option AskUserQuestion cap). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-setup-2/SKILL.md | 52 +++++++++++++++++------------ 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/.claude/skills/new-setup-2/SKILL.md b/.claude/skills/new-setup-2/SKILL.md index d95b2b2..eedea15 100644 --- a/.claude/skills/new-setup-2/SKILL.md +++ b/.claude/skills/new-setup-2/SKILL.md @@ -40,36 +40,40 @@ Capture as `AGENT_NAME`. If skipped, set `AGENT_NAME = OPERATOR_NAME`. Nothing p Run `pnpm exec tsx setup/index.ts --step timezone` and parse the status block. -- **RESOLVED_TZ is `UTC` or `Etc/UTC`** — before leaving UTC in `.env`, confirm with the user in plain prose: +- **RESOLVED_TZ is `UTC` or `Etc/UTC`** — before leaving UTC in `.env`, confirm with `AskUserQuestion`: - > Your system reports UTC as the timezone. Is that actually right, or are you somewhere else? If elsewhere, tell me the IANA zone (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`). Skip to keep UTC. + - **Question**: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?" + - **Header**: "Timezone" + - **Options**: + 1. `Keep UTC` — "Leave timezone as UTC." + 2. `I'm somewhere else` — "I'll name the IANA zone (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`) via Other." - If they name a different IANA timezone, re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` to overwrite `.env`. If they skip, leave UTC in place — nothing else to do. + If they pick "I'm somewhere else" (or type an IANA zone via Other), re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` to overwrite `.env`. If they keep UTC or skip, leave UTC in place. -- **NEEDS_USER_INPUT=true** — autodetection failed. Ask for an IANA timezone (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`), then re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz `. If they skip, move on. +- **NEEDS_USER_INPUT=true** — autodetection failed. Use `AskUserQuestion` with the same two options above (reword the question to "Autodetection failed — what timezone are you in?"), then re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` if they supply one. If they skip, move on. - Otherwise — timezone is already set; move on. ### 4. Pick a messaging channel -Print the list as plain prose. **Do not use `AskUserQuestion` for this step** — just the list, then wait for the user's reply: +Print the list as a numbered plain-prose list (too many options for `AskUserQuestion`, which caps at 4). The user replies with a number or channel name. Preserve the numbering exactly: > Which messaging channel should I wire your agent to? > -> - **WhatsApp (native)** — `/add-whatsapp` -> - **WhatsApp Cloud (Meta official)** — `/add-whatsapp-cloud` -> - **Telegram** — `/add-telegram` -> - **Slack** — `/add-slack` -> - **Discord** — `/add-discord` -> - **iMessage** — `/add-imessage` -> - **Teams** — `/add-teams` -> - **Matrix** — `/add-matrix` -> - **Google Chat** — `/add-gchat` -> - **Linear** — `/add-linear` -> - **GitHub** — `/add-github` -> - **Webex** — `/add-webex` -> - **Resend (email)** — `/add-resend` -> - **Emacs** — `/add-emacs` +> 1. **WhatsApp (native)** — `/add-whatsapp` +> 2. **WhatsApp Cloud (Meta official)** — `/add-whatsapp-cloud` +> 3. **Telegram** — `/add-telegram` +> 4. **Slack** — `/add-slack` +> 5. **Discord** — `/add-discord` +> 6. **iMessage** — `/add-imessage` +> 7. **Teams** — `/add-teams` +> 8. **Matrix** — `/add-matrix` +> 9. **Google Chat** — `/add-gchat` +> 10. **Linear** — `/add-linear` +> 11. **GitHub** — `/add-github` +> 12. **Webex** — `/add-webex` +> 13. **Resend (email)** — `/add-resend` +> 14. **Emacs** — `/add-emacs` > > Or say "skip" to leave this for later. @@ -101,11 +105,15 @@ If the user skipped, move on to step 5. By default, agent containers can only touch their own workspace. If the user wants the agent to read or write files in specific host directories, those paths need to go on the mount allowlist. -Plain-prose ask: +Use `AskUserQuestion`: -> Want your agent to be able to read or write files in any host directories (e.g. a code project, `~/Documents`)? Name the paths and I'll add them — or skip to keep the default isolated workspace. +- **Question**: "Want your agent to read or write files in any host directories (e.g. a code project, `~/Documents`)?" +- **Header**: "Host mounts" +- **Options**: + 1. `Keep isolated` — "Agent only touches its own workspace (Recommended)." + 2. `Add host paths` — "I'll name the directories to allowlist via Other." -If the user names paths, invoke `/manage-mounts` via the Skill tool to add them. If they skip, move on. +If they pick "Add host paths" (or name paths via Other), invoke `/manage-mounts` via the Skill tool to add them. If they keep it isolated or skip, move on. ### 6. Quality of life From 712a0e1e010f1abe796569dd8a60b6b506fc11a1 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 20 Apr 2026 15:18:35 +0000 Subject: [PATCH 040/185] feat(new-setup): wrap node/docker installs and add generic set-env step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three allowlist-friendly setup helpers so /new-setup and /new-setup-2 don't hit unmatchable commands during a fresh install: - setup/install-node.sh — idempotent Node 22 install wrapper (macOS via brew, Linux via NodeSource + apt). Replaces the raw `curl | sudo -E bash -` flow whose stdin-consuming `bash -` segment can't be pre-approved. - setup/install-docker.sh — same pattern for Docker (brew --cask on macOS, get.docker.com on Linux + usermod). - setup/set-env.ts — generic `--step set-env` that writes KEY=VALUE to .env (and optionally syncs to data/env/env) so channel-install flows don't invent `grep && sed && rm` pipelines, which split at each && and can't be tightly allowlisted. new-setup-2's Telegram path now uses set-env for TELEGRAM_BOT_TOKEN and explicitly skips /add-telegram's Credentials section. new-setup step 1 and step 2 now call the install wrappers; the raw curl/apt entries are gone from the allowed-tools list. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-setup-2/SKILL.md | 17 +++++-- .claude/skills/new-setup/SKILL.md | 13 ++--- setup/index.ts | 1 + setup/install-docker.sh | 56 +++++++++++++++++++++ setup/install-node.sh | 54 ++++++++++++++++++++ setup/set-env.ts | 77 +++++++++++++++++++++++++++++ 6 files changed, 206 insertions(+), 12 deletions(-) create mode 100755 setup/install-docker.sh create mode 100755 setup/install-node.sh create mode 100644 setup/set-env.ts diff --git a/.claude/skills/new-setup-2/SKILL.md b/.claude/skills/new-setup-2/SKILL.md index eedea15..1b98443 100644 --- a/.claude/skills/new-setup-2/SKILL.md +++ b/.claude/skills/new-setup-2/SKILL.md @@ -1,7 +1,7 @@ --- name: new-setup-2 description: Follow-on to /new-setup. Captures the operator and agent names, wires a real messaging channel, and adds quality-of-life extras. Linear rollthrough; every step is skippable. Invoked when the user picks "continue setup" at the end of /new-setup. -allowed-tools: Bash(bash setup/probe.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm exec tsx scripts/init-first-agent.ts:*) Bash(tail:*) Bash(head:*) Bash(grep:*) +allowed-tools: Bash(bash setup/probe.sh) Bash(bash setup/install-telegram.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm exec tsx scripts/init-first-agent.ts:*) Bash(tail:*) Bash(head:*) Bash(grep:*) --- # NanoClaw phase-2 setup @@ -79,8 +79,19 @@ Print the list as a numbered plain-prose list (too many options for `AskUserQues When the user picks one: -1. **Install the adapter.** For **Telegram**, run `bash setup/install-telegram.sh` directly — it bundles the preflight + fetch + copy + register + `pnpm install` + build from `/add-telegram` into one idempotent call, then continue with credentials and pairing (invoke `/add-telegram` afterwards and its preflight will skip straight to Credentials). For every other channel, invoke the matching `/add-` skill via the Skill tool; it copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels also run a pairing step as part of their flow. -2. **Capture platform IDs.** After the `/add-` skill finishes, you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, for example, the `pair-telegram` step emits `PLATFORM_ID` and `ADMIN_USER_ID` in a status block once the user sends the 4-digit code. +1. **Install the adapter.** For **Telegram**, run `bash setup/install-telegram.sh` directly — it bundles the preflight + fetch + copy + register + `pnpm install` + build from `/add-telegram` into one idempotent call. Then handle Telegram credentials inline (below) — **do not** invoke `/add-telegram` afterward; its Credentials section would generate an unapprovable `grep && sed && rm` to write `.env`. For every other channel, invoke the matching `/add-` skill via the Skill tool; it copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels also run a pairing step as part of their flow. + + **Telegram credentials (inline):** + - Walk the user through BotFather: `/newbot` → pick name + username ending in `bot` → copy the token. + - Remind them: in `@BotFather` → `/mybots` → their bot → Bot Settings → Group Privacy → **Turn off** (only needed if the bot will live in groups; DM-only can skip). + - Persist the token and sync it to the container mount with the generic setter: + + ``` + pnpm exec tsx setup/index.ts --step set-env -- \ + --key TELEGRAM_BOT_TOKEN --value "" --sync-container + ``` + +2. **Capture platform IDs.** After the `/add-` skill finishes (or after inline credentials for Telegram), you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent ` directly and follow its `PAIR_TELEGRAM_ISSUED`/`PAIR_TELEGRAM STATUS=success` blocks — `PLATFORM_ID` and `ADMIN_USER_ID` land in the success block. 3. **Wire the agent.** Run `init-first-agent.ts` in DM mode with `--no-cli-bonus` (this keeps the new agent off the CLI messaging group so the pre-existing throwaway agent still owns CLI routing cleanly): ``` diff --git a/.claude/skills/new-setup/SKILL.md b/.claude/skills/new-setup/SKILL.md index d15ba66..02cef98 100644 --- a/.claude/skills/new-setup/SKILL.md +++ b/.claude/skills/new-setup/SKILL.md @@ -1,7 +1,7 @@ --- name: new-setup description: Shortest path from zero to a working two-way agent chat, for any user regardless of technical background — ends at a running NanoClaw instance with at least one CLI-reachable agent. -allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm run chat:*) Bash(curl -fsSL https://get.docker.com | sh) Bash(curl -fsSL https://deb.nodesource.com/setup_22.x) Bash(sudo apt-get install -y nodejs) Bash(sudo usermod -aG docker:*) Bash(open -a Docker) Bash(sudo systemctl start docker) Bash(node --version) Bash(tail:*) Bash(head:*) Bash(grep:*) +allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(bash setup/install-node.sh) Bash(bash setup/install-docker.sh) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm run chat) Bash(pnpm run chat:*) Bash(open -a Docker) Bash(sudo systemctl start docker) Bash(node --version) Bash(tail:*) Bash(head:*) Bash(grep:*) --- # NanoClaw bare-minimum setup @@ -38,10 +38,7 @@ One permitted parallelism: Check probe results and skip if `HOST_DEPS=ok` — Node, pnpm, `node_modules`, and `better-sqlite3`'s native binding are already in place. -If `HOST_DEPS=missing` and `node --version` fails (Node isn't installed at all), install Node 22 **before** running `bash setup.sh`, otherwise the first bootstrap run is guaranteed to fail: - -- macOS: `brew install node@22` -- Linux / WSL: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs` +If `HOST_DEPS=missing` and `node --version` fails (Node isn't installed at all), run `bash setup/install-node.sh` **before** `bash setup.sh` — the script handles both macOS (via `brew`) and Linux/WSL (NodeSource + apt). It's idempotent and short-circuits when node is already on PATH. Then run `bash setup.sh`. If Node is already present and only `HOST_DEPS=missing`, run `bash setup.sh` directly — deps just haven't been installed yet. @@ -57,16 +54,14 @@ Parse the status block: Check probe results and skip if `DOCKER=running` AND `IMAGE_PRESENT=true`. **Runtime:** -- `DOCKER=not_found` → Docker itself is missing — install it so agent containers have an isolated place to run. - - macOS: `brew install --cask docker && open -a Docker` - - Linux: `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER` (tell user they may need to log out/in for group membership) +- `DOCKER=not_found` → Docker is missing — install it so agent containers have an isolated place to run. Run `bash setup/install-docker.sh` (handles macOS via `brew --cask` and Linux via the official get.docker.com script, and adds the user to the `docker` group on Linux). On Linux the user may need to log out/in for group membership to take effect. On macOS, launch Docker afterwards with `open -a Docker`. - `DOCKER=installed_not_running` → Docker is installed but the daemon is down — start it. - macOS: `open -a Docker` - Linux: `sudo systemctl start docker` Wait ~15s after either, then proceed. -> **Loose commands:** Docker install/start. Justification: platform-specific package-manager invocations. Wrapping them in a `--step` would just move the same branching into TypeScript with no added value. +> **Loose commands:** `open -a Docker`, `sudo systemctl start docker`. Justification: daemon-start is a one-liner per platform, not worth wrapping. The actual install (which had the unmatchable `curl | sh` pattern) is now inside `setup/install-docker.sh`. **Image (run if `IMAGE_PRESENT=false`):** build the agent container image — takes a few minutes the first time, one-off cost. diff --git a/setup/index.ts b/setup/index.ts index 526ea7d..2112cd1 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -10,6 +10,7 @@ const STEPS: Record< () => Promise<{ run: (args: string[]) => Promise }> > = { timezone: () => import('./timezone.js'), + 'set-env': () => import('./set-env.js'), environment: () => import('./environment.js'), container: () => import('./container.js'), register: () => import('./register.js'), diff --git a/setup/install-docker.sh b/setup/install-docker.sh new file mode 100755 index 0000000..4aaadce --- /dev/null +++ b/setup/install-docker.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Setup helper: install-docker — bundles Docker install into one idempotent +# script so /new-setup can run it without needing `curl | sh` in the allowlist +# (pipelines split at matching time, and `sh` receiving stdin can't be +# pre-approved safely). +# +# The script itself is the allowlisted unit; the pipes and sudo live inside +# it. Starting the daemon (after install) stays separate — `open -a Docker` +# and `sudo systemctl start docker` are already in the allowlist. +set -euo pipefail + +echo "=== NANOCLAW SETUP: INSTALL_DOCKER ===" + +if command -v docker >/dev/null 2>&1; then + echo "STATUS: already-installed" + echo "DOCKER_VERSION: $(docker --version 2>/dev/null || echo unknown)" + echo "=== END ===" + exit 0 +fi + +case "$(uname -s)" in + Darwin) + echo "STEP: brew-install-docker" + if ! command -v brew >/dev/null 2>&1; then + echo "STATUS: failed" + echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run." + echo "=== END ===" + exit 1 + fi + brew install --cask docker + ;; + Linux) + echo "STEP: docker-get-script" + curl -fsSL https://get.docker.com | sh + echo "STEP: usermod-docker-group" + sudo usermod -aG docker "$USER" + echo "NOTE: you may need to log out and back in for docker group membership to take effect" + ;; + *) + echo "STATUS: failed" + echo "ERROR: Unsupported platform: $(uname -s)" + echo "=== END ===" + exit 1 + ;; +esac + +if ! command -v docker >/dev/null 2>&1; then + echo "STATUS: failed" + echo "ERROR: docker not found on PATH after install" + echo "=== END ===" + exit 1 +fi + +echo "STATUS: installed" +echo "DOCKER_VERSION: $(docker --version 2>/dev/null || echo unknown)" +echo "=== END ===" diff --git a/setup/install-node.sh b/setup/install-node.sh new file mode 100755 index 0000000..e100ccd --- /dev/null +++ b/setup/install-node.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Setup helper: install-node — bundles Node 22 install into one idempotent +# script so /new-setup can run it without needing `curl | sudo -E bash -` in +# the allowlist (that pattern is inherently unmatchable — bash reads from +# stdin, so pre-approval can't inspect what's being executed). +# +# The script itself is the allowlisted unit; the pipes and sudo live inside +# it. Pure bash by design — runs before Node exists on the host. +set -euo pipefail + +echo "=== NANOCLAW SETUP: INSTALL_NODE ===" + +if command -v node >/dev/null 2>&1; then + echo "STATUS: already-installed" + echo "NODE_VERSION: $(node --version)" + echo "=== END ===" + exit 0 +fi + +case "$(uname -s)" in + Darwin) + echo "STEP: brew-install-node" + if ! command -v brew >/dev/null 2>&1; then + echo "STATUS: failed" + echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run." + echo "=== END ===" + exit 1 + fi + brew install node@22 + ;; + Linux) + echo "STEP: nodesource-setup" + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + echo "STEP: apt-install-nodejs" + sudo apt-get install -y nodejs + ;; + *) + echo "STATUS: failed" + echo "ERROR: Unsupported platform: $(uname -s)" + echo "=== END ===" + exit 1 + ;; +esac + +if ! command -v node >/dev/null 2>&1; then + echo "STATUS: failed" + echo "ERROR: node not found on PATH after install" + echo "=== END ===" + exit 1 +fi + +echo "STATUS: installed" +echo "NODE_VERSION: $(node --version)" +echo "=== END ===" diff --git a/setup/set-env.ts b/setup/set-env.ts new file mode 100644 index 0000000..5ee4b4e --- /dev/null +++ b/setup/set-env.ts @@ -0,0 +1,77 @@ +/** + * Step: set-env — Write or update a KEY=VALUE in .env, with optional sync to + * data/env/env (the container-mounted copy). + * + * Usage: + * pnpm exec tsx setup/index.ts --step set-env -- \ + * --key TELEGRAM_BOT_TOKEN --value "" [--sync-container] + * + * Exists so channel-install flows don't have to invent grep/sed/rm pipelines + * (which can't be allowlisted tightly — sed can read any file, and each + * segment of an && chain is matched separately). + * + * Logs the key but never the value. + */ +import fs from 'fs'; +import path from 'path'; + +import { log } from '../src/log.js'; +import { emitStatus } from './status.js'; + +export async function run(args: string[]): Promise { + const keyIdx = args.indexOf('--key'); + const valueIdx = args.indexOf('--value'); + const syncContainer = args.includes('--sync-container'); + + if (keyIdx === -1 || !args[keyIdx + 1]) { + throw new Error('--key is required'); + } + if (valueIdx === -1 || args[valueIdx + 1] === undefined) { + throw new Error('--value is required'); + } + + const key = args[keyIdx + 1]; + const value = args[valueIdx + 1]; + + if (!/^[A-Z][A-Z0-9_]*$/.test(key)) { + throw new Error(`Invalid env key: ${key} (must be UPPER_SNAKE_CASE)`); + } + + const projectRoot = process.cwd(); + const envFile = path.join(projectRoot, '.env'); + + let content = ''; + if (fs.existsSync(envFile)) { + content = fs.readFileSync(envFile, 'utf-8'); + } + + const lineRegex = new RegExp(`^${key}=.*$`, 'm'); + const newLine = `${key}=${value}`; + const existed = lineRegex.test(content); + + if (existed) { + content = content.replace(lineRegex, newLine); + } else { + const sep = content && !content.endsWith('\n') ? '\n' : ''; + content = content + sep + newLine + '\n'; + } + + fs.writeFileSync(envFile, content); + log.info('Updated .env', { key, existed }); + + let synced = false; + if (syncContainer) { + const dataEnvDir = path.join(projectRoot, 'data', 'env'); + fs.mkdirSync(dataEnvDir, { recursive: true }); + fs.copyFileSync(envFile, path.join(dataEnvDir, 'env')); + synced = true; + log.info('Synced .env to container mount', { path: 'data/env/env' }); + } + + emitStatus('SET_ENV', { + KEY: key, + EXISTED: existed, + SYNCED_TO_CONTAINER: synced, + STATUS: 'success', + }); +} From 866b7915b524b0da35763a52ae82885945a27248 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 18:25:32 +0300 Subject: [PATCH 041/185] fix(container): add /start to filtered commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram clients send /start when a user first DMs a bot (and when they tap "Start" on a bot profile). It's a platform handshake, not a user-intended prompt — forwarding it to the agent wastes a turn and produces a confused response. Matches the existing filter pattern for /help, /login, /logout, /doctor, /config. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/formatter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index b03f5bd..2e90720 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -12,7 +12,7 @@ import { TIMEZONE, formatLocalTime } from './timezone.js'; export type CommandCategory = 'admin' | 'filtered' | 'passthrough' | 'none'; const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files']); -const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config']); +const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/start']); export interface CommandInfo { category: CommandCategory; From dadf258136b7a8c851681c4e632bfb976fac2e04 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 20 Apr 2026 15:33:56 +0000 Subject: [PATCH 042/185] feat(new-setup-2): add per-channel bundled install scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve idempotent install scripts matching install-telegram.sh's shape, one per channel in /new-setup-2's pick list (except Emacs, which uses a git-merge install flow). Each bundles preflight + fetch + copy + register + pnpm install + build so a single allowlisted bash call can replace a chain of permission prompts. Linear's also patches chat-sdk-bridge.ts for catchAll forwarding; Matrix's runs the post-install ESM-extension dist patch; WhatsApp-native's covers the deterministic install portion only — QR/pairing auth still lives in /add-whatsapp. Scripts only; new-setup-2/SKILL.md integration deferred pending a decision on whether to generalize the set-env pattern from 712a0e1 across the Chat SDK channels (each /add-/SKILL.md's Credentials section has a similar unapprovable shell chain). Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/install-discord.sh | 46 ++++++++++++++++ setup/install-gchat.sh | 46 ++++++++++++++++ setup/install-github.sh | 46 ++++++++++++++++ setup/install-imessage.sh | 47 ++++++++++++++++ setup/install-linear.sh | 95 +++++++++++++++++++++++++++++++++ setup/install-matrix.sh | 62 +++++++++++++++++++++ setup/install-resend.sh | 46 ++++++++++++++++ setup/install-slack.sh | 46 ++++++++++++++++ setup/install-teams.sh | 46 ++++++++++++++++ setup/install-webex.sh | 46 ++++++++++++++++ setup/install-whatsapp-cloud.sh | 46 ++++++++++++++++ setup/install-whatsapp.sh | 75 ++++++++++++++++++++++++++ 12 files changed, 647 insertions(+) create mode 100755 setup/install-discord.sh create mode 100755 setup/install-gchat.sh create mode 100755 setup/install-github.sh create mode 100755 setup/install-imessage.sh create mode 100755 setup/install-linear.sh create mode 100755 setup/install-matrix.sh create mode 100755 setup/install-resend.sh create mode 100755 setup/install-slack.sh create mode 100755 setup/install-teams.sh create mode 100755 setup/install-webex.sh create mode 100755 setup/install-whatsapp-cloud.sh create mode 100755 setup/install-whatsapp.sh diff --git a/setup/install-discord.sh b/setup/install-discord.sh new file mode 100755 index 0000000..ee221f9 --- /dev/null +++ b/setup/install-discord.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Setup helper: install-discord — bundles the preflight + install commands +# from the /add-discord skill into one idempotent script so /new-setup-2 can +# run them programmatically before continuing to credentials. +# +# Copies the Discord adapter in from the `channels` branch; appends the +# self-registration import; installs the pinned @chat-adapter/discord package; +# builds. All steps are safe to re-run. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +echo "=== NANOCLAW SETUP: INSTALL_DISCORD ===" + +needs_install=false +[[ -f src/channels/discord.ts ]] || needs_install=true +grep -q "import './discord.js';" src/channels/index.ts || needs_install=true +grep -q '"@chat-adapter/discord"' package.json || needs_install=true +[[ -d node_modules/@chat-adapter/discord ]] || needs_install=true + +if ! $needs_install; then + echo "STATUS: already-installed" + echo "=== END ===" + exit 0 +fi + +echo "STEP: fetch-channels-branch" +git fetch origin channels + +echo "STEP: copy-files" +git show origin/channels:src/channels/discord.ts > src/channels/discord.ts + +echo "STEP: register-import" +if ! grep -q "import './discord.js';" src/channels/index.ts; then + printf "import './discord.js';\n" >> src/channels/index.ts +fi + +echo "STEP: pnpm-install" +pnpm install @chat-adapter/discord@4.26.0 + +echo "STEP: pnpm-build" +pnpm run build + +echo "STATUS: installed" +echo "=== END ===" diff --git a/setup/install-gchat.sh b/setup/install-gchat.sh new file mode 100755 index 0000000..f5c210b --- /dev/null +++ b/setup/install-gchat.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Setup helper: install-gchat — bundles the preflight + install commands +# from the /add-gchat skill into one idempotent script so /new-setup-2 can +# run them programmatically before continuing to credentials. +# +# Copies the Google Chat adapter in from the `channels` branch; appends the +# self-registration import; installs the pinned @chat-adapter/gchat package; +# builds. All steps are safe to re-run. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +echo "=== NANOCLAW SETUP: INSTALL_GCHAT ===" + +needs_install=false +[[ -f src/channels/gchat.ts ]] || needs_install=true +grep -q "import './gchat.js';" src/channels/index.ts || needs_install=true +grep -q '"@chat-adapter/gchat"' package.json || needs_install=true +[[ -d node_modules/@chat-adapter/gchat ]] || needs_install=true + +if ! $needs_install; then + echo "STATUS: already-installed" + echo "=== END ===" + exit 0 +fi + +echo "STEP: fetch-channels-branch" +git fetch origin channels + +echo "STEP: copy-files" +git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts + +echo "STEP: register-import" +if ! grep -q "import './gchat.js';" src/channels/index.ts; then + printf "import './gchat.js';\n" >> src/channels/index.ts +fi + +echo "STEP: pnpm-install" +pnpm install @chat-adapter/gchat@4.26.0 + +echo "STEP: pnpm-build" +pnpm run build + +echo "STATUS: installed" +echo "=== END ===" diff --git a/setup/install-github.sh b/setup/install-github.sh new file mode 100755 index 0000000..81c2977 --- /dev/null +++ b/setup/install-github.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Setup helper: install-github — bundles the preflight + install commands +# from the /add-github skill into one idempotent script so /new-setup-2 can +# run them programmatically before continuing to credentials. +# +# Copies the GitHub adapter in from the `channels` branch; appends the +# self-registration import; installs the pinned @chat-adapter/github package; +# builds. All steps are safe to re-run. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +echo "=== NANOCLAW SETUP: INSTALL_GITHUB ===" + +needs_install=false +[[ -f src/channels/github.ts ]] || needs_install=true +grep -q "import './github.js';" src/channels/index.ts || needs_install=true +grep -q '"@chat-adapter/github"' package.json || needs_install=true +[[ -d node_modules/@chat-adapter/github ]] || needs_install=true + +if ! $needs_install; then + echo "STATUS: already-installed" + echo "=== END ===" + exit 0 +fi + +echo "STEP: fetch-channels-branch" +git fetch origin channels + +echo "STEP: copy-files" +git show origin/channels:src/channels/github.ts > src/channels/github.ts + +echo "STEP: register-import" +if ! grep -q "import './github.js';" src/channels/index.ts; then + printf "import './github.js';\n" >> src/channels/index.ts +fi + +echo "STEP: pnpm-install" +pnpm install @chat-adapter/github@4.26.0 + +echo "STEP: pnpm-build" +pnpm run build + +echo "STATUS: installed" +echo "=== END ===" diff --git a/setup/install-imessage.sh b/setup/install-imessage.sh new file mode 100755 index 0000000..0b1df34 --- /dev/null +++ b/setup/install-imessage.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Setup helper: install-imessage — bundles the preflight + install commands +# from the /add-imessage skill into one idempotent script so /new-setup-2 can +# run them programmatically before continuing to credentials. +# +# Copies the iMessage adapter in from the `channels` branch; appends the +# self-registration import; installs the pinned chat-adapter-imessage package; +# builds. Local vs remote mode pick stays in the skill — this script only +# handles the deterministic install. All steps are safe to re-run. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +echo "=== NANOCLAW SETUP: INSTALL_IMESSAGE ===" + +needs_install=false +[[ -f src/channels/imessage.ts ]] || needs_install=true +grep -q "import './imessage.js';" src/channels/index.ts || needs_install=true +grep -q '"chat-adapter-imessage"' package.json || needs_install=true +[[ -d node_modules/chat-adapter-imessage ]] || needs_install=true + +if ! $needs_install; then + echo "STATUS: already-installed" + echo "=== END ===" + exit 0 +fi + +echo "STEP: fetch-channels-branch" +git fetch origin channels + +echo "STEP: copy-files" +git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts + +echo "STEP: register-import" +if ! grep -q "import './imessage.js';" src/channels/index.ts; then + printf "import './imessage.js';\n" >> src/channels/index.ts +fi + +echo "STEP: pnpm-install" +pnpm install chat-adapter-imessage@0.1.1 + +echo "STEP: pnpm-build" +pnpm run build + +echo "STATUS: installed" +echo "=== END ===" diff --git a/setup/install-linear.sh b/setup/install-linear.sh new file mode 100755 index 0000000..9f42bec --- /dev/null +++ b/setup/install-linear.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Setup helper: install-linear — bundles the preflight + install commands +# from the /add-linear skill into one idempotent script so /new-setup-2 can +# run them programmatically before continuing to credentials. +# +# Copies the Linear adapter in from the `channels` branch; appends the +# self-registration import; patches src/channels/chat-sdk-bridge.ts to add +# catch-all forwarding (Linear OAuth apps can't be @-mentioned, so the +# onNewMention handler never fires — the bridge needs a catchAll path); +# installs the pinned @chat-adapter/linear package; builds. All steps are +# safe to re-run. +# +# Note: the bridge patch's onNewMessage handler passes `false` for isMention +# (current trunk signature requires the arg). The /add-linear SKILL's +# snippet omits the arg — this script uses the full signature so TypeScript +# builds cleanly. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +echo "=== NANOCLAW SETUP: INSTALL_LINEAR ===" + +needs_install=false +[[ -f src/channels/linear.ts ]] || needs_install=true +grep -q "import './linear.js';" src/channels/index.ts || needs_install=true +grep -q '"@chat-adapter/linear"' package.json || needs_install=true +[[ -d node_modules/@chat-adapter/linear ]] || needs_install=true +grep -q 'catchAll' src/channels/chat-sdk-bridge.ts || needs_install=true + +if ! $needs_install; then + echo "STATUS: already-installed" + echo "=== END ===" + exit 0 +fi + +echo "STEP: fetch-channels-branch" +git fetch origin channels + +echo "STEP: copy-files" +git show origin/channels:src/channels/linear.ts > src/channels/linear.ts + +echo "STEP: register-import" +if ! grep -q "import './linear.js';" src/channels/index.ts; then + printf "import './linear.js';\n" >> src/channels/index.ts +fi + +echo "STEP: patch-bridge-catchall-field" +if ! grep -q 'catchAll?: boolean;' src/channels/chat-sdk-bridge.ts; then + awk ' + /^export interface ChatSdkBridgeConfig \{/ { in_iface = 1 } + in_iface && /^\}/ && !inserted { + print " /**" + print " * Forward ALL messages in unsubscribed threads, not just @-mentions." + print " * Use for platforms where the bot identity can'\''t be @-mentioned (e.g." + print " * Linear OAuth apps). The thread is auto-subscribed on first message." + print " */" + print " catchAll?: boolean;" + inserted = 1 + in_iface = 0 + } + { print } + ' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \ + && mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts +fi + +echo "STEP: patch-bridge-catchall-handler" +if ! grep -q 'if (config.catchAll) {' src/channels/chat-sdk-bridge.ts; then + awk ' + / \/\/ DMs — apply engage rules too/ && !inserted { + print " // Catch-all for platforms where @-mention isn'\''t possible (e.g. Linear" + print " // OAuth apps). Forward every unsubscribed message and auto-subscribe." + print " if (config.catchAll) {" + print " chat.onNewMessage(/.*/, async (thread, message) => {" + print " const channelId = adapter.channelIdFromThreadId(thread.id);" + print " await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false));" + print " await thread.subscribe();" + print " });" + print " }" + print "" + inserted = 1 + } + { print } + ' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \ + && mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts +fi + +echo "STEP: pnpm-install" +pnpm install @chat-adapter/linear@4.26.0 + +echo "STEP: pnpm-build" +pnpm run build + +echo "STATUS: installed" +echo "=== END ===" diff --git a/setup/install-matrix.sh b/setup/install-matrix.sh new file mode 100755 index 0000000..06d5ccd --- /dev/null +++ b/setup/install-matrix.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Setup helper: install-matrix — bundles the preflight + install commands +# from the /add-matrix skill into one idempotent script so /new-setup-2 can +# run them programmatically before continuing to credentials. +# +# Copies the Matrix adapter in from the `channels` branch; appends the +# self-registration import; installs the pinned @beeper/chat-adapter-matrix +# package; patches the adapter's published dist so its matrix-js-sdk/lib +# imports carry .js extensions (required under Node 22 strict ESM); builds. +# All steps are safe to re-run — re-run this script after any pnpm install +# that touches the adapter. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +echo "=== NANOCLAW SETUP: INSTALL_MATRIX ===" + +needs_install=false +[[ -f src/channels/matrix.ts ]] || needs_install=true +grep -q "import './matrix.js';" src/channels/index.ts || needs_install=true +grep -q '"@beeper/chat-adapter-matrix"' package.json || needs_install=true +[[ -d node_modules/@beeper/chat-adapter-matrix ]] || needs_install=true + +if ! $needs_install; then + echo "STATUS: already-installed" + echo "=== END ===" + exit 0 +fi + +echo "STEP: fetch-channels-branch" +git fetch origin channels + +echo "STEP: copy-files" +git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts + +echo "STEP: register-import" +if ! grep -q "import './matrix.js';" src/channels/index.ts; then + printf "import './matrix.js';\n" >> src/channels/index.ts +fi + +echo "STEP: pnpm-install" +pnpm install @beeper/chat-adapter-matrix@0.2.0 + +echo "STEP: patch-esm-extensions" +node -e ' + const fs = require("fs"), path = require("path"); + const root = "node_modules/.pnpm"; + const dir = fs.readdirSync(root).find(d => d.startsWith("@beeper+chat-adapter-matrix@")); + if (!dir) { console.log("Matrix adapter not installed"); process.exit(0); } + const f = path.join(root, dir, "node_modules/@beeper/chat-adapter-matrix/dist/index.js"); + fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace( + /from "(matrix-js-sdk\/lib\/[^"]+?)(? src/channels/resend.ts + +echo "STEP: register-import" +if ! grep -q "import './resend.js';" src/channels/index.ts; then + printf "import './resend.js';\n" >> src/channels/index.ts +fi + +echo "STEP: pnpm-install" +pnpm install @resend/chat-sdk-adapter@0.1.1 + +echo "STEP: pnpm-build" +pnpm run build + +echo "STATUS: installed" +echo "=== END ===" diff --git a/setup/install-slack.sh b/setup/install-slack.sh new file mode 100755 index 0000000..8be6a37 --- /dev/null +++ b/setup/install-slack.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Setup helper: install-slack — bundles the preflight + install commands +# from the /add-slack skill into one idempotent script so /new-setup-2 can +# run them programmatically before continuing to credentials. +# +# Copies the Slack adapter in from the `channels` branch; appends the +# self-registration import; installs the pinned @chat-adapter/slack package; +# builds. All steps are safe to re-run. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +echo "=== NANOCLAW SETUP: INSTALL_SLACK ===" + +needs_install=false +[[ -f src/channels/slack.ts ]] || needs_install=true +grep -q "import './slack.js';" src/channels/index.ts || needs_install=true +grep -q '"@chat-adapter/slack"' package.json || needs_install=true +[[ -d node_modules/@chat-adapter/slack ]] || needs_install=true + +if ! $needs_install; then + echo "STATUS: already-installed" + echo "=== END ===" + exit 0 +fi + +echo "STEP: fetch-channels-branch" +git fetch origin channels + +echo "STEP: copy-files" +git show origin/channels:src/channels/slack.ts > src/channels/slack.ts + +echo "STEP: register-import" +if ! grep -q "import './slack.js';" src/channels/index.ts; then + printf "import './slack.js';\n" >> src/channels/index.ts +fi + +echo "STEP: pnpm-install" +pnpm install @chat-adapter/slack@4.26.0 + +echo "STEP: pnpm-build" +pnpm run build + +echo "STATUS: installed" +echo "=== END ===" diff --git a/setup/install-teams.sh b/setup/install-teams.sh new file mode 100755 index 0000000..cb66f67 --- /dev/null +++ b/setup/install-teams.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Setup helper: install-teams — bundles the preflight + install commands +# from the /add-teams skill into one idempotent script so /new-setup-2 can +# run them programmatically before continuing to credentials. +# +# Copies the Teams adapter in from the `channels` branch; appends the +# self-registration import; installs the pinned @chat-adapter/teams package; +# builds. All steps are safe to re-run. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +echo "=== NANOCLAW SETUP: INSTALL_TEAMS ===" + +needs_install=false +[[ -f src/channels/teams.ts ]] || needs_install=true +grep -q "import './teams.js';" src/channels/index.ts || needs_install=true +grep -q '"@chat-adapter/teams"' package.json || needs_install=true +[[ -d node_modules/@chat-adapter/teams ]] || needs_install=true + +if ! $needs_install; then + echo "STATUS: already-installed" + echo "=== END ===" + exit 0 +fi + +echo "STEP: fetch-channels-branch" +git fetch origin channels + +echo "STEP: copy-files" +git show origin/channels:src/channels/teams.ts > src/channels/teams.ts + +echo "STEP: register-import" +if ! grep -q "import './teams.js';" src/channels/index.ts; then + printf "import './teams.js';\n" >> src/channels/index.ts +fi + +echo "STEP: pnpm-install" +pnpm install @chat-adapter/teams@4.26.0 + +echo "STEP: pnpm-build" +pnpm run build + +echo "STATUS: installed" +echo "=== END ===" diff --git a/setup/install-webex.sh b/setup/install-webex.sh new file mode 100755 index 0000000..8bbbc83 --- /dev/null +++ b/setup/install-webex.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Setup helper: install-webex — bundles the preflight + install commands +# from the /add-webex skill into one idempotent script so /new-setup-2 can +# run them programmatically before continuing to credentials. +# +# Copies the Webex adapter in from the `channels` branch; appends the +# self-registration import; installs the pinned @bitbasti/chat-adapter-webex +# package; builds. All steps are safe to re-run. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +echo "=== NANOCLAW SETUP: INSTALL_WEBEX ===" + +needs_install=false +[[ -f src/channels/webex.ts ]] || needs_install=true +grep -q "import './webex.js';" src/channels/index.ts || needs_install=true +grep -q '"@bitbasti/chat-adapter-webex"' package.json || needs_install=true +[[ -d node_modules/@bitbasti/chat-adapter-webex ]] || needs_install=true + +if ! $needs_install; then + echo "STATUS: already-installed" + echo "=== END ===" + exit 0 +fi + +echo "STEP: fetch-channels-branch" +git fetch origin channels + +echo "STEP: copy-files" +git show origin/channels:src/channels/webex.ts > src/channels/webex.ts + +echo "STEP: register-import" +if ! grep -q "import './webex.js';" src/channels/index.ts; then + printf "import './webex.js';\n" >> src/channels/index.ts +fi + +echo "STEP: pnpm-install" +pnpm install @bitbasti/chat-adapter-webex@0.1.0 + +echo "STEP: pnpm-build" +pnpm run build + +echo "STATUS: installed" +echo "=== END ===" diff --git a/setup/install-whatsapp-cloud.sh b/setup/install-whatsapp-cloud.sh new file mode 100755 index 0000000..3773278 --- /dev/null +++ b/setup/install-whatsapp-cloud.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Setup helper: install-whatsapp-cloud — bundles the preflight + install +# commands from the /add-whatsapp-cloud skill into one idempotent script so +# /new-setup-2 can run them programmatically before continuing to credentials. +# +# Copies the WhatsApp Cloud adapter in from the `channels` branch; appends the +# self-registration import; installs the pinned @chat-adapter/whatsapp package; +# builds. All steps are safe to re-run. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +echo "=== NANOCLAW SETUP: INSTALL_WHATSAPP_CLOUD ===" + +needs_install=false +[[ -f src/channels/whatsapp-cloud.ts ]] || needs_install=true +grep -q "import './whatsapp-cloud.js';" src/channels/index.ts || needs_install=true +grep -q '"@chat-adapter/whatsapp"' package.json || needs_install=true +[[ -d node_modules/@chat-adapter/whatsapp ]] || needs_install=true + +if ! $needs_install; then + echo "STATUS: already-installed" + echo "=== END ===" + exit 0 +fi + +echo "STEP: fetch-channels-branch" +git fetch origin channels + +echo "STEP: copy-files" +git show origin/channels:src/channels/whatsapp-cloud.ts > src/channels/whatsapp-cloud.ts + +echo "STEP: register-import" +if ! grep -q "import './whatsapp-cloud.js';" src/channels/index.ts; then + printf "import './whatsapp-cloud.js';\n" >> src/channels/index.ts +fi + +echo "STEP: pnpm-install" +pnpm install @chat-adapter/whatsapp@4.26.0 + +echo "STEP: pnpm-build" +pnpm run build + +echo "STATUS: installed" +echo "=== END ===" diff --git a/setup/install-whatsapp.sh b/setup/install-whatsapp.sh new file mode 100755 index 0000000..0d307f5 --- /dev/null +++ b/setup/install-whatsapp.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Setup helper: install-whatsapp — bundles the preflight + install commands +# from the /add-whatsapp skill into one idempotent script so /new-setup-2 can +# run them programmatically before continuing to QR/pairing-code auth. +# +# Copies the native Baileys WhatsApp adapter, its whatsapp-auth and groups +# setup steps in from the `channels` branch; appends the self-registration +# import; registers `groups` and `whatsapp-auth` entries in the setup STEPS +# map; installs the pinned @whiskeysockets/baileys + qrcode + pino packages; +# builds. All steps are safe to re-run. QR/pairing-code authentication +# stays in the skill — this script only handles the deterministic install. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +echo "=== NANOCLAW SETUP: INSTALL_WHATSAPP ===" + +CHANNEL_FILES=( + src/channels/whatsapp.ts + setup/whatsapp-auth.ts + setup/groups.ts +) + +needs_install=false +for f in "${CHANNEL_FILES[@]}"; do + [[ -f "$f" ]] || needs_install=true +done +grep -q "import './whatsapp.js';" src/channels/index.ts || needs_install=true +grep -q "groups: " setup/index.ts || needs_install=true +grep -q "'whatsapp-auth':" setup/index.ts || needs_install=true +grep -q '"@whiskeysockets/baileys"' package.json || needs_install=true +grep -q '"qrcode"' package.json || needs_install=true +grep -q '"pino"' package.json || needs_install=true +[[ -d node_modules/@whiskeysockets/baileys ]] || needs_install=true + +if ! $needs_install; then + echo "STATUS: already-installed" + echo "=== END ===" + exit 0 +fi + +echo "STEP: fetch-channels-branch" +git fetch origin channels + +echo "STEP: copy-files" +for f in "${CHANNEL_FILES[@]}"; do + git show "origin/channels:$f" > "$f" +done + +echo "STEP: register-import" +if ! grep -q "import './whatsapp.js';" src/channels/index.ts; then + printf "import './whatsapp.js';\n" >> src/channels/index.ts +fi + +echo "STEP: register-setup-steps" +if ! grep -q "'whatsapp-auth':" setup/index.ts; then + awk ' + { print } + /register: \(\) => import/ && !inserted { + print " groups: () => import('\''./groups.js'\'')," + print " '\''whatsapp-auth'\'': () => import('\''./whatsapp-auth.js'\'')," + inserted = 1 + } + ' setup/index.ts > setup/index.ts.tmp && mv setup/index.ts.tmp setup/index.ts +fi + +echo "STEP: pnpm-install" +pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 + +echo "STEP: pnpm-build" +pnpm run build + +echo "STATUS: installed" +echo "=== END ===" From 6c26c0413a35366ecb0c08474c83cb0501cc3985 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 23:30:47 +0300 Subject: [PATCH 043/185] feat(router,cli): replyTo override + CLI admin-transport flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InboundEvent gains an optional replyTo; router stamps the row's address fields from it when set, so replies can route to a different channel than the one the inbound came in on. - ChannelSetup adds onInboundEvent for admin-transport adapters that build the full event themselves. - CLI wire format accepts {text, to, reply_to}. Routed messages go through onInboundEvent and do not evict an active chat client. - init-first-agent hands the DM welcome to the running service via data/cli.sock — synchronous wake, no sweep wait. Fails loudly if the service is down; no silent fallback. - Split the CLI scratch-agent bootstrap into scripts/init-cli-agent.ts; init-first-agent is DM-only. Agents cannot set replyTo: it lives only on the inbound/router seam and is consumed once when writing messages_in. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/init-first-agent/SKILL.md | 7 +- scripts/init-cli-agent.ts | 179 ++++++++++++++ scripts/init-first-agent.ts | 244 ++++++++++---------- setup/cli-agent.ts | 28 +-- src/channels/adapter.ts | 43 ++++ src/channels/channel-registry.test.ts | 2 + src/channels/cli.ts | 156 ++++++++++--- src/host-core.test.ts | 2 +- src/index.ts | 9 + src/modules/approvals/picks.test.ts | 1 + src/modules/permissions/channel-approval.ts | 2 +- src/modules/permissions/index.ts | 2 +- src/modules/permissions/permissions.test.ts | 1 + src/modules/permissions/sender-approval.ts | 2 +- src/router.ts | 38 ++- 15 files changed, 503 insertions(+), 213 deletions(-) create mode 100644 scripts/init-cli-agent.ts diff --git a/.claude/skills/init-first-agent/SKILL.md b/.claude/skills/init-first-agent/SKILL.md index be78845..6b110d3 100644 --- a/.claude/skills/init-first-agent/SKILL.md +++ b/.claude/skills/init-first-agent/SKILL.md @@ -87,18 +87,17 @@ The script: 2. Creates the `agent_groups` row and calls `initGroupFilesystem` at `groups/dm-with-/`. 3. Reuses or creates the DM `messaging_groups` row. 4. Wires them via `messaging_group_agents` (which auto-creates the companion `agent_destinations` row). -5. Resolves the session (creates `inbound.db` / `outbound.db`). -6. Writes a `kind: 'chat'`, `sender: 'system'` welcome message into `inbound.db`. +5. Hands the welcome message to the running service via its CLI socket (`data/cli.sock`), targeting the DM messaging group. The service routes it into the DM session, which wakes the container synchronously. If the socket isn't reachable (service down), falls back to a direct `inbound.db` write that the next host sweep picks up. Show the script's output to the user. ## 5. Verify -Host sweep runs every ~60s. Within one sweep window the container wakes, the agent processes the system message, and the reply flows through `outbound.db` to the channel. +The welcome DM is queued synchronously; the only wait is container cold-start (~60s on first launch) before the agent processes the message and the reply flows through `outbound.db` to the channel. Do not tail the log or poll in a sleep loop. Ask the user in plain text: -> The welcome DM should arrive within ~60 seconds. Let me know when you've received it (or if it doesn't arrive within two minutes). +> The welcome DM should arrive shortly. Let me know when you've received it (or if it doesn't arrive within two minutes). Wait for the user's reply. If they confirm receipt, the skill is done. diff --git a/scripts/init-cli-agent.ts b/scripts/init-cli-agent.ts new file mode 100644 index 0000000..ccd9387 --- /dev/null +++ b/scripts/init-cli-agent.ts @@ -0,0 +1,179 @@ +/** + * Initialize the scratch CLI agent used during `/new-setup`. + * + * Creates the synthetic `cli:local` user, grants owner role if no owner + * exists yet, builds an agent group with a minimal CLAUDE.md, and wires it + * to the CLI messaging group so `pnpm run chat` works immediately. + * + * No welcome is staged — the operator's first `pnpm run chat` is the + * natural wake, and the agent introduces itself on first contact per its + * CLAUDE.md. + * + * Runs alongside the service (WAL-mode sqlite) — does NOT initialize + * channel adapters, so there's no Gateway conflict. + * + * Usage: + * pnpm exec tsx scripts/init-cli-agent.ts \ + * --display-name "Gavriel" \ + * [--agent-name "Andy"] + */ +import path from 'path'; + +import { DATA_DIR } from '../src/config.js'; +import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js'; +import { initDb } from '../src/db/connection.js'; +import { + createMessagingGroup, + createMessagingGroupAgent, + getMessagingGroupAgentByPair, + getMessagingGroupByPlatform, +} from '../src/db/messaging-groups.js'; +import { runMigrations } from '../src/db/migrations/index.js'; +import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.js'; +import { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles.js'; +import { upsertUser } from '../src/modules/permissions/db/users.js'; +import { initGroupFilesystem } from '../src/group-init.js'; +import type { AgentGroup, MessagingGroup } from '../src/types.js'; + +const CLI_CHANNEL = 'cli'; +const CLI_PLATFORM_ID = 'local'; +const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`; + +interface Args { + displayName: string; + agentName: string; +} + +function parseArgs(argv: string[]): Args { + let displayName: string | undefined; + let agentName: string | undefined; + for (let i = 0; i < argv.length; i++) { + const key = argv[i]; + const val = argv[i + 1]; + if (key === '--display-name') { + displayName = val; + i++; + } else if (key === '--agent-name') { + agentName = val; + i++; + } + } + + if (!displayName) { + console.error('Missing required arg: --display-name'); + console.error('See scripts/init-cli-agent.ts header for usage.'); + process.exit(2); + } + + return { + displayName, + agentName: agentName?.trim() || displayName, + }; +} + +function generateId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + + const db = initDb(path.join(DATA_DIR, 'v2.db')); + runMigrations(db); + + const now = new Date().toISOString(); + + // 1. Synthetic CLI user + owner grant if none exists. + upsertUser({ + id: CLI_SYNTHETIC_USER_ID, + kind: CLI_CHANNEL, + display_name: args.displayName, + created_at: now, + }); + + let promotedToOwner = false; + if (!hasAnyOwner()) { + grantRole({ + user_id: CLI_SYNTHETIC_USER_ID, + role: 'owner', + agent_group_id: null, + granted_by: null, + granted_at: now, + }); + promotedToOwner = true; + } + + // 2. Agent group + filesystem. + const folder = `cli-with-${normalizeName(args.displayName)}`; + let ag: AgentGroup | undefined = getAgentGroupByFolder(folder); + if (!ag) { + const agId = generateId('ag'); + createAgentGroup({ + id: agId, + name: args.agentName, + folder, + agent_provider: null, + created_at: now, + }); + ag = getAgentGroupByFolder(folder)!; + console.log(`Created agent group: ${ag.id} (${folder})`); + } else { + console.log(`Reusing agent group: ${ag.id} (${folder})`); + } + initGroupFilesystem(ag, { + instructions: + `# ${args.agentName}\n\n` + + `You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` + + 'When the user first reaches out, introduce yourself briefly and invite them to chat. Keep replies concise.', + }); + + // 3. CLI messaging group + wiring. + let cliMg: MessagingGroup | undefined = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID); + if (!cliMg) { + cliMg = { + id: generateId('mg'), + channel_type: CLI_CHANNEL, + platform_id: CLI_PLATFORM_ID, + name: 'Local CLI', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now, + }; + createMessagingGroup(cliMg); + console.log(`Created CLI messaging group: ${cliMg.id}`); + } + + const existing = getMessagingGroupAgentByPair(cliMg.id, ag.id); + if (!existing) { + createMessagingGroupAgent({ + id: generateId('mga'), + messaging_group_id: cliMg.id, + agent_group_id: ag.id, + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', + session_mode: 'shared', + priority: 0, + created_at: now, + }); + console.log(`Wired cli: ${cliMg.id} -> ${ag.id}`); + } else { + console.log(`Wiring already exists: ${existing.id}`); + } + + console.log(''); + console.log('Init complete.'); + console.log( + ` owner: ${CLI_SYNTHETIC_USER_ID}${promotedToOwner ? ' (promoted on first owner)' : ''}`, + ); + console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`); + console.log(` channel: cli/${CLI_PLATFORM_ID}`); + console.log(''); + console.log('Run `pnpm run chat hi` to talk to your agent.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index 8468778..29ca6d4 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -1,43 +1,39 @@ /** - * Init the first (or Nth) NanoClaw v2 agent. + * Init the first (or Nth) NanoClaw v2 agent for a DM channel. * - * Two modes: + * Wires a real DM channel (discord, telegram, etc.) to a new agent group + * (and the local CLI channel as a convenience bonus), then hands a welcome + * message to the running service via its CLI socket. The service routes + * that message into the DM session, which wakes the container synchronously — + * the agent processes the welcome and DMs the operator through the normal + * delivery path. * - * 1. **DM channel mode** (default): wires a real DM channel (discord, telegram, - * etc.) + the CLI channel to the same agent, stages a welcome into the DM - * session so the agent greets the operator over that channel. - * - * 2. **CLI-only mode** (`--cli-only`): wires only the CLI channel. Used by - * `/new-setup` to get to a working 2-way CLI chat with the bare minimum. - * Owner grant uses a synthetic `cli:local` user so admin-gated flows work. + * For the CLI-only scratch agent used during `/new-setup`, see + * `scripts/init-cli-agent.ts` — that's a distinct flow and doesn't run + * through here. * * Creates/reuses: user, owner grant (if none), agent group + filesystem, - * messaging group(s), wiring, session. Stages a system welcome message so - * the host sweep wakes the container and the agent sends the greeting via - * the normal delivery path. + * messaging group(s), wiring. * - * Runs alongside the service (WAL-mode sqlite) — does NOT initialize - * channel adapters, so there's no Gateway conflict. + * Runs alongside the service (WAL-mode sqlite + CLI socket IPC) — does NOT + * initialize channel adapters, so there's no Gateway conflict. Requires + * the service to be running: the welcome hand-off goes over the CLI socket + * and fails loudly if the service isn't up. * * Usage: - * # DM mode * pnpm exec tsx scripts/init-first-agent.ts \ * --channel discord \ * --user-id discord:1470183333427675709 \ * --platform-id discord:@me:1491573333382523708 \ * --display-name "Gavriel" \ * [--agent-name "Andy"] \ - * [--welcome "System instruction: ..."] - * - * # CLI-only mode - * pnpm exec tsx scripts/init-first-agent.ts --cli-only \ - * --display-name "Gavriel" \ - * [--agent-name "Andy"] \ - * [--welcome "System instruction: ..."] + * [--welcome "System instruction: ..."] \ + * [--no-cli-bonus] * * For direct-addressable channels (telegram, whatsapp, etc.), --platform-id * is typically the same as the handle in --user-id, with the channel prefix. */ +import net from 'net'; import path from 'path'; import { DATA_DIR } from '../src/config.js'; @@ -54,11 +50,9 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio import { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles.js'; import { upsertUser } from '../src/modules/permissions/db/users.js'; import { initGroupFilesystem } from '../src/group-init.js'; -import { resolveSession, writeSessionMessage } from '../src/session-manager.js'; import type { AgentGroup, MessagingGroup } from '../src/types.js'; interface Args { - cliOnly: boolean; noCliBonus: boolean; channel: string; userId: string; @@ -73,17 +67,13 @@ const DEFAULT_WELCOME = const CLI_CHANNEL = 'cli'; const CLI_PLATFORM_ID = 'local'; -const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`; function parseArgs(argv: string[]): Args { - const out: Partial = { cliOnly: false, noCliBonus: false }; + const out: Partial = { noCliBonus: false }; for (let i = 0; i < argv.length; i++) { const key = argv[i]; const val = argv[i + 1]; switch (key) { - case '--cli-only': - out.cliOnly = true; - break; case '--no-cli-bonus': out.noCliBonus = true; break; @@ -114,42 +104,23 @@ function parseArgs(argv: string[]): Args { } } - if (!out.displayName) { - console.error('Missing required arg: --display-name'); - console.error('See scripts/init-first-agent.ts header for usage.'); - process.exit(2); - } - - if (out.cliOnly) { - // CLI-only: channel/user/platform default to the synthetic local CLI identity. - return { - cliOnly: true, - noCliBonus: out.noCliBonus ?? false, - channel: CLI_CHANNEL, - userId: CLI_SYNTHETIC_USER_ID, - platformId: CLI_PLATFORM_ID, - displayName: out.displayName, - agentName: out.agentName?.trim() || out.displayName, - welcome: out.welcome?.trim() || DEFAULT_WELCOME, - }; - } - - const required: (keyof Args)[] = ['channel', 'userId', 'platformId']; + const required: (keyof Args)[] = ['channel', 'userId', 'platformId', 'displayName']; const missing = required.filter((k) => !out[k]); if (missing.length) { - console.error(`Missing required args: ${missing.map((k) => `--${k.replace(/([A-Z])/g, '-$1').toLowerCase()}`).join(', ')}`); + console.error( + `Missing required args: ${missing.map((k) => `--${k.replace(/([A-Z])/g, '-$1').toLowerCase()}`).join(', ')}`, + ); console.error('See scripts/init-first-agent.ts header for usage.'); process.exit(2); } return { - cliOnly: false, noCliBonus: out.noCliBonus ?? false, channel: out.channel!, userId: out.userId!, platformId: out.platformId!, - displayName: out.displayName, - agentName: out.agentName?.trim() || out.displayName, + displayName: out.displayName!, + agentName: out.agentName?.trim() || out.displayName!, welcome: out.welcome?.trim() || DEFAULT_WELCOME, }; } @@ -217,7 +188,6 @@ async function main(): Promise { const now = new Date().toISOString(); // 1. User + (conditional) owner grant. - // In cli-only mode, the synthetic `cli:local` user becomes the first owner. const userId = namespacedUserId(args.channel, args.userId); upsertUser({ id: userId, @@ -238,10 +208,8 @@ async function main(): Promise { promotedToOwner = true; } - // 2. Agent group + filesystem - const folder = args.cliOnly - ? `cli-with-${normalizeName(args.displayName)}` - : `dm-with-${normalizeName(args.displayName)}`; + // 2. Agent group + filesystem. + const folder = `dm-with-${normalizeName(args.displayName)}`; let ag: AgentGroup | undefined = getAgentGroupByFolder(folder); if (!ag) { const agId = generateId('ag'); @@ -261,89 +229,115 @@ async function main(): Promise { instructions: `# ${args.agentName}\n\n` + `You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` + - 'When you receive a system welcome prompt, introduce yourself briefly and invite them to chat. Keep replies concise.', + 'When the user first reaches out (or you receive a system welcome prompt), introduce yourself briefly and invite them to chat. Keep replies concise.', }); - // 3. Primary messaging group + wiring + welcome session. - // In DM mode: the DM messaging group is primary, CLI is wired as a bonus. - // In cli-only mode: the CLI messaging group is primary; no DM group. - const cliMg = ensureCliMessagingGroup(now); - - let primaryMg: MessagingGroup; - if (args.cliOnly) { - primaryMg = cliMg; + // 3. DM messaging group. + const platformId = namespacedPlatformId(args.channel, args.platformId); + let dmMg = getMessagingGroupByPlatform(args.channel, platformId); + if (!dmMg) { + const mgId = generateId('mg'); + createMessagingGroup({ + id: mgId, + channel_type: args.channel, + platform_id: platformId, + name: args.displayName, + is_group: 0, + unknown_sender_policy: 'strict', + created_at: now, + }); + dmMg = getMessagingGroupByPlatform(args.channel, platformId)!; + console.log(`Created messaging group: ${dmMg.id} (${platformId})`); } else { - const platformId = namespacedPlatformId(args.channel, args.platformId); - let dmMg = getMessagingGroupByPlatform(args.channel, platformId); - if (!dmMg) { - const mgId = generateId('mg'); - createMessagingGroup({ - id: mgId, - channel_type: args.channel, - platform_id: platformId, - name: args.displayName, - is_group: 0, - unknown_sender_policy: 'strict', - created_at: now, - }); - dmMg = getMessagingGroupByPlatform(args.channel, platformId)!; - console.log(`Created messaging group: ${dmMg.id} (${platformId})`); - } else { - console.log(`Reusing messaging group: ${dmMg.id} (${platformId})`); - } - primaryMg = dmMg; + console.log(`Reusing messaging group: ${dmMg.id} (${platformId})`); } - // Wire primary (DM or CLI), auto-creates companion agent_destinations row. - wireIfMissing(primaryMg, ag, now, args.cliOnly ? 'cli' : 'dm'); - - // In DM mode also wire CLI so `pnpm run chat` works immediately. - // Skip the bonus when --no-cli-bonus is set — used by /new-setup-2 so the - // throwaway CLI-only agent from /new-setup still owns CLI routing cleanly. - if (!args.cliOnly && !args.noCliBonus) { + // 4. Wire DM (auto-creates companion agent_destinations row) and, + // unless suppressed, also wire the CLI channel so `pnpm run chat` works + // against the new agent immediately. `/new-setup-2` sets --no-cli-bonus + // so the scratch CLI agent from `/new-setup` keeps owning CLI routing. + wireIfMissing(dmMg, ag, now, 'dm'); + if (!args.noCliBonus) { + const cliMg = ensureCliMessagingGroup(now); wireIfMissing(cliMg, ag, now, 'cli-bonus'); } - // 4. Session + staged welcome (on the primary messaging group) - const { session, created } = resolveSession(ag.id, primaryMg.id, null, 'shared'); - console.log(`${created ? 'Created' : 'Reusing'} session: ${session.id}`); - - writeSessionMessage(ag.id, session.id, { - id: generateId('sys-welcome'), - kind: 'chat', - timestamp: now, - platformId: primaryMg.platform_id, - channelType: primaryMg.channel_type, - threadId: null, - content: JSON.stringify({ - text: args.welcome, - sender: 'system', - senderId: 'system', - }), - }); + // 5. Welcome delivery over the CLI socket. Router picks up the line, + // writes the message into the DM session's inbound.db, and wakes the + // container synchronously — no sweep wait. + await sendWelcomeViaCliSocket(dmMg, args.welcome); console.log(''); console.log('Init complete.'); console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`); console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`); - if (args.cliOnly) { - console.log(` channel: cli/${CLI_PLATFORM_ID}`); - } else { - console.log(` channel: ${args.channel} ${primaryMg.platform_id}`); - if (!args.noCliBonus) { - console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``); - } + console.log(` channel: ${args.channel} ${dmMg.platform_id}`); + if (!args.noCliBonus) { + console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``); } - console.log(` session: ${session.id}`); console.log(''); - console.log( - args.cliOnly - ? 'Host sweep (<=60s) will wake the container. Try `pnpm run chat hi`.' - : 'Host sweep (<=60s) will wake the container and the agent will send the welcome DM.', - ); + console.log('Welcome DM queued — the agent will greet you shortly.'); +} + +/** + * Hand the welcome to the running service via its CLI Unix socket. The + * service's CLI adapter receives `{text, to}`, builds an InboundEvent + * targeting the DM messaging group, and calls routeInbound(). Router writes + * the message into inbound.db and wakes the container synchronously. + * + * Throws if the socket isn't reachable — this script requires the service + * to be running. + */ +async function sendWelcomeViaCliSocket(dmMg: MessagingGroup, welcome: string): Promise { + const sockPath = path.join(DATA_DIR, 'cli.sock'); + + await new Promise((resolve, reject) => { + const socket = net.connect(sockPath); + let settled = false; + + const settle = (err: Error | null) => { + if (settled) return; + settled = true; + try { + socket.end(); + } catch { + /* noop */ + } + if (err) reject(err); + else resolve(); + }; + + socket.once('error', (err) => + settle( + new Error( + `CLI socket at ${sockPath} not reachable: ${err.message}. Is the NanoClaw service running?`, + ), + ), + ); + socket.once('connect', () => { + const payload = + JSON.stringify({ + text: welcome, + to: { + channelType: dmMg.channel_type, + platformId: dmMg.platform_id, + threadId: null, + }, + }) + '\n'; + socket.write(payload, (err) => { + if (err) { + settle(err); + return; + } + // Brief flush delay so the router picks up the line before we close. + // Router handles it synchronously once read, so 50ms is plenty. + setTimeout(() => settle(null), 50); + }); + }); + }); } main().catch((err) => { - console.error(err); + console.error(err instanceof Error ? err.message : err); process.exit(1); }); diff --git a/setup/cli-agent.ts b/setup/cli-agent.ts index e5a901d..d9a90c5 100644 --- a/setup/cli-agent.ts +++ b/setup/cli-agent.ts @@ -1,14 +1,13 @@ /** - * Step: cli-agent — Create the first agent wired to the CLI channel. + * Step: cli-agent — Create the scratch CLI agent for `/new-setup`. * - * Thin wrapper around `scripts/init-first-agent.ts --cli-only`. Emits a - * status block so /new-setup SKILL.md can parse the result without having - * to read the script's plain stdout. + * Thin wrapper around `scripts/init-cli-agent.ts`. Emits a status block so + * /new-setup SKILL.md can parse the result without having to read the + * script's plain stdout. * * Args: * --display-name (required) operator's display name * --agent-name (optional) agent persona name, defaults to display-name - * --welcome (optional) system welcome instruction */ import { execFileSync } from 'child_process'; import path from 'path'; @@ -19,11 +18,9 @@ import { emitStatus } from './status.js'; function parseArgs(args: string[]): { displayName: string; agentName?: string; - welcome?: string; } { let displayName: string | undefined; let agentName: string | undefined; - let welcome: string | undefined; for (let i = 0; i < args.length; i++) { const key = args[i]; @@ -37,10 +34,6 @@ function parseArgs(args: string[]): { agentName = val; i++; break; - case '--welcome': - welcome = val; - i++; - break; } } @@ -53,20 +46,19 @@ function parseArgs(args: string[]): { process.exit(2); } - return { displayName, agentName, welcome }; + return { displayName, agentName }; } export async function run(args: string[]): Promise { - const { displayName, agentName, welcome } = parseArgs(args); + const { displayName, agentName } = parseArgs(args); const projectRoot = process.cwd(); - const script = path.join(projectRoot, 'scripts', 'init-first-agent.ts'); + const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts'); - const scriptArgs = ['exec', 'tsx', script, '--cli-only', '--display-name', displayName]; + const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName]; if (agentName) scriptArgs.push('--agent-name', agentName); - if (welcome) scriptArgs.push('--welcome', welcome); - log.info('Invoking init-first-agent in cli-only mode', { displayName, agentName }); + log.info('Invoking init-cli-agent', { displayName, agentName }); try { execFileSync('pnpm', scriptArgs, { @@ -76,7 +68,7 @@ export async function run(args: string[]): Promise { }); } catch (err) { const e = err as { stdout?: string; stderr?: string; status?: number }; - log.error('init-first-agent failed', { + log.error('init-cli-agent failed', { status: e.status, stdout: e.stdout, stderr: e.stderr, diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 9343258..d8d8f9d 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -10,6 +10,14 @@ export interface ChannelSetup { /** Called when an inbound message arrives from the platform. */ onInbound(platformId: string, threadId: string | null, message: InboundMessage): void | Promise; + /** + * Called by admin-transport adapters (CLI) that want to route a message to + * an arbitrary channel/platform and optionally redirect replies elsewhere. + * Regular chat adapters should use `onInbound`; `onInboundEvent` skips the + * adapter-channel-type injection so the caller can target any wired mg. + */ + onInboundEvent(event: InboundEvent): void | Promise; + /** Called when the adapter discovers metadata about a conversation. */ onMetadata(platformId: string, name?: string, isGroup?: boolean): void; @@ -17,6 +25,41 @@ export interface ChannelSetup { onAction(questionId: string, selectedOption: string, userId: string): void; } +/** Delivery address used for reply-to overrides and (normally) the inbound's own origin. */ +export interface DeliveryAddress { + channelType: string; + platformId: string; + threadId: string | null; +} + +/** + * Full inbound event handed to the router. + * + * `channelType` + `platformId` + `threadId` identify which messaging group / + * session receives the message. `replyTo`, when set, overrides where the + * agent's reply is delivered — used by the CLI admin transport when the + * operator wants a message routed to one channel but replies echoed back to + * their terminal. Agents cannot set `replyTo`; it is a router-layer concept + * set only by external adapters carrying operator intent. + */ +export interface InboundEvent { + channelType: string; + platformId: string; + threadId: string | null; + message: { + id: string; + kind: 'chat' | 'chat-sdk'; + content: string; // JSON blob + timestamp: string; + /** + * Platform-confirmed bot-mention signal forwarded from the adapter. + * See InboundMessage.isMention for the full explanation. + */ + isMention?: boolean; + }; + replyTo?: DeliveryAddress; +} + /** Inbound message from adapter to host. */ export interface InboundMessage { id: string; diff --git a/src/channels/channel-registry.test.ts b/src/channels/channel-registry.test.ts index 5121c64..27ee660 100644 --- a/src/channels/channel-registry.test.ts +++ b/src/channels/channel-registry.test.ts @@ -105,6 +105,7 @@ describe('channel registry', () => { await initChannelAdapters(() => ({ conversations: [], onInbound: () => {}, + onInboundEvent: () => {}, onMetadata: () => {}, onAction: () => {}, })); @@ -208,6 +209,7 @@ describe('channel + router integration', () => { await initChannelAdapters(() => ({ conversations: [], onInbound: () => {}, + onInboundEvent: () => {}, onMetadata: () => {}, onAction: () => {}, })); diff --git a/src/channels/cli.ts b/src/channels/cli.ts index c84952c..ad5e7e3 100644 --- a/src/channels/cli.ts +++ b/src/channels/cli.ts @@ -7,19 +7,31 @@ * the normal router/delivery path like any other adapter — `/clear` and * other session-level commands work identically. * - * MVP shape: - * - One hardcoded messaging_group: `cli/local`. Wired to one agent via - * the setup flow (see `scripts/init-first-agent.ts`). Multi-agent - * support can add per-agent messaging_groups later without breaking - * the wire protocol. - * - Single connected client at a time. A second connection closes the - * first with a "superseded" notice. - * - Wire format: one JSON object per line. - * Client → server: { "text": "user message" } - * Server → client: { "text": "agent reply" } - * - deliver() silently no-ops when no client is connected. The outbound - * row is already in outbound.db, so the message isn't lost — it just - * doesn't reach this run's terminal. Reconnect to see subsequent replies. + * Wire format: one JSON object per line. + * + * Client → server: + * { "text": "user message" } # default — talk to cli/local + * { "text": "...", "to": {"channelType": "discord", + * "platformId": "discord:@me:149...", + * "threadId": null} } # route to a specific mg + * { "text": "...", "to": {...}, "reply_to": {...} } # + redirect replies + * Server → client: + * { "text": "agent reply" } + * + * The `to` and `reply_to` addressing is how admin transports (the bootstrap + * script) inject messages targeting any wired channel. `reply_to` is a + * router-layer concept — agents cannot set it; it is carried only on + * inbound events from CLI clients that hold operator privilege (the socket + * is chmod 0600, so "connected to this socket" ≈ "is the owner"). + * + * Single-client chat semantics: one connected terminal at a time. A second + * "chat" connection closes the first with a "superseded" notice. Admin + * route-opcode connections (`to` set) are one-shot and do NOT evict an + * active chat client. + * + * deliver() silently no-ops when no client is connected. The outbound row + * is already in outbound.db, so the message isn't lost — it just doesn't + * reach this run's terminal. Reconnect to see subsequent replies. */ import fs from 'fs'; import net from 'net'; @@ -30,7 +42,8 @@ import { log } from '../log.js'; import type { ChannelAdapter, ChannelSetup, - InboundMessage, + DeliveryAddress, + InboundEvent, OutboundMessage, } from './adapter.js'; import { registerChannelAdapter } from './channel-registry.js'; @@ -129,16 +142,25 @@ function createAdapter(): ChannelAdapter { }; function handleConnection(socket: net.Socket, config: ChannelSetup): void { - if (client) { - try { - client.write(JSON.stringify({ text: '[superseded by a newer client]' }) + '\n'); - client.end(); - } catch { - // swallow + // Defer the chat-slot swap until we see the first line — if it turns out + // to be a routed (`to`-bearing) one-shot, we leave the existing chat + // client in place. Only plain chat connections participate in supersede. + let claimedChatSlot = false; + + const claimChatSlot = () => { + if (claimedChatSlot) return; + claimedChatSlot = true; + if (client && client !== socket) { + try { + client.write(JSON.stringify({ text: '[superseded by a newer client]' }) + '\n'); + client.end(); + } catch { + // swallow + } } - } - client = socket; - log.info('CLI client connected'); + client = socket; + log.info('CLI client connected'); + }; let buffer = ''; socket.on('data', (chunk) => { @@ -148,13 +170,13 @@ function createAdapter(): ChannelAdapter { const line = buffer.slice(0, idx).trim(); buffer = buffer.slice(idx + 1); if (!line) continue; - void handleLine(line, config); + void handleLine(line, config, claimChatSlot); } }); socket.on('close', () => { if (client === socket) client = null; - log.info('CLI client disconnected'); + if (claimedChatSlot) log.info('CLI client disconnected'); }); socket.on('error', (err) => { @@ -162,8 +184,16 @@ function createAdapter(): ChannelAdapter { }); } - async function handleLine(line: string, config: ChannelSetup): Promise { - let payload: { text?: unknown }; + async function handleLine( + line: string, + config: ChannelSetup, + claimChatSlot: () => void, + ): Promise { + let payload: { + text?: unknown; + to?: unknown; + reply_to?: unknown; + }; try { payload = JSON.parse(line); } catch (err) { @@ -172,23 +202,73 @@ function createAdapter(): ChannelAdapter { } if (typeof payload.text !== 'string' || payload.text.length === 0) return; - const inbound: InboundMessage = { - id: `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - kind: 'chat', - timestamp: new Date().toISOString(), - content: { - text: payload.text, - sender: 'cli', - senderId: `cli:${PLATFORM_ID}`, - }, - }; + const to = parseAddress(payload.to); + const replyTo = parseAddress(payload.reply_to); + + if (to) { + // Routed message — admin transport. Build a full InboundEvent targeting + // `to`'s channel/platform, and let `reply_to` (if any) redirect replies. + // Does NOT claim the chat slot, so an active terminal chat isn't evicted. + const event: InboundEvent = { + channelType: to.channelType, + platformId: to.platformId, + threadId: to.threadId, + message: { + id: `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + content: JSON.stringify({ + text: payload.text, + sender: 'cli', + senderId: `cli:${PLATFORM_ID}`, + }), + }, + replyTo: replyTo ?? undefined, + }; + try { + await config.onInboundEvent(event); + } catch (err) { + log.error('CLI: onInboundEvent threw', { err }); + } + return; + } + + // Plain chat — claim the slot (evicting any prior client) and route via + // the standard onInbound path (adapter injects its own channelType). + claimChatSlot(); try { - await config.onInbound(PLATFORM_ID, null, inbound); + await config.onInbound(PLATFORM_ID, null, { + id: `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + content: { + text: payload.text, + sender: 'cli', + senderId: `cli:${PLATFORM_ID}`, + }, + }); } catch (err) { log.error('CLI: onInbound threw', { err }); } } + function parseAddress(raw: unknown): DeliveryAddress | null { + if (!raw || typeof raw !== 'object') return null; + const obj = raw as Record; + if (typeof obj.channelType !== 'string' || typeof obj.platformId !== 'string') return null; + const threadId = + obj.threadId === null || obj.threadId === undefined + ? null + : typeof obj.threadId === 'string' + ? obj.threadId + : null; + return { + channelType: obj.channelType, + platformId: obj.platformId, + threadId, + }; + } + return adapter; } diff --git a/src/host-core.test.ts b/src/host-core.test.ts index da2fd37..9906c4b 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -25,7 +25,7 @@ import { outboundDbPath, } from './session-manager.js'; import { getSession, findSession } from './db/sessions.js'; -import type { InboundEvent } from './router.js'; +import type { InboundEvent } from './channels/adapter.js'; // Mock container runner to prevent actual Docker spawning vi.mock('./container-runner.js', () => ({ diff --git a/src/index.ts b/src/index.ts index 7c5ab24..1ec8619 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,6 +86,15 @@ async function main(): Promise { log.error('Failed to route inbound message', { channelType: adapter.channelType, err }); }); }, + onInboundEvent(event) { + routeInbound(event).catch((err) => { + log.error('Failed to route inbound event', { + sourceAdapter: adapter.channelType, + targetChannelType: event.channelType, + err, + }); + }); + }, onMetadata(platformId, name, isGroup) { log.info('Channel metadata discovered', { channelType: adapter.channelType, diff --git a/src/modules/approvals/picks.test.ts b/src/modules/approvals/picks.test.ts index 508aa35..c48f58d 100644 --- a/src/modules/approvals/picks.test.ts +++ b/src/modules/approvals/picks.test.ts @@ -57,6 +57,7 @@ async function mountMockAdapter( await initChannelAdapters(() => ({ conversations: [], onInbound: () => {}, + onInboundEvent: () => {}, onMetadata: () => {}, onAction: () => {}, })); diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index 9c65f8e..caef815 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -41,7 +41,7 @@ import { getAllAgentGroups } from '../../db/agent-groups.js'; import { getMessagingGroup } from '../../db/messaging-groups.js'; import { getDeliveryAdapter } from '../../delivery.js'; import { log } from '../../log.js'; -import type { InboundEvent } from '../../router.js'; +import type { InboundEvent } from '../../channels/adapter.js'; import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js'; import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js'; diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index e2f100c..6913c72 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -27,8 +27,8 @@ import { setSenderResolver, setSenderScopeGate, type AccessGateResult, - type InboundEvent, } from '../../router.js'; +import type { InboundEvent } from '../../channels/adapter.js'; import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; import { log } from '../../log.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; diff --git a/src/modules/permissions/permissions.test.ts b/src/modules/permissions/permissions.test.ts index d76d0d6..c66e082 100644 --- a/src/modules/permissions/permissions.test.ts +++ b/src/modules/permissions/permissions.test.ts @@ -60,6 +60,7 @@ async function mountMockAdapter( await initChannelAdapters(() => ({ conversations: [], onInbound: () => {}, + onInboundEvent: () => {}, onMetadata: () => {}, onAction: () => {}, })); diff --git a/src/modules/permissions/sender-approval.ts b/src/modules/permissions/sender-approval.ts index be60280..e08123a 100644 --- a/src/modules/permissions/sender-approval.ts +++ b/src/modules/permissions/sender-approval.ts @@ -30,7 +30,7 @@ import { normalizeOptions, type RawOption } from '../../channels/ask-question.js import { getMessagingGroup } from '../../db/messaging-groups.js'; import { getDeliveryAdapter } from '../../delivery.js'; import { log } from '../../log.js'; -import type { InboundEvent } from '../../router.js'; +import type { InboundEvent } from '../../channels/adapter.js'; import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js'; import { createPendingSenderApproval, hasInFlightSenderApproval } from './db/pending-sender-approvals.js'; diff --git a/src/router.ts b/src/router.ts index 4289f1f..c1e8881 100644 --- a/src/router.ts +++ b/src/router.ts @@ -32,32 +32,12 @@ import { resolveSession, writeSessionMessage } from './session-manager.js'; import { wakeContainer } from './container-runner.js'; import { getSession } from './db/sessions.js'; import type { AgentGroup, MessagingGroup, MessagingGroupAgent } from './types.js'; +import type { InboundEvent } from './channels/adapter.js'; function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -export interface InboundEvent { - channelType: string; - platformId: string; - threadId: string | null; - message: { - id: string; - kind: 'chat' | 'chat-sdk'; - content: string; // JSON blob - timestamp: string; - /** - * Platform-confirmed bot-mention signal forwarded from the adapter. - * When defined, it's authoritative — use this instead of text-matching - * agent_group_name, which breaks on platforms where the mention token - * is the bot's platform username (e.g. Telegram). undefined means the - * adapter doesn't provide the signal; evaluateEngage falls back to - * agent-name regex. - */ - isMention?: boolean; - }; -} - /** * Sender-resolver hook. Runs before agent resolution. * @@ -408,13 +388,23 @@ async function deliverToAgent( const { session, created } = resolveSession(agent.agent_group_id, mg.id, event.threadId, effectiveSessionMode); + // The inbound row's (channel_type, platform_id, thread_id) is the address + // the agent's reply will be delivered to. Normally it mirrors the source + // (stamped from the event). When the caller supplied `replyTo` (CLI admin + // transport acting on operator intent), the reply is redirected there. + const deliveryAddr = event.replyTo ?? { + channelType: event.channelType, + platformId: event.platformId, + threadId: event.threadId, + }; + writeSessionMessage(session.agent_group_id, session.id, { id: messageIdForAgent(event.message.id, agent.agent_group_id), kind: event.message.kind, timestamp: event.message.timestamp, - platformId: event.platformId, - channelType: event.channelType, - threadId: event.threadId, + platformId: deliveryAddr.platformId, + channelType: deliveryAddr.channelType, + threadId: deliveryAddr.threadId, content: event.message.content, trigger: wake ? 1 : 0, }); From 0f6a1ba1ed45da96b76cf7cb75517d966ecf7e39 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 23:31:42 +0300 Subject: [PATCH 044/185] style: apply prettier formatting to touched files Pre-commit hook reflowed imports on files changed in the previous commit. Unrelated format drift on other files intentionally left unstaged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/cli.ts | 18 ++++-------------- src/modules/approvals/picks.test.ts | 6 +++++- src/modules/permissions/index.ts | 10 ++-------- src/modules/permissions/permissions.test.ts | 6 +++++- 4 files changed, 16 insertions(+), 24 deletions(-) diff --git a/src/channels/cli.ts b/src/channels/cli.ts index ad5e7e3..b738186 100644 --- a/src/channels/cli.ts +++ b/src/channels/cli.ts @@ -39,13 +39,7 @@ import path from 'path'; import { DATA_DIR } from '../config.js'; import { log } from '../log.js'; -import type { - ChannelAdapter, - ChannelSetup, - DeliveryAddress, - InboundEvent, - OutboundMessage, -} from './adapter.js'; +import type { ChannelAdapter, ChannelSetup, DeliveryAddress, InboundEvent, OutboundMessage } from './adapter.js'; import { registerChannelAdapter } from './channel-registry.js'; const PLATFORM_ID = 'local'; @@ -184,11 +178,7 @@ function createAdapter(): ChannelAdapter { }); } - async function handleLine( - line: string, - config: ChannelSetup, - claimChatSlot: () => void, - ): Promise { + async function handleLine(line: string, config: ChannelSetup, claimChatSlot: () => void): Promise { let payload: { text?: unknown; to?: unknown; @@ -260,8 +250,8 @@ function createAdapter(): ChannelAdapter { obj.threadId === null || obj.threadId === undefined ? null : typeof obj.threadId === 'string' - ? obj.threadId - : null; + ? obj.threadId + : null; return { channelType: obj.channelType, platformId: obj.platformId, diff --git a/src/modules/approvals/picks.test.ts b/src/modules/approvals/picks.test.ts index c48f58d..0d1784a 100644 --- a/src/modules/approvals/picks.test.ts +++ b/src/modules/approvals/picks.test.ts @@ -6,7 +6,11 @@ import { beforeEach, afterEach, describe, expect, it } from 'vitest'; import type { ChannelAdapter, OutboundMessage } from '../../channels/adapter.js'; -import { initChannelAdapters, registerChannelAdapter, teardownChannelAdapters } from '../../channels/channel-registry.js'; +import { + initChannelAdapters, + registerChannelAdapter, + teardownChannelAdapters, +} from '../../channels/channel-registry.js'; import { closeDb, createAgentGroup, initTestDb, runMigrations } from '../../db/index.js'; import { createUser } from '../permissions/db/users.js'; import { grantRole } from '../permissions/db/user-roles.js'; diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index 6913c72..83390d8 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -16,10 +16,7 @@ * access gate is not registered and core defaults to allow-all. */ import { recordDroppedMessage } from '../../db/dropped-messages.js'; -import { - createMessagingGroupAgent, - setMessagingGroupDeniedAt, -} from '../../db/messaging-groups.js'; +import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js'; import { routeInbound, setAccessGate, @@ -35,10 +32,7 @@ import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import { canAccessAgentGroup } from './access.js'; import { requestChannelApproval } from './channel-approval.js'; import { addMember } from './db/agent-group-members.js'; -import { - deletePendingChannelApproval, - getPendingChannelApproval, -} from './db/pending-channel-approvals.js'; +import { deletePendingChannelApproval, getPendingChannelApproval } from './db/pending-channel-approvals.js'; import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js'; import { hasAdminPrivilege } from './db/user-roles.js'; import { getUser, upsertUser } from './db/users.js'; diff --git a/src/modules/permissions/permissions.test.ts b/src/modules/permissions/permissions.test.ts index c66e082..505c926 100644 --- a/src/modules/permissions/permissions.test.ts +++ b/src/modules/permissions/permissions.test.ts @@ -6,7 +6,11 @@ import { beforeEach, afterEach, describe, expect, it } from 'vitest'; import type { ChannelAdapter, OutboundMessage } from '../../channels/adapter.js'; -import { initChannelAdapters, registerChannelAdapter, teardownChannelAdapters } from '../../channels/channel-registry.js'; +import { + initChannelAdapters, + registerChannelAdapter, + teardownChannelAdapters, +} from '../../channels/channel-registry.js'; import { closeDb, createAgentGroup, createMessagingGroup, initTestDb, runMigrations } from '../../db/index.js'; import { canAccessAgentGroup } from './access.js'; import { addMember, isMember } from './db/agent-group-members.js'; From 63b8beb0fb4a51a329971ebdbb260fc00ba7ce56 Mon Sep 17 00:00:00 2001 From: Simeon Simeonov Date: Mon, 20 Apr 2026 23:28:35 -0400 Subject: [PATCH 045/185] fix(container): bump Claude Code to 2.1.116 and Agent SDK to ^0.2.116 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agent SDK's IPC protocol must match the Claude Code version. Also allowlist @anthropic-ai/claude-code in only-built-dependencies so its postinstall script runs during Docker build — without this, the native binary is never installed and the SDK fails at spawn time. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/Dockerfile | 3 +- container/agent-runner/bun.lock | 52 ++++++++++------------------- container/agent-runner/package.json | 2 +- 3 files changed, 21 insertions(+), 36 deletions(-) diff --git a/container/Dockerfile b/container/Dockerfile index 12d2bf6..be37638 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -15,7 +15,7 @@ ARG INSTALL_CJK_FONTS=false # Pin CLI versions for reproducibility. Bump deliberately — unpinned installs # mean every rebuild silently picks up the latest and can break in lockstep # across all users. -ARG CLAUDE_CODE_VERSION=2.1.112 +ARG CLAUDE_CODE_VERSION=2.1.116 ARG AGENT_BROWSER_VERSION=latest ARG VERCEL_VERSION=latest ARG BUN_VERSION=1.3.12 @@ -76,6 +76,7 @@ RUN corepack enable # package. Pinned versions so every rebuild is reproducible. RUN --mount=type=cache,target=/root/.cache/pnpm \ echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \ + echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \ pnpm install -g \ "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ "agent-browser@${AGENT_BROWSER_VERSION}" \ diff --git a/container/agent-runner/bun.lock b/container/agent-runner/bun.lock index 99fe840..3c08828 100644 --- a/container/agent-runner/bun.lock +++ b/container/agent-runner/bun.lock @@ -5,7 +5,7 @@ "": { "name": "nanoclaw-agent-runner", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.92", + "@anthropic-ai/claude-agent-sdk": "^0.2.116", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0", @@ -18,7 +18,23 @@ }, }, "packages": { - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.112", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-vMFoiDKlOive8p3tphpV1gQaaytOipwGJ+uw9mvvaLQUODSC2+fCdRDAY25i2Tsv+lOtxzXBKctmaDuWqZY7ig=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.116", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.116", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.116", "", { "os": "darwin", "cpu": "x64" }, "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.116", "", { "os": "win32", "cpu": "arm64" }, "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.116", "", { "os": "win32", "cpu": "x64" }, "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], @@ -26,38 +42,6 @@ "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json index 06eb394..e9af0b1 100644 --- a/container/agent-runner/package.json +++ b/container/agent-runner/package.json @@ -9,7 +9,7 @@ "test": "bun test" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.92", + "@anthropic-ai/claude-agent-sdk": "^0.2.116", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" From f0090ebbb9670d168ed85e099994698482db91a4 Mon Sep 17 00:00:00 2001 From: Simeon Simeonov Date: Mon, 20 Apr 2026 23:28:54 -0400 Subject: [PATCH 046/185] fix(container): point SDK to pnpm-installed Claude Code binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agent SDK's default binary resolution picks the musl-linked native binary (claude-agent-sdk-linux-arm64-musl), which cannot execute on the Debian-based container image (glibc). Explicitly set pathToClaudeCodeExecutable to /pnpm/claude — the pnpm global symlink that resolves to the correct glibc binary regardless of architecture. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/providers/claude.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index a797f06..fbb077c 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -271,6 +271,7 @@ export class ClaudeProvider implements AgentProvider { cwd: input.cwd, additionalDirectories: this.additionalDirectories, resume: input.continuation, + pathToClaudeCodeExecutable: '/pnpm/claude', systemPrompt: instructions ? { type: 'preset' as const, preset: 'claude_code' as const, append: instructions } : undefined, allowedTools: TOOL_ALLOWLIST, disallowedTools: SDK_DISALLOWED_TOOLS, From 53c11a2d53a97565e18acffc2eeac90005b7e554 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 17 Apr 2026 15:10:17 +0300 Subject: [PATCH 047/185] chore(skills): delete 9 irrelevant legacy skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These shipped with the old v1 architecture and are no longer needed: - add-reactions, add-voice-transcription, add-image-vision, add-pdf-reader, use-local-whisper — Chat SDK channels handle these natively now; the WhatsApp native (Baileys) adapter on the channels branch covers attachments and reactions out of the box. - add-compact — no longer needed. - add-telegram-swarm — Chat SDK Teams adapter handles multi-bot identity. - channel-formatting — Chat SDK does per-channel formatting natively. - add-gmail — was built on a legacy MCP server; deprecated. add-emacs and use-native-credential-proxy are kept and will be ported to the current architecture in follow-up commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-compact/SKILL.md | 135 ------ .claude/skills/add-gmail/SKILL.md | 236 ----------- .claude/skills/add-image-vision/SKILL.md | 94 ----- .claude/skills/add-pdf-reader/SKILL.md | 104 ----- .claude/skills/add-reactions/SKILL.md | 117 ------ .claude/skills/add-telegram-swarm/SKILL.md | 384 ------------------ .../skills/add-voice-transcription/SKILL.md | 148 ------- .claude/skills/channel-formatting/SKILL.md | 137 ------- .claude/skills/use-local-whisper/SKILL.md | 152 ------- 9 files changed, 1507 deletions(-) delete mode 100644 .claude/skills/add-compact/SKILL.md delete mode 100644 .claude/skills/add-gmail/SKILL.md delete mode 100644 .claude/skills/add-image-vision/SKILL.md delete mode 100644 .claude/skills/add-pdf-reader/SKILL.md delete mode 100644 .claude/skills/add-reactions/SKILL.md delete mode 100644 .claude/skills/add-telegram-swarm/SKILL.md delete mode 100644 .claude/skills/add-voice-transcription/SKILL.md delete mode 100644 .claude/skills/channel-formatting/SKILL.md delete mode 100644 .claude/skills/use-local-whisper/SKILL.md diff --git a/.claude/skills/add-compact/SKILL.md b/.claude/skills/add-compact/SKILL.md deleted file mode 100644 index ee9674a..0000000 --- a/.claude/skills/add-compact/SKILL.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -name: add-compact -description: Add /compact command for manual context compaction. Solves context rot in long sessions by forwarding the SDK's built-in /compact slash command. Main-group or trusted sender only. ---- - -# Add /compact Command - -Adds a `/compact` session command that compacts conversation history to fight context rot in long-running sessions. Uses the Claude Agent SDK's built-in `/compact` slash command — no synthetic system prompts. - -**Session contract:** `/compact` keeps the same logical session alive. The SDK returns a new session ID after compaction (via the `init` system message), which the agent-runner forwards to the orchestrator as `newSessionId`. No destructive reset occurs — the agent retains summarized context. - -## Phase 1: Pre-flight - -Check if `src/session-commands.ts` exists: - -```bash -test -f src/session-commands.ts && echo "Already applied" || echo "Not applied" -``` - -If already applied, skip to Phase 3 (Verify). - -## Phase 2: Apply Code Changes - -Merge the skill branch: - -```bash -git fetch upstream skill/compact -git merge upstream/skill/compact -``` - -> **Note:** `upstream` is the remote pointing to `qwibitai/nanoclaw`. If using a different remote name, substitute accordingly. - -This adds: -- `src/session-commands.ts` (extract and authorize session commands) -- `src/session-commands.test.ts` (unit tests for command parsing and auth) -- Session command interception in `src/index.ts` (both `processGroupMessages` and `startMessageLoop`) -- Slash command handling in `container/agent-runner/src/index.ts` - -### Validate - -```bash -pnpm test -pnpm run build -``` - -### Rebuild container - -```bash -./container/build.sh -``` - -### Restart service - -```bash -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 3: Verify - -### Integration Test - -1. Start NanoClaw in dev mode: `pnpm run dev` -2. From the **main group** (self-chat), send exactly: `/compact` -3. Verify: - - The agent acknowledges compaction (e.g., "Conversation compacted.") - - The session continues — send a follow-up message and verify the agent responds coherently - - A conversation archive is written to `groups/{folder}/conversations/` (by the PreCompact hook) - - Container logs show `Compact boundary observed` (confirms SDK actually compacted) - - If `compact_boundary` was NOT observed, the response says "compact_boundary was not observed" -4. From a **non-main group** as a non-admin user, send: `@ /compact` -5. Verify: - - The bot responds with "Session commands require admin access." - - No compaction occurs, no container is spawned for the command -6. From a **non-main group** as the admin (device owner / `is_from_me`), send: `@ /compact` -7. Verify: - - Compaction proceeds normally (same behavior as main group) -8. While an **active container** is running for the main group, send `/compact` -9. Verify: - - The active container is signaled to close (authorized senders only — untrusted senders cannot kill in-flight work) - - Compaction proceeds via a new container once the active one exits - - The command is not dropped (no cursor race) -10. Send a normal message, then `/compact`, then another normal message in quick succession (same polling batch): -11. Verify: - - Pre-compact messages are sent to the agent first (check container logs for two `runAgent` calls) - - Compaction proceeds after pre-compact messages are processed - - Messages **after** `/compact` in the batch are preserved (cursor advances to `/compact`'s timestamp only) and processed on the next poll cycle -12. From a **non-main group** as a non-admin user, send `@ /compact`: -13. Verify: - - Denial message is sent ("Session commands require admin access.") - - The `/compact` is consumed (cursor advanced) — it does NOT replay on future polls - - Other messages in the same batch are also consumed (cursor is a high-water mark — this is an accepted tradeoff for the narrow edge case of denied `/compact` + other messages in the same polling interval) - - No container is killed or interrupted -14. From a **non-main group** (with `requiresTrigger` enabled) as a non-admin user, send bare `/compact` (no trigger prefix): -15. Verify: - - No denial message is sent (trigger policy prevents untrusted bot responses) - - The `/compact` is consumed silently - - Note: in groups where `requiresTrigger` is `false`, a denial message IS sent because the sender is considered reachable -16. After compaction, verify **no auto-compaction** behavior — only manual `/compact` triggers it - -### Validation on Fresh Clone - -```bash -git clone /tmp/nanoclaw-test -cd /tmp/nanoclaw-test -claude # then run /add-compact -pnpm run build -pnpm test -./container/build.sh -# Manual: send /compact from main group, verify compaction + continuation -# Manual: send @ /compact from non-main as non-admin, verify denial -# Manual: send @ /compact from non-main as admin, verify allowed -# Manual: verify no auto-compaction behavior -``` - -## Security Constraints - -- **Main-group or trusted/admin sender only.** The main group is the user's private self-chat and is trusted (see `docs/SECURITY.md`). Non-main groups are untrusted — a careless or malicious user could wipe the agent's short-term memory. However, the device owner (`is_from_me`) is always trusted and can compact from any group. -- **No auto-compaction.** This skill implements manual compaction only. Automatic threshold-based compaction is a separate concern and should be a separate skill. -- **No config file.** NanoClaw's philosophy is customization through code changes, not configuration sprawl. -- **Transcript archived before compaction.** The existing `PreCompact` hook in the agent-runner archives the full transcript to `conversations/` before the SDK compacts it. -- **Session continues after compaction.** This is not a destructive reset. The conversation continues with summarized context. - -## What This Does NOT Do - -- No automatic compaction threshold (add separately if desired) -- No `/clear` command (separate skill, separate semantics — `/clear` is a destructive reset) -- No cross-group compaction (each group's session is isolated) -- No changes to the container image, Dockerfile, or build script - -## Troubleshooting - -- **"Session commands require admin access"**: Only the device owner (`is_from_me`) or main-group senders can use `/compact`. Other users are denied. -- **No compact_boundary in logs**: The SDK may not emit this event in all versions. Check the agent-runner logs for the warning message. Compaction may still have succeeded. -- **Pre-compact failure**: If messages before `/compact` fail to process, the error message says "Failed to process messages before /compact." The cursor advances past sent output to prevent duplicates; `/compact` remains pending for the next attempt. diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md deleted file mode 100644 index 6a13291..0000000 --- a/.claude/skills/add-gmail/SKILL.md +++ /dev/null @@ -1,236 +0,0 @@ ---- -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 support to NanoClaw — either as a tool (read, send, search, draft) or as a full channel that polls the inbox. - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `src/channels/gmail.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. - -### Ask the user - -Use `AskUserQuestion`: - -AskUserQuestion: Should incoming emails be able to trigger the agent? - -- **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. - -## Phase 2: Apply Code Changes - -### Ensure channel remote - -```bash -git remote -v -``` - -If `gmail` is missing, add it: - -```bash -git remote add gmail https://github.com/qwibitai/nanoclaw-gmail.git -``` - -### Merge the skill branch - -```bash -git fetch gmail main -git merge gmail/main || { - git checkout --theirs pnpm-lock.yaml - git add pnpm-lock.yaml - git merge --continue -} -``` - -This merges in: -- `src/channels/gmail.ts` (GmailChannel class with self-registration via `registerChannel`) -- `src/channels/gmail.test.ts` (unit tests) -- `import './gmail.js'` appended to the channel barrel file `src/channels/index.ts` -- Gmail credentials mount (`~/.gmail-mcp`) in `src/container-runner.ts` -- Gmail MCP server (`@gongrzhe/server-gmail-autoauth-mcp`) and `mcp__gmail__*` allowed tool in `container/agent-runner/src/index.ts` -- `googleapis` npm dependency in `package.json` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Add email handling instructions (Channel mode only) - -If the user chose channel mode, 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 code changes - -```bash -pnpm install -pnpm run build -pnpm exec vitest run src/channels/gmail.test.ts -``` - -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` already exists with real tokens (not `onecli-managed` values), skip to "Build and restart" below. - -### GCP Project Setup - -Check if OneCLI is configured: - -```bash -grep -q 'ONECLI_URL=.' .env 2>/dev/null && echo "onecli" || echo "manual" -``` - -**If OneCLI:** Tell the user to open `${ONECLI_URL}/connections?connect=gmail` to set up their Gmail connection. The dashboard walks them through creating a Google Cloud OAuth app and authorizing it. Ask them to let you know when done. - -Once the user confirms, run: - -```bash -onecli apps get --provider gmail -``` - -Check that `config.hasCredentials` is `true` or `connection` is not null. The response `hint` field has instructions and a docs URL for what stub credential files to create under `~/.gmail-mcp/`. Follow the hint — never overwrite existing files that don't contain `onecli-managed` values. - -**If manual:** Tell the user: - -> I need you to set up Google Cloud OAuth credentials: -> -> 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 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 JSON content, write it to `~/.gmail-mcp/gcp-oauth.keys.json`. - -### OAuth Authorization - -Tell the user: - -> 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: - -```bash -pnpm dlx @gongrzhe/server-gmail-autoauth-mcp auth -``` - -If that fails (some versions don't have an auth subcommand), try `timeout 60 pnpm dlx @gongrzhe/server-gmail-autoauth-mcp || true`. Verify with `ls ~/.gmail-mcp/credentials.json`. - -### 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): - -```bash -rm -r data/sessions/*/agent-runner-src 2>/dev/null || true -``` - -Rebuild the container (agent-runner changed): - -```bash -cd container && ./build.sh -``` - -Then compile and restart: - -```bash -pnpm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 4: Verify - -### Test tool access (both modes) - -Tell the user: - -> Gmail is connected! Send this in your main channel: -> -> `@Andy check my recent emails` or `@Andy list my Gmail labels` - -### 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 -``` - -## Troubleshooting - -### Gmail connection not responding - -Test directly: - -```bash -pnpm dlx @gongrzhe/server-gmail-autoauth-mcp -``` - -### OAuth token expired - -Re-authorize: - -```bash -rm ~/.gmail-mcp/credentials.json -pnpm dlx @gongrzhe/server-gmail-autoauth-mcp -``` - -### Container can't access Gmail - -- 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) - -- By default, the channel polls unread Primary inbox emails (`is:unread category:primary`) -- Check logs for Gmail polling errors - -## Removal - -### Tool-only mode - -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. Rebuild and restart -4. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` -5. Rebuild: `cd container && ./build.sh && cd .. && pnpm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) - -### Channel mode - -1. Delete `src/channels/gmail.ts` and `src/channels/gmail.test.ts` -2. Remove `import './gmail.js'` from `src/channels/index.ts` -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. Uninstall: `pnpm uninstall googleapis` -6. Rebuild and restart -7. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` -8. Rebuild: `cd container && ./build.sh && cd .. && pnpm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) diff --git a/.claude/skills/add-image-vision/SKILL.md b/.claude/skills/add-image-vision/SKILL.md deleted file mode 100644 index 4a9da26..0000000 --- a/.claude/skills/add-image-vision/SKILL.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -name: add-image-vision -description: Add image vision to NanoClaw agents. Resizes and processes WhatsApp image attachments, then sends them to Claude as multimodal content blocks. ---- - -# Image Vision Skill - -Adds the ability for NanoClaw agents to see and understand images sent via WhatsApp. Images are downloaded, resized with sharp, saved to the group workspace, and passed to the agent as base64-encoded multimodal content blocks. - -## Phase 1: Pre-flight - -1. Check if `src/image.ts` exists — skip to Phase 3 if already applied -2. Confirm `sharp` is installable (native bindings require build tools) - -**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files. - -## Phase 2: Apply Code Changes - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/image-vision -git merge whatsapp/skill/image-vision || { - git checkout --theirs pnpm-lock.yaml - git add pnpm-lock.yaml - git merge --continue -} -``` - -This merges in: -- `src/image.ts` (image download, resize via sharp, base64 encoding) -- `src/image.test.ts` (8 unit tests) -- Image attachment handling in `src/channels/whatsapp.ts` -- Image passing to agent in `src/index.ts` and `src/container-runner.ts` -- Image content block support in `container/agent-runner/src/index.ts` -- `sharp` npm dependency in `package.json` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes - -```bash -pnpm install -pnpm run build -pnpm exec vitest run src/image.test.ts -``` - -All tests must pass and build must be clean before proceeding. - -## Phase 3: Configure - -1. Rebuild the container (agent-runner changes need a rebuild): - ```bash - ./container/build.sh - ``` - -2. Sync agent-runner source to group caches: - ```bash - for dir in data/sessions/*/agent-runner-src/; do - cp container/agent-runner/src/*.ts "$dir" - done - ``` - -3. Restart the service: - ```bash - launchctl kickstart -k gui/$(id -u)/com.nanoclaw - ``` - -## Phase 4: Verify - -1. Send an image in a registered WhatsApp group -2. Check the agent responds with understanding of the image content -3. Check logs for "Processed image attachment": - ```bash - tail -50 groups/*/logs/container-*.log - ``` - -## Troubleshooting - -- **"Image - download failed"**: Check WhatsApp connection stability. The download may timeout on slow connections. -- **"Image - processing failed"**: Sharp may not be installed correctly. Run `pnpm ls sharp` to verify. -- **Agent doesn't mention image content**: Check container logs for "Loaded image" messages. If missing, ensure agent-runner source was synced to group caches. diff --git a/.claude/skills/add-pdf-reader/SKILL.md b/.claude/skills/add-pdf-reader/SKILL.md deleted file mode 100644 index aecc347..0000000 --- a/.claude/skills/add-pdf-reader/SKILL.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -name: add-pdf-reader -description: Add PDF reading to NanoClaw agents. Extracts text from PDFs via pdftotext CLI. Handles WhatsApp attachments, URLs, and local files. ---- - -# Add PDF Reader - -Adds PDF reading capability to all container agents using poppler-utils (pdftotext/pdfinfo). PDFs sent as WhatsApp attachments are auto-downloaded to the group workspace. - -## Phase 1: Pre-flight - -1. Check if `container/skills/pdf-reader/pdf-reader` exists — skip to Phase 3 if already applied -2. Confirm WhatsApp is installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files. - -## Phase 2: Apply Code Changes - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/pdf-reader -git merge whatsapp/skill/pdf-reader || { - git checkout --theirs pnpm-lock.yaml - git add pnpm-lock.yaml - git merge --continue -} -``` - -This merges in: -- `container/skills/pdf-reader/SKILL.md` (agent-facing documentation) -- `container/skills/pdf-reader/pdf-reader` (CLI script) -- `poppler-utils` in `container/Dockerfile` -- PDF attachment download in `src/channels/whatsapp.ts` -- PDF tests in `src/channels/whatsapp.test.ts` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate - -```bash -pnpm run build -pnpm exec vitest run src/channels/whatsapp.test.ts -``` - -### Rebuild container - -```bash -./container/build.sh -``` - -### Restart service - -```bash -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 3: Verify - -### Test PDF extraction - -Send a PDF file in any registered WhatsApp chat. The agent should: -1. Download the PDF to `attachments/` -2. Respond acknowledging the PDF -3. Be able to extract text when asked - -### Test URL fetching - -Ask the agent to read a PDF from a URL. It should use `pdf-reader fetch `. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log | grep -i pdf -``` - -Look for: -- `Downloaded PDF attachment` — successful download -- `Failed to download PDF attachment` — media download issue - -## Troubleshooting - -### Agent says pdf-reader command not found - -Container needs rebuilding. Run `./container/build.sh` and restart the service. - -### PDF text extraction is empty - -The PDF may be scanned (image-based). pdftotext only handles text-based PDFs. Consider using the agent-browser to open the PDF visually instead. - -### WhatsApp PDF not detected - -Verify the message has `documentMessage` with `mimetype: application/pdf`. Some file-sharing apps send PDFs as generic files without the correct mimetype. diff --git a/.claude/skills/add-reactions/SKILL.md b/.claude/skills/add-reactions/SKILL.md deleted file mode 100644 index 435bef9..0000000 --- a/.claude/skills/add-reactions/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: add-reactions -description: Add WhatsApp emoji reaction support — receive, send, store, and search reactions. ---- - -# Add Reactions - -This skill adds emoji reaction support to NanoClaw's WhatsApp channel: receive and store reactions, send reactions from the container agent via MCP tool, and query reaction history from SQLite. - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `src/status-tracker.ts` exists: - -```bash -test -f src/status-tracker.ts && echo "Already applied" || echo "Not applied" -``` - -If already applied, skip to Phase 3 (Verify). - -## Phase 2: Apply Code Changes - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/reactions -git merge whatsapp/skill/reactions || { - git checkout --theirs pnpm-lock.yaml - git add pnpm-lock.yaml - git merge --continue -} -``` - -This adds: -- `scripts/migrate-reactions.ts` (database migration for `reactions` table with composite PK and indexes) -- `src/status-tracker.ts` (forward-only emoji state machine for message lifecycle signaling, with persistence and retry) -- `src/status-tracker.test.ts` (unit tests for StatusTracker) -- `container/skills/reactions/SKILL.md` (agent-facing documentation for the `react_to_message` MCP tool) -- Reaction support in `src/db.ts`, `src/channels/whatsapp.ts`, `src/types.ts`, `src/ipc.ts`, `src/index.ts`, `src/group-queue.ts`, and `container/agent-runner/src/ipc-mcp-stdio.ts` - -### Run database migration - -```bash -pnpm exec tsx scripts/migrate-reactions.ts -``` - -### Validate code changes - -```bash -pnpm test -pnpm run build -``` - -All tests must pass and build must be clean before proceeding. - -## Phase 3: Verify - -### Build and restart - -```bash -pnpm run build -``` - -Linux: -```bash -systemctl --user restart nanoclaw -``` - -macOS: -```bash -launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` - -### Test receiving reactions - -1. Send a message from your phone -2. React to it with an emoji on WhatsApp -3. Check the database: - -```bash -sqlite3 store/messages.db "SELECT * FROM reactions ORDER BY timestamp DESC LIMIT 5;" -``` - -### Test sending reactions - -Ask the agent to react to a message via the `react_to_message` MCP tool. Check your phone — the reaction should appear on the message. - -## Troubleshooting - -### Reactions not appearing in database - -- Check NanoClaw logs for `Failed to process reaction` errors -- Verify the chat is registered -- Confirm the service is running - -### Migration fails - -- Ensure `store/messages.db` exists and is accessible -- If "table reactions already exists", the migration already ran — skip it - -### Agent can't send reactions - -- Check IPC logs for `Unauthorized IPC reaction attempt blocked` — the agent can only react in its own group's chat -- Verify WhatsApp is connected: check logs for connection status diff --git a/.claude/skills/add-telegram-swarm/SKILL.md b/.claude/skills/add-telegram-swarm/SKILL.md deleted file mode 100644 index 8f6a4fc..0000000 --- a/.claude/skills/add-telegram-swarm/SKILL.md +++ /dev/null @@ -1,384 +0,0 @@ ---- -name: add-telegram-swarm -description: Add Agent Swarm (Teams) support to Telegram. Each subagent gets its own bot identity in the group. Requires Telegram channel to be set up first (use /add-telegram). Triggers on "agent swarm", "agent teams telegram", "telegram swarm", "bot pool". ---- - -# Add Agent Swarm to Telegram - -This skill adds Agent Teams (Swarm) support to an existing Telegram channel. Each subagent in a team gets its own bot identity in the Telegram group, so users can visually distinguish which agent is speaking. - -**Prerequisite**: Telegram must already be set up via the `/add-telegram` skill. If `src/telegram.ts` does not exist or `TELEGRAM_BOT_TOKEN` is not configured, tell the user to run `/add-telegram` first. - -## How It Works - -- The **main bot** receives messages and sends lead agent responses (already set up by `/add-telegram`) -- **Pool bots** are send-only — each gets a Grammy `Api` instance (no polling) -- When a subagent calls `send_message` with a `sender` parameter, the host assigns a pool bot and renames it to match the sender's role -- Messages appear in Telegram from different bot identities - -``` -Subagent calls send_message(text: "Found 3 results", sender: "Researcher") - → MCP writes IPC file with sender field - → Host IPC watcher picks it up - → Assigns pool bot #2 to "Researcher" (round-robin, stable per-group) - → Renames pool bot #2 to "Researcher" via setMyName - → Sends message via pool bot #2's Api instance - → Appears in Telegram from "Researcher" bot -``` - -## Prerequisites - -### 1. Create Pool Bots - -Tell the user: - -> I need you to create 3-5 Telegram bots to use as the agent pool. These will be renamed dynamically to match agent roles. -> -> 1. Open Telegram and search for `@BotFather` -> 2. Send `/newbot` for each bot: -> - Give them any placeholder name (e.g., "Bot 1", "Bot 2") -> - Usernames like `myproject_swarm_1_bot`, `myproject_swarm_2_bot`, etc. -> 3. Copy all the tokens -> 4. Add all bots to your Telegram group(s) where you want agent teams - -Wait for user to provide the tokens. - -### 2. Disable Group Privacy for Pool Bots - -Tell the user: - -> **Important**: Each pool bot needs Group Privacy disabled so it can send messages in groups. -> -> For each pool bot in `@BotFather`: -> 1. Send `/mybots` and select the bot -> 2. Go to **Bot Settings** > **Group Privacy** > **Turn off** -> -> Then add all pool bots to your Telegram group(s). - -## Implementation - -### Step 1: Update Configuration - -Read `src/config.ts` and add the bot pool config near the other Telegram exports: - -```typescript -export const TELEGRAM_BOT_POOL = (process.env.TELEGRAM_BOT_POOL || '') - .split(',') - .map((t) => t.trim()) - .filter(Boolean); -``` - -### Step 2: Add Bot Pool to Telegram Module - -Read `src/telegram.ts` and add the following: - -1. **Update imports** — add `Api` to the Grammy import: - -```typescript -import { Api, Bot } from 'grammy'; -``` - -2. **Add pool state** after the existing `let bot` declaration: - -```typescript -// Bot pool for agent teams: send-only Api instances (no polling) -const poolApis: Api[] = []; -// Maps "{groupFolder}:{senderName}" → pool Api index for stable assignment -const senderBotMap = new Map(); -let nextPoolIndex = 0; -``` - -3. **Add pool functions** — place these before the `isTelegramConnected` function: - -```typescript -/** - * Initialize send-only Api instances for the bot pool. - * Each pool bot can send messages but doesn't poll for updates. - */ -export async function initBotPool(tokens: string[]): Promise { - for (const token of tokens) { - try { - const api = new Api(token); - const me = await api.getMe(); - poolApis.push(api); - logger.info( - { username: me.username, id: me.id, poolSize: poolApis.length }, - 'Pool bot initialized', - ); - } catch (err) { - logger.error({ err }, 'Failed to initialize pool bot'); - } - } - if (poolApis.length > 0) { - logger.info({ count: poolApis.length }, 'Telegram bot pool ready'); - } -} - -/** - * Send a message via a pool bot assigned to the given sender name. - * Assigns bots round-robin on first use; subsequent messages from the - * same sender in the same group always use the same bot. - * On first assignment, renames the bot to match the sender's role. - */ -export async function sendPoolMessage( - chatId: string, - text: string, - sender: string, - groupFolder: string, -): Promise { - if (poolApis.length === 0) { - // No pool bots — fall back to main bot - await sendTelegramMessage(chatId, text); - return; - } - - const key = `${groupFolder}:${sender}`; - let idx = senderBotMap.get(key); - if (idx === undefined) { - idx = nextPoolIndex % poolApis.length; - nextPoolIndex++; - senderBotMap.set(key, idx); - // Rename the bot to match the sender's role, then wait for Telegram to propagate - try { - await poolApis[idx].setMyName(sender); - await new Promise((r) => setTimeout(r, 2000)); - logger.info({ sender, groupFolder, poolIndex: idx }, 'Assigned and renamed pool bot'); - } catch (err) { - logger.warn({ sender, err }, 'Failed to rename pool bot (sending anyway)'); - } - } - - const api = poolApis[idx]; - try { - const numericId = chatId.replace(/^tg:/, ''); - const MAX_LENGTH = 4096; - if (text.length <= MAX_LENGTH) { - await api.sendMessage(numericId, text); - } else { - for (let i = 0; i < text.length; i += MAX_LENGTH) { - await api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH)); - } - } - logger.info({ chatId, sender, poolIndex: idx, length: text.length }, 'Pool message sent'); - } catch (err) { - logger.error({ chatId, sender, err }, 'Failed to send pool message'); - } -} -``` - -### Step 3: Add sender Parameter to MCP Tool - -Read `container/agent-runner/src/ipc-mcp-stdio.ts` and update the `send_message` tool to accept an optional `sender` parameter: - -Change the tool's schema from: -```typescript -{ text: z.string().describe('The message text to send') }, -``` - -To: -```typescript -{ - text: z.string().describe('The message text to send'), - sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'), -}, -``` - -And update the handler to include `sender` in the IPC data: - -```typescript -async (args) => { - const data: Record = { - type: 'message', - chatJid, - text: args.text, - sender: args.sender || undefined, - groupFolder, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(MESSAGES_DIR, data); - - return { content: [{ type: 'text' as const, text: 'Message sent.' }] }; - }, -``` - -### Step 4: Update Host IPC Routing - -Read `src/ipc.ts` and make these changes: - -1. **Add imports** — add `sendPoolMessage` and `initBotPool` from the Telegram swarm module, and `TELEGRAM_BOT_POOL` from config. - -2. **Update IPC message routing** — in `src/ipc.ts`, find where the `sendMessage` dependency is called to deliver IPC messages (inside `processIpcFiles`). The `sendMessage` is passed in via the `IpcDeps` parameter. Wrap it to route Telegram swarm messages through the bot pool: - -```typescript -if (data.sender && data.chatJid.startsWith('tg:')) { - await sendPoolMessage( - data.chatJid, - data.text, - data.sender, - sourceGroup, - ); -} else { - await deps.sendMessage(data.chatJid, data.text); -} -``` - -Note: The assistant name prefix is handled by `formatOutbound()` in the router — Telegram channels have `prefixAssistantName = false` so no prefix is added for `tg:` JIDs. - -3. **Initialize pool in `main()` in `src/index.ts`** — after creating the Telegram channel, add: - -```typescript -if (TELEGRAM_BOT_POOL.length > 0) { - await initBotPool(TELEGRAM_BOT_POOL); -} -``` - -### Step 5: Update CLAUDE.md Files - -#### 5a. Add global message formatting rules - -Read `groups/global/CLAUDE.md` and add a Message Formatting section: - -```markdown -## Message Formatting - -NEVER use markdown. Only use WhatsApp/Telegram formatting: -- *single asterisks* for bold (NEVER **double asterisks**) -- _underscores_ for italic -- • bullet points -- ```triple backticks``` for code - -No ## headings. No [links](url). No **double stars**. -``` - -#### 5b. Update existing group CLAUDE.md headings - -In any group CLAUDE.md that has a "WhatsApp Formatting" section (e.g. `groups/main/CLAUDE.md`), rename the heading to reflect multi-channel support: - -``` -## WhatsApp Formatting (and other messaging apps) -``` - -#### 5c. Add Agent Teams instructions to Telegram groups - -For each Telegram group that will use agent teams, create or update its `groups/{folder}/CLAUDE.md` with these instructions. Read the existing CLAUDE.md first (or `groups/global/CLAUDE.md` as a base) and add the Agent Teams section: - -```markdown -## Agent Teams - -When creating a team to tackle a complex task, follow these rules: - -### CRITICAL: Follow the user's prompt exactly - -Create *exactly* the team the user asked for — same number of agents, same roles, same names. Do NOT add extra agents, rename roles, or use generic names like "Researcher 1". If the user says "a marine biologist, a physicist, and Alexander Hamilton", create exactly those three agents with those exact names. - -### Team member instructions - -Each team member MUST be instructed to: - -1. *Share progress in the group* via `mcp__nanoclaw__send_message` with a `sender` parameter matching their exact role/character name (e.g., `sender: "Marine Biologist"` or `sender: "Alexander Hamilton"`). This makes their messages appear from a dedicated bot in the Telegram group. -2. *Also communicate with teammates* via `SendMessage` as normal for coordination. -3. Keep group messages *short* — 2-4 sentences max per message. Break longer content into multiple `send_message` calls. No walls of text. -4. Use the `sender` parameter consistently — always the same name so the bot identity stays stable. -5. NEVER use markdown formatting. Use ONLY WhatsApp/Telegram formatting: single *asterisks* for bold (NOT **double**), _underscores_ for italic, • for bullets, ```backticks``` for code. No ## headings, no [links](url), no **double asterisks**. - -### Example team creation prompt - -When creating a teammate, include instructions like: - -\``` -You are the Marine Biologist. When you have findings or updates for the user, send them to the group using mcp__nanoclaw__send_message with sender set to "Marine Biologist". Keep each message short (2-4 sentences max). Use emojis for strong reactions. ONLY use single *asterisks* for bold (never **double**), _underscores_ for italic, • for bullets. No markdown. Also communicate with teammates via SendMessage. -\``` - -### Lead agent behavior - -As the lead agent who created the team: - -- You do NOT need to react to or relay every teammate message. The user sees those directly from the teammate bots. -- Send your own messages only to comment, share thoughts, synthesize, or direct the team. -- When processing an internal update from a teammate that doesn't need a user-facing response, wrap your *entire* output in `` tags. -- Focus on high-level coordination and the final synthesis. -``` - -### Step 6: Update Environment - -Add pool tokens to `.env`: - -```bash -TELEGRAM_BOT_POOL=TOKEN1,TOKEN2,TOKEN3,... -``` - -**Important**: Sync to all required locations: - -```bash -cp .env data/env/env -``` - -Also add `TELEGRAM_BOT_POOL` to the launchd plist (`~/Library/LaunchAgents/com.nanoclaw.plist`) in the `EnvironmentVariables` dict if using launchd. - -### Step 7: Rebuild and Restart - -```bash -pnpm run build -./container/build.sh # Required — MCP tool changed -# macOS: -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist -# Linux: -# systemctl --user restart nanoclaw -``` - -Must use `unload/load` (macOS) or `restart` (Linux) because the service env vars changed. - -### Step 8: Test - -Tell the user: - -> Send a message in your Telegram group asking for a multi-agent task, e.g.: -> "Assemble a team of a researcher and a coder to build me a hello world app" -> -> You should see: -> - The lead agent (main bot) acknowledging and creating the team -> - Each subagent messaging from a different bot, renamed to their role -> - Short, scannable messages from each agent -> -> Check logs: `tail -f logs/nanoclaw.log | grep -i pool` - -## Architecture Notes - -- Pool bots use Grammy's `Api` class — lightweight, no polling, just send -- Bot names are set via `setMyName` — changes are global to the bot, not per-chat -- A 2-second delay after `setMyName` allows Telegram to propagate the name change before the first message -- Sender→bot mapping is stable within a group (keyed as `{groupFolder}:{senderName}`) -- Mapping resets on service restart — pool bots get reassigned fresh -- If pool runs out, bots are reused (round-robin wraps) - -## Troubleshooting - -### Pool bots not sending messages - -1. Verify tokens: `curl -s "https://api.telegram.org/botTOKEN/getMe"` -2. Check pool initialized: `grep "Pool bot" logs/nanoclaw.log` -3. Ensure all pool bots are members of the Telegram group -4. Check Group Privacy is disabled for each pool bot - -### Bot names not updating - -Telegram caches bot names client-side. The 2-second delay after `setMyName` helps, but users may need to restart their Telegram client to see updated names immediately. - -### Subagents not using send_message - -Check the group's `CLAUDE.md` has the Agent Teams instructions. The lead agent reads this when creating teammates and must include the `send_message` + `sender` instructions in each teammate's prompt. - -## Removal - -To remove Agent Swarm support while keeping basic Telegram: - -1. Remove `TELEGRAM_BOT_POOL` from `src/config.ts` -2. Remove pool code from `src/telegram.ts` (`poolApis`, `senderBotMap`, `initBotPool`, `sendPoolMessage`) -3. Remove pool routing from IPC handler in `src/index.ts` (revert to plain `sendMessage`) -4. Remove `initBotPool` call from `main()` -5. Remove `sender` param from MCP tool in `container/agent-runner/src/ipc-mcp-stdio.ts` -6. Remove Agent Teams section from group CLAUDE.md files -7. Remove `TELEGRAM_BOT_POOL` from `.env`, `data/env/env`, and launchd plist/systemd unit -8. Rebuild: `pnpm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` (macOS) or `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw` (Linux) diff --git a/.claude/skills/add-voice-transcription/SKILL.md b/.claude/skills/add-voice-transcription/SKILL.md deleted file mode 100644 index cae1e47..0000000 --- a/.claude/skills/add-voice-transcription/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: add-voice-transcription -description: Add voice message transcription to NanoClaw using OpenAI's Whisper API. Automatically transcribes WhatsApp voice notes so the agent can read and respond to them. ---- - -# Add Voice Transcription - -This skill adds automatic voice message transcription to NanoClaw's WhatsApp channel using OpenAI's Whisper API. When a voice note arrives, it is downloaded, transcribed, and delivered to the agent as `[Voice: ]`. - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `src/transcription.ts` exists. If it does, skip to Phase 3 (Configure). The code changes are already in place. - -### Ask the user - -Use `AskUserQuestion` to collect information: - -AskUserQuestion: Do you have an OpenAI API key for Whisper transcription? - -If yes, collect it now. If no, direct them to create one at https://platform.openai.com/api-keys. - -## Phase 2: Apply Code Changes - -**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files. - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/voice-transcription -git merge whatsapp/skill/voice-transcription || { - git checkout --theirs pnpm-lock.yaml - git add pnpm-lock.yaml - git merge --continue -} -``` - -This merges in: -- `src/transcription.ts` (voice transcription module using OpenAI Whisper) -- Voice handling in `src/channels/whatsapp.ts` (isVoiceMessage check, transcribeAudioMessage call) -- Transcription tests in `src/channels/whatsapp.test.ts` -- `openai` npm dependency in `package.json` -- `OPENAI_API_KEY` in `.env.example` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes - -```bash -pnpm install -pnpm run build -pnpm exec vitest run src/channels/whatsapp.test.ts -``` - -All tests must pass and build must be clean before proceeding. - -## Phase 3: Configure - -### Get OpenAI API key (if needed) - -If the user doesn't have an API key: - -> I need you to create an OpenAI API key: -> -> 1. Go to https://platform.openai.com/api-keys -> 2. Click "Create new secret key" -> 3. Give it a name (e.g., "NanoClaw Transcription") -> 4. Copy the key (starts with `sk-`) -> -> Cost: ~$0.006 per minute of audio (~$0.003 per typical 30-second voice note) - -Wait for the user to provide the key. - -### Add to environment - -Add to `.env`: - -```bash -OPENAI_API_KEY= -``` - -Sync to container environment: - -```bash -mkdir -p data/env && cp .env data/env/env -``` - -The container reads environment from `data/env/env`, not `.env` directly. - -### Build and restart - -```bash -pnpm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 4: Verify - -### Test with a voice note - -Tell the user: - -> Send a voice note in any registered WhatsApp chat. The agent should receive it as `[Voice: ]` and respond to its content. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log | grep -i voice -``` - -Look for: -- `Transcribed voice message` — successful transcription with character count -- `OPENAI_API_KEY not set` — key missing from `.env` -- `OpenAI transcription failed` — API error (check key validity, billing) -- `Failed to download audio message` — media download issue - -## Troubleshooting - -### Voice notes show "[Voice Message - transcription unavailable]" - -1. Check `OPENAI_API_KEY` is set in `.env` AND synced to `data/env/env` -2. Verify key works: `curl -s https://api.openai.com/v1/models -H "Authorization: Bearer $OPENAI_API_KEY" | head -c 200` -3. Check OpenAI billing — Whisper requires a funded account - -### Voice notes show "[Voice Message - transcription failed]" - -Check logs for the specific error. Common causes: -- Network timeout — transient, will work on next message -- Invalid API key — regenerate at https://platform.openai.com/api-keys -- Rate limiting — wait and retry - -### Agent doesn't respond to voice notes - -Verify the chat is registered and the agent is running. Voice transcription only runs for registered groups. diff --git a/.claude/skills/channel-formatting/SKILL.md b/.claude/skills/channel-formatting/SKILL.md deleted file mode 100644 index 8d27ffc..0000000 --- a/.claude/skills/channel-formatting/SKILL.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -name: channel-formatting -description: Convert Claude's Markdown output to each channel's native text syntax before delivery. Adds zero-dependency formatting for WhatsApp, Telegram, and Slack (marker substitution). Also ships a Signal rich-text helper (parseSignalStyles) used by the Signal skill. ---- - -# Channel Formatting - -This skill wires channel-aware Markdown conversion into the outbound pipeline so Claude's -responses render natively on each platform — no more literal `**asterisks**` in WhatsApp or -Telegram. - -| Channel | Transformation | -|---------|---------------| -| WhatsApp | `**bold**` → `*bold*`, `*italic*` → `_italic_`, headings → bold, links → `text (url)` | -| Telegram | same as WhatsApp, but `[text](url)` links are preserved (Markdown v1 renders them natively) | -| Slack | same as WhatsApp, but links become `` | -| Discord | passthrough (Discord already renders Markdown) | -| Signal | passthrough for `parseTextStyles`; `parseSignalStyles` in `src/text-styles.ts` produces plain text + native `textStyle` ranges for use by the Signal skill | - -Code blocks (fenced and inline) are always protected — their content is never transformed. - -## Phase 1: Pre-flight - -### Check if already applied - -```bash -test -f src/text-styles.ts && echo "already applied" || echo "not yet applied" -``` - -If `already applied`, skip to Phase 3 (Verify). - -## Phase 2: Apply Code Changes - -### Ensure the upstream remote - -```bash -git remote -v -``` - -If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing, -add it: - -```bash -git remote add upstream https://github.com/qwibitai/nanoclaw.git -``` - -### Merge the skill branch - -```bash -git fetch upstream skill/channel-formatting -git merge upstream/skill/channel-formatting -``` - -If there are merge conflicts on `pnpm-lock.yaml`, resolve them by accepting the incoming -version and continuing: - -```bash -git checkout --theirs pnpm-lock.yaml -git add pnpm-lock.yaml -git merge --continue -``` - -For any other conflict, read the conflicted file and reconcile both sides manually. - -This merge adds: - -- `src/text-styles.ts` — `parseTextStyles(text, channel)` for marker substitution and - `parseSignalStyles(text)` for Signal native rich text -- `src/router.ts` — `formatOutbound` gains an optional `channel` parameter; when provided - it calls `parseTextStyles` after stripping `` tags -- `src/index.ts` — both outbound `sendMessage` paths pass `channel.name` to `formatOutbound` -- `src/formatting.test.ts` — test coverage for both functions across all channels - -### Validate - -```bash -pnpm install -pnpm run build -pnpm exec vitest run src/formatting.test.ts -``` - -All 73 tests should pass and the build should be clean before continuing. - -## Phase 3: Verify - -### Rebuild and restart - -```bash -pnpm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -### Spot-check formatting - -Send a message through any registered WhatsApp or Telegram chat that will trigger a -response from Claude. Ask something that will produce formatted output, such as: - -> Summarise the three main advantages of TypeScript using bullet points and **bold** headings. - -Confirm that the response arrives with native bold (`*text*`) rather than raw double -asterisks. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log -``` - -## Signal Skill Integration - -If you have the Signal skill installed, `src/channels/signal.ts` can import -`parseSignalStyles` from the newly present `src/text-styles.ts`: - -```typescript -import { parseSignalStyles, SignalTextStyle } from '../text-styles.js'; -``` - -`parseSignalStyles` returns `{ text: string, textStyle: SignalTextStyle[] }` where -`textStyle` is an array of `{ style, start, length }` objects suitable for the -`signal-cli` JSON-RPC `textStyles` parameter (format: `"start:length:STYLE"`). - -## Removal - -```bash -# Remove the new file -rm src/text-styles.ts - -# Revert router.ts to remove the channel param -git diff upstream/main src/router.ts # review changes -git checkout upstream/main -- src/router.ts - -# Revert the index.ts sendMessage call sites to plain formatOutbound(rawText) -# (edit manually or: git checkout upstream/main -- src/index.ts) - -pnpm run build -``` \ No newline at end of file diff --git a/.claude/skills/use-local-whisper/SKILL.md b/.claude/skills/use-local-whisper/SKILL.md deleted file mode 100644 index 664cafa..0000000 --- a/.claude/skills/use-local-whisper/SKILL.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -name: use-local-whisper -description: Use when the user wants local voice transcription instead of OpenAI Whisper API. Switches to whisper.cpp running on Apple Silicon. WhatsApp only for now. Requires voice-transcription skill to be applied first. ---- - -# Use Local Whisper - -Switches voice transcription from OpenAI's Whisper API to local whisper.cpp. Runs entirely on-device — no API key, no network, no cost. - -**Channel support:** Currently WhatsApp only. The transcription module (`src/transcription.ts`) uses Baileys types for audio download. Other channels (Telegram, Discord, etc.) would need their own audio-download logic before this skill can serve them. - -**Note:** The Homebrew package is `whisper-cpp`, but the CLI binary it installs is `whisper-cli`. - -## Prerequisites - -- `voice-transcription` skill must be applied first (WhatsApp channel) -- macOS with Apple Silicon (M1+) recommended -- `whisper-cpp` installed: `brew install whisper-cpp` (provides the `whisper-cli` binary) -- `ffmpeg` installed: `brew install ffmpeg` -- A GGML model file downloaded to `data/models/` - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `src/transcription.ts` already uses `whisper-cli`: - -```bash -grep 'whisper-cli' src/transcription.ts && echo "Already applied" || echo "Not applied" -``` - -If already applied, skip to Phase 3 (Verify). - -### Check dependencies are installed - -```bash -whisper-cli --help >/dev/null 2>&1 && echo "WHISPER_OK" || echo "WHISPER_MISSING" -ffmpeg -version >/dev/null 2>&1 && echo "FFMPEG_OK" || echo "FFMPEG_MISSING" -``` - -If missing, install via Homebrew: -```bash -brew install whisper-cpp ffmpeg -``` - -### Check for model file - -```bash -ls data/models/ggml-*.bin 2>/dev/null || echo "NO_MODEL" -``` - -If no model exists, download the base model (148MB, good balance of speed and accuracy): -```bash -mkdir -p data/models -curl -L -o data/models/ggml-base.bin "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin" -``` - -For better accuracy at the cost of speed, use `ggml-small.bin` (466MB) or `ggml-medium.bin` (1.5GB). - -## Phase 2: Apply Code Changes - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/local-whisper -git merge whatsapp/skill/local-whisper || { - git checkout --theirs pnpm-lock.yaml - git add pnpm-lock.yaml - git merge --continue -} -``` - -This modifies `src/transcription.ts` to use the `whisper-cli` binary instead of the OpenAI API. - -### Validate - -```bash -pnpm run build -``` - -## Phase 3: Verify - -### Ensure launchd PATH includes Homebrew - -The NanoClaw launchd service runs with a restricted PATH. `whisper-cli` and `ffmpeg` are in `/opt/homebrew/bin/` (Apple Silicon) or `/usr/local/bin/` (Intel), which may not be in the plist's PATH. - -Check the current PATH: -```bash -grep -A1 'PATH' ~/Library/LaunchAgents/com.nanoclaw.plist -``` - -If `/opt/homebrew/bin` is missing, add it to the `` value inside the `PATH` key in the plist. Then reload: -```bash -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist -``` - -### Build and restart - -```bash -pnpm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` - -### Test - -Send a voice note in any registered group. The agent should receive it as `[Voice: ]`. - -### Check logs - -```bash -tail -f logs/nanoclaw.log | grep -i -E "voice|transcri|whisper" -``` - -Look for: -- `Transcribed voice message` — successful transcription -- `whisper.cpp transcription failed` — check model path, ffmpeg, or PATH - -## Configuration - -Environment variables (optional, set in `.env`): - -| Variable | Default | Description | -|----------|---------|-------------| -| `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary | -| `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file | - -## Troubleshooting - -**"whisper.cpp transcription failed"**: Ensure both `whisper-cli` and `ffmpeg` are in PATH. The launchd service uses a restricted PATH — see Phase 3 above. Test manually: -```bash -ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 -f wav /tmp/test.wav -y -whisper-cli -m data/models/ggml-base.bin -f /tmp/test.wav --no-timestamps -nt -``` - -**Transcription works in dev but not as service**: The launchd plist PATH likely doesn't include `/opt/homebrew/bin`. See "Ensure launchd PATH includes Homebrew" in Phase 3. - -**Slow transcription**: The base model processes ~30s of audio in <1s on M1+. If slower, check CPU usage — another process may be competing. - -**Wrong language**: whisper.cpp auto-detects language. To force a language, you can set `WHISPER_LANG` and modify `src/transcription.ts` to pass `-l $WHISPER_LANG`. From 212fc1f1b5c054c95be2ef4dd323a600f822ab37 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 17 Apr 2026 15:28:44 +0300 Subject: [PATCH 048/185] docs(add-emacs): rewrite skill for copy-from-channels-branch pattern Ports the emacs channel skill to match the other channel skills: copy src/channels/emacs.ts + emacs/nanoclaw.el from the channels branch, append the self-registration import, enable via EMACS_ENABLED, and wire through the register setup step. Documents the v2 entity model (single messaging group, platform_id="default") and drops the v1 auto-register / symlink behavior that the old adapter did. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-emacs/SKILL.md | 261 +++++++++++++++--------------- 1 file changed, 134 insertions(+), 127 deletions(-) diff --git a/.claude/skills/add-emacs/SKILL.md b/.claude/skills/add-emacs/SKILL.md index 8a4100e..82a5098 100644 --- a/.claude/skills/add-emacs/SKILL.md +++ b/.claude/skills/add-emacs/SKILL.md @@ -1,12 +1,11 @@ --- name: add-emacs -description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Uses a local HTTP bridge — no bot token or external service needed. +description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Local HTTP bridge — no bot token or external service needed. --- # Add Emacs Channel -This skill adds Emacs support to NanoClaw, then walks through interactive setup. -Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+. +Adds Emacs support via a local HTTP bridge. Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+. ## What you can do with this @@ -15,95 +14,99 @@ Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+. - **Meeting notes** — send an org agenda entry; get a summary or action item list back as a child node - **Draft writing** — send org prose; receive revisions or continuations in place - **Research capture** — ask a question directly in your org notes; the answer lands exactly where you need it -- **Schedule tasks** — ask Andy to set a reminder or create a scheduled NanoClaw task (e.g. "remind me tomorrow to review the PR") -## Phase 1: Pre-flight +## Install -### Check if already applied +NanoClaw doesn't ship channels in trunk. This skill copies the Emacs adapter and the Lisp client in from the `channels` branch. Native HTTP bridge — no Chat SDK, no adapter package. -Check if `src/channels/emacs.ts` exists: +### Pre-flight (idempotent) + +Skip to **Enable** if all of these are already in place: + +- `src/channels/emacs.ts` exists +- `emacs/nanoclaw.el` exists +- `src/channels/index.ts` contains `import './emacs.js';` + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch ```bash -test -f src/channels/emacs.ts && echo "already applied" || echo "not applied" +git fetch origin channels ``` -If it exists, skip to Phase 3 (Setup). The code changes are already in place. - -## Phase 2: Apply Code Changes - -### Ensure the upstream remote +### 2. Copy the adapter and Lisp client ```bash -git remote -v +mkdir -p emacs +git show origin/channels:src/channels/emacs.ts > src/channels/emacs.ts +git show origin/channels:src/channels/emacs.test.ts > src/channels/emacs.test.ts +git show origin/channels:emacs/nanoclaw.el > emacs/nanoclaw.el ``` -If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing, -add it: +### 3. Append the self-registration import -```bash -git remote add upstream https://github.com/qwibitai/nanoclaw.git +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './emacs.js'; ``` -### Merge the skill branch - -```bash -git fetch upstream skill/emacs -git merge upstream/skill/emacs -``` - -If there are merge conflicts on `pnpm-lock.yaml`, resolve them by accepting the incoming -version and continuing: - -```bash -git checkout --theirs pnpm-lock.yaml -git add pnpm-lock.yaml -git merge --continue -``` - -For any other conflict, read the conflicted file and reconcile both sides manually. - -This adds: -- `src/channels/emacs.ts` — `EmacsBridgeChannel` HTTP server (port 8766) -- `src/channels/emacs.test.ts` — unit tests -- `emacs/nanoclaw.el` — Emacs Lisp package (`nanoclaw-chat`, `nanoclaw-org-send`) -- `import './emacs.js'` appended to `src/channels/index.ts` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes +### 4. Build ```bash pnpm run build -pnpm exec vitest run src/channels/emacs.test.ts ``` -Build must be clean and tests must pass before proceeding. +No npm package to install — the adapter uses only Node builtins (`http`). -## Phase 3: Setup +## Enable -### Configure environment (optional) - -The channel works out of the box with defaults. Add to `.env` only if you need non-defaults: +The adapter is gated by `EMACS_ENABLED` so the HTTP port isn't opened on hosts that aren't running Emacs. Add to `.env`: ```bash -EMACS_CHANNEL_PORT=8766 # default — change if 8766 is already in use -EMACS_AUTH_TOKEN= # optional — locks the endpoint to Emacs only +EMACS_ENABLED=true +EMACS_CHANNEL_PORT=8766 # optional — change only if 8766 is taken +EMACS_AUTH_TOKEN= # optional — set to a random string to lock the endpoint +EMACS_PLATFORM_ID=default # optional — only change if you want a non-default chat id ``` -If you change or add values, sync to the container environment: +Generate an auth token (recommended even on single-user machines — prevents other local processes from poking the endpoint): ```bash -mkdir -p data/env && cp .env data/env/env +node -e "console.log(require('crypto').randomBytes(16).toString('hex'))" ``` -### Configure Emacs +## Wire the channel -The `nanoclaw.el` package requires only Emacs 27.1+ built-in libraries (`url`, `json`, `org`) — no package manager setup needed. +Emacs is a single-user, single-chat channel. One host = one messaging group with `platform_id = "default"`. + +### If this is your first agent group + +Run `/init-first-agent` — pick **Emacs** as the channel, use any short handle as the "user id" (e.g. your OS username), and the skill will create the agent group, wire the channel, and write a welcome message that the agent delivers back to your Emacs buffer. + +### Otherwise — wire to an existing agent group + +Run the `register` step directly. The `EMACS_PLATFORM_ID` (default `default`) becomes the messaging group's platform id: + +```bash +pnpm exec tsx setup/index.ts --step register -- \ + --platform-id "default" --name "Emacs" \ + --folder "" --channel "emacs" \ + --session-mode "agent-shared" \ + --assistant-name "" +``` + +`agent-shared` puts Emacs messages in the same session as any other channel wired to the same agent group — so a conversation you started in Telegram continues in Emacs. Use `shared` to keep an independent Emacs thread with the same workspace, or a new `--folder` for a dedicated Emacs-only agent. + +## Configure Emacs + +`nanoclaw.el` needs only Emacs 27.1+ builtins (`url`, `json`, `org`) — no package manager. AskUserQuestion: Which Emacs distribution are you using? -- **Doom Emacs** - config.el with map! keybindings -- **Spacemacs** - dotspacemacs/user-config in ~/.spacemacs -- **Vanilla Emacs / other** - init.el with global-set-key +- **Doom Emacs** — `config.el` with `map!` keybindings +- **Spacemacs** — `dotspacemacs/user-config` in `~/.spacemacs` +- **Vanilla Emacs / other** — `init.el` with `global-set-key` **Doom Emacs** — add to `~/.config/doom/config.el` (or `~/.doom.d/config.el`): @@ -117,7 +120,7 @@ AskUserQuestion: Which Emacs distribution are you using? :desc "Send org" "o" #'nanoclaw-org-send) ``` -Then reload: `M-x doom/reload` +Reload: `M-x doom/reload` **Spacemacs** — add to `dotspacemacs/user-config` in `~/.spacemacs`: @@ -129,9 +132,9 @@ Then reload: `M-x doom/reload` (spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send) ``` -Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs. +Reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs. -**Vanilla Emacs** — add to `~/.emacs.d/init.el` (or `~/.emacs`): +**Vanilla Emacs** — add to `~/.emacs.d/init.el`: ```elisp ;; NanoClaw — personal AI assistant channel @@ -141,61 +144,75 @@ Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs. (global-set-key (kbd "C-c n o") #'nanoclaw-org-send) ``` -Then reload: `M-x eval-buffer` or restart Emacs. +Reload: `M-x eval-buffer` or restart Emacs. -If `EMACS_AUTH_TOKEN` was set, also add (any distribution): +Replace `~/src/nanoclaw/emacs/nanoclaw.el` with your actual NanoClaw checkout path. + +If `EMACS_AUTH_TOKEN` is set, also add (any distribution): ```elisp (setq nanoclaw-auth-token "") ``` -If `EMACS_CHANNEL_PORT` was changed from the default, also add: +If you changed `EMACS_CHANNEL_PORT` from the default: ```elisp (setq nanoclaw-port ) ``` -### Restart NanoClaw +## Restart NanoClaw ```bash pnpm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux ``` -## Phase 4: Verify +## Verify -### Test the HTTP endpoint +### HTTP endpoint ```bash -curl -s "http://localhost:8766/api/messages?since=0" +curl -s http://localhost:8766/api/messages?since=0 ``` -Expected: `{"messages":[]}` - -If you set `EMACS_AUTH_TOKEN`: +Expected: `{"messages":[]}`. With an auth token: ```bash -curl -s -H "Authorization: Bearer " "http://localhost:8766/api/messages?since=0" +curl -s -H "Authorization: Bearer " http://localhost:8766/api/messages?since=0 ``` -### Test from Emacs +### From Emacs Tell the user: > 1. Open the chat buffer with your keybinding (`SPC N c`, `SPC a N c`, or `C-c n c`) -> 2. Type a message and press `RET` -> 3. A response from Andy should appear within a few seconds +> 2. Type a message and press `C-c C-c` to send (RET inserts newlines) +> 3. A response should appear within a few seconds > > For org-mode: open any `.org` file, position the cursor on a heading, and use `SPC N o` / `SPC a N o` / `C-c n o` -### Check logs if needed +### Log line -```bash -tail -f logs/nanoclaw.log -``` +`tail -f logs/nanoclaw.log` should show `Emacs channel listening` at startup. -Look for `Emacs channel listening` at startup and `Emacs message received` when a message is sent. +## Channel Info + +- **type**: `emacs` +- **terminology**: Single local buffer. There are no "groups" or separate chats — one host = one chat, addressed by a `platform_id` string (default `default`). +- **how-to-find-id**: The platform id is whatever you set in `EMACS_PLATFORM_ID` (default `default`). User handles are arbitrary; your OS username or first name is fine (e.g. `emacs:`). +- **supports-threads**: no +- **typical-use**: Single developer talking to the assistant from within Emacs, alongside whatever other channel they use (Slack, Telegram, Discord). +- **default-isolation**: Same agent group as the primary DM, with `session-mode = agent-shared` so a conversation started elsewhere continues in Emacs. Pick a separate folder only if you specifically want an Emacs-only persona. + +### Features + +- Interactive chat buffer (`nanoclaw-chat`) with markdown → org-mode rendering +- Org integration (`nanoclaw-org-send`) — sends the current subtree or region; reply lands as a child heading +- Optional bearer-token auth for the local endpoint +- Single-user: the adapter exposes exactly one messaging group per host + +Not applicable (design): multi-user channels, threads, cold DM initiation, typing indicators, attachments. ## Troubleshooting @@ -205,66 +222,53 @@ Look for `Emacs channel listening` at startup and `Emacs message received` when Error: listen EADDRINUSE: address already in use :::8766 ``` -Either a stale NanoClaw process is running, or 8766 is taken by another app. - -Find and kill the stale process: +Either a stale NanoClaw is running or another app has the port. Kill stale process or change port: ```bash lsof -ti :8766 | xargs kill -9 +# or set EMACS_CHANNEL_PORT in .env and mirror in Emacs config (nanoclaw-port) ``` -Or change the port in `.env` (`EMACS_CHANNEL_PORT=8767`) and update `nanoclaw-port` in Emacs config. +### Adapter not starting + +If `grep "Emacs channel listening" logs/nanoclaw.log` returns nothing, check that `EMACS_ENABLED=true` is in `.env` and that the adapter import is present: + +```bash +grep -q '^EMACS_ENABLED=true' .env && echo "enabled" || echo "not enabled" +grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "not imported" +``` ### No response from agent -Check: -1. NanoClaw is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux) -2. Emacs group is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid = 'emacs:default'"` -3. Logs show activity: `tail -50 logs/nanoclaw.log` +1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) +2. Messaging group wired: `sqlite3 data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"` +3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20` -If the group is not registered, it will be created automatically on the next NanoClaw restart. +If no messaging group row exists, run the `register` command above. ### Auth token mismatch (401 Unauthorized) -Verify the token in Emacs matches `.env`: - ```elisp -;; M-x describe-variable RET nanoclaw-auth-token RET +M-x describe-variable RET nanoclaw-auth-token RET ``` -Must exactly match `EMACS_AUTH_TOKEN` in `.env`. +Must match `EMACS_AUTH_TOKEN` in `.env`. If you didn't set one server-side, clear it in Emacs too: + +```elisp +(setq nanoclaw-auth-token nil) +``` ### nanoclaw.el not loading -Check the path is correct: - ```bash ls ~/src/nanoclaw/emacs/nanoclaw.el ``` If NanoClaw is cloned elsewhere, update the `load`/`load-file` path in your Emacs config. -## After Setup - -If running `pnpm run dev` while the service is active: - -```bash -# macOS: -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -pnpm run dev -# When done testing: -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist - -# Linux: -# systemctl --user stop nanoclaw -# pnpm run dev -# systemctl --user start nanoclaw -``` - ## Agent Formatting -The Emacs bridge converts markdown → org-mode automatically. Agents should -output standard markdown — **not** org-mode syntax. The conversion handles: +The Emacs bridge converts markdown → org-mode automatically. Agents should output standard markdown, **not** org-mode syntax: | Markdown | Org-mode | |----------|----------| @@ -274,16 +278,19 @@ output standard markdown — **not** org-mode syntax. The conversion handles: | `` `code` `` | `~code~` | | ` ```lang ` | `#+begin_src lang` | -If an agent outputs org-mode directly, bold/italic/etc. will be double-converted -and render incorrectly. +If an agent outputs org-mode directly, markers get double-converted and render incorrectly. ## Removal -To remove the Emacs channel: +```bash +rm src/channels/emacs.ts src/channels/emacs.test.ts emacs/nanoclaw.el +# Remove the `import './emacs.js';` line from src/channels/index.ts +# Remove EMACS_* lines from .env +pnpm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux -1. Delete `src/channels/emacs.ts`, `src/channels/emacs.test.ts`, and `emacs/nanoclaw.el` -2. Remove `import './emacs.js'` from `src/channels/index.ts` -3. Remove the NanoClaw block from your Emacs config file -4. Remove Emacs registration from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid = 'emacs:default'"` -5. Remove `EMACS_CHANNEL_PORT` and `EMACS_AUTH_TOKEN` from `.env` if set -6. Rebuild: `pnpm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `pnpm run build && systemctl --user restart nanoclaw` (Linux) \ No newline at end of file +# Remove the NanoClaw block from your Emacs config +# Optionally clean up the messaging group: +sqlite3 data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';" +``` From d8d61d3695196a53969eaaf47a2f6829bc4ed1c3 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 10:16:13 +0300 Subject: [PATCH 049/185] fix: Teams user-id prefix + defer cli:local owner grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseUserId now falls back to user.kind when the id prefix isn't a registered adapter — Teams uses `29:` rather than `teams:`, so the literal prefix wouldn't resolve the channel adapter for cold DMs. init-cli-agent no longer claims the first-owner slot on `cli:local`. The CLI identity is scratch; owner promotion belongs to init-first-agent once the real channel user is wired. --- scripts/init-cli-agent.ts | 15 +++------------ src/modules/permissions/user-dm.ts | 15 +++++++++------ 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/scripts/init-cli-agent.ts b/scripts/init-cli-agent.ts index ccd9387..4a56827 100644 --- a/scripts/init-cli-agent.ts +++ b/scripts/init-cli-agent.ts @@ -30,7 +30,6 @@ import { } from '../src/db/messaging-groups.js'; import { runMigrations } from '../src/db/migrations/index.js'; import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.js'; -import { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles.js'; import { upsertUser } from '../src/modules/permissions/db/users.js'; import { initGroupFilesystem } from '../src/group-init.js'; import type { AgentGroup, MessagingGroup } from '../src/types.js'; @@ -91,17 +90,9 @@ async function main(): Promise { created_at: now, }); - let promotedToOwner = false; - if (!hasAnyOwner()) { - grantRole({ - user_id: CLI_SYNTHETIC_USER_ID, - role: 'owner', - agent_group_id: null, - granted_by: null, - granted_at: now, - }); - promotedToOwner = true; - } + // Owner grant deferred to init-first-agent when the real channel user is + // wired — cli:local is a scratch identity, not the operator. + const promotedToOwner = false; // 2. Agent group + filesystem. const folder = `cli-with-${normalizeName(args.displayName)}`; diff --git a/src/modules/permissions/user-dm.ts b/src/modules/permissions/user-dm.ts index ef9566a..a5274d1 100644 --- a/src/modules/permissions/user-dm.ts +++ b/src/modules/permissions/user-dm.ts @@ -136,11 +136,14 @@ async function resolveDmPlatformId(channelType: string, handle: string): Promise function parseUserId(user: User): { channelType: string; handle: string } | { channelType: null; handle: null } { const idx = user.id.indexOf(':'); if (idx < 0) return { channelType: null, handle: null }; - const channelType = user.id.slice(0, idx); + const prefix = user.id.slice(0, idx); const handle = user.id.slice(idx + 1); - if (!channelType || !handle) return { channelType: null, handle: null }; - // The `kind` on users mirrors the channel_type prefix in our current - // scheme. Pull it from `user.kind` if we ever decouple them later, but - // today the id prefix is authoritative. - return { channelType, handle }; + if (!prefix || !handle) return { channelType: null, handle: null }; + // Teams user IDs use a `29:` prefix, not `teams:`. When the id prefix + // isn't a registered adapter, fall back to user.kind and treat the full + // id as the handle. + if (!getChannelAdapter(prefix) && user.kind && getChannelAdapter(user.kind)) { + return { channelType: user.kind, handle: user.id }; + } + return { channelType: prefix, handle }; } From 9fe529984a646eb71c82e29a0a42c0959f65eb39 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Tue, 21 Apr 2026 08:50:59 +0000 Subject: [PATCH 050/185] fix(init-first-agent): seed welcome via inbound.db; drop --no-cli-bonus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The welcome DM used to be handed to the running service over the CLI admin socket, which stamped `cli:local` as the sender. On a strict messaging group (any fresh DM wired by init-first-agent), that tripped the unknown-sender approval gate — the operator's own bootstrap script ended up requesting its own approval. Fix by writing the welcome directly into the session's inbound.db with a `System` sender; the running service's host-sweep wakes the container on its next pass. Also drop `--no-cli-bonus`. Now that init-cli-agent always wires cli/local to the scratch CLI agent, every caller of init-first-agent had to pass --no-cli-bonus to avoid double-wiring; the flag has become mandatory, so it's just removed. The cli-bonus branch goes with it. Skill docs updated in lockstep. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/init-first-agent/SKILL.md | 4 +- .claude/skills/new-setup-2/SKILL.md | 5 +- scripts/init-first-agent.ts | 157 ++++++----------------- 3 files changed, 45 insertions(+), 121 deletions(-) diff --git a/.claude/skills/init-first-agent/SKILL.md b/.claude/skills/init-first-agent/SKILL.md index 6b110d3..68eab87 100644 --- a/.claude/skills/init-first-agent/SKILL.md +++ b/.claude/skills/init-first-agent/SKILL.md @@ -87,13 +87,13 @@ The script: 2. Creates the `agent_groups` row and calls `initGroupFilesystem` at `groups/dm-with-/`. 3. Reuses or creates the DM `messaging_groups` row. 4. Wires them via `messaging_group_agents` (which auto-creates the companion `agent_destinations` row). -5. Hands the welcome message to the running service via its CLI socket (`data/cli.sock`), targeting the DM messaging group. The service routes it into the DM session, which wakes the container synchronously. If the socket isn't reachable (service down), falls back to a direct `inbound.db` write that the next host sweep picks up. +5. Seeds the welcome message directly into the DM session's `inbound.db` (sender tagged `System`). The running service's host-sweep picks it up on the next pass and wakes the container through the normal path — no CLI-socket hand-off, no `cli:local` identity on the new agent's permission surface. Show the script's output to the user. ## 5. Verify -The welcome DM is queued synchronously; the only wait is container cold-start (~60s on first launch) before the agent processes the message and the reply flows through `outbound.db` to the channel. +The welcome is written to `inbound.db` immediately; the wait is host-sweep pickup (≤60s) plus container cold-start (~60s on first launch) before the agent processes the message and the reply flows through `outbound.db` to the channel. Do not tail the log or poll in a sleep loop. Ask the user in plain text: diff --git a/.claude/skills/new-setup-2/SKILL.md b/.claude/skills/new-setup-2/SKILL.md index 1b98443..8d75cd3 100644 --- a/.claude/skills/new-setup-2/SKILL.md +++ b/.claude/skills/new-setup-2/SKILL.md @@ -92,7 +92,7 @@ When the user picks one: ``` 2. **Capture platform IDs.** After the `/add-` skill finishes (or after inline credentials for Telegram), you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent ` directly and follow its `PAIR_TELEGRAM_ISSUED`/`PAIR_TELEGRAM STATUS=success` blocks — `PLATFORM_ID` and `ADMIN_USER_ID` land in the success block. -3. **Wire the agent.** Run `init-first-agent.ts` in DM mode with `--no-cli-bonus` (this keeps the new agent off the CLI messaging group so the pre-existing throwaway agent still owns CLI routing cleanly): +3. **Wire the agent.** Run `init-first-agent.ts` in DM mode: ``` pnpm exec tsx scripts/init-first-agent.ts \ @@ -100,8 +100,7 @@ When the user picks one: --user-id "" \ --platform-id "" \ --display-name "" \ - --agent-name "" \ - --no-cli-bonus + --agent-name "" ``` 4. **Announce.** On success, emit the encouragement line verbatim: diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index 29ca6d4..9dc8b6d 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -1,24 +1,25 @@ /** * Init the first (or Nth) NanoClaw v2 agent for a DM channel. * - * Wires a real DM channel (discord, telegram, etc.) to a new agent group - * (and the local CLI channel as a convenience bonus), then hands a welcome - * message to the running service via its CLI socket. The service routes - * that message into the DM session, which wakes the container synchronously — - * the agent processes the welcome and DMs the operator through the normal - * delivery path. + * Wires a real DM channel (discord, telegram, etc.) to a new agent group, + * then seeds a welcome message directly into the session's inbound DB. The + * running service's host-sweep picks it up on its next pass (within + * SWEEP_INTERVAL_MS) and wakes the container through the normal path; the + * agent introduces itself via the channel. * - * For the CLI-only scratch agent used during `/new-setup`, see - * `scripts/init-cli-agent.ts` — that's a distinct flow and doesn't run - * through here. + * CLI channel wiring is NOT touched here — `scripts/init-cli-agent.ts` owns + * the cli/local messaging group and its scratch agent. Keeping the two + * scripts disjoint means no `cli:local` identity ever appears on the new + * agent's permission surface, so the unknown-sender approval card that used + * to fire when the welcome was queued via the CLI admin socket no longer + * happens. * * Creates/reuses: user, owner grant (if none), agent group + filesystem, - * messaging group(s), wiring. + * messaging group, wiring, session, welcome message. * - * Runs alongside the service (WAL-mode sqlite + CLI socket IPC) — does NOT - * initialize channel adapters, so there's no Gateway conflict. Requires - * the service to be running: the welcome hand-off goes over the CLI socket - * and fails loudly if the service isn't up. + * Runs alongside the service (WAL-mode sqlite) — does NOT initialize channel + * adapters, so there's no Gateway conflict. No IPC to the service is needed; + * the sweep is the sole hand-off. * * Usage: * pnpm exec tsx scripts/init-first-agent.ts \ @@ -27,13 +28,11 @@ * --platform-id discord:@me:1491573333382523708 \ * --display-name "Gavriel" \ * [--agent-name "Andy"] \ - * [--welcome "System instruction: ..."] \ - * [--no-cli-bonus] + * [--welcome "System instruction: ..."] * * For direct-addressable channels (telegram, whatsapp, etc.), --platform-id * is typically the same as the handle in --user-id, with the channel prefix. */ -import net from 'net'; import path from 'path'; import { DATA_DIR } from '../src/config.js'; @@ -50,10 +49,10 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio import { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles.js'; import { upsertUser } from '../src/modules/permissions/db/users.js'; import { initGroupFilesystem } from '../src/group-init.js'; +import { resolveSession, writeSessionMessage } from '../src/session-manager.js'; import type { AgentGroup, MessagingGroup } from '../src/types.js'; interface Args { - noCliBonus: boolean; channel: string; userId: string; platformId: string; @@ -65,18 +64,12 @@ interface Args { const DEFAULT_WELCOME = 'System instruction: run /welcome to introduce yourself to the user on this new channel.'; -const CLI_CHANNEL = 'cli'; -const CLI_PLATFORM_ID = 'local'; - function parseArgs(argv: string[]): Args { - const out: Partial = { noCliBonus: false }; + const out: Partial = {}; for (let i = 0; i < argv.length; i++) { const key = argv[i]; const val = argv[i + 1]; switch (key) { - case '--no-cli-bonus': - out.noCliBonus = true; - break; case '--channel': out.channel = (val ?? '').toLowerCase(); i++; @@ -115,7 +108,6 @@ function parseArgs(argv: string[]): Args { } return { - noCliBonus: out.noCliBonus ?? false, channel: out.channel!, userId: out.userId!, platformId: out.platformId!, @@ -137,24 +129,6 @@ function generateId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -function ensureCliMessagingGroup(now: string): MessagingGroup { - let cliMg = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID); - if (cliMg) return cliMg; - - cliMg = { - id: generateId('mg'), - channel_type: CLI_CHANNEL, - platform_id: CLI_PLATFORM_ID, - name: 'Local CLI', - is_group: 0, - unknown_sender_policy: 'public', - created_at: now, - }; - createMessagingGroup(cliMg); - console.log(`Created CLI messaging group: ${cliMg.id}`); - return cliMg; -} - function wireIfMissing(mg: MessagingGroup, ag: AgentGroup, now: string, label: string): void { const existing = getMessagingGroupAgentByPair(mg.id, ag.id); if (existing) { @@ -165,9 +139,8 @@ function wireIfMissing(mg: MessagingGroup, ag: AgentGroup, now: string, label: s id: generateId('mga'), messaging_group_id: mg.id, agent_group_id: ag.id, - // DM / CLI (is_group=0) default to "respond to everything" via a '.' regex. - // Group chats default to mention-only; admins can upgrade to mention-sticky - // via /manage-channels once the agent is in use. + // DMs default to "respond to everything" via a '.' regex. Group chats + // default to mention-only; admins can upgrade via /manage-channels. engage_mode: mg.is_group === 0 ? 'pattern' : 'mention', engage_pattern: mg.is_group === 0 ? '.' : null, sender_scope: 'all', @@ -252,88 +225,40 @@ async function main(): Promise { console.log(`Reusing messaging group: ${dmMg.id} (${platformId})`); } - // 4. Wire DM (auto-creates companion agent_destinations row) and, - // unless suppressed, also wire the CLI channel so `pnpm run chat` works - // against the new agent immediately. `/new-setup-2` sets --no-cli-bonus - // so the scratch CLI agent from `/new-setup` keeps owning CLI routing. + // 4. Wire DM. wireIfMissing(dmMg, ag, now, 'dm'); - if (!args.noCliBonus) { - const cliMg = ensureCliMessagingGroup(now); - wireIfMissing(cliMg, ag, now, 'cli-bonus'); - } - // 5. Welcome delivery over the CLI socket. Router picks up the line, - // writes the message into the DM session's inbound.db, and wakes the - // container synchronously — no sweep wait. - await sendWelcomeViaCliSocket(dmMg, args.welcome); + // 5. Seed the welcome directly into the session's inbound.db. The running + // service's sweep will observe trigger=1 and wake the container on its next + // pass — no IPC, no CLI socket, no `cli:local` sender in the router path. + seedWelcome(ag.id, dmMg, args.welcome); console.log(''); console.log('Init complete.'); console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`); console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`); console.log(` channel: ${args.channel} ${dmMg.platform_id}`); - if (!args.noCliBonus) { - console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``); - } console.log(''); - console.log('Welcome DM queued — the agent will greet you shortly.'); + console.log('Welcome seeded — the agent will greet you on the next sweep pass.'); } /** - * Hand the welcome to the running service via its CLI Unix socket. The - * service's CLI adapter receives `{text, to}`, builds an InboundEvent - * targeting the DM messaging group, and calls routeInbound(). Router writes - * the message into inbound.db and wakes the container synchronously. - * - * Throws if the socket isn't reachable — this script requires the service - * to be running. + * Write the welcome as a due inbound message on a shared session for the + * new agent group + messaging group pair. Sender is tagged "System" — the + * welcome carries no real user identity and never crosses the router's + * sender-approval gate. */ -async function sendWelcomeViaCliSocket(dmMg: MessagingGroup, welcome: string): Promise { - const sockPath = path.join(DATA_DIR, 'cli.sock'); - - await new Promise((resolve, reject) => { - const socket = net.connect(sockPath); - let settled = false; - - const settle = (err: Error | null) => { - if (settled) return; - settled = true; - try { - socket.end(); - } catch { - /* noop */ - } - if (err) reject(err); - else resolve(); - }; - - socket.once('error', (err) => - settle( - new Error( - `CLI socket at ${sockPath} not reachable: ${err.message}. Is the NanoClaw service running?`, - ), - ), - ); - socket.once('connect', () => { - const payload = - JSON.stringify({ - text: welcome, - to: { - channelType: dmMg.channel_type, - platformId: dmMg.platform_id, - threadId: null, - }, - }) + '\n'; - socket.write(payload, (err) => { - if (err) { - settle(err); - return; - } - // Brief flush delay so the router picks up the line before we close. - // Router handles it synchronously once read, so 50ms is plenty. - setTimeout(() => settle(null), 50); - }); - }); +function seedWelcome(agentGroupId: string, mg: MessagingGroup, welcome: string): void { + const { session } = resolveSession(agentGroupId, mg.id, null, 'shared'); + writeSessionMessage(agentGroupId, session.id, { + id: generateId('welcome'), + kind: 'chat', + timestamp: new Date().toISOString(), + channelType: mg.channel_type, + platformId: mg.platform_id, + threadId: null, + content: JSON.stringify({ text: welcome, sender: 'System' }), + trigger: 1, }); } From 483969a1948469daf3141afe0c6a3bffdc430faa Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Tue, 21 Apr 2026 10:37:06 +0000 Subject: [PATCH 051/185] refactor(skills): merge /new-setup-2 into unified /new-setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapses the two-phase setup into a single linear skill: steps 1-6 (prereqs through end-to-end CLI ping) run straight through, steps 7-13 (naming, timezone, channel wiring, mounts, QoL, done) are skippable. Drops the "chat now vs. continue" branch point — after the ping the flow emits "Test Agent success, proceeding with setup" and continues directly into the naming questions. Also updates stale `/new-setup-2` header comments in setup/install-*.sh. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-setup-2/SKILL.md | 154 --------------------------- .claude/skills/new-setup/SKILL.md | 155 +++++++++++++++++++++++----- setup/install-discord.sh | 2 +- setup/install-gchat.sh | 2 +- setup/install-github.sh | 2 +- setup/install-imessage.sh | 2 +- setup/install-linear.sh | 2 +- setup/install-matrix.sh | 2 +- setup/install-resend.sh | 2 +- setup/install-slack.sh | 2 +- setup/install-teams.sh | 2 +- setup/install-telegram.sh | 2 +- setup/install-webex.sh | 2 +- setup/install-whatsapp-cloud.sh | 2 +- setup/install-whatsapp.sh | 2 +- 15 files changed, 145 insertions(+), 190 deletions(-) delete mode 100644 .claude/skills/new-setup-2/SKILL.md diff --git a/.claude/skills/new-setup-2/SKILL.md b/.claude/skills/new-setup-2/SKILL.md deleted file mode 100644 index 8d75cd3..0000000 --- a/.claude/skills/new-setup-2/SKILL.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -name: new-setup-2 -description: Follow-on to /new-setup. Captures the operator and agent names, wires a real messaging channel, and adds quality-of-life extras. Linear rollthrough; every step is skippable. Invoked when the user picks "continue setup" at the end of /new-setup. -allowed-tools: Bash(bash setup/probe.sh) Bash(bash setup/install-telegram.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm exec tsx scripts/init-first-agent.ts:*) Bash(tail:*) Bash(head:*) Bash(grep:*) ---- - -# NanoClaw phase-2 setup - -Runs after `/new-setup`. At this point the host is running and a throwaway CLI-only agent exists (used during /new-setup for the end-to-end ping check — inferred name, not user-facing). This flow creates the **real** agent and wires it to a messaging channel. - -**Linear — one step at a time.** Every step is skippable: if the user says "skip", "not now", "later", or similar, move on without complaint. If they say they're done at any point, stop cleanly — don't push the remaining steps. - -Before each step, narrate in your own words what's about to happen — one short, friendly sentence, no jargon. Match the tone of `/new-setup`. - -## Current state - -!`bash setup/probe.sh` - -Parse the probe block above for `INFERRED_DISPLAY_NAME` and `PLATFORM` — referenced below. - -## Steps - -### 1. What should the agent call you? - -Plain-prose ask (do **not** use `AskUserQuestion`): - -> What should your agent call you? (Default: ``) - -Capture the answer into a local variable `OPERATOR_NAME`. **Don't persist yet** — this value is consumed by step 3's channel wiring. If the user skips or confirms the default, set `OPERATOR_NAME = INFERRED_DISPLAY_NAME`. - -### 2. What's your agent's name? - -Plain-prose ask: - -> What would you like to call your agent? (Default: ``) - -Capture as `AGENT_NAME`. If skipped, set `AGENT_NAME = OPERATOR_NAME`. Nothing persisted yet. - -### 3. Timezone - -Run `pnpm exec tsx setup/index.ts --step timezone` and parse the status block. - -- **RESOLVED_TZ is `UTC` or `Etc/UTC`** — before leaving UTC in `.env`, confirm with `AskUserQuestion`: - - - **Question**: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?" - - **Header**: "Timezone" - - **Options**: - 1. `Keep UTC` — "Leave timezone as UTC." - 2. `I'm somewhere else` — "I'll name the IANA zone (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`) via Other." - - If they pick "I'm somewhere else" (or type an IANA zone via Other), re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` to overwrite `.env`. If they keep UTC or skip, leave UTC in place. - -- **NEEDS_USER_INPUT=true** — autodetection failed. Use `AskUserQuestion` with the same two options above (reword the question to "Autodetection failed — what timezone are you in?"), then re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` if they supply one. If they skip, move on. - -- Otherwise — timezone is already set; move on. - -### 4. Pick a messaging channel - -Print the list as a numbered plain-prose list (too many options for `AskUserQuestion`, which caps at 4). The user replies with a number or channel name. Preserve the numbering exactly: - -> Which messaging channel should I wire your agent to? -> -> 1. **WhatsApp (native)** — `/add-whatsapp` -> 2. **WhatsApp Cloud (Meta official)** — `/add-whatsapp-cloud` -> 3. **Telegram** — `/add-telegram` -> 4. **Slack** — `/add-slack` -> 5. **Discord** — `/add-discord` -> 6. **iMessage** — `/add-imessage` -> 7. **Teams** — `/add-teams` -> 8. **Matrix** — `/add-matrix` -> 9. **Google Chat** — `/add-gchat` -> 10. **Linear** — `/add-linear` -> 11. **GitHub** — `/add-github` -> 12. **Webex** — `/add-webex` -> 13. **Resend (email)** — `/add-resend` -> 14. **Emacs** — `/add-emacs` -> -> Or say "skip" to leave this for later. - -When the user picks one: - -1. **Install the adapter.** For **Telegram**, run `bash setup/install-telegram.sh` directly — it bundles the preflight + fetch + copy + register + `pnpm install` + build from `/add-telegram` into one idempotent call. Then handle Telegram credentials inline (below) — **do not** invoke `/add-telegram` afterward; its Credentials section would generate an unapprovable `grep && sed && rm` to write `.env`. For every other channel, invoke the matching `/add-` skill via the Skill tool; it copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels also run a pairing step as part of their flow. - - **Telegram credentials (inline):** - - Walk the user through BotFather: `/newbot` → pick name + username ending in `bot` → copy the token. - - Remind them: in `@BotFather` → `/mybots` → their bot → Bot Settings → Group Privacy → **Turn off** (only needed if the bot will live in groups; DM-only can skip). - - Persist the token and sync it to the container mount with the generic setter: - - ``` - pnpm exec tsx setup/index.ts --step set-env -- \ - --key TELEGRAM_BOT_TOKEN --value "" --sync-container - ``` - -2. **Capture platform IDs.** After the `/add-` skill finishes (or after inline credentials for Telegram), you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent ` directly and follow its `PAIR_TELEGRAM_ISSUED`/`PAIR_TELEGRAM STATUS=success` blocks — `PLATFORM_ID` and `ADMIN_USER_ID` land in the success block. -3. **Wire the agent.** Run `init-first-agent.ts` in DM mode: - - ``` - pnpm exec tsx scripts/init-first-agent.ts \ - --channel \ - --user-id "" \ - --platform-id "" \ - --display-name "" \ - --agent-name "" - ``` - -4. **Announce.** On success, emit the encouragement line verbatim: - - > Your agent is now available on {channel-name}, you can already start chatting — But I encourage you to continue and finish this setup, we're almost done! - - Substitute `{channel-name}` with the friendly name (e.g. "Telegram", "WhatsApp", "Slack"). - -If the user skipped, move on to step 5. - -### 5. Host directory access - -By default, agent containers can only touch their own workspace. If the user wants the agent to read or write files in specific host directories, those paths need to go on the mount allowlist. - -Use `AskUserQuestion`: - -- **Question**: "Want your agent to read or write files in any host directories (e.g. a code project, `~/Documents`)?" -- **Header**: "Host mounts" -- **Options**: - 1. `Keep isolated` — "Agent only touches its own workspace (Recommended)." - 2. `Add host paths` — "I'll name the directories to allowlist via Other." - -If they pick "Add host paths" (or name paths via Other), invoke `/manage-mounts` via the Skill tool to add them. If they keep it isolated or skip, move on. - -### 6. Quality of life - -Optional polish. Print the list; the user may pick zero, one, or several — invoke each chosen skill in sequence: - -> Want to add any of these? Pick any that sound useful — or skip: -> -> - `/add-dashboard` — browser dashboard showing agent activity -> - `/add-compact` — `/compact` slash command for managing long sessions -> - `/add-karpathy-llm-wiki` — persistent knowledge-base memory for the agent - -If the probe reports `PLATFORM=darwin`, also offer: - -> - `/add-macos-statusbar` — macOS menu bar indicator with Start/Stop/Restart controls - -Do **not** list `/add-macos-statusbar` on Linux. If the user skips everything, just move on. - -### 7. Done - -Short wrap-up: - -> Setup complete. You can chat with your agent on {channel-name} — or via CLI with `pnpm run chat `. - -Substitute `{channel-name}` with whatever was wired in step 4. If step 4 was skipped, drop the "on {channel-name} — or" clause entirely so the line just mentions the CLI form. - -## If anything fails - -Same rule as `/new-setup`: don't bypass errors to keep moving. Read `logs/setup.log` or `logs/nanoclaw.log`, diagnose, fix the underlying cause, re-run the failed step. diff --git a/.claude/skills/new-setup/SKILL.md b/.claude/skills/new-setup/SKILL.md index 02cef98..ef88c75 100644 --- a/.claude/skills/new-setup/SKILL.md +++ b/.claude/skills/new-setup/SKILL.md @@ -1,14 +1,17 @@ --- name: new-setup -description: Shortest path from zero to a working two-way agent chat, for any user regardless of technical background — ends at a running NanoClaw instance with at least one CLI-reachable agent. -allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(bash setup/install-node.sh) Bash(bash setup/install-docker.sh) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm run chat) Bash(pnpm run chat:*) Bash(open -a Docker) Bash(sudo systemctl start docker) Bash(node --version) Bash(tail:*) Bash(head:*) Bash(grep:*) +description: End-to-end NanoClaw setup for any user regardless of technical background — from zero to a named agent reachable on a real messaging channel, with sensible defaults and every post-verification step skippable. +allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(bash setup/install-node.sh) Bash(bash setup/install-docker.sh) Bash(bash setup/install-telegram.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm exec tsx scripts/init-first-agent.ts:*) Bash(pnpm run chat) Bash(pnpm run chat:*) Bash(open -a Docker) Bash(sudo systemctl start docker) Bash(node --version) Bash(tail:*) Bash(head:*) Bash(grep:*) --- -# NanoClaw bare-minimum setup +# NanoClaw setup -Purpose of this skill is to take any user — technical or not — from zero to a two-way chat with an agent in the fewest steps possible. Done means a running NanoClaw instance with at least one agent reachable via the CLI channel. +Purpose of this skill is to take any user — technical or not — from zero to a named agent wired to a real messaging channel in the fewest steps possible. -Only run the steps strictly required for the NanoClaw process to start and respond to the user end-to-end. Everything else is deferred to post-setup skills. +The flow has two halves: + +- **Steps 1–6 — required.** Prerequisites, credential, service start, end-to-end ping. These run straight through. +- **Steps 7–12 — skippable.** Naming, channel wiring, QoL. Every step here is skippable: if the user says "skip", "not now", "later", or similar, move on without complaint. If they say they're done at any point, stop cleanly — don't push the remaining steps. Before each step, narrate to the user in your own words what's about to happen — one short, friendly sentence, no jargon. Don't read a scripted line; use the step context below to speak naturally. @@ -109,13 +112,13 @@ Start the NanoClaw background service — it relays messages between the user an `pnpm exec tsx setup/index.ts --step service` -### 6. Wire the CLI agent and verify end-to-end +### 6. Wire a scratch CLI agent and verify end-to-end **Do not narrate this step.** No "creating your first agent…", no "sending a ping…" chatter. The user's experience here is: they finished the last visible step (service), then a single success line appears. Wiring is an implementation detail at this point, not a user-facing milestone. If step 2's container build is still running in the background, join it here before proceeding — the agent needs the image. -Use `INFERRED_DISPLAY_NAME` from the probe silently. **Do not ask the user.** The CLI agent at this stage is a scratch agent whose only purpose is to verify the end-to-end pipeline (host → container → agent → back). The user's real name capture happens in `/new-setup-2` when they wire a messaging channel. +Use `INFERRED_DISPLAY_NAME` from the probe silently. **Do not ask the user.** The CLI agent at this stage is a scratch agent whose only purpose is to verify the end-to-end pipeline (host → container → agent → back). The user's real name capture happens in step 7. Run wiring and ping back-to-back, silently: @@ -126,35 +129,141 @@ pnpm run chat ping First container spin-up takes ~60s. When the agent's reply arrives, emit exactly one line to the user: -> Your agent is up, running and ready to go! +> Test Agent success, proceeding with setup If `pnpm run chat ping` times out or errors, tail `logs/nanoclaw.log`, diagnose, and fix — don't surface a half-success. > **Loose command:** `pnpm run chat ping`. Justification: this is the same command the user will keep using after setup, so verification and the real channel are one and the same. -### 7. Chat now, or keep setting up? +### 7. What should the agent call you? -Ask the user via `AskUserQuestion` which they'd like to do next: +Plain-prose ask (do **not** use `AskUserQuestion`): -1. **Keep chatting with the agent via CLI** — happy with the CLI channel for now. -2. **Continue setup** — name the agent, wire a messaging channel, add quality-of-life extras. +> What should your agent call you? (Default: ``) -**If they pick "keep chatting":** print both options below, then stop. The user is chatting with the agent now, not with you — no further output from you. +Capture the answer into a local variable `OPERATOR_NAME`. **Don't persist yet** — this value is consumed by step 10's channel wiring. If the user skips or confirms the default, set `OPERATOR_NAME = INFERRED_DISPLAY_NAME`. -**Option 1 — from inside this Claude Code session.** Type your message with a leading `!`, which runs it as a bash command in this shell: +### 8. What's your agent's name? -``` -!pnpm run chat your message here -``` +Plain-prose ask: -**Option 2 — from a separate terminal.** Open a new terminal, `cd` into your nanoclaw checkout, then: +> What would you like to call your agent? (Default: ``) -``` -pnpm run chat your message here -``` +Capture as `AGENT_NAME`. If skipped, set `AGENT_NAME = OPERATOR_NAME`. Nothing persisted yet. -**If they pick "continue setup":** hand off directly to `/new-setup-2` via the Skill tool. That follow-on flow is structured like this one (linear, skippable steps) and covers naming, messaging-channel wiring, and QoL. Invoke it immediately — do not offer a menu of individual skills. +### 9. Timezone + +Run `pnpm exec tsx setup/index.ts --step timezone` and parse the status block. + +- **RESOLVED_TZ is `UTC` or `Etc/UTC`** — before leaving UTC in `.env`, confirm with `AskUserQuestion`: + + - **Question**: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?" + - **Header**: "Timezone" + - **Options**: + 1. `Keep UTC` — "Leave timezone as UTC." + 2. `I'm somewhere else` — "I'll name the IANA zone (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`) via Other." + + If they pick "I'm somewhere else" (or type an IANA zone via Other), re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` to overwrite `.env`. If they keep UTC or skip, leave UTC in place. + +- **NEEDS_USER_INPUT=true** — autodetection failed. Use `AskUserQuestion` with the same two options above (reword the question to "Autodetection failed — what timezone are you in?"), then re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` if they supply one. If they skip, move on. + +- Otherwise — timezone is already set; move on. + +### 10. Pick a messaging channel + +Print the list as a numbered plain-prose list (too many options for `AskUserQuestion`, which caps at 4). The user replies with a number or channel name. Preserve the numbering exactly: + +> Which messaging channel should I wire your agent to? +> +> 1. **WhatsApp (native)** — `/add-whatsapp` +> 2. **WhatsApp Cloud (Meta official)** — `/add-whatsapp-cloud` +> 3. **Telegram** — `/add-telegram` +> 4. **Slack** — `/add-slack` +> 5. **Discord** — `/add-discord` +> 6. **iMessage** — `/add-imessage` +> 7. **Teams** — `/add-teams` +> 8. **Matrix** — `/add-matrix` +> 9. **Google Chat** — `/add-gchat` +> 10. **Linear** — `/add-linear` +> 11. **GitHub** — `/add-github` +> 12. **Webex** — `/add-webex` +> 13. **Resend (email)** — `/add-resend` +> 14. **Emacs** — `/add-emacs` +> +> Or say "skip" to leave this for later. + +When the user picks one: + +1. **Install the adapter.** For **Telegram**, run `bash setup/install-telegram.sh` directly — it bundles the preflight + fetch + copy + register + `pnpm install` + build from `/add-telegram` into one idempotent call. Then handle Telegram credentials inline (below) — **do not** invoke `/add-telegram` afterward; its Credentials section would generate an unapprovable `grep && sed && rm` to write `.env`. For every other channel, invoke the matching `/add-` skill via the Skill tool; it copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels also run a pairing step as part of their flow. + + **Telegram credentials (inline):** + - Walk the user through BotFather: `/newbot` → pick name + username ending in `bot` → copy the token. + - Remind them: in `@BotFather` → `/mybots` → their bot → Bot Settings → Group Privacy → **Turn off** (only needed if the bot will live in groups; DM-only can skip). + - Persist the token and sync it to the container mount with the generic setter: + + ``` + pnpm exec tsx setup/index.ts --step set-env -- \ + --key TELEGRAM_BOT_TOKEN --value "" --sync-container + ``` + +2. **Capture platform IDs.** After the `/add-` skill finishes (or after inline credentials for Telegram), you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent ` directly and follow its `PAIR_TELEGRAM_ISSUED`/`PAIR_TELEGRAM STATUS=success` blocks — `PLATFORM_ID` and `ADMIN_USER_ID` land in the success block. +3. **Wire the agent.** Run `init-first-agent.ts` in DM mode: + + ``` + pnpm exec tsx scripts/init-first-agent.ts \ + --channel \ + --user-id "" \ + --platform-id "" \ + --display-name "" \ + --agent-name "" + ``` + +4. **Announce.** On success, emit the encouragement line verbatim: + + > Your agent is now available on {channel-name}, you can already start chatting — But I encourage you to continue and finish this setup, we're almost done! + + Substitute `{channel-name}` with the friendly name (e.g. "Telegram", "WhatsApp", "Slack"). + +If the user skipped, move on to step 11. + +### 11. Host directory access + +By default, agent containers can only touch their own workspace. If the user wants the agent to read or write files in specific host directories, those paths need to go on the mount allowlist. + +Use `AskUserQuestion`: + +- **Question**: "Want your agent to read or write files in any host directories (e.g. a code project, `~/Documents`)?" +- **Header**: "Host mounts" +- **Options**: + 1. `Keep isolated` — "Agent only touches its own workspace (Recommended)." + 2. `Add host paths` — "I'll name the directories to allowlist via Other." + +If they pick "Add host paths" (or name paths via Other), invoke `/manage-mounts` via the Skill tool to add them. If they keep it isolated or skip, move on. + +### 12. Quality of life + +Optional polish. Print the list; the user may pick zero, one, or several — invoke each chosen skill in sequence: + +> Want to add any of these? Pick any that sound useful — or skip: +> +> - `/add-dashboard` — browser dashboard showing agent activity +> - `/add-compact` — `/compact` slash command for managing long sessions +> - `/add-karpathy-llm-wiki` — persistent knowledge-base memory for the agent + +If the probe reports `PLATFORM=darwin`, also offer: + +> - `/add-macos-statusbar` — macOS menu bar indicator with Start/Stop/Restart controls + +Do **not** list `/add-macos-statusbar` on Linux. If the user skips everything, just move on. + +### 13. Done + +Short wrap-up: + +> Setup complete. You can chat with your agent on {channel-name} — or via CLI with `pnpm run chat `. + +Substitute `{channel-name}` with whatever was wired in step 10. If step 10 was skipped, drop the "on {channel-name} — or" clause entirely so the line just mentions the CLI form. ## If anything fails -Any step that reports `STATUS: failed` in its status block: read `logs/setup.log`, diagnose, fix the underlying cause, re-run the same `--step`. Don't bypass errors to keep moving. +Any step that reports `STATUS: failed` in its status block: read `logs/setup.log` (or `logs/nanoclaw.log` for runtime failures), diagnose, fix the underlying cause, re-run the same `--step`. Don't bypass errors to keep moving. diff --git a/setup/install-discord.sh b/setup/install-discord.sh index ee221f9..6f5a9c8 100755 --- a/setup/install-discord.sh +++ b/setup/install-discord.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-discord — bundles the preflight + install commands -# from the /add-discord skill into one idempotent script so /new-setup-2 can +# from the /add-discord skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Discord adapter in from the `channels` branch; appends the diff --git a/setup/install-gchat.sh b/setup/install-gchat.sh index f5c210b..b9166f1 100755 --- a/setup/install-gchat.sh +++ b/setup/install-gchat.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-gchat — bundles the preflight + install commands -# from the /add-gchat skill into one idempotent script so /new-setup-2 can +# from the /add-gchat skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Google Chat adapter in from the `channels` branch; appends the diff --git a/setup/install-github.sh b/setup/install-github.sh index 81c2977..cb28bfc 100755 --- a/setup/install-github.sh +++ b/setup/install-github.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-github — bundles the preflight + install commands -# from the /add-github skill into one idempotent script so /new-setup-2 can +# from the /add-github skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the GitHub adapter in from the `channels` branch; appends the diff --git a/setup/install-imessage.sh b/setup/install-imessage.sh index 0b1df34..864e127 100755 --- a/setup/install-imessage.sh +++ b/setup/install-imessage.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-imessage — bundles the preflight + install commands -# from the /add-imessage skill into one idempotent script so /new-setup-2 can +# from the /add-imessage skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the iMessage adapter in from the `channels` branch; appends the diff --git a/setup/install-linear.sh b/setup/install-linear.sh index 9f42bec..f8788be 100755 --- a/setup/install-linear.sh +++ b/setup/install-linear.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-linear — bundles the preflight + install commands -# from the /add-linear skill into one idempotent script so /new-setup-2 can +# from the /add-linear skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Linear adapter in from the `channels` branch; appends the diff --git a/setup/install-matrix.sh b/setup/install-matrix.sh index 06d5ccd..c985473 100755 --- a/setup/install-matrix.sh +++ b/setup/install-matrix.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-matrix — bundles the preflight + install commands -# from the /add-matrix skill into one idempotent script so /new-setup-2 can +# from the /add-matrix skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Matrix adapter in from the `channels` branch; appends the diff --git a/setup/install-resend.sh b/setup/install-resend.sh index 4f0bb2e..9f18a9f 100755 --- a/setup/install-resend.sh +++ b/setup/install-resend.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-resend — bundles the preflight + install commands -# from the /add-resend skill into one idempotent script so /new-setup-2 can +# from the /add-resend skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Resend adapter in from the `channels` branch; appends the diff --git a/setup/install-slack.sh b/setup/install-slack.sh index 8be6a37..55d5e85 100755 --- a/setup/install-slack.sh +++ b/setup/install-slack.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-slack — bundles the preflight + install commands -# from the /add-slack skill into one idempotent script so /new-setup-2 can +# from the /add-slack skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Slack adapter in from the `channels` branch; appends the diff --git a/setup/install-teams.sh b/setup/install-teams.sh index cb66f67..4b8c216 100755 --- a/setup/install-teams.sh +++ b/setup/install-teams.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-teams — bundles the preflight + install commands -# from the /add-teams skill into one idempotent script so /new-setup-2 can +# from the /add-teams skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Teams adapter in from the `channels` branch; appends the diff --git a/setup/install-telegram.sh b/setup/install-telegram.sh index 7eaf9e1..307dba2 100755 --- a/setup/install-telegram.sh +++ b/setup/install-telegram.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-telegram — bundles the preflight + install commands -# from the /add-telegram skill into one idempotent script so /new-setup-2 can +# from the /add-telegram skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials and pairing. # # Copies the Telegram adapter, helpers, tests, and the pair-telegram setup diff --git a/setup/install-webex.sh b/setup/install-webex.sh index 8bbbc83..adf52fc 100755 --- a/setup/install-webex.sh +++ b/setup/install-webex.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-webex — bundles the preflight + install commands -# from the /add-webex skill into one idempotent script so /new-setup-2 can +# from the /add-webex skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Webex adapter in from the `channels` branch; appends the diff --git a/setup/install-whatsapp-cloud.sh b/setup/install-whatsapp-cloud.sh index 3773278..70e8e02 100755 --- a/setup/install-whatsapp-cloud.sh +++ b/setup/install-whatsapp-cloud.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Setup helper: install-whatsapp-cloud — bundles the preflight + install # commands from the /add-whatsapp-cloud skill into one idempotent script so -# /new-setup-2 can run them programmatically before continuing to credentials. +# /new-setup can run them programmatically before continuing to credentials. # # Copies the WhatsApp Cloud adapter in from the `channels` branch; appends the # self-registration import; installs the pinned @chat-adapter/whatsapp package; diff --git a/setup/install-whatsapp.sh b/setup/install-whatsapp.sh index 0d307f5..1c62d65 100755 --- a/setup/install-whatsapp.sh +++ b/setup/install-whatsapp.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-whatsapp — bundles the preflight + install commands -# from the /add-whatsapp skill into one idempotent script so /new-setup-2 can +# from the /add-whatsapp skill into one idempotent script so /new-setup can # run them programmatically before continuing to QR/pairing-code auth. # # Copies the native Baileys WhatsApp adapter, its whatsapp-auth and groups From 9776dd4f32f19f07e655b44501e8fc259e3328f3 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Tue, 21 Apr 2026 11:40:12 +0000 Subject: [PATCH 052/185] fix(permissions): welcome new approved channels via /welcome, route to them When the unknown-channel approval flow completes, seed a /welcome task into the newly-wired session so the agent greets the new user on first contact. The replayed /start (Telegram's default first-message) is filtered by the agent-runner's command-command filter, so without an explicit onboarding trigger the first interaction produced nothing. Pin the destination by its local_name from agent_destinations to avoid the agent picking the wrong named destination (previously it greeted the owner, whose DM is in CLAUDE.md). Also guard dispatchResultText against echoing trailing status lines when the agent has already sent messages explicitly via send_message. Otherwise a task-triggered flow that calls send_message then emits "welcome message sent" produces a duplicate chat to the recipient. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 35 ++++++-- .../permissions/channel-approval.test.ts | 81 +++++++++++++++++++ src/modules/permissions/index.ts | 70 ++++++++++++++++ 3 files changed, 181 insertions(+), 5 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 3f0e364..cd7b7e1 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -1,7 +1,7 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js'; import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; import { writeMessageOut } from './db/messages-out.js'; -import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; +import { touchHeartbeat, clearStaleProcessingAcks, getOutboundDb } from './db/connection.js'; import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js'; import { formatMessages, extractRouting, categorizeMessage, stripInternalTags, type RoutingContext } from './formatter.js'; import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js'; @@ -280,6 +280,17 @@ async function processQuery(query: AgentQuery, routing: RoutingContext): Promise let queryContinuation: string | undefined; let done = false; + // Track the outbound row count between result events. When the agent uses + // send_message (or any MCP tool that writes to messages_out) during a turn, + // the count grows. We pass that signal to dispatchResultText so it can tell + // the difference between "agent wrote text meant as the reply" (send the + // scratchpad) and "agent did explicit tool sends AND then emitted a trailing + // status line" (don't echo the status line back to the channel). + // + // Reset after each result dispatch so subsequent turns in the same query + // (follow-up messages pushed into the stream) are evaluated independently. + let outboundAtLastResult = getOutboundCount(); + // Concurrent polling: push follow-ups into the active query as they arrive. // We do NOT force-end the stream on silence — keeping the query open is // strictly cheaper than close+reopen (no cold prompt cache, no reconnect). @@ -323,7 +334,9 @@ async function processQuery(query: AgentQuery, routing: RoutingContext): Promise if (event.type === 'init') { queryContinuation = event.continuation; } else if (event.type === 'result' && event.text) { - dispatchResultText(event.text, routing); + const hasExplicitSends = getOutboundCount() > outboundAtLastResult; + dispatchResultText(event.text, routing, hasExplicitSends); + outboundAtLastResult = getOutboundCount(); } } } finally { @@ -363,7 +376,7 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { * This preserves the simple case of one user on one channel — the agent * doesn't need to know about wrapping syntax at all. */ -function dispatchResultText(text: string, routing: RoutingContext): void { +function dispatchResultText(text: string, routing: RoutingContext, hasExplicitSends: boolean): void { const MESSAGE_RE = /([\s\S]*?)<\/message>/g; let match: RegExpExecArray | null; @@ -397,7 +410,15 @@ function dispatchResultText(text: string, routing: RoutingContext): void { // Single-destination shortcut: the agent wrote plain text — send to // the session's originating channel (from session_routing) if available, // otherwise fall back to the single destination. - if (sent === 0 && scratchpad) { + // + // If the agent already sent messages explicitly this turn (via send_message + // or another MCP tool that writes to outbound), treat trailing plain text as + // a status/summary line and DO NOT echo it back to the channel. Without this + // guard, task-driven flows like the onboarding /welcome cause duplicate + // delivery: the skill uses `send_message` to greet the new user, then the + // model emits "Welcome message sent." which used to be dispatched as a + // second chat message to the same recipient. + if (sent === 0 && scratchpad && !hasExplicitSends) { if (routing.channelType && routing.platformId) { // Reply to the channel/thread the message came from writeMessageOut({ @@ -422,11 +443,15 @@ function dispatchResultText(text: string, routing: RoutingContext): void { log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`); } - if (sent === 0 && text.trim()) { + if (sent === 0 && text.trim() && !hasExplicitSends) { log(`WARNING: agent output had no blocks — nothing was sent`); } } +function getOutboundCount(): number { + return (getOutboundDb().prepare('SELECT COUNT(*) AS c FROM messages_out').get() as { c: number }).c; +} + function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void { const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!; const channelType = dest.type === 'channel' ? dest.channelType! : 'agent'; diff --git a/src/modules/permissions/channel-approval.test.ts b/src/modules/permissions/channel-approval.test.ts index f3ea7e9..340a9ed 100644 --- a/src/modules/permissions/channel-approval.test.ts +++ b/src/modules/permissions/channel-approval.test.ts @@ -247,6 +247,87 @@ describe('unknown-channel registration flow', () => { expect(wakeContainer).toHaveBeenCalled(); }); + it('approve → seeds a /welcome onboarding task into the session', async () => { + const { routeInbound } = await import('../../router.js'); + const { getResponseHandlers } = await import('../../response-registry.js'); + + // `/start` is filtered by the agent-runner (Claude Code slash command), + // so without the seeded onboarding task a Telegram user's first DM would + // produce zero response. The seed ensures the agent runs /welcome regardless. + const startDm = { + channelType: 'telegram', + platformId: 'dm-new-friend', + threadId: null, + message: { + id: 'msg-start', + kind: 'chat' as const, + content: JSON.stringify({ senderId: 'friend', senderName: 'Friend', text: '/start' }), + timestamp: now(), + isMention: true, + }, + }; + await routeInbound(startDm); + await new Promise((r) => setTimeout(r, 10)); + + const { getDb } = await import('../../db/connection.js'); + const pending = getDb() + .prepare('SELECT messaging_group_id, agent_group_id FROM pending_channel_approvals') + .get() as { messaging_group_id: string; agent_group_id: string }; + + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.messaging_group_id, + value: 'approve', + userId: 'owner', + channelType: 'telegram', + platformId: 'dm-owner', + threadId: null, + }); + if (claimed) break; + } + + // Look up the session that got created, then open its inbound.db and + // confirm an onboarding task with a /welcome prompt landed before the + // replayed chat message. + const session = getDb() + .prepare('SELECT id FROM sessions WHERE agent_group_id = ? AND messaging_group_id = ?') + .get(pending.agent_group_id, pending.messaging_group_id) as { id: string } | undefined; + expect(session).toBeDefined(); + + const Database = (await import('better-sqlite3')).default; + const path = await import('path'); + const inboundPath = path.join(TEST_DIR, 'v2-sessions', pending.agent_group_id, session!.id, 'inbound.db'); + const inbound = new Database(inboundPath, { readonly: true }); + const rows = inbound + .prepare('SELECT kind, content, seq FROM messages_in ORDER BY seq') + .all() as { kind: string; content: string; seq: number }[]; + inbound.close(); + + const taskRow = rows.find((r) => r.kind === 'task'); + expect(taskRow).toBeDefined(); + const prompt: string = JSON.parse(taskRow!.content).prompt; + expect(prompt).toMatch(/\/welcome/); + // Prompt must name the new user — otherwise with multiple destinations + // configured the model may greet the owner instead of the new sender + // (see bug where "Hey Daniel!" landed in the owner's DM). + expect(prompt).toContain('Friend'); + expect(prompt).toContain('dm-new-friend'); + + // Prompt must pin the exact destination by its agent_destinations + // local_name. That name is auto-created by createMessagingGroupAgent + // above; look it up and assert it appears in the prompt verbatim. + const destRow = getDb() + .prepare('SELECT local_name FROM agent_destinations WHERE agent_group_id = ? AND target_id = ?') + .get(pending.agent_group_id, pending.messaging_group_id) as { local_name: string }; + expect(destRow).toBeDefined(); + expect(prompt).toContain(`send_message(to: '${destRow.local_name}'`); + + // Order: task seeded before the replayed /start chat message. + const chatRow = rows.find((r) => r.kind === 'chat'); + expect(chatRow).toBeDefined(); + expect(taskRow!.seq).toBeLessThan(chatRow!.seq); + }); + it('approve on a DM wires with pattern="." defaults', async () => { const { routeInbound } = await import('../../router.js'); const { getResponseHandlers } = await import('../../response-registry.js'); diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index 83390d8..a1cd03a 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -28,6 +28,7 @@ import { import type { InboundEvent } from '../../channels/adapter.js'; import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; import { log } from '../../log.js'; +import { resolveSession, writeSessionMessage } from '../../session-manager.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import { canAccessAgentGroup } from './access.js'; import { requestChannelApproval } from './channel-approval.js'; @@ -379,6 +380,75 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< // path normally. deletePendingChannelApproval(row.messaging_group_id); + // Seed a /welcome onboarding task into the session *before* the replayed + // message so the agent greets the new user on first contact. Mirrors the + // owner-setup path (setup/register.ts). Without this, a Telegram user's + // default `/start` greeting gets silently dropped by the agent-runner's + // command filter and the first interaction produces nothing. + // + // The prompt pins the exact destination by name: createMessagingGroupAgent + // above auto-created an `agent_destinations` row pointing at this messaging + // group, and we look it up here. Without that, the agent — which already + // knows about the owner's DM and earlier friends' DMs as named destinations + // — tends to greet the owner (CLAUDE.md anchors on them). Passing the + // specific destination name removes the ambiguity entirely. + try { + const { session } = resolveSession(row.agent_group_id, row.messaging_group_id, event.threadId, 'shared'); + const parsed = safeParseContent(event.message.content); + const author = + parsed && typeof parsed === 'object' && 'author' in parsed && typeof (parsed as { author?: unknown }).author === 'object' + ? ((parsed as { author: Record }).author) + : undefined; + const senderName = + (typeof (parsed as { senderName?: unknown }).senderName === 'string' ? (parsed as { senderName: string }).senderName : undefined) ?? + (typeof (parsed as { sender?: unknown }).sender === 'string' ? (parsed as { sender: string }).sender : undefined) ?? + (typeof author?.fullName === 'string' ? (author.fullName as string) : undefined) ?? + (typeof author?.userName === 'string' ? (author.userName as string) : undefined) ?? + null; + const senderLabel = senderName ? `${senderName} (${event.platformId})` : event.platformId; + + // Pin the destination. Guarded behind hasTable in case the agent-to-agent + // module isn't installed — without it there are no named destinations at + // all, so the agent's send_message call falls through to session default + // routing (which is this new user). Either way, unambiguous. + let destinationClause: string; + const { hasTable, getDb } = await import('../../db/connection.js'); + if (hasTable(getDb(), 'agent_destinations')) { + const { getDestinationByTarget } = await import('../agent-to-agent/db/agent-destinations.js'); + const dest = getDestinationByTarget(row.agent_group_id, 'channel', row.messaging_group_id); + destinationClause = dest + ? `Send your welcome with send_message(to: '${dest.local_name}', text: ...) — that destination resolves to their DM.` + : `Reply using send_message with no \`to\` — the session's default routing points at their DM.`; + } else { + destinationClause = `Reply using send_message — it will land in their DM.`; + } + + writeSessionMessage(row.agent_group_id, session.id, { + id: `onboard-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'task', + timestamp: new Date().toISOString(), + platformId: event.platformId, + channelType: event.channelType, + content: JSON.stringify({ + prompt: + `A new ${event.channelType} user — ${senderLabel} — just started a conversation. ` + + `Run /welcome to introduce yourself to them. ${destinationClause}`, + }), + }); + log.info('Onboarding message seeded after channel approval', { + sessionId: session.id, + messagingGroupId: row.messaging_group_id, + agentGroupId: row.agent_group_id, + senderName, + }); + } catch (err) { + // Don't block the replay if onboarding write fails. + log.error('Failed to seed onboarding message after channel approval', { + messagingGroupId: row.messaging_group_id, + err, + }); + } + try { await routeInbound(event); } catch (err) { From 01ffce6f74fa7bc3195f207667d39d7b57272180 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 15:20:06 +0300 Subject: [PATCH 053/185] Revert "fix(permissions): welcome new approved channels via /welcome, route to them" This reverts commit 9776dd4f32f19f07e655b44501e8fc259e3328f3. --- container/agent-runner/src/poll-loop.ts | 35 ++------ .../permissions/channel-approval.test.ts | 81 ------------------- src/modules/permissions/index.ts | 70 ---------------- 3 files changed, 5 insertions(+), 181 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index cd7b7e1..3f0e364 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -1,7 +1,7 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js'; import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; import { writeMessageOut } from './db/messages-out.js'; -import { touchHeartbeat, clearStaleProcessingAcks, getOutboundDb } from './db/connection.js'; +import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js'; import { formatMessages, extractRouting, categorizeMessage, stripInternalTags, type RoutingContext } from './formatter.js'; import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js'; @@ -280,17 +280,6 @@ async function processQuery(query: AgentQuery, routing: RoutingContext): Promise let queryContinuation: string | undefined; let done = false; - // Track the outbound row count between result events. When the agent uses - // send_message (or any MCP tool that writes to messages_out) during a turn, - // the count grows. We pass that signal to dispatchResultText so it can tell - // the difference between "agent wrote text meant as the reply" (send the - // scratchpad) and "agent did explicit tool sends AND then emitted a trailing - // status line" (don't echo the status line back to the channel). - // - // Reset after each result dispatch so subsequent turns in the same query - // (follow-up messages pushed into the stream) are evaluated independently. - let outboundAtLastResult = getOutboundCount(); - // Concurrent polling: push follow-ups into the active query as they arrive. // We do NOT force-end the stream on silence — keeping the query open is // strictly cheaper than close+reopen (no cold prompt cache, no reconnect). @@ -334,9 +323,7 @@ async function processQuery(query: AgentQuery, routing: RoutingContext): Promise if (event.type === 'init') { queryContinuation = event.continuation; } else if (event.type === 'result' && event.text) { - const hasExplicitSends = getOutboundCount() > outboundAtLastResult; - dispatchResultText(event.text, routing, hasExplicitSends); - outboundAtLastResult = getOutboundCount(); + dispatchResultText(event.text, routing); } } } finally { @@ -376,7 +363,7 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { * This preserves the simple case of one user on one channel — the agent * doesn't need to know about wrapping syntax at all. */ -function dispatchResultText(text: string, routing: RoutingContext, hasExplicitSends: boolean): void { +function dispatchResultText(text: string, routing: RoutingContext): void { const MESSAGE_RE = /([\s\S]*?)<\/message>/g; let match: RegExpExecArray | null; @@ -410,15 +397,7 @@ function dispatchResultText(text: string, routing: RoutingContext, hasExplicitSe // Single-destination shortcut: the agent wrote plain text — send to // the session's originating channel (from session_routing) if available, // otherwise fall back to the single destination. - // - // If the agent already sent messages explicitly this turn (via send_message - // or another MCP tool that writes to outbound), treat trailing plain text as - // a status/summary line and DO NOT echo it back to the channel. Without this - // guard, task-driven flows like the onboarding /welcome cause duplicate - // delivery: the skill uses `send_message` to greet the new user, then the - // model emits "Welcome message sent." which used to be dispatched as a - // second chat message to the same recipient. - if (sent === 0 && scratchpad && !hasExplicitSends) { + if (sent === 0 && scratchpad) { if (routing.channelType && routing.platformId) { // Reply to the channel/thread the message came from writeMessageOut({ @@ -443,15 +422,11 @@ function dispatchResultText(text: string, routing: RoutingContext, hasExplicitSe log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`); } - if (sent === 0 && text.trim() && !hasExplicitSends) { + if (sent === 0 && text.trim()) { log(`WARNING: agent output had no blocks — nothing was sent`); } } -function getOutboundCount(): number { - return (getOutboundDb().prepare('SELECT COUNT(*) AS c FROM messages_out').get() as { c: number }).c; -} - function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void { const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!; const channelType = dest.type === 'channel' ? dest.channelType! : 'agent'; diff --git a/src/modules/permissions/channel-approval.test.ts b/src/modules/permissions/channel-approval.test.ts index 340a9ed..f3ea7e9 100644 --- a/src/modules/permissions/channel-approval.test.ts +++ b/src/modules/permissions/channel-approval.test.ts @@ -247,87 +247,6 @@ describe('unknown-channel registration flow', () => { expect(wakeContainer).toHaveBeenCalled(); }); - it('approve → seeds a /welcome onboarding task into the session', async () => { - const { routeInbound } = await import('../../router.js'); - const { getResponseHandlers } = await import('../../response-registry.js'); - - // `/start` is filtered by the agent-runner (Claude Code slash command), - // so without the seeded onboarding task a Telegram user's first DM would - // produce zero response. The seed ensures the agent runs /welcome regardless. - const startDm = { - channelType: 'telegram', - platformId: 'dm-new-friend', - threadId: null, - message: { - id: 'msg-start', - kind: 'chat' as const, - content: JSON.stringify({ senderId: 'friend', senderName: 'Friend', text: '/start' }), - timestamp: now(), - isMention: true, - }, - }; - await routeInbound(startDm); - await new Promise((r) => setTimeout(r, 10)); - - const { getDb } = await import('../../db/connection.js'); - const pending = getDb() - .prepare('SELECT messaging_group_id, agent_group_id FROM pending_channel_approvals') - .get() as { messaging_group_id: string; agent_group_id: string }; - - for (const handler of getResponseHandlers()) { - const claimed = await handler({ - questionId: pending.messaging_group_id, - value: 'approve', - userId: 'owner', - channelType: 'telegram', - platformId: 'dm-owner', - threadId: null, - }); - if (claimed) break; - } - - // Look up the session that got created, then open its inbound.db and - // confirm an onboarding task with a /welcome prompt landed before the - // replayed chat message. - const session = getDb() - .prepare('SELECT id FROM sessions WHERE agent_group_id = ? AND messaging_group_id = ?') - .get(pending.agent_group_id, pending.messaging_group_id) as { id: string } | undefined; - expect(session).toBeDefined(); - - const Database = (await import('better-sqlite3')).default; - const path = await import('path'); - const inboundPath = path.join(TEST_DIR, 'v2-sessions', pending.agent_group_id, session!.id, 'inbound.db'); - const inbound = new Database(inboundPath, { readonly: true }); - const rows = inbound - .prepare('SELECT kind, content, seq FROM messages_in ORDER BY seq') - .all() as { kind: string; content: string; seq: number }[]; - inbound.close(); - - const taskRow = rows.find((r) => r.kind === 'task'); - expect(taskRow).toBeDefined(); - const prompt: string = JSON.parse(taskRow!.content).prompt; - expect(prompt).toMatch(/\/welcome/); - // Prompt must name the new user — otherwise with multiple destinations - // configured the model may greet the owner instead of the new sender - // (see bug where "Hey Daniel!" landed in the owner's DM). - expect(prompt).toContain('Friend'); - expect(prompt).toContain('dm-new-friend'); - - // Prompt must pin the exact destination by its agent_destinations - // local_name. That name is auto-created by createMessagingGroupAgent - // above; look it up and assert it appears in the prompt verbatim. - const destRow = getDb() - .prepare('SELECT local_name FROM agent_destinations WHERE agent_group_id = ? AND target_id = ?') - .get(pending.agent_group_id, pending.messaging_group_id) as { local_name: string }; - expect(destRow).toBeDefined(); - expect(prompt).toContain(`send_message(to: '${destRow.local_name}'`); - - // Order: task seeded before the replayed /start chat message. - const chatRow = rows.find((r) => r.kind === 'chat'); - expect(chatRow).toBeDefined(); - expect(taskRow!.seq).toBeLessThan(chatRow!.seq); - }); - it('approve on a DM wires with pattern="." defaults', async () => { const { routeInbound } = await import('../../router.js'); const { getResponseHandlers } = await import('../../response-registry.js'); diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index a1cd03a..83390d8 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -28,7 +28,6 @@ import { import type { InboundEvent } from '../../channels/adapter.js'; import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; import { log } from '../../log.js'; -import { resolveSession, writeSessionMessage } from '../../session-manager.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import { canAccessAgentGroup } from './access.js'; import { requestChannelApproval } from './channel-approval.js'; @@ -380,75 +379,6 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< // path normally. deletePendingChannelApproval(row.messaging_group_id); - // Seed a /welcome onboarding task into the session *before* the replayed - // message so the agent greets the new user on first contact. Mirrors the - // owner-setup path (setup/register.ts). Without this, a Telegram user's - // default `/start` greeting gets silently dropped by the agent-runner's - // command filter and the first interaction produces nothing. - // - // The prompt pins the exact destination by name: createMessagingGroupAgent - // above auto-created an `agent_destinations` row pointing at this messaging - // group, and we look it up here. Without that, the agent — which already - // knows about the owner's DM and earlier friends' DMs as named destinations - // — tends to greet the owner (CLAUDE.md anchors on them). Passing the - // specific destination name removes the ambiguity entirely. - try { - const { session } = resolveSession(row.agent_group_id, row.messaging_group_id, event.threadId, 'shared'); - const parsed = safeParseContent(event.message.content); - const author = - parsed && typeof parsed === 'object' && 'author' in parsed && typeof (parsed as { author?: unknown }).author === 'object' - ? ((parsed as { author: Record }).author) - : undefined; - const senderName = - (typeof (parsed as { senderName?: unknown }).senderName === 'string' ? (parsed as { senderName: string }).senderName : undefined) ?? - (typeof (parsed as { sender?: unknown }).sender === 'string' ? (parsed as { sender: string }).sender : undefined) ?? - (typeof author?.fullName === 'string' ? (author.fullName as string) : undefined) ?? - (typeof author?.userName === 'string' ? (author.userName as string) : undefined) ?? - null; - const senderLabel = senderName ? `${senderName} (${event.platformId})` : event.platformId; - - // Pin the destination. Guarded behind hasTable in case the agent-to-agent - // module isn't installed — without it there are no named destinations at - // all, so the agent's send_message call falls through to session default - // routing (which is this new user). Either way, unambiguous. - let destinationClause: string; - const { hasTable, getDb } = await import('../../db/connection.js'); - if (hasTable(getDb(), 'agent_destinations')) { - const { getDestinationByTarget } = await import('../agent-to-agent/db/agent-destinations.js'); - const dest = getDestinationByTarget(row.agent_group_id, 'channel', row.messaging_group_id); - destinationClause = dest - ? `Send your welcome with send_message(to: '${dest.local_name}', text: ...) — that destination resolves to their DM.` - : `Reply using send_message with no \`to\` — the session's default routing points at their DM.`; - } else { - destinationClause = `Reply using send_message — it will land in their DM.`; - } - - writeSessionMessage(row.agent_group_id, session.id, { - id: `onboard-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - kind: 'task', - timestamp: new Date().toISOString(), - platformId: event.platformId, - channelType: event.channelType, - content: JSON.stringify({ - prompt: - `A new ${event.channelType} user — ${senderLabel} — just started a conversation. ` + - `Run /welcome to introduce yourself to them. ${destinationClause}`, - }), - }); - log.info('Onboarding message seeded after channel approval', { - sessionId: session.id, - messagingGroupId: row.messaging_group_id, - agentGroupId: row.agent_group_id, - senderName, - }); - } catch (err) { - // Don't block the replay if onboarding write fails. - log.error('Failed to seed onboarding message after channel approval', { - messagingGroupId: row.messaging_group_id, - err, - }); - } - try { await routeInbound(event); } catch (err) { From 77e6d3bc66bcd5fc3a3ae21ee7d01b1b82f359f8 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 15:20:06 +0300 Subject: [PATCH 054/185] Revert "refactor(skills): merge /new-setup-2 into unified /new-setup" This reverts commit 483969a1948469daf3141afe0c6a3bffdc430faa. --- .claude/skills/new-setup-2/SKILL.md | 154 +++++++++++++++++++++++++++ .claude/skills/new-setup/SKILL.md | 155 +++++----------------------- setup/install-discord.sh | 2 +- setup/install-gchat.sh | 2 +- setup/install-github.sh | 2 +- setup/install-imessage.sh | 2 +- setup/install-linear.sh | 2 +- setup/install-matrix.sh | 2 +- setup/install-resend.sh | 2 +- setup/install-slack.sh | 2 +- setup/install-teams.sh | 2 +- setup/install-telegram.sh | 2 +- setup/install-webex.sh | 2 +- setup/install-whatsapp-cloud.sh | 2 +- setup/install-whatsapp.sh | 2 +- 15 files changed, 190 insertions(+), 145 deletions(-) create mode 100644 .claude/skills/new-setup-2/SKILL.md diff --git a/.claude/skills/new-setup-2/SKILL.md b/.claude/skills/new-setup-2/SKILL.md new file mode 100644 index 0000000..8d75cd3 --- /dev/null +++ b/.claude/skills/new-setup-2/SKILL.md @@ -0,0 +1,154 @@ +--- +name: new-setup-2 +description: Follow-on to /new-setup. Captures the operator and agent names, wires a real messaging channel, and adds quality-of-life extras. Linear rollthrough; every step is skippable. Invoked when the user picks "continue setup" at the end of /new-setup. +allowed-tools: Bash(bash setup/probe.sh) Bash(bash setup/install-telegram.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm exec tsx scripts/init-first-agent.ts:*) Bash(tail:*) Bash(head:*) Bash(grep:*) +--- + +# NanoClaw phase-2 setup + +Runs after `/new-setup`. At this point the host is running and a throwaway CLI-only agent exists (used during /new-setup for the end-to-end ping check — inferred name, not user-facing). This flow creates the **real** agent and wires it to a messaging channel. + +**Linear — one step at a time.** Every step is skippable: if the user says "skip", "not now", "later", or similar, move on without complaint. If they say they're done at any point, stop cleanly — don't push the remaining steps. + +Before each step, narrate in your own words what's about to happen — one short, friendly sentence, no jargon. Match the tone of `/new-setup`. + +## Current state + +!`bash setup/probe.sh` + +Parse the probe block above for `INFERRED_DISPLAY_NAME` and `PLATFORM` — referenced below. + +## Steps + +### 1. What should the agent call you? + +Plain-prose ask (do **not** use `AskUserQuestion`): + +> What should your agent call you? (Default: ``) + +Capture the answer into a local variable `OPERATOR_NAME`. **Don't persist yet** — this value is consumed by step 3's channel wiring. If the user skips or confirms the default, set `OPERATOR_NAME = INFERRED_DISPLAY_NAME`. + +### 2. What's your agent's name? + +Plain-prose ask: + +> What would you like to call your agent? (Default: ``) + +Capture as `AGENT_NAME`. If skipped, set `AGENT_NAME = OPERATOR_NAME`. Nothing persisted yet. + +### 3. Timezone + +Run `pnpm exec tsx setup/index.ts --step timezone` and parse the status block. + +- **RESOLVED_TZ is `UTC` or `Etc/UTC`** — before leaving UTC in `.env`, confirm with `AskUserQuestion`: + + - **Question**: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?" + - **Header**: "Timezone" + - **Options**: + 1. `Keep UTC` — "Leave timezone as UTC." + 2. `I'm somewhere else` — "I'll name the IANA zone (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`) via Other." + + If they pick "I'm somewhere else" (or type an IANA zone via Other), re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` to overwrite `.env`. If they keep UTC or skip, leave UTC in place. + +- **NEEDS_USER_INPUT=true** — autodetection failed. Use `AskUserQuestion` with the same two options above (reword the question to "Autodetection failed — what timezone are you in?"), then re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` if they supply one. If they skip, move on. + +- Otherwise — timezone is already set; move on. + +### 4. Pick a messaging channel + +Print the list as a numbered plain-prose list (too many options for `AskUserQuestion`, which caps at 4). The user replies with a number or channel name. Preserve the numbering exactly: + +> Which messaging channel should I wire your agent to? +> +> 1. **WhatsApp (native)** — `/add-whatsapp` +> 2. **WhatsApp Cloud (Meta official)** — `/add-whatsapp-cloud` +> 3. **Telegram** — `/add-telegram` +> 4. **Slack** — `/add-slack` +> 5. **Discord** — `/add-discord` +> 6. **iMessage** — `/add-imessage` +> 7. **Teams** — `/add-teams` +> 8. **Matrix** — `/add-matrix` +> 9. **Google Chat** — `/add-gchat` +> 10. **Linear** — `/add-linear` +> 11. **GitHub** — `/add-github` +> 12. **Webex** — `/add-webex` +> 13. **Resend (email)** — `/add-resend` +> 14. **Emacs** — `/add-emacs` +> +> Or say "skip" to leave this for later. + +When the user picks one: + +1. **Install the adapter.** For **Telegram**, run `bash setup/install-telegram.sh` directly — it bundles the preflight + fetch + copy + register + `pnpm install` + build from `/add-telegram` into one idempotent call. Then handle Telegram credentials inline (below) — **do not** invoke `/add-telegram` afterward; its Credentials section would generate an unapprovable `grep && sed && rm` to write `.env`. For every other channel, invoke the matching `/add-` skill via the Skill tool; it copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels also run a pairing step as part of their flow. + + **Telegram credentials (inline):** + - Walk the user through BotFather: `/newbot` → pick name + username ending in `bot` → copy the token. + - Remind them: in `@BotFather` → `/mybots` → their bot → Bot Settings → Group Privacy → **Turn off** (only needed if the bot will live in groups; DM-only can skip). + - Persist the token and sync it to the container mount with the generic setter: + + ``` + pnpm exec tsx setup/index.ts --step set-env -- \ + --key TELEGRAM_BOT_TOKEN --value "" --sync-container + ``` + +2. **Capture platform IDs.** After the `/add-` skill finishes (or after inline credentials for Telegram), you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent ` directly and follow its `PAIR_TELEGRAM_ISSUED`/`PAIR_TELEGRAM STATUS=success` blocks — `PLATFORM_ID` and `ADMIN_USER_ID` land in the success block. +3. **Wire the agent.** Run `init-first-agent.ts` in DM mode: + + ``` + pnpm exec tsx scripts/init-first-agent.ts \ + --channel \ + --user-id "" \ + --platform-id "" \ + --display-name "" \ + --agent-name "" + ``` + +4. **Announce.** On success, emit the encouragement line verbatim: + + > Your agent is now available on {channel-name}, you can already start chatting — But I encourage you to continue and finish this setup, we're almost done! + + Substitute `{channel-name}` with the friendly name (e.g. "Telegram", "WhatsApp", "Slack"). + +If the user skipped, move on to step 5. + +### 5. Host directory access + +By default, agent containers can only touch their own workspace. If the user wants the agent to read or write files in specific host directories, those paths need to go on the mount allowlist. + +Use `AskUserQuestion`: + +- **Question**: "Want your agent to read or write files in any host directories (e.g. a code project, `~/Documents`)?" +- **Header**: "Host mounts" +- **Options**: + 1. `Keep isolated` — "Agent only touches its own workspace (Recommended)." + 2. `Add host paths` — "I'll name the directories to allowlist via Other." + +If they pick "Add host paths" (or name paths via Other), invoke `/manage-mounts` via the Skill tool to add them. If they keep it isolated or skip, move on. + +### 6. Quality of life + +Optional polish. Print the list; the user may pick zero, one, or several — invoke each chosen skill in sequence: + +> Want to add any of these? Pick any that sound useful — or skip: +> +> - `/add-dashboard` — browser dashboard showing agent activity +> - `/add-compact` — `/compact` slash command for managing long sessions +> - `/add-karpathy-llm-wiki` — persistent knowledge-base memory for the agent + +If the probe reports `PLATFORM=darwin`, also offer: + +> - `/add-macos-statusbar` — macOS menu bar indicator with Start/Stop/Restart controls + +Do **not** list `/add-macos-statusbar` on Linux. If the user skips everything, just move on. + +### 7. Done + +Short wrap-up: + +> Setup complete. You can chat with your agent on {channel-name} — or via CLI with `pnpm run chat `. + +Substitute `{channel-name}` with whatever was wired in step 4. If step 4 was skipped, drop the "on {channel-name} — or" clause entirely so the line just mentions the CLI form. + +## If anything fails + +Same rule as `/new-setup`: don't bypass errors to keep moving. Read `logs/setup.log` or `logs/nanoclaw.log`, diagnose, fix the underlying cause, re-run the failed step. diff --git a/.claude/skills/new-setup/SKILL.md b/.claude/skills/new-setup/SKILL.md index ef88c75..02cef98 100644 --- a/.claude/skills/new-setup/SKILL.md +++ b/.claude/skills/new-setup/SKILL.md @@ -1,17 +1,14 @@ --- name: new-setup -description: End-to-end NanoClaw setup for any user regardless of technical background — from zero to a named agent reachable on a real messaging channel, with sensible defaults and every post-verification step skippable. -allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(bash setup/install-node.sh) Bash(bash setup/install-docker.sh) Bash(bash setup/install-telegram.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm exec tsx scripts/init-first-agent.ts:*) Bash(pnpm run chat) Bash(pnpm run chat:*) Bash(open -a Docker) Bash(sudo systemctl start docker) Bash(node --version) Bash(tail:*) Bash(head:*) Bash(grep:*) +description: Shortest path from zero to a working two-way agent chat, for any user regardless of technical background — ends at a running NanoClaw instance with at least one CLI-reachable agent. +allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(bash setup/install-node.sh) Bash(bash setup/install-docker.sh) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm run chat) Bash(pnpm run chat:*) Bash(open -a Docker) Bash(sudo systemctl start docker) Bash(node --version) Bash(tail:*) Bash(head:*) Bash(grep:*) --- -# NanoClaw setup +# NanoClaw bare-minimum setup -Purpose of this skill is to take any user — technical or not — from zero to a named agent wired to a real messaging channel in the fewest steps possible. +Purpose of this skill is to take any user — technical or not — from zero to a two-way chat with an agent in the fewest steps possible. Done means a running NanoClaw instance with at least one agent reachable via the CLI channel. -The flow has two halves: - -- **Steps 1–6 — required.** Prerequisites, credential, service start, end-to-end ping. These run straight through. -- **Steps 7–12 — skippable.** Naming, channel wiring, QoL. Every step here is skippable: if the user says "skip", "not now", "later", or similar, move on without complaint. If they say they're done at any point, stop cleanly — don't push the remaining steps. +Only run the steps strictly required for the NanoClaw process to start and respond to the user end-to-end. Everything else is deferred to post-setup skills. Before each step, narrate to the user in your own words what's about to happen — one short, friendly sentence, no jargon. Don't read a scripted line; use the step context below to speak naturally. @@ -112,13 +109,13 @@ Start the NanoClaw background service — it relays messages between the user an `pnpm exec tsx setup/index.ts --step service` -### 6. Wire a scratch CLI agent and verify end-to-end +### 6. Wire the CLI agent and verify end-to-end **Do not narrate this step.** No "creating your first agent…", no "sending a ping…" chatter. The user's experience here is: they finished the last visible step (service), then a single success line appears. Wiring is an implementation detail at this point, not a user-facing milestone. If step 2's container build is still running in the background, join it here before proceeding — the agent needs the image. -Use `INFERRED_DISPLAY_NAME` from the probe silently. **Do not ask the user.** The CLI agent at this stage is a scratch agent whose only purpose is to verify the end-to-end pipeline (host → container → agent → back). The user's real name capture happens in step 7. +Use `INFERRED_DISPLAY_NAME` from the probe silently. **Do not ask the user.** The CLI agent at this stage is a scratch agent whose only purpose is to verify the end-to-end pipeline (host → container → agent → back). The user's real name capture happens in `/new-setup-2` when they wire a messaging channel. Run wiring and ping back-to-back, silently: @@ -129,141 +126,35 @@ pnpm run chat ping First container spin-up takes ~60s. When the agent's reply arrives, emit exactly one line to the user: -> Test Agent success, proceeding with setup +> Your agent is up, running and ready to go! If `pnpm run chat ping` times out or errors, tail `logs/nanoclaw.log`, diagnose, and fix — don't surface a half-success. > **Loose command:** `pnpm run chat ping`. Justification: this is the same command the user will keep using after setup, so verification and the real channel are one and the same. -### 7. What should the agent call you? +### 7. Chat now, or keep setting up? -Plain-prose ask (do **not** use `AskUserQuestion`): +Ask the user via `AskUserQuestion` which they'd like to do next: -> What should your agent call you? (Default: ``) +1. **Keep chatting with the agent via CLI** — happy with the CLI channel for now. +2. **Continue setup** — name the agent, wire a messaging channel, add quality-of-life extras. -Capture the answer into a local variable `OPERATOR_NAME`. **Don't persist yet** — this value is consumed by step 10's channel wiring. If the user skips or confirms the default, set `OPERATOR_NAME = INFERRED_DISPLAY_NAME`. +**If they pick "keep chatting":** print both options below, then stop. The user is chatting with the agent now, not with you — no further output from you. -### 8. What's your agent's name? +**Option 1 — from inside this Claude Code session.** Type your message with a leading `!`, which runs it as a bash command in this shell: -Plain-prose ask: +``` +!pnpm run chat your message here +``` -> What would you like to call your agent? (Default: ``) +**Option 2 — from a separate terminal.** Open a new terminal, `cd` into your nanoclaw checkout, then: -Capture as `AGENT_NAME`. If skipped, set `AGENT_NAME = OPERATOR_NAME`. Nothing persisted yet. +``` +pnpm run chat your message here +``` -### 9. Timezone - -Run `pnpm exec tsx setup/index.ts --step timezone` and parse the status block. - -- **RESOLVED_TZ is `UTC` or `Etc/UTC`** — before leaving UTC in `.env`, confirm with `AskUserQuestion`: - - - **Question**: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?" - - **Header**: "Timezone" - - **Options**: - 1. `Keep UTC` — "Leave timezone as UTC." - 2. `I'm somewhere else` — "I'll name the IANA zone (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`) via Other." - - If they pick "I'm somewhere else" (or type an IANA zone via Other), re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` to overwrite `.env`. If they keep UTC or skip, leave UTC in place. - -- **NEEDS_USER_INPUT=true** — autodetection failed. Use `AskUserQuestion` with the same two options above (reword the question to "Autodetection failed — what timezone are you in?"), then re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` if they supply one. If they skip, move on. - -- Otherwise — timezone is already set; move on. - -### 10. Pick a messaging channel - -Print the list as a numbered plain-prose list (too many options for `AskUserQuestion`, which caps at 4). The user replies with a number or channel name. Preserve the numbering exactly: - -> Which messaging channel should I wire your agent to? -> -> 1. **WhatsApp (native)** — `/add-whatsapp` -> 2. **WhatsApp Cloud (Meta official)** — `/add-whatsapp-cloud` -> 3. **Telegram** — `/add-telegram` -> 4. **Slack** — `/add-slack` -> 5. **Discord** — `/add-discord` -> 6. **iMessage** — `/add-imessage` -> 7. **Teams** — `/add-teams` -> 8. **Matrix** — `/add-matrix` -> 9. **Google Chat** — `/add-gchat` -> 10. **Linear** — `/add-linear` -> 11. **GitHub** — `/add-github` -> 12. **Webex** — `/add-webex` -> 13. **Resend (email)** — `/add-resend` -> 14. **Emacs** — `/add-emacs` -> -> Or say "skip" to leave this for later. - -When the user picks one: - -1. **Install the adapter.** For **Telegram**, run `bash setup/install-telegram.sh` directly — it bundles the preflight + fetch + copy + register + `pnpm install` + build from `/add-telegram` into one idempotent call. Then handle Telegram credentials inline (below) — **do not** invoke `/add-telegram` afterward; its Credentials section would generate an unapprovable `grep && sed && rm` to write `.env`. For every other channel, invoke the matching `/add-` skill via the Skill tool; it copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels also run a pairing step as part of their flow. - - **Telegram credentials (inline):** - - Walk the user through BotFather: `/newbot` → pick name + username ending in `bot` → copy the token. - - Remind them: in `@BotFather` → `/mybots` → their bot → Bot Settings → Group Privacy → **Turn off** (only needed if the bot will live in groups; DM-only can skip). - - Persist the token and sync it to the container mount with the generic setter: - - ``` - pnpm exec tsx setup/index.ts --step set-env -- \ - --key TELEGRAM_BOT_TOKEN --value "" --sync-container - ``` - -2. **Capture platform IDs.** After the `/add-` skill finishes (or after inline credentials for Telegram), you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent ` directly and follow its `PAIR_TELEGRAM_ISSUED`/`PAIR_TELEGRAM STATUS=success` blocks — `PLATFORM_ID` and `ADMIN_USER_ID` land in the success block. -3. **Wire the agent.** Run `init-first-agent.ts` in DM mode: - - ``` - pnpm exec tsx scripts/init-first-agent.ts \ - --channel \ - --user-id "" \ - --platform-id "" \ - --display-name "" \ - --agent-name "" - ``` - -4. **Announce.** On success, emit the encouragement line verbatim: - - > Your agent is now available on {channel-name}, you can already start chatting — But I encourage you to continue and finish this setup, we're almost done! - - Substitute `{channel-name}` with the friendly name (e.g. "Telegram", "WhatsApp", "Slack"). - -If the user skipped, move on to step 11. - -### 11. Host directory access - -By default, agent containers can only touch their own workspace. If the user wants the agent to read or write files in specific host directories, those paths need to go on the mount allowlist. - -Use `AskUserQuestion`: - -- **Question**: "Want your agent to read or write files in any host directories (e.g. a code project, `~/Documents`)?" -- **Header**: "Host mounts" -- **Options**: - 1. `Keep isolated` — "Agent only touches its own workspace (Recommended)." - 2. `Add host paths` — "I'll name the directories to allowlist via Other." - -If they pick "Add host paths" (or name paths via Other), invoke `/manage-mounts` via the Skill tool to add them. If they keep it isolated or skip, move on. - -### 12. Quality of life - -Optional polish. Print the list; the user may pick zero, one, or several — invoke each chosen skill in sequence: - -> Want to add any of these? Pick any that sound useful — or skip: -> -> - `/add-dashboard` — browser dashboard showing agent activity -> - `/add-compact` — `/compact` slash command for managing long sessions -> - `/add-karpathy-llm-wiki` — persistent knowledge-base memory for the agent - -If the probe reports `PLATFORM=darwin`, also offer: - -> - `/add-macos-statusbar` — macOS menu bar indicator with Start/Stop/Restart controls - -Do **not** list `/add-macos-statusbar` on Linux. If the user skips everything, just move on. - -### 13. Done - -Short wrap-up: - -> Setup complete. You can chat with your agent on {channel-name} — or via CLI with `pnpm run chat `. - -Substitute `{channel-name}` with whatever was wired in step 10. If step 10 was skipped, drop the "on {channel-name} — or" clause entirely so the line just mentions the CLI form. +**If they pick "continue setup":** hand off directly to `/new-setup-2` via the Skill tool. That follow-on flow is structured like this one (linear, skippable steps) and covers naming, messaging-channel wiring, and QoL. Invoke it immediately — do not offer a menu of individual skills. ## If anything fails -Any step that reports `STATUS: failed` in its status block: read `logs/setup.log` (or `logs/nanoclaw.log` for runtime failures), diagnose, fix the underlying cause, re-run the same `--step`. Don't bypass errors to keep moving. +Any step that reports `STATUS: failed` in its status block: read `logs/setup.log`, diagnose, fix the underlying cause, re-run the same `--step`. Don't bypass errors to keep moving. diff --git a/setup/install-discord.sh b/setup/install-discord.sh index 6f5a9c8..ee221f9 100755 --- a/setup/install-discord.sh +++ b/setup/install-discord.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-discord — bundles the preflight + install commands -# from the /add-discord skill into one idempotent script so /new-setup can +# from the /add-discord skill into one idempotent script so /new-setup-2 can # run them programmatically before continuing to credentials. # # Copies the Discord adapter in from the `channels` branch; appends the diff --git a/setup/install-gchat.sh b/setup/install-gchat.sh index b9166f1..f5c210b 100755 --- a/setup/install-gchat.sh +++ b/setup/install-gchat.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-gchat — bundles the preflight + install commands -# from the /add-gchat skill into one idempotent script so /new-setup can +# from the /add-gchat skill into one idempotent script so /new-setup-2 can # run them programmatically before continuing to credentials. # # Copies the Google Chat adapter in from the `channels` branch; appends the diff --git a/setup/install-github.sh b/setup/install-github.sh index cb28bfc..81c2977 100755 --- a/setup/install-github.sh +++ b/setup/install-github.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-github — bundles the preflight + install commands -# from the /add-github skill into one idempotent script so /new-setup can +# from the /add-github skill into one idempotent script so /new-setup-2 can # run them programmatically before continuing to credentials. # # Copies the GitHub adapter in from the `channels` branch; appends the diff --git a/setup/install-imessage.sh b/setup/install-imessage.sh index 864e127..0b1df34 100755 --- a/setup/install-imessage.sh +++ b/setup/install-imessage.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-imessage — bundles the preflight + install commands -# from the /add-imessage skill into one idempotent script so /new-setup can +# from the /add-imessage skill into one idempotent script so /new-setup-2 can # run them programmatically before continuing to credentials. # # Copies the iMessage adapter in from the `channels` branch; appends the diff --git a/setup/install-linear.sh b/setup/install-linear.sh index f8788be..9f42bec 100755 --- a/setup/install-linear.sh +++ b/setup/install-linear.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-linear — bundles the preflight + install commands -# from the /add-linear skill into one idempotent script so /new-setup can +# from the /add-linear skill into one idempotent script so /new-setup-2 can # run them programmatically before continuing to credentials. # # Copies the Linear adapter in from the `channels` branch; appends the diff --git a/setup/install-matrix.sh b/setup/install-matrix.sh index c985473..06d5ccd 100755 --- a/setup/install-matrix.sh +++ b/setup/install-matrix.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-matrix — bundles the preflight + install commands -# from the /add-matrix skill into one idempotent script so /new-setup can +# from the /add-matrix skill into one idempotent script so /new-setup-2 can # run them programmatically before continuing to credentials. # # Copies the Matrix adapter in from the `channels` branch; appends the diff --git a/setup/install-resend.sh b/setup/install-resend.sh index 9f18a9f..4f0bb2e 100755 --- a/setup/install-resend.sh +++ b/setup/install-resend.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-resend — bundles the preflight + install commands -# from the /add-resend skill into one idempotent script so /new-setup can +# from the /add-resend skill into one idempotent script so /new-setup-2 can # run them programmatically before continuing to credentials. # # Copies the Resend adapter in from the `channels` branch; appends the diff --git a/setup/install-slack.sh b/setup/install-slack.sh index 55d5e85..8be6a37 100755 --- a/setup/install-slack.sh +++ b/setup/install-slack.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-slack — bundles the preflight + install commands -# from the /add-slack skill into one idempotent script so /new-setup can +# from the /add-slack skill into one idempotent script so /new-setup-2 can # run them programmatically before continuing to credentials. # # Copies the Slack adapter in from the `channels` branch; appends the diff --git a/setup/install-teams.sh b/setup/install-teams.sh index 4b8c216..cb66f67 100755 --- a/setup/install-teams.sh +++ b/setup/install-teams.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-teams — bundles the preflight + install commands -# from the /add-teams skill into one idempotent script so /new-setup can +# from the /add-teams skill into one idempotent script so /new-setup-2 can # run them programmatically before continuing to credentials. # # Copies the Teams adapter in from the `channels` branch; appends the diff --git a/setup/install-telegram.sh b/setup/install-telegram.sh index 307dba2..7eaf9e1 100755 --- a/setup/install-telegram.sh +++ b/setup/install-telegram.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-telegram — bundles the preflight + install commands -# from the /add-telegram skill into one idempotent script so /new-setup can +# from the /add-telegram skill into one idempotent script so /new-setup-2 can # run them programmatically before continuing to credentials and pairing. # # Copies the Telegram adapter, helpers, tests, and the pair-telegram setup diff --git a/setup/install-webex.sh b/setup/install-webex.sh index adf52fc..8bbbc83 100755 --- a/setup/install-webex.sh +++ b/setup/install-webex.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-webex — bundles the preflight + install commands -# from the /add-webex skill into one idempotent script so /new-setup can +# from the /add-webex skill into one idempotent script so /new-setup-2 can # run them programmatically before continuing to credentials. # # Copies the Webex adapter in from the `channels` branch; appends the diff --git a/setup/install-whatsapp-cloud.sh b/setup/install-whatsapp-cloud.sh index 70e8e02..3773278 100755 --- a/setup/install-whatsapp-cloud.sh +++ b/setup/install-whatsapp-cloud.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Setup helper: install-whatsapp-cloud — bundles the preflight + install # commands from the /add-whatsapp-cloud skill into one idempotent script so -# /new-setup can run them programmatically before continuing to credentials. +# /new-setup-2 can run them programmatically before continuing to credentials. # # Copies the WhatsApp Cloud adapter in from the `channels` branch; appends the # self-registration import; installs the pinned @chat-adapter/whatsapp package; diff --git a/setup/install-whatsapp.sh b/setup/install-whatsapp.sh index 1c62d65..0d307f5 100755 --- a/setup/install-whatsapp.sh +++ b/setup/install-whatsapp.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-whatsapp — bundles the preflight + install commands -# from the /add-whatsapp skill into one idempotent script so /new-setup can +# from the /add-whatsapp skill into one idempotent script so /new-setup-2 can # run them programmatically before continuing to QR/pairing-code auth. # # Copies the native Baileys WhatsApp adapter, its whatsapp-auth and groups From 40ddc94d0ad0d3c8d3dc7c8a5e3f4179d13ab7ab Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 15:20:06 +0300 Subject: [PATCH 055/185] Revert "fix(init-first-agent): seed welcome via inbound.db; drop --no-cli-bonus" This reverts commit 9fe529984a646eb71c82e29a0a42c0959f65eb39. --- .claude/skills/init-first-agent/SKILL.md | 4 +- .claude/skills/new-setup-2/SKILL.md | 5 +- scripts/init-first-agent.ts | 157 +++++++++++++++++------ 3 files changed, 121 insertions(+), 45 deletions(-) diff --git a/.claude/skills/init-first-agent/SKILL.md b/.claude/skills/init-first-agent/SKILL.md index 68eab87..6b110d3 100644 --- a/.claude/skills/init-first-agent/SKILL.md +++ b/.claude/skills/init-first-agent/SKILL.md @@ -87,13 +87,13 @@ The script: 2. Creates the `agent_groups` row and calls `initGroupFilesystem` at `groups/dm-with-/`. 3. Reuses or creates the DM `messaging_groups` row. 4. Wires them via `messaging_group_agents` (which auto-creates the companion `agent_destinations` row). -5. Seeds the welcome message directly into the DM session's `inbound.db` (sender tagged `System`). The running service's host-sweep picks it up on the next pass and wakes the container through the normal path — no CLI-socket hand-off, no `cli:local` identity on the new agent's permission surface. +5. Hands the welcome message to the running service via its CLI socket (`data/cli.sock`), targeting the DM messaging group. The service routes it into the DM session, which wakes the container synchronously. If the socket isn't reachable (service down), falls back to a direct `inbound.db` write that the next host sweep picks up. Show the script's output to the user. ## 5. Verify -The welcome is written to `inbound.db` immediately; the wait is host-sweep pickup (≤60s) plus container cold-start (~60s on first launch) before the agent processes the message and the reply flows through `outbound.db` to the channel. +The welcome DM is queued synchronously; the only wait is container cold-start (~60s on first launch) before the agent processes the message and the reply flows through `outbound.db` to the channel. Do not tail the log or poll in a sleep loop. Ask the user in plain text: diff --git a/.claude/skills/new-setup-2/SKILL.md b/.claude/skills/new-setup-2/SKILL.md index 8d75cd3..1b98443 100644 --- a/.claude/skills/new-setup-2/SKILL.md +++ b/.claude/skills/new-setup-2/SKILL.md @@ -92,7 +92,7 @@ When the user picks one: ``` 2. **Capture platform IDs.** After the `/add-` skill finishes (or after inline credentials for Telegram), you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent ` directly and follow its `PAIR_TELEGRAM_ISSUED`/`PAIR_TELEGRAM STATUS=success` blocks — `PLATFORM_ID` and `ADMIN_USER_ID` land in the success block. -3. **Wire the agent.** Run `init-first-agent.ts` in DM mode: +3. **Wire the agent.** Run `init-first-agent.ts` in DM mode with `--no-cli-bonus` (this keeps the new agent off the CLI messaging group so the pre-existing throwaway agent still owns CLI routing cleanly): ``` pnpm exec tsx scripts/init-first-agent.ts \ @@ -100,7 +100,8 @@ When the user picks one: --user-id "" \ --platform-id "" \ --display-name "" \ - --agent-name "" + --agent-name "" \ + --no-cli-bonus ``` 4. **Announce.** On success, emit the encouragement line verbatim: diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index 9dc8b6d..29ca6d4 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -1,25 +1,24 @@ /** * Init the first (or Nth) NanoClaw v2 agent for a DM channel. * - * Wires a real DM channel (discord, telegram, etc.) to a new agent group, - * then seeds a welcome message directly into the session's inbound DB. The - * running service's host-sweep picks it up on its next pass (within - * SWEEP_INTERVAL_MS) and wakes the container through the normal path; the - * agent introduces itself via the channel. + * Wires a real DM channel (discord, telegram, etc.) to a new agent group + * (and the local CLI channel as a convenience bonus), then hands a welcome + * message to the running service via its CLI socket. The service routes + * that message into the DM session, which wakes the container synchronously — + * the agent processes the welcome and DMs the operator through the normal + * delivery path. * - * CLI channel wiring is NOT touched here — `scripts/init-cli-agent.ts` owns - * the cli/local messaging group and its scratch agent. Keeping the two - * scripts disjoint means no `cli:local` identity ever appears on the new - * agent's permission surface, so the unknown-sender approval card that used - * to fire when the welcome was queued via the CLI admin socket no longer - * happens. + * For the CLI-only scratch agent used during `/new-setup`, see + * `scripts/init-cli-agent.ts` — that's a distinct flow and doesn't run + * through here. * * Creates/reuses: user, owner grant (if none), agent group + filesystem, - * messaging group, wiring, session, welcome message. + * messaging group(s), wiring. * - * Runs alongside the service (WAL-mode sqlite) — does NOT initialize channel - * adapters, so there's no Gateway conflict. No IPC to the service is needed; - * the sweep is the sole hand-off. + * Runs alongside the service (WAL-mode sqlite + CLI socket IPC) — does NOT + * initialize channel adapters, so there's no Gateway conflict. Requires + * the service to be running: the welcome hand-off goes over the CLI socket + * and fails loudly if the service isn't up. * * Usage: * pnpm exec tsx scripts/init-first-agent.ts \ @@ -28,11 +27,13 @@ * --platform-id discord:@me:1491573333382523708 \ * --display-name "Gavriel" \ * [--agent-name "Andy"] \ - * [--welcome "System instruction: ..."] + * [--welcome "System instruction: ..."] \ + * [--no-cli-bonus] * * For direct-addressable channels (telegram, whatsapp, etc.), --platform-id * is typically the same as the handle in --user-id, with the channel prefix. */ +import net from 'net'; import path from 'path'; import { DATA_DIR } from '../src/config.js'; @@ -49,10 +50,10 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio import { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles.js'; import { upsertUser } from '../src/modules/permissions/db/users.js'; import { initGroupFilesystem } from '../src/group-init.js'; -import { resolveSession, writeSessionMessage } from '../src/session-manager.js'; import type { AgentGroup, MessagingGroup } from '../src/types.js'; interface Args { + noCliBonus: boolean; channel: string; userId: string; platformId: string; @@ -64,12 +65,18 @@ interface Args { const DEFAULT_WELCOME = 'System instruction: run /welcome to introduce yourself to the user on this new channel.'; +const CLI_CHANNEL = 'cli'; +const CLI_PLATFORM_ID = 'local'; + function parseArgs(argv: string[]): Args { - const out: Partial = {}; + const out: Partial = { noCliBonus: false }; for (let i = 0; i < argv.length; i++) { const key = argv[i]; const val = argv[i + 1]; switch (key) { + case '--no-cli-bonus': + out.noCliBonus = true; + break; case '--channel': out.channel = (val ?? '').toLowerCase(); i++; @@ -108,6 +115,7 @@ function parseArgs(argv: string[]): Args { } return { + noCliBonus: out.noCliBonus ?? false, channel: out.channel!, userId: out.userId!, platformId: out.platformId!, @@ -129,6 +137,24 @@ function generateId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +function ensureCliMessagingGroup(now: string): MessagingGroup { + let cliMg = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID); + if (cliMg) return cliMg; + + cliMg = { + id: generateId('mg'), + channel_type: CLI_CHANNEL, + platform_id: CLI_PLATFORM_ID, + name: 'Local CLI', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now, + }; + createMessagingGroup(cliMg); + console.log(`Created CLI messaging group: ${cliMg.id}`); + return cliMg; +} + function wireIfMissing(mg: MessagingGroup, ag: AgentGroup, now: string, label: string): void { const existing = getMessagingGroupAgentByPair(mg.id, ag.id); if (existing) { @@ -139,8 +165,9 @@ function wireIfMissing(mg: MessagingGroup, ag: AgentGroup, now: string, label: s id: generateId('mga'), messaging_group_id: mg.id, agent_group_id: ag.id, - // DMs default to "respond to everything" via a '.' regex. Group chats - // default to mention-only; admins can upgrade via /manage-channels. + // DM / CLI (is_group=0) default to "respond to everything" via a '.' regex. + // Group chats default to mention-only; admins can upgrade to mention-sticky + // via /manage-channels once the agent is in use. engage_mode: mg.is_group === 0 ? 'pattern' : 'mention', engage_pattern: mg.is_group === 0 ? '.' : null, sender_scope: 'all', @@ -225,40 +252,88 @@ async function main(): Promise { console.log(`Reusing messaging group: ${dmMg.id} (${platformId})`); } - // 4. Wire DM. + // 4. Wire DM (auto-creates companion agent_destinations row) and, + // unless suppressed, also wire the CLI channel so `pnpm run chat` works + // against the new agent immediately. `/new-setup-2` sets --no-cli-bonus + // so the scratch CLI agent from `/new-setup` keeps owning CLI routing. wireIfMissing(dmMg, ag, now, 'dm'); + if (!args.noCliBonus) { + const cliMg = ensureCliMessagingGroup(now); + wireIfMissing(cliMg, ag, now, 'cli-bonus'); + } - // 5. Seed the welcome directly into the session's inbound.db. The running - // service's sweep will observe trigger=1 and wake the container on its next - // pass — no IPC, no CLI socket, no `cli:local` sender in the router path. - seedWelcome(ag.id, dmMg, args.welcome); + // 5. Welcome delivery over the CLI socket. Router picks up the line, + // writes the message into the DM session's inbound.db, and wakes the + // container synchronously — no sweep wait. + await sendWelcomeViaCliSocket(dmMg, args.welcome); console.log(''); console.log('Init complete.'); console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`); console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`); console.log(` channel: ${args.channel} ${dmMg.platform_id}`); + if (!args.noCliBonus) { + console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``); + } console.log(''); - console.log('Welcome seeded — the agent will greet you on the next sweep pass.'); + console.log('Welcome DM queued — the agent will greet you shortly.'); } /** - * Write the welcome as a due inbound message on a shared session for the - * new agent group + messaging group pair. Sender is tagged "System" — the - * welcome carries no real user identity and never crosses the router's - * sender-approval gate. + * Hand the welcome to the running service via its CLI Unix socket. The + * service's CLI adapter receives `{text, to}`, builds an InboundEvent + * targeting the DM messaging group, and calls routeInbound(). Router writes + * the message into inbound.db and wakes the container synchronously. + * + * Throws if the socket isn't reachable — this script requires the service + * to be running. */ -function seedWelcome(agentGroupId: string, mg: MessagingGroup, welcome: string): void { - const { session } = resolveSession(agentGroupId, mg.id, null, 'shared'); - writeSessionMessage(agentGroupId, session.id, { - id: generateId('welcome'), - kind: 'chat', - timestamp: new Date().toISOString(), - channelType: mg.channel_type, - platformId: mg.platform_id, - threadId: null, - content: JSON.stringify({ text: welcome, sender: 'System' }), - trigger: 1, +async function sendWelcomeViaCliSocket(dmMg: MessagingGroup, welcome: string): Promise { + const sockPath = path.join(DATA_DIR, 'cli.sock'); + + await new Promise((resolve, reject) => { + const socket = net.connect(sockPath); + let settled = false; + + const settle = (err: Error | null) => { + if (settled) return; + settled = true; + try { + socket.end(); + } catch { + /* noop */ + } + if (err) reject(err); + else resolve(); + }; + + socket.once('error', (err) => + settle( + new Error( + `CLI socket at ${sockPath} not reachable: ${err.message}. Is the NanoClaw service running?`, + ), + ), + ); + socket.once('connect', () => { + const payload = + JSON.stringify({ + text: welcome, + to: { + channelType: dmMg.channel_type, + platformId: dmMg.platform_id, + threadId: null, + }, + }) + '\n'; + socket.write(payload, (err) => { + if (err) { + settle(err); + return; + } + // Brief flush delay so the router picks up the line before we close. + // Router handles it synchronously once read, so 50ms is plenty. + setTimeout(() => settle(null), 50); + }); + }); }); } From 1f7508f2aac1f911511287a547089f2148532e80 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Tue, 21 Apr 2026 10:37:06 +0000 Subject: [PATCH 056/185] refactor(skills): merge /new-setup-2 into unified /new-setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapses the two-phase setup into a single linear skill: steps 1-6 (prereqs through end-to-end CLI ping) run straight through, steps 7-13 (naming, timezone, channel wiring, mounts, QoL, done) are skippable. Drops the "chat now vs. continue" branch point — after the ping the flow emits "Test Agent success, proceeding with setup" and continues directly into the naming questions. Also updates stale `/new-setup-2` header comments in setup/install-*.sh. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-setup-2/SKILL.md | 155 ---------------------------- .claude/skills/new-setup/SKILL.md | 155 +++++++++++++++++++++++----- setup/install-discord.sh | 2 +- setup/install-gchat.sh | 2 +- setup/install-github.sh | 2 +- setup/install-imessage.sh | 2 +- setup/install-linear.sh | 2 +- setup/install-matrix.sh | 2 +- setup/install-resend.sh | 2 +- setup/install-slack.sh | 2 +- setup/install-teams.sh | 2 +- setup/install-telegram.sh | 2 +- setup/install-webex.sh | 2 +- setup/install-whatsapp-cloud.sh | 2 +- setup/install-whatsapp.sh | 2 +- 15 files changed, 145 insertions(+), 191 deletions(-) delete mode 100644 .claude/skills/new-setup-2/SKILL.md diff --git a/.claude/skills/new-setup-2/SKILL.md b/.claude/skills/new-setup-2/SKILL.md deleted file mode 100644 index 1b98443..0000000 --- a/.claude/skills/new-setup-2/SKILL.md +++ /dev/null @@ -1,155 +0,0 @@ ---- -name: new-setup-2 -description: Follow-on to /new-setup. Captures the operator and agent names, wires a real messaging channel, and adds quality-of-life extras. Linear rollthrough; every step is skippable. Invoked when the user picks "continue setup" at the end of /new-setup. -allowed-tools: Bash(bash setup/probe.sh) Bash(bash setup/install-telegram.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm exec tsx scripts/init-first-agent.ts:*) Bash(tail:*) Bash(head:*) Bash(grep:*) ---- - -# NanoClaw phase-2 setup - -Runs after `/new-setup`. At this point the host is running and a throwaway CLI-only agent exists (used during /new-setup for the end-to-end ping check — inferred name, not user-facing). This flow creates the **real** agent and wires it to a messaging channel. - -**Linear — one step at a time.** Every step is skippable: if the user says "skip", "not now", "later", or similar, move on without complaint. If they say they're done at any point, stop cleanly — don't push the remaining steps. - -Before each step, narrate in your own words what's about to happen — one short, friendly sentence, no jargon. Match the tone of `/new-setup`. - -## Current state - -!`bash setup/probe.sh` - -Parse the probe block above for `INFERRED_DISPLAY_NAME` and `PLATFORM` — referenced below. - -## Steps - -### 1. What should the agent call you? - -Plain-prose ask (do **not** use `AskUserQuestion`): - -> What should your agent call you? (Default: ``) - -Capture the answer into a local variable `OPERATOR_NAME`. **Don't persist yet** — this value is consumed by step 3's channel wiring. If the user skips or confirms the default, set `OPERATOR_NAME = INFERRED_DISPLAY_NAME`. - -### 2. What's your agent's name? - -Plain-prose ask: - -> What would you like to call your agent? (Default: ``) - -Capture as `AGENT_NAME`. If skipped, set `AGENT_NAME = OPERATOR_NAME`. Nothing persisted yet. - -### 3. Timezone - -Run `pnpm exec tsx setup/index.ts --step timezone` and parse the status block. - -- **RESOLVED_TZ is `UTC` or `Etc/UTC`** — before leaving UTC in `.env`, confirm with `AskUserQuestion`: - - - **Question**: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?" - - **Header**: "Timezone" - - **Options**: - 1. `Keep UTC` — "Leave timezone as UTC." - 2. `I'm somewhere else` — "I'll name the IANA zone (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`) via Other." - - If they pick "I'm somewhere else" (or type an IANA zone via Other), re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` to overwrite `.env`. If they keep UTC or skip, leave UTC in place. - -- **NEEDS_USER_INPUT=true** — autodetection failed. Use `AskUserQuestion` with the same two options above (reword the question to "Autodetection failed — what timezone are you in?"), then re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` if they supply one. If they skip, move on. - -- Otherwise — timezone is already set; move on. - -### 4. Pick a messaging channel - -Print the list as a numbered plain-prose list (too many options for `AskUserQuestion`, which caps at 4). The user replies with a number or channel name. Preserve the numbering exactly: - -> Which messaging channel should I wire your agent to? -> -> 1. **WhatsApp (native)** — `/add-whatsapp` -> 2. **WhatsApp Cloud (Meta official)** — `/add-whatsapp-cloud` -> 3. **Telegram** — `/add-telegram` -> 4. **Slack** — `/add-slack` -> 5. **Discord** — `/add-discord` -> 6. **iMessage** — `/add-imessage` -> 7. **Teams** — `/add-teams` -> 8. **Matrix** — `/add-matrix` -> 9. **Google Chat** — `/add-gchat` -> 10. **Linear** — `/add-linear` -> 11. **GitHub** — `/add-github` -> 12. **Webex** — `/add-webex` -> 13. **Resend (email)** — `/add-resend` -> 14. **Emacs** — `/add-emacs` -> -> Or say "skip" to leave this for later. - -When the user picks one: - -1. **Install the adapter.** For **Telegram**, run `bash setup/install-telegram.sh` directly — it bundles the preflight + fetch + copy + register + `pnpm install` + build from `/add-telegram` into one idempotent call. Then handle Telegram credentials inline (below) — **do not** invoke `/add-telegram` afterward; its Credentials section would generate an unapprovable `grep && sed && rm` to write `.env`. For every other channel, invoke the matching `/add-` skill via the Skill tool; it copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels also run a pairing step as part of their flow. - - **Telegram credentials (inline):** - - Walk the user through BotFather: `/newbot` → pick name + username ending in `bot` → copy the token. - - Remind them: in `@BotFather` → `/mybots` → their bot → Bot Settings → Group Privacy → **Turn off** (only needed if the bot will live in groups; DM-only can skip). - - Persist the token and sync it to the container mount with the generic setter: - - ``` - pnpm exec tsx setup/index.ts --step set-env -- \ - --key TELEGRAM_BOT_TOKEN --value "" --sync-container - ``` - -2. **Capture platform IDs.** After the `/add-` skill finishes (or after inline credentials for Telegram), you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent ` directly and follow its `PAIR_TELEGRAM_ISSUED`/`PAIR_TELEGRAM STATUS=success` blocks — `PLATFORM_ID` and `ADMIN_USER_ID` land in the success block. -3. **Wire the agent.** Run `init-first-agent.ts` in DM mode with `--no-cli-bonus` (this keeps the new agent off the CLI messaging group so the pre-existing throwaway agent still owns CLI routing cleanly): - - ``` - pnpm exec tsx scripts/init-first-agent.ts \ - --channel \ - --user-id "" \ - --platform-id "" \ - --display-name "" \ - --agent-name "" \ - --no-cli-bonus - ``` - -4. **Announce.** On success, emit the encouragement line verbatim: - - > Your agent is now available on {channel-name}, you can already start chatting — But I encourage you to continue and finish this setup, we're almost done! - - Substitute `{channel-name}` with the friendly name (e.g. "Telegram", "WhatsApp", "Slack"). - -If the user skipped, move on to step 5. - -### 5. Host directory access - -By default, agent containers can only touch their own workspace. If the user wants the agent to read or write files in specific host directories, those paths need to go on the mount allowlist. - -Use `AskUserQuestion`: - -- **Question**: "Want your agent to read or write files in any host directories (e.g. a code project, `~/Documents`)?" -- **Header**: "Host mounts" -- **Options**: - 1. `Keep isolated` — "Agent only touches its own workspace (Recommended)." - 2. `Add host paths` — "I'll name the directories to allowlist via Other." - -If they pick "Add host paths" (or name paths via Other), invoke `/manage-mounts` via the Skill tool to add them. If they keep it isolated or skip, move on. - -### 6. Quality of life - -Optional polish. Print the list; the user may pick zero, one, or several — invoke each chosen skill in sequence: - -> Want to add any of these? Pick any that sound useful — or skip: -> -> - `/add-dashboard` — browser dashboard showing agent activity -> - `/add-compact` — `/compact` slash command for managing long sessions -> - `/add-karpathy-llm-wiki` — persistent knowledge-base memory for the agent - -If the probe reports `PLATFORM=darwin`, also offer: - -> - `/add-macos-statusbar` — macOS menu bar indicator with Start/Stop/Restart controls - -Do **not** list `/add-macos-statusbar` on Linux. If the user skips everything, just move on. - -### 7. Done - -Short wrap-up: - -> Setup complete. You can chat with your agent on {channel-name} — or via CLI with `pnpm run chat `. - -Substitute `{channel-name}` with whatever was wired in step 4. If step 4 was skipped, drop the "on {channel-name} — or" clause entirely so the line just mentions the CLI form. - -## If anything fails - -Same rule as `/new-setup`: don't bypass errors to keep moving. Read `logs/setup.log` or `logs/nanoclaw.log`, diagnose, fix the underlying cause, re-run the failed step. diff --git a/.claude/skills/new-setup/SKILL.md b/.claude/skills/new-setup/SKILL.md index 02cef98..ef88c75 100644 --- a/.claude/skills/new-setup/SKILL.md +++ b/.claude/skills/new-setup/SKILL.md @@ -1,14 +1,17 @@ --- name: new-setup -description: Shortest path from zero to a working two-way agent chat, for any user regardless of technical background — ends at a running NanoClaw instance with at least one CLI-reachable agent. -allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(bash setup/install-node.sh) Bash(bash setup/install-docker.sh) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm run chat) Bash(pnpm run chat:*) Bash(open -a Docker) Bash(sudo systemctl start docker) Bash(node --version) Bash(tail:*) Bash(head:*) Bash(grep:*) +description: End-to-end NanoClaw setup for any user regardless of technical background — from zero to a named agent reachable on a real messaging channel, with sensible defaults and every post-verification step skippable. +allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(bash setup/install-node.sh) Bash(bash setup/install-docker.sh) Bash(bash setup/install-telegram.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm exec tsx scripts/init-first-agent.ts:*) Bash(pnpm run chat) Bash(pnpm run chat:*) Bash(open -a Docker) Bash(sudo systemctl start docker) Bash(node --version) Bash(tail:*) Bash(head:*) Bash(grep:*) --- -# NanoClaw bare-minimum setup +# NanoClaw setup -Purpose of this skill is to take any user — technical or not — from zero to a two-way chat with an agent in the fewest steps possible. Done means a running NanoClaw instance with at least one agent reachable via the CLI channel. +Purpose of this skill is to take any user — technical or not — from zero to a named agent wired to a real messaging channel in the fewest steps possible. -Only run the steps strictly required for the NanoClaw process to start and respond to the user end-to-end. Everything else is deferred to post-setup skills. +The flow has two halves: + +- **Steps 1–6 — required.** Prerequisites, credential, service start, end-to-end ping. These run straight through. +- **Steps 7–12 — skippable.** Naming, channel wiring, QoL. Every step here is skippable: if the user says "skip", "not now", "later", or similar, move on without complaint. If they say they're done at any point, stop cleanly — don't push the remaining steps. Before each step, narrate to the user in your own words what's about to happen — one short, friendly sentence, no jargon. Don't read a scripted line; use the step context below to speak naturally. @@ -109,13 +112,13 @@ Start the NanoClaw background service — it relays messages between the user an `pnpm exec tsx setup/index.ts --step service` -### 6. Wire the CLI agent and verify end-to-end +### 6. Wire a scratch CLI agent and verify end-to-end **Do not narrate this step.** No "creating your first agent…", no "sending a ping…" chatter. The user's experience here is: they finished the last visible step (service), then a single success line appears. Wiring is an implementation detail at this point, not a user-facing milestone. If step 2's container build is still running in the background, join it here before proceeding — the agent needs the image. -Use `INFERRED_DISPLAY_NAME` from the probe silently. **Do not ask the user.** The CLI agent at this stage is a scratch agent whose only purpose is to verify the end-to-end pipeline (host → container → agent → back). The user's real name capture happens in `/new-setup-2` when they wire a messaging channel. +Use `INFERRED_DISPLAY_NAME` from the probe silently. **Do not ask the user.** The CLI agent at this stage is a scratch agent whose only purpose is to verify the end-to-end pipeline (host → container → agent → back). The user's real name capture happens in step 7. Run wiring and ping back-to-back, silently: @@ -126,35 +129,141 @@ pnpm run chat ping First container spin-up takes ~60s. When the agent's reply arrives, emit exactly one line to the user: -> Your agent is up, running and ready to go! +> Test Agent success, proceeding with setup If `pnpm run chat ping` times out or errors, tail `logs/nanoclaw.log`, diagnose, and fix — don't surface a half-success. > **Loose command:** `pnpm run chat ping`. Justification: this is the same command the user will keep using after setup, so verification and the real channel are one and the same. -### 7. Chat now, or keep setting up? +### 7. What should the agent call you? -Ask the user via `AskUserQuestion` which they'd like to do next: +Plain-prose ask (do **not** use `AskUserQuestion`): -1. **Keep chatting with the agent via CLI** — happy with the CLI channel for now. -2. **Continue setup** — name the agent, wire a messaging channel, add quality-of-life extras. +> What should your agent call you? (Default: ``) -**If they pick "keep chatting":** print both options below, then stop. The user is chatting with the agent now, not with you — no further output from you. +Capture the answer into a local variable `OPERATOR_NAME`. **Don't persist yet** — this value is consumed by step 10's channel wiring. If the user skips or confirms the default, set `OPERATOR_NAME = INFERRED_DISPLAY_NAME`. -**Option 1 — from inside this Claude Code session.** Type your message with a leading `!`, which runs it as a bash command in this shell: +### 8. What's your agent's name? -``` -!pnpm run chat your message here -``` +Plain-prose ask: -**Option 2 — from a separate terminal.** Open a new terminal, `cd` into your nanoclaw checkout, then: +> What would you like to call your agent? (Default: ``) -``` -pnpm run chat your message here -``` +Capture as `AGENT_NAME`. If skipped, set `AGENT_NAME = OPERATOR_NAME`. Nothing persisted yet. -**If they pick "continue setup":** hand off directly to `/new-setup-2` via the Skill tool. That follow-on flow is structured like this one (linear, skippable steps) and covers naming, messaging-channel wiring, and QoL. Invoke it immediately — do not offer a menu of individual skills. +### 9. Timezone + +Run `pnpm exec tsx setup/index.ts --step timezone` and parse the status block. + +- **RESOLVED_TZ is `UTC` or `Etc/UTC`** — before leaving UTC in `.env`, confirm with `AskUserQuestion`: + + - **Question**: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?" + - **Header**: "Timezone" + - **Options**: + 1. `Keep UTC` — "Leave timezone as UTC." + 2. `I'm somewhere else` — "I'll name the IANA zone (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`) via Other." + + If they pick "I'm somewhere else" (or type an IANA zone via Other), re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` to overwrite `.env`. If they keep UTC or skip, leave UTC in place. + +- **NEEDS_USER_INPUT=true** — autodetection failed. Use `AskUserQuestion` with the same two options above (reword the question to "Autodetection failed — what timezone are you in?"), then re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` if they supply one. If they skip, move on. + +- Otherwise — timezone is already set; move on. + +### 10. Pick a messaging channel + +Print the list as a numbered plain-prose list (too many options for `AskUserQuestion`, which caps at 4). The user replies with a number or channel name. Preserve the numbering exactly: + +> Which messaging channel should I wire your agent to? +> +> 1. **WhatsApp (native)** — `/add-whatsapp` +> 2. **WhatsApp Cloud (Meta official)** — `/add-whatsapp-cloud` +> 3. **Telegram** — `/add-telegram` +> 4. **Slack** — `/add-slack` +> 5. **Discord** — `/add-discord` +> 6. **iMessage** — `/add-imessage` +> 7. **Teams** — `/add-teams` +> 8. **Matrix** — `/add-matrix` +> 9. **Google Chat** — `/add-gchat` +> 10. **Linear** — `/add-linear` +> 11. **GitHub** — `/add-github` +> 12. **Webex** — `/add-webex` +> 13. **Resend (email)** — `/add-resend` +> 14. **Emacs** — `/add-emacs` +> +> Or say "skip" to leave this for later. + +When the user picks one: + +1. **Install the adapter.** For **Telegram**, run `bash setup/install-telegram.sh` directly — it bundles the preflight + fetch + copy + register + `pnpm install` + build from `/add-telegram` into one idempotent call. Then handle Telegram credentials inline (below) — **do not** invoke `/add-telegram` afterward; its Credentials section would generate an unapprovable `grep && sed && rm` to write `.env`. For every other channel, invoke the matching `/add-` skill via the Skill tool; it copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels also run a pairing step as part of their flow. + + **Telegram credentials (inline):** + - Walk the user through BotFather: `/newbot` → pick name + username ending in `bot` → copy the token. + - Remind them: in `@BotFather` → `/mybots` → their bot → Bot Settings → Group Privacy → **Turn off** (only needed if the bot will live in groups; DM-only can skip). + - Persist the token and sync it to the container mount with the generic setter: + + ``` + pnpm exec tsx setup/index.ts --step set-env -- \ + --key TELEGRAM_BOT_TOKEN --value "" --sync-container + ``` + +2. **Capture platform IDs.** After the `/add-` skill finishes (or after inline credentials for Telegram), you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent ` directly and follow its `PAIR_TELEGRAM_ISSUED`/`PAIR_TELEGRAM STATUS=success` blocks — `PLATFORM_ID` and `ADMIN_USER_ID` land in the success block. +3. **Wire the agent.** Run `init-first-agent.ts` in DM mode: + + ``` + pnpm exec tsx scripts/init-first-agent.ts \ + --channel \ + --user-id "" \ + --platform-id "" \ + --display-name "" \ + --agent-name "" + ``` + +4. **Announce.** On success, emit the encouragement line verbatim: + + > Your agent is now available on {channel-name}, you can already start chatting — But I encourage you to continue and finish this setup, we're almost done! + + Substitute `{channel-name}` with the friendly name (e.g. "Telegram", "WhatsApp", "Slack"). + +If the user skipped, move on to step 11. + +### 11. Host directory access + +By default, agent containers can only touch their own workspace. If the user wants the agent to read or write files in specific host directories, those paths need to go on the mount allowlist. + +Use `AskUserQuestion`: + +- **Question**: "Want your agent to read or write files in any host directories (e.g. a code project, `~/Documents`)?" +- **Header**: "Host mounts" +- **Options**: + 1. `Keep isolated` — "Agent only touches its own workspace (Recommended)." + 2. `Add host paths` — "I'll name the directories to allowlist via Other." + +If they pick "Add host paths" (or name paths via Other), invoke `/manage-mounts` via the Skill tool to add them. If they keep it isolated or skip, move on. + +### 12. Quality of life + +Optional polish. Print the list; the user may pick zero, one, or several — invoke each chosen skill in sequence: + +> Want to add any of these? Pick any that sound useful — or skip: +> +> - `/add-dashboard` — browser dashboard showing agent activity +> - `/add-compact` — `/compact` slash command for managing long sessions +> - `/add-karpathy-llm-wiki` — persistent knowledge-base memory for the agent + +If the probe reports `PLATFORM=darwin`, also offer: + +> - `/add-macos-statusbar` — macOS menu bar indicator with Start/Stop/Restart controls + +Do **not** list `/add-macos-statusbar` on Linux. If the user skips everything, just move on. + +### 13. Done + +Short wrap-up: + +> Setup complete. You can chat with your agent on {channel-name} — or via CLI with `pnpm run chat `. + +Substitute `{channel-name}` with whatever was wired in step 10. If step 10 was skipped, drop the "on {channel-name} — or" clause entirely so the line just mentions the CLI form. ## If anything fails -Any step that reports `STATUS: failed` in its status block: read `logs/setup.log`, diagnose, fix the underlying cause, re-run the same `--step`. Don't bypass errors to keep moving. +Any step that reports `STATUS: failed` in its status block: read `logs/setup.log` (or `logs/nanoclaw.log` for runtime failures), diagnose, fix the underlying cause, re-run the same `--step`. Don't bypass errors to keep moving. diff --git a/setup/install-discord.sh b/setup/install-discord.sh index ee221f9..6f5a9c8 100755 --- a/setup/install-discord.sh +++ b/setup/install-discord.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-discord — bundles the preflight + install commands -# from the /add-discord skill into one idempotent script so /new-setup-2 can +# from the /add-discord skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Discord adapter in from the `channels` branch; appends the diff --git a/setup/install-gchat.sh b/setup/install-gchat.sh index f5c210b..b9166f1 100755 --- a/setup/install-gchat.sh +++ b/setup/install-gchat.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-gchat — bundles the preflight + install commands -# from the /add-gchat skill into one idempotent script so /new-setup-2 can +# from the /add-gchat skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Google Chat adapter in from the `channels` branch; appends the diff --git a/setup/install-github.sh b/setup/install-github.sh index 81c2977..cb28bfc 100755 --- a/setup/install-github.sh +++ b/setup/install-github.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-github — bundles the preflight + install commands -# from the /add-github skill into one idempotent script so /new-setup-2 can +# from the /add-github skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the GitHub adapter in from the `channels` branch; appends the diff --git a/setup/install-imessage.sh b/setup/install-imessage.sh index 0b1df34..864e127 100755 --- a/setup/install-imessage.sh +++ b/setup/install-imessage.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-imessage — bundles the preflight + install commands -# from the /add-imessage skill into one idempotent script so /new-setup-2 can +# from the /add-imessage skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the iMessage adapter in from the `channels` branch; appends the diff --git a/setup/install-linear.sh b/setup/install-linear.sh index 9f42bec..f8788be 100755 --- a/setup/install-linear.sh +++ b/setup/install-linear.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-linear — bundles the preflight + install commands -# from the /add-linear skill into one idempotent script so /new-setup-2 can +# from the /add-linear skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Linear adapter in from the `channels` branch; appends the diff --git a/setup/install-matrix.sh b/setup/install-matrix.sh index 06d5ccd..c985473 100755 --- a/setup/install-matrix.sh +++ b/setup/install-matrix.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-matrix — bundles the preflight + install commands -# from the /add-matrix skill into one idempotent script so /new-setup-2 can +# from the /add-matrix skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Matrix adapter in from the `channels` branch; appends the diff --git a/setup/install-resend.sh b/setup/install-resend.sh index 4f0bb2e..9f18a9f 100755 --- a/setup/install-resend.sh +++ b/setup/install-resend.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-resend — bundles the preflight + install commands -# from the /add-resend skill into one idempotent script so /new-setup-2 can +# from the /add-resend skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Resend adapter in from the `channels` branch; appends the diff --git a/setup/install-slack.sh b/setup/install-slack.sh index 8be6a37..55d5e85 100755 --- a/setup/install-slack.sh +++ b/setup/install-slack.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-slack — bundles the preflight + install commands -# from the /add-slack skill into one idempotent script so /new-setup-2 can +# from the /add-slack skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Slack adapter in from the `channels` branch; appends the diff --git a/setup/install-teams.sh b/setup/install-teams.sh index cb66f67..4b8c216 100755 --- a/setup/install-teams.sh +++ b/setup/install-teams.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-teams — bundles the preflight + install commands -# from the /add-teams skill into one idempotent script so /new-setup-2 can +# from the /add-teams skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Teams adapter in from the `channels` branch; appends the diff --git a/setup/install-telegram.sh b/setup/install-telegram.sh index 7eaf9e1..307dba2 100755 --- a/setup/install-telegram.sh +++ b/setup/install-telegram.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-telegram — bundles the preflight + install commands -# from the /add-telegram skill into one idempotent script so /new-setup-2 can +# from the /add-telegram skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials and pairing. # # Copies the Telegram adapter, helpers, tests, and the pair-telegram setup diff --git a/setup/install-webex.sh b/setup/install-webex.sh index 8bbbc83..adf52fc 100755 --- a/setup/install-webex.sh +++ b/setup/install-webex.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-webex — bundles the preflight + install commands -# from the /add-webex skill into one idempotent script so /new-setup-2 can +# from the /add-webex skill into one idempotent script so /new-setup can # run them programmatically before continuing to credentials. # # Copies the Webex adapter in from the `channels` branch; appends the diff --git a/setup/install-whatsapp-cloud.sh b/setup/install-whatsapp-cloud.sh index 3773278..70e8e02 100755 --- a/setup/install-whatsapp-cloud.sh +++ b/setup/install-whatsapp-cloud.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Setup helper: install-whatsapp-cloud — bundles the preflight + install # commands from the /add-whatsapp-cloud skill into one idempotent script so -# /new-setup-2 can run them programmatically before continuing to credentials. +# /new-setup can run them programmatically before continuing to credentials. # # Copies the WhatsApp Cloud adapter in from the `channels` branch; appends the # self-registration import; installs the pinned @chat-adapter/whatsapp package; diff --git a/setup/install-whatsapp.sh b/setup/install-whatsapp.sh index 0d307f5..1c62d65 100755 --- a/setup/install-whatsapp.sh +++ b/setup/install-whatsapp.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Setup helper: install-whatsapp — bundles the preflight + install commands -# from the /add-whatsapp skill into one idempotent script so /new-setup-2 can +# from the /add-whatsapp skill into one idempotent script so /new-setup can # run them programmatically before continuing to QR/pairing-code auth. # # Copies the native Baileys WhatsApp adapter, its whatsapp-auth and groups From c9977d6b696ce2f0ee2b2bab45d301cfdfe626ed Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 15:27:12 +0300 Subject: [PATCH 057/185] chore(settings): drop permissions allowlist from checked-in settings.json Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/settings.json | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 9d91475..c4beb6f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,41 +1,5 @@ { "sandbox": { "enabled": false - }, - "permissions": { - "allow": [ - "Bash(bash setup.sh*)", - "Bash(git remote *)", - "Bash(pnpm exec tsx setup/index.ts*)", - "Bash(pnpm exec tsx scripts/init-first-agent.ts*)", - "Bash(pnpm install @chat-adapter/*)", - "Bash(pnpm install chat-adapter-imessage*)", - "Bash(pnpm install @bitbasti/chat-adapter-webex*)", - "Bash(pnpm install @resend/chat-sdk-adapter*)", - "Bash(pnpm install @whiskeysockets/baileys*)", - "Bash(pnpm install @beeper/chat-adapter-matrix*)", - "Bash(pnpm install @nanoco/nanoclaw-dashboard*)", - "Bash(pnpm install --frozen-lockfile*)", - "Bash(pnpm run build*)", - "Bash(curl -fsSL onecli.sh*)", - "Bash(onecli *)", - "Bash(grep -q *)", - "Bash(echo *>> .env)", - "Bash(ls *)", - "Bash(cat ~/.config/nanoclaw/*)", - "Bash(tail *logs/*)", - "Bash(launchctl *nanoclaw*)", - "Bash(sqlite3 data/*)", - "Bash(docker info*)", - "Bash(docker logs *)", - "Bash(mkdir -p *)", - "Bash(cp .env *)", - "Bash(rsync -a .claude/skills/*)", - "Bash(head *)", - "Bash(xattr *)", - "Bash(find ~/.npm *)", - "Bash(which onecli*)", - "Bash(./container/build.sh*)" - ] } } From 91c668e0cc2795fd63351059af7594c363014568 Mon Sep 17 00:00:00 2001 From: Dave Kim Date: Tue, 21 Apr 2026 13:04:57 +0000 Subject: [PATCH 058/185] fix: persist SDK session_id on init + split long messages before adapter truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs that surfaced together when a Discord response exceeded 2000 chars: 1. **Session id lost on mid-turn container exit.** `runPollLoop` was calling `setStoredSessionId` only after `processQuery` returned. If the container died between the SDK's `init` event (where session_id arrives) and the stream completing, the id was never persisted. The next wake called `getStoredSessionId()` → undefined and started a fresh Claude session, dropping all prior context. Fix: persist immediately in the `init` branch inside `processQuery`. The existing post-query store becomes a harmless no-op. 2. **Silent truncation past adapter limits.** `chat-sdk-bridge.deliver` handed full text straight to `adapter.postMessage`. Discord's adapter hard-truncates at 2000 chars; Telegram's at 4096. Responses longer than that were cut off without any signal to the user or host. Fix: add `maxTextLength` to `ChatSdkBridgeConfig` and a `splitForLimit` helper that breaks on paragraph → line → hard-char boundaries, then posts chunks sequentially. Files ride on the first chunk; the returned id is the first chunk's so edits and reactions still target the reply head. Channel adapter files (Discord, Telegram, …) live on the `channels` branch — a companion PR wires `maxTextLength: 1900` for Discord and `4000` for Telegram so the splitter actually engages in those installs. Without wiring, behavior is unchanged. --- container/agent-runner/src/poll-loop.ts | 7 ++++ src/channels/chat-sdk-bridge.test.ts | 30 +++++++++++++- src/channels/chat-sdk-bridge.ts | 54 ++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 3f0e364..119b1d4 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -322,6 +322,13 @@ async function processQuery(query: AgentQuery, routing: RoutingContext): Promise if (event.type === 'init') { queryContinuation = event.continuation; + // Persist immediately so a mid-turn container crash still lets the + // next wake resume the conversation. Without this, the session id + // was only written after the full stream completed — if the + // container died between `init` and `result`, the SDK session was + // effectively orphaned and the next message started a blank + // Claude session with no prior context. + setStoredSessionId(event.continuation); } else if (event.type === 'result' && event.text) { dispatchResultText(event.text, routing); } diff --git a/src/channels/chat-sdk-bridge.test.ts b/src/channels/chat-sdk-bridge.test.ts index 7ddad4f..7e3c4ff 100644 --- a/src/channels/chat-sdk-bridge.test.ts +++ b/src/channels/chat-sdk-bridge.test.ts @@ -2,12 +2,40 @@ import { describe, expect, it } from 'vitest'; import type { Adapter } from 'chat'; -import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { createChatSdkBridge, splitForLimit } from './chat-sdk-bridge.js'; function stubAdapter(partial: Partial): Adapter { return { name: 'stub', ...partial } as unknown as Adapter; } +describe('splitForLimit', () => { + it('returns a single chunk when text fits', () => { + expect(splitForLimit('short text', 100)).toEqual(['short text']); + }); + + it('splits on paragraph boundaries when available', () => { + const text = 'para one line one\npara one line two\n\npara two line one\npara two line two'; + const chunks = splitForLimit(text, 40); + expect(chunks.length).toBeGreaterThan(1); + for (const c of chunks) expect(c.length).toBeLessThanOrEqual(40); + }); + + it('falls back to line boundaries when no paragraph fits', () => { + const text = 'alpha\nbravo\ncharlie\ndelta\necho\nfoxtrot'; + const chunks = splitForLimit(text, 15); + expect(chunks.length).toBeGreaterThan(1); + for (const c of chunks) expect(c.length).toBeLessThanOrEqual(15); + }); + + it('hard-cuts when no whitespace is available', () => { + const text = 'a'.repeat(100); + const chunks = splitForLimit(text, 30); + expect(chunks.length).toBe(Math.ceil(100 / 30)); + for (const c of chunks) expect(c.length).toBeLessThanOrEqual(30); + expect(chunks.join('')).toBe(text); + }); +}); + describe('createChatSdkBridge', () => { // The bridge is now transport-only: forward inbound events, relay outbound // ops. All per-wiring engage / accumulate / drop / subscribe decisions live diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index ef2195e..5c120e0 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -63,6 +63,38 @@ export interface ChatSdkBridgeConfig { * quirk (e.g. Telegram's legacy Markdown parse mode). */ transformOutboundText?: (text: string) => string; + /** + * Maximum text length the underlying adapter accepts in a single message. + * When set, the bridge splits outbound text longer than this on paragraph + * → line → hard-char boundaries and posts multiple messages. Without this, + * adapters like Discord (2000) and Telegram (4096) silently truncate + * mid-response. The returned id is the first chunk's id so subsequent edits + * and reactions still target the head of the reply. + */ + maxTextLength?: number; +} + +/** + * Split `text` into chunks no larger than `limit`, preferring paragraph + * breaks, then line breaks, then a hard character cut as a last resort. + * Preserves code fences only structurally — a fenced block that straddles a + * chunk boundary will render as two independent blocks on the receiving + * platform, which is the same behavior as manually re-opening a fence. + */ +export function splitForLimit(text: string, limit: number): string[] { + if (text.length <= limit) return [text]; + const chunks: string[] = []; + let remaining = text; + while (remaining.length > limit) { + let cut = remaining.lastIndexOf('\n\n', limit); + if (cut <= 0) cut = remaining.lastIndexOf('\n', limit); + if (cut <= 0) cut = remaining.lastIndexOf(' ', limit); + if (cut <= 0) cut = limit; + chunks.push(remaining.slice(0, cut).trimEnd()); + remaining = remaining.slice(cut).trimStart(); + } + if (remaining.length > 0) chunks.push(remaining); + return chunks; } export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter { @@ -338,13 +370,23 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter data: f.data, filename: f.filename, })); - if (fileUploads && fileUploads.length > 0) { - const result = await adapter.postMessage(tid, { markdown: text, files: fileUploads }); - return result?.id; - } else { - const result = await adapter.postMessage(tid, { markdown: text }); - return result?.id; + // Split if over the adapter's max length. Files ride on the first + // chunk so the head of the reply still carries them. + const chunks = + config.maxTextLength && text.length > config.maxTextLength + ? splitForLimit(text, config.maxTextLength) + : [text]; + let firstId: string | undefined; + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const attachFiles = i === 0 && fileUploads && fileUploads.length > 0; + const result = await adapter.postMessage( + tid, + attachFiles ? { markdown: chunk, files: fileUploads } : { markdown: chunk }, + ); + if (i === 0) firstId = result?.id; } + return firstId; } else if (message.files && message.files.length > 0) { // Files only, no text const fileUploads = message.files.map((f: { data: Buffer; filename: string }) => ({ From 010722803f6523f2f8a89b9c0d2ab503448409cb Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 17:02:22 +0300 Subject: [PATCH 059/185] refactor(setup): drop Apple Container support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple Container is no longer supported — the runtime abstraction in src/container-runtime.ts is already Docker-only. Remove the remaining setup-time branches that probed for it: the Apple Container runtime option in the container build step, the APPLE_CONTAINER field emitted by the environment check, and the `command -v container` probe in verify. `--runtime docker` still parses for backwards compatibility with the /setup skill. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/container.ts | 95 ++++++++++++++++---------------------------- setup/environment.ts | 8 ---- setup/verify.ts | 11 ++--- 3 files changed, 37 insertions(+), 77 deletions(-) diff --git a/setup/container.ts b/setup/container.ts index d810539..3e48ecf 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -10,7 +10,9 @@ import { commandExists } from './platform.js'; import { emitStatus } from './status.js'; function parseArgs(args: string[]): { runtime: string } { - let runtime = ''; + // `--runtime` is still accepted for backwards compatibility with the /setup + // skill, but `docker` is the only supported value. + let runtime = 'docker'; for (let i = 0; i < args.length; i++) { if (args[i] === '--runtime' && args[i + 1]) { runtime = args[i + 1]; @@ -26,63 +28,7 @@ export async function run(args: string[]): Promise { const image = 'nanoclaw-agent:latest'; const logFile = path.join(projectRoot, 'logs', 'setup.log'); - if (!runtime) { - emitStatus('SETUP_CONTAINER', { - RUNTIME: 'unknown', - IMAGE: image, - BUILD_OK: false, - TEST_OK: false, - STATUS: 'failed', - ERROR: 'missing_runtime_flag', - LOG: 'logs/setup.log', - }); - process.exit(4); - } - - // Validate runtime availability - if (runtime === 'apple-container' && !commandExists('container')) { - emitStatus('SETUP_CONTAINER', { - RUNTIME: runtime, - IMAGE: image, - BUILD_OK: false, - TEST_OK: false, - STATUS: 'failed', - ERROR: 'runtime_not_available', - LOG: 'logs/setup.log', - }); - process.exit(2); - } - - if (runtime === 'docker') { - if (!commandExists('docker')) { - emitStatus('SETUP_CONTAINER', { - RUNTIME: runtime, - IMAGE: image, - BUILD_OK: false, - TEST_OK: false, - STATUS: 'failed', - ERROR: 'runtime_not_available', - LOG: 'logs/setup.log', - }); - process.exit(2); - } - try { - execSync('docker info', { stdio: 'ignore' }); - } catch { - emitStatus('SETUP_CONTAINER', { - RUNTIME: runtime, - IMAGE: image, - BUILD_OK: false, - TEST_OK: false, - STATUS: 'failed', - ERROR: 'runtime_not_available', - LOG: 'logs/setup.log', - }); - process.exit(2); - } - } - - if (!['apple-container', 'docker'].includes(runtime)) { + if (runtime !== 'docker') { emitStatus('SETUP_CONTAINER', { RUNTIME: runtime, IMAGE: image, @@ -95,9 +41,36 @@ export async function run(args: string[]): Promise { process.exit(4); } - const buildCmd = - runtime === 'apple-container' ? 'container build' : 'docker build'; - const runCmd = runtime === 'apple-container' ? 'container' : 'docker'; + if (!commandExists('docker')) { + emitStatus('SETUP_CONTAINER', { + RUNTIME: runtime, + IMAGE: image, + BUILD_OK: false, + TEST_OK: false, + STATUS: 'failed', + ERROR: 'runtime_not_available', + LOG: 'logs/setup.log', + }); + process.exit(2); + } + + try { + execSync('docker info', { stdio: 'ignore' }); + } catch { + emitStatus('SETUP_CONTAINER', { + RUNTIME: runtime, + IMAGE: image, + BUILD_OK: false, + TEST_OK: false, + STATUS: 'failed', + ERROR: 'runtime_not_available', + LOG: 'logs/setup.log', + }); + process.exit(2); + } + + const buildCmd = 'docker build'; + const runCmd = 'docker'; // Build-args from .env. Only INSTALL_CJK_FONTS is passed through today. // Keeps /setup and ./container/build.sh in sync — both read the same source. diff --git a/setup/environment.ts b/setup/environment.ts index 27de9f4..4a83665 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -21,12 +21,6 @@ export async function run(_args: string[]): Promise { const wsl = isWSL(); const headless = isHeadless(); - // Check Apple Container - let appleContainer: 'installed' | 'not_found' = 'not_found'; - if (commandExists('container')) { - appleContainer = 'installed'; - } - // Check Docker let docker: 'running' | 'installed_not_running' | 'not_found' = 'not_found'; if (commandExists('docker')) { @@ -78,7 +72,6 @@ export async function run(_args: string[]): Promise { { platform, wsl, - appleContainer, docker, hasEnv, hasAuth, @@ -91,7 +84,6 @@ export async function run(_args: string[]): Promise { PLATFORM: platform, IS_WSL: wsl, IS_HEADLESS: headless, - APPLE_CONTAINER: appleContainer, DOCKER: docker, HAS_ENV: hasEnv, HAS_AUTH: hasAuth, diff --git a/setup/verify.ts b/setup/verify.ts index 566cc9b..6dd6a44 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -85,15 +85,10 @@ export async function run(_args: string[]): Promise { // 2. Check container runtime let containerRuntime = 'none'; try { - execSync('command -v container', { stdio: 'ignore' }); - containerRuntime = 'apple-container'; + execSync('docker info', { stdio: 'ignore' }); + containerRuntime = 'docker'; } catch { - try { - execSync('docker info', { stdio: 'ignore' }); - containerRuntime = 'docker'; - } catch { - // No runtime - } + // Docker not running } // 3. Check credentials From 2311721375bd3e6f880ab688f588c8f2a328a562 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 17:04:48 +0300 Subject: [PATCH 060/185] feat(setup): add scripted setup driver and auto-start Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm run setup:auto` chains the deterministic setup steps (environment → timezone → container → mounts → service → verify) by spawning the existing per-step CLI and parsing its status blocks. Config via env: NANOCLAW_TZ, NANOCLAW_SKIP. Credentials + channel install + /manage-channels stay interactive — verify reports what's left and exits 0 rather than failing the driver. Also have the container step try to start Docker when it's installed but not running (open -a Docker on macOS, sudo systemctl start docker on Linux) and poll `docker info` for up to 60s before giving up. Both /setup and setup:auto pick this up automatically. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 1 + setup/auto.ts | 164 +++++++++++++++++++++++++++++++++++++++++++++ setup/container.ts | 72 ++++++++++++++++---- 3 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 setup/auto.ts diff --git a/package.json b/package.json index e2af027..a7f8804 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "format:check": "prettier --check \"src/**/*.ts\"", "prepare": "husky", "setup": "tsx setup/index.ts", + "setup:auto": "tsx setup/auto.ts", "chat": "tsx scripts/chat.ts", "auth": "tsx src/whatsapp-auth.ts", "lint": "eslint src/", diff --git a/setup/auto.ts b/setup/auto.ts new file mode 100644 index 0000000..0cbac93 --- /dev/null +++ b/setup/auto.ts @@ -0,0 +1,164 @@ +/** + * Non-interactive setup driver. Chains the deterministic setup steps so a + * scripted install can go from a fresh checkout to a running service without + * the `/setup` skill. + * + * Prerequisite: `bash setup.sh` has run (Node >= 20, pnpm install, native + * module check). This driver picks up from there. + * + * Config via env: + * NANOCLAW_TZ IANA zone override (skip autodetect) + * NANOCLAW_SKIP comma-separated step names to skip + * (environment|timezone|container|mounts|service|verify) + * + * Credential setup (OneCLI + channel auth + `/manage-channels`) is *not* + * scripted — those require interactive platform flows and are handled by + * `/setup`, `/add-`, and `/manage-channels` afterwards. + */ +import { spawn } from 'child_process'; + +type Fields = Record; +type StepResult = { ok: boolean; fields: Fields; exitCode: number }; + +function parseStatus(stdout: string): Fields { + const out: Fields = {}; + let inBlock = false; + for (const line of stdout.split('\n')) { + if (line.startsWith('=== NANOCLAW SETUP:')) { + inBlock = true; + continue; + } + if (line.startsWith('=== END ===')) { + inBlock = false; + continue; + } + if (!inBlock) continue; + const idx = line.indexOf(':'); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (key) out[key] = value; + } + return out; +} + +function runStep(name: string, extra: string[] = []): Promise { + return new Promise((resolve) => { + console.log(`\n── ${name} ────────────────────────────────────`); + const args = ['exec', 'tsx', 'setup/index.ts', '--step', name]; + if (extra.length > 0) args.push('--', ...extra); + + const child = spawn('pnpm', args, { stdio: ['inherit', 'pipe', 'inherit'] }); + let buf = ''; + child.stdout.on('data', (chunk: Buffer) => { + const s = chunk.toString('utf-8'); + buf += s; + process.stdout.write(s); + }); + child.on('close', (code) => { + const fields = parseStatus(buf); + resolve({ + ok: code === 0 && fields.STATUS === 'success', + fields, + exitCode: code ?? 1, + }); + }); + }); +} + +function fail(msg: string, hint?: string): never { + console.error(`\n[setup:auto] ${msg}`); + if (hint) console.error(` ${hint}`); + console.error(' Logs: logs/setup.log'); + process.exit(1); +} + +async function main(): Promise { + const skip = new Set( + (process.env.NANOCLAW_SKIP ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ); + const tz = process.env.NANOCLAW_TZ; + + if (!skip.has('environment')) { + const env = await runStep('environment'); + if (!env.ok) fail('environment check failed'); + } + + if (!skip.has('timezone')) { + const res = await runStep('timezone', tz ? ['--tz', tz] : []); + if (res.fields.NEEDS_USER_INPUT === 'true') { + fail( + 'Timezone could not be autodetected.', + 'Set NANOCLAW_TZ to an IANA zone (e.g. NANOCLAW_TZ=America/New_York).', + ); + } + if (!res.ok) fail('timezone step failed'); + } + + if (!skip.has('container')) { + const res = await runStep('container'); + if (!res.ok) { + if (res.fields.ERROR === 'runtime_not_available') { + fail( + 'Docker is not available and could not be started automatically.', + 'Install Docker Desktop or start it manually, then retry.', + ); + } + fail( + 'container build/test failed', + 'For stale build cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', + ); + } + } + + if (!skip.has('mounts')) { + const res = await runStep('mounts', ['--empty']); + if (!res.ok && res.fields.STATUS !== 'skipped') { + fail('mount allowlist step failed'); + } + } + + if (!skip.has('service')) { + const res = await runStep('service'); + if (!res.ok) { + fail( + 'service install failed', + 'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.', + ); + } + if (res.fields.DOCKER_GROUP_STALE === 'true') { + console.warn( + '\n[setup:auto] Docker group stale in systemd session. Run:\n' + + ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + + ' systemctl --user restart nanoclaw', + ); + } + } + + if (!skip.has('verify')) { + const res = await runStep('verify'); + if (!res.ok) { + console.log('\n[setup:auto] Scripted steps done. Remaining (interactive):'); + if (res.fields.CREDENTIALS !== 'configured') { + console.log(' • OneCLI + Anthropic secret — see `/setup` §4 or https://onecli.sh'); + } + if (!res.fields.CONFIGURED_CHANNELS) { + console.log(' • Install a channel: `/add-discord`, `/add-slack`, `/add-telegram`, …'); + } + if (res.fields.REGISTERED_GROUPS === '0') { + console.log(' • Wire the channel to an agent group: `/manage-channels`'); + } + return; + } + } + + console.log('\n[setup:auto] Complete.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/setup/container.ts b/setup/container.ts index 3e48ecf..aadd04c 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -4,11 +4,54 @@ */ import { execSync } from 'child_process'; import path from 'path'; +import { setTimeout as sleep } from 'timers/promises'; import { log } from '../src/log.js'; -import { commandExists } from './platform.js'; +import { commandExists, getPlatform } from './platform.js'; import { emitStatus } from './status.js'; +function dockerRunning(): boolean { + try { + execSync('docker info', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * Try to start Docker if it's installed but idle. Poll for up to 60s. + * Returns true once `docker info` succeeds, false if we gave up. + */ +async function tryStartDocker(): Promise { + const platform = getPlatform(); + log.info('Docker not running — attempting to start', { platform }); + + try { + if (platform === 'macos') { + execSync('open -a Docker', { stdio: 'ignore' }); + } else if (platform === 'linux') { + // Inherit stdio so sudo can prompt for a password if needed. + execSync('sudo systemctl start docker', { stdio: 'inherit' }); + } else { + return false; + } + } catch (err) { + log.warn('Start command failed', { err }); + return false; + } + + for (let i = 0; i < 30; i++) { + await sleep(2000); + if (dockerRunning()) { + log.info('Docker is up'); + return true; + } + } + log.warn('Docker did not become ready within 60s'); + return false; +} + function parseArgs(args: string[]): { runtime: string } { // `--runtime` is still accepted for backwards compatibility with the /setup // skill, but `docker` is the only supported value. @@ -54,19 +97,20 @@ export async function run(args: string[]): Promise { process.exit(2); } - try { - execSync('docker info', { stdio: 'ignore' }); - } catch { - emitStatus('SETUP_CONTAINER', { - RUNTIME: runtime, - IMAGE: image, - BUILD_OK: false, - TEST_OK: false, - STATUS: 'failed', - ERROR: 'runtime_not_available', - LOG: 'logs/setup.log', - }); - process.exit(2); + if (!dockerRunning()) { + const started = await tryStartDocker(); + if (!started) { + emitStatus('SETUP_CONTAINER', { + RUNTIME: runtime, + IMAGE: image, + BUILD_OK: false, + TEST_OK: false, + STATUS: 'failed', + ERROR: 'runtime_not_available', + LOG: 'logs/setup.log', + }); + process.exit(2); + } } const buildCmd = 'docker build'; From 3ce4101cd9ce9770f4f11fce792c4e15aeee564e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 17:13:39 +0300 Subject: [PATCH 061/185] feat(setup): chain OneCLI install in setup:auto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install half of the OneCLI step is fully scriptable (the gateway and CLI install themselves via `curl | sh`, PATH + api-host + .env updates are idempotent). Register the Anthropic secret is still interactive — the auto driver leaves that for `/setup` §4 to handle. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 0cbac93..84db937 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -9,11 +9,12 @@ * Config via env: * NANOCLAW_TZ IANA zone override (skip autodetect) * NANOCLAW_SKIP comma-separated step names to skip - * (environment|timezone|container|mounts|service|verify) + * (environment|timezone|container|onecli|mounts|service|verify) * - * Credential setup (OneCLI + channel auth + `/manage-channels`) is *not* - * scripted — those require interactive platform flows and are handled by - * `/setup`, `/add-`, and `/manage-channels` afterwards. + * OneCLI is installed and configured here, but secret registration (the + * Anthropic token or API key), channel auth, and `/manage-channels` stay + * interactive — they need human input. Finish those with `/setup` §4 + * onwards, `/add-`, and `/manage-channels`. */ import { spawn } from 'child_process'; @@ -114,6 +115,22 @@ async function main(): Promise { } } + if (!skip.has('onecli')) { + const res = await runStep('onecli'); + if (!res.ok) { + if (res.fields.ERROR === 'onecli_not_on_path_after_install') { + fail( + 'OneCLI installed but not on PATH.', + 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', + ); + } + fail( + `OneCLI install failed (${res.fields.ERROR ?? 'unknown'})`, + 'Check that curl + a writable ~/.local/bin are available; re-run `pnpm run setup:auto`.', + ); + } + } + if (!skip.has('mounts')) { const res = await runStep('mounts', ['--empty']); if (!res.ok && res.fields.STATUS !== 'skipped') { @@ -143,7 +160,7 @@ async function main(): Promise { if (!res.ok) { console.log('\n[setup:auto] Scripted steps done. Remaining (interactive):'); if (res.fields.CREDENTIALS !== 'configured') { - console.log(' • OneCLI + Anthropic secret — see `/setup` §4 or https://onecli.sh'); + console.log(' • Register an Anthropic secret in OneCLI — see `/setup` §4'); } if (!res.fields.CONFIGURED_CHANNELS) { console.log(' • Install a channel: `/add-discord`, `/add-slack`, `/add-telegram`, …'); From ee5995ae16a9fa5a7a350c53f07243710bd1d6fe Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 17:38:43 +0300 Subject: [PATCH 062/185] feat(setup): add register-claude-token.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bash helper that registers an Anthropic credential in OneCLI via three paths: Claude subscription (runs `claude setup-token` under script(1) for PTY capture), paste an existing sk-ant-oat… OAuth token, or paste an sk-ant-api… API key. On bash 4+ the `claude setup-token` command is pre-filled in the readline buffer so Enter submits it. On bash 3.2 (macOS default /bin/bash) we fall back to a plain confirmation prompt. Token extraction strips ANSI + TTY-wrap line breaks and anchors on sk-ant-oat…AA with a length cap (via perl; BSD grep caps {n,m} at 255). Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/register-claude-token.sh | 127 +++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100755 setup/register-claude-token.sh diff --git a/setup/register-claude-token.sh b/setup/register-claude-token.sh new file mode 100755 index 0000000..2c0860d --- /dev/null +++ b/setup/register-claude-token.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Prefer bash 4+ (for `read -e -i` readline preload). macOS ships 3.2 in +# /bin/bash, but Homebrew users usually have 5.x first on PATH. The readline +# preload is optional — on 3.x we fall back to a plain confirmation prompt. + +# Register an Anthropic credential with OneCLI. Three paths: +# 1) Claude subscription — run `claude setup-token` (browser sign-in) +# and capture the resulting OAuth token. +# 2) Paste an existing sk-ant-oat… OAuth token you already have. +# 3) Paste an Anthropic API key (sk-ant-api…). +# +# Env overrides: +# SECRET_NAME OneCLI secret name (default: Anthropic) +# HOST_PATTERN OneCLI host pattern (default: api.anthropic.com) + +SECRET_NAME="${SECRET_NAME:-Anthropic}" +HOST_PATTERN="${HOST_PATTERN:-api.anthropic.com}" + +command -v onecli >/dev/null \ + || { echo "onecli not found. Install it first (see /setup §4)." >&2; exit 1; } + +TOKEN="" + +capture_via_claude_setup_token() { + command -v claude >/dev/null \ + || { echo "claude CLI not found. Install from https://claude.ai/download" >&2; exit 1; } + command -v script >/dev/null \ + || { echo "script(1) is required for PTY capture." >&2; exit 1; } + + local tmpfile + tmpfile=$(mktemp -t claude-setup-token.XXXXXX) + trap 'rm -f "$tmpfile"' RETURN + + cat <<'EOF' +A browser window will open for sign-in. Token is captured automatically. +Press Enter to run, or edit the command first. + +EOF + + local cmd="claude setup-token" + if [[ ${BASH_VERSINFO[0]:-0} -ge 4 ]]; then + # bash 4+: pre-fill the readline buffer so Enter literally submits. + read -r -e -i "$cmd" -p "$ " cmd /dev/null | grep -q util-linux; then + script -q -c "$cmd" "$tmpfile" + else + # BSD script: command is argv after the file, so let it word-split. + # shellcheck disable=SC2086 + script -q "$tmpfile" $cmd + fi + + # Strip ANSI codes + newlines (TTY wraps the token mid-string), then match + # the sk-ant-oat…AA token. perl because BSD grep caps {n,m} at 255. + TOKEN=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \ + | tr -d '\n\r' \ + | perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \ + | tail -1 || true) + + if [[ -z "$TOKEN" ]]; then + local keep + keep=$(mktemp -t claude-setup-token-log.XXXXXX) + cp "$tmpfile" "$keep" + echo >&2 + echo "No sk-ant-oat…AA token found. Raw log: $keep" >&2 + exit 1 + fi +} + +prompt_for_pasted() { + local prefix="$1" # "oat" or "api" + local value + echo + echo "Paste your sk-ant-${prefix}… credential and press Enter." + echo "(Input is hidden for safety.)" + read -r -s -p "> " value &2 + exit 1 + fi + if [[ ! "$value" =~ ^sk-ant-${prefix} ]]; then + echo "Value does not start with sk-ant-${prefix}. Aborting." >&2 + exit 1 + fi + TOKEN="$value" +} + +cat <&2; exit 1 ;; +esac + +echo +echo "Got token: ${TOKEN:0:16}…${TOKEN: -4}" +echo "Registering with OneCLI as '$SECRET_NAME' (host pattern: $HOST_PATTERN)…" + +onecli secrets create \ + --name "$SECRET_NAME" \ + --type anthropic \ + --value "$TOKEN" \ + --host-pattern "$HOST_PATTERN" + +echo "Done." From b0cae1ba4cef1fa4a21beae5907f36334184adfc Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 17:41:29 +0300 Subject: [PATCH 063/185] feat(setup): chain register-claude-token.sh into setup:auto Runs after the OneCLI install step and before mounts/service. Skips silently when `onecli secrets list` already reports an Anthropic secret, so re-running setup:auto on a configured install is a no-op. Child process uses stdio:inherit so the menu + browser sign-in flow work normally. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 84db937..66d6880 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -9,14 +9,15 @@ * Config via env: * NANOCLAW_TZ IANA zone override (skip autodetect) * NANOCLAW_SKIP comma-separated step names to skip - * (environment|timezone|container|onecli|mounts|service|verify) + * (environment|timezone|container|onecli|auth|mounts|service|verify) * - * OneCLI is installed and configured here, but secret registration (the - * Anthropic token or API key), channel auth, and `/manage-channels` stay - * interactive — they need human input. Finish those with `/setup` §4 - * onwards, `/add-`, and `/manage-channels`. + * Anthropic credential registration runs via setup/register-claude-token.sh + * (the only step that truly requires human input — browser sign-in or a + * pasted token/key). Channel auth and `/manage-channels` remain separate + * because they're platform-specific and typically handled via `/add-` + * and `/manage-channels` after this driver completes. */ -import { spawn } from 'child_process'; +import { spawn, spawnSync } from 'child_process'; type Fields = Record; type StepResult = { ok: boolean; fields: Fields; exitCode: number }; @@ -67,6 +68,26 @@ function runStep(name: string, extra: string[] = []): Promise { }); } +function anthropicSecretExists(): boolean { + try { + const res = spawnSync('onecli', ['secrets', 'list'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (res.status !== 0) return false; + return /anthropic/i.test(res.stdout ?? ''); + } catch { + return false; + } +} + +function runBashScript(relPath: string): Promise { + return new Promise((resolve) => { + const child = spawn('bash', [relPath], { stdio: 'inherit' }); + child.on('close', (code) => resolve(code ?? 1)); + }); +} + function fail(msg: string, hint?: string): never { console.error(`\n[setup:auto] ${msg}`); if (hint) console.error(` ${hint}`); @@ -131,6 +152,24 @@ async function main(): Promise { } } + if (!skip.has('auth')) { + if (anthropicSecretExists()) { + console.log( + '\n── auth ────────────────────────────────────\n' + + '[setup:auto] OneCLI already has an Anthropic secret — skipping.', + ); + } else { + console.log('\n── auth ────────────────────────────────────'); + const code = await runBashScript('setup/register-claude-token.sh'); + if (code !== 0) { + fail( + 'Anthropic credential registration failed or was aborted.', + 'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.', + ); + } + } + } + if (!skip.has('mounts')) { const res = await runStep('mounts', ['--empty']); if (!res.ok && res.fields.STATUS !== 'skipped') { @@ -160,7 +199,7 @@ async function main(): Promise { if (!res.ok) { console.log('\n[setup:auto] Scripted steps done. Remaining (interactive):'); if (res.fields.CREDENTIALS !== 'configured') { - console.log(' • Register an Anthropic secret in OneCLI — see `/setup` §4'); + console.log(' • Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`'); } if (!res.fields.CONFIGURED_CHANNELS) { console.log(' • Install a channel: `/add-discord`, `/add-slack`, `/add-telegram`, …'); From 264849da6c05305d758a338bdd18dd6677f851d2 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 17:45:04 +0300 Subject: [PATCH 064/185] feat(setup): add nanoclaw.sh entry point Single command end-to-end: `bash nanoclaw.sh` runs setup.sh for bootstrap and hands off to `pnpm run setup:auto` on success. Passes through NANOCLAW_TZ, NANOCLAW_SKIP, SECRET_NAME, HOST_PATTERN via env. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100755 nanoclaw.sh diff --git a/nanoclaw.sh b/nanoclaw.sh new file mode 100755 index 0000000..6a23558 --- /dev/null +++ b/nanoclaw.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# NanoClaw — scripted end-to-end install. +# +# Runs `bash setup.sh` (bootstrap: Node check, pnpm install, native module +# verify), then `pnpm run setup:auto` (environment → timezone → container → +# onecli → auth → mounts → service → verify). +# +# Everything that can be scripted runs unattended; the one interactive pause +# is the auth step (browser sign-in or paste token/API key). +# +# Config via env — passed through unchanged: +# NANOCLAW_TZ IANA zone override +# NANOCLAW_SKIP comma-separated setup:auto step names to skip +# SECRET_NAME OneCLI secret name (default: Anthropic) +# HOST_PATTERN OneCLI host pattern (default: api.anthropic.com) + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_ROOT" + +cat <<'EOF' +═══════════════════════════════════════════════════════════════ + NanoClaw scripted setup +═══════════════════════════════════════════════════════════════ + +Phase 1: bootstrap (Node + pnpm + native modules) + +EOF + +if ! bash setup.sh; then + echo + echo "[nanoclaw.sh] Bootstrap failed. Inspect logs/setup.log and retry." >&2 + exit 1 +fi + +cat <<'EOF' + +═══════════════════════════════════════════════════════════════ + Phase 2: setup:auto +═══════════════════════════════════════════════════════════════ + +EOF + +# exec so signals (Ctrl-C) propagate directly to the child. +exec pnpm run setup:auto From fd2e404ba95aec6a478ce55af9bdd7b8280e4402 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 21 Apr 2026 15:05:52 +0000 Subject: [PATCH 065/185] fix(setup): auto-install Node and bypass corepack prompt Node check now triggers setup/install-node.sh when missing/too old, and COREPACK_ENABLE_DOWNLOAD_PROMPT=0 prevents the first-use prompt from hanging the script when stdout is redirected to the log. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/setup.sh b/setup.sh index af2c5e5..e163df8 100755 --- a/setup.sh +++ b/setup.sh @@ -72,6 +72,11 @@ install_deps() { cd "$PROJECT_ROOT" + # Corepack's first-use "Do you want to continue? [Y/n]" prompt would hang + # the script since we redirect stdout/stderr to the log file — the prompt + # is invisible but corepack still blocks on stdin. Auto-accept. + export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + # Enable corepack so `pnpm` shim lands on PATH. log "Enabling corepack" corepack enable >> "$LOG_FILE" 2>&1 || true @@ -131,6 +136,16 @@ log "=== Bootstrap started ===" detect_platform check_node +if [ "$NODE_OK" = "false" ]; then + log "Node missing or too old — running setup/install-node.sh" + echo "Node not found — installing via setup/install-node.sh" + if bash "$PROJECT_ROOT/setup/install-node.sh" 2>&1 | tee -a "$LOG_FILE"; then + hash -r 2>/dev/null || true + check_node + else + log "install-node.sh failed" + fi +fi install_deps check_build_tools From e86d0d93dd50c474015e6aa73fc16e8c08fe7e18 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 18:45:19 +0300 Subject: [PATCH 066/185] feat(setup): wire CLI agent in setup:auto Chains `cli-agent` (wraps scripts/init-cli-agent.ts) between service and verify. Without this wiring, the socket at data/cli.sock accepts the connection but there's no agent group routed to `cli/local`, so `pnpm run chat` hangs waiting for a reply. Defaults: display name from NANOCLAW_DISPLAY_NAME env, falling back to \$USER then "Operator". Agent persona name from NANOCLAW_AGENT_NAME, defaulting to the display name. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 66d6880..bbe6326 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -7,9 +7,12 @@ * module check). This driver picks up from there. * * Config via env: - * NANOCLAW_TZ IANA zone override (skip autodetect) - * NANOCLAW_SKIP comma-separated step names to skip - * (environment|timezone|container|onecli|auth|mounts|service|verify) + * NANOCLAW_TZ IANA zone override (skip autodetect) + * NANOCLAW_DISPLAY_NAME operator name for the CLI agent (default: $USER) + * NANOCLAW_AGENT_NAME agent persona name (default: display name) + * NANOCLAW_SKIP comma-separated step names to skip + * (environment|timezone|container|onecli|auth| + * mounts|service|cli-agent|verify) * * Anthropic credential registration runs via setup/register-claude-token.sh * (the only step that truly requires human input — browser sign-in or a @@ -194,6 +197,24 @@ async function main(): Promise { } } + if (!skip.has('cli-agent')) { + const displayName = + process.env.NANOCLAW_DISPLAY_NAME?.trim() || + process.env.USER?.trim() || + 'Operator'; + const agentName = process.env.NANOCLAW_AGENT_NAME?.trim(); + const args = ['--display-name', displayName]; + if (agentName) args.push('--agent-name', agentName); + + const res = await runStep('cli-agent', args); + if (!res.ok) { + fail( + 'CLI agent wiring failed', + 'Re-run `pnpm exec tsx scripts/init-cli-agent.ts --display-name ""` to fix.', + ); + } + } + if (!skip.has('verify')) { const res = await runStep('verify'); if (!res.ok) { @@ -202,10 +223,10 @@ async function main(): Promise { console.log(' • Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`'); } if (!res.fields.CONFIGURED_CHANNELS) { - console.log(' • Install a channel: `/add-discord`, `/add-slack`, `/add-telegram`, …'); - } - if (res.fields.REGISTERED_GROUPS === '0') { - console.log(' • Wire the channel to an agent group: `/manage-channels`'); + console.log( + ' • Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …', + ); + console.log(' (CLI channel is already wired: `pnpm run chat hi`)'); } return; } From be6cec59adc64b8f0d24e8d8710d33ac3fff6b8e Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 21 Apr 2026 15:55:04 +0000 Subject: [PATCH 067/185] fix(setup): auto-recover from stale docker group mid-session - container: install Docker via setup/install-docker.sh when missing, distinguish socket EACCES from daemon-down so we bail fast instead of polling 60s, and re-exec the step under `sg docker` when usermod hasn't reached the current shell. - auto: after the container step, re-exec the whole driver under `sg docker` (with a NANOCLAW_REEXEC_SG guard) so onecli/service/verify also get docker-group access without a re-login. Surface the new docker_group_not_active error from the container step. - service: when the systemd user manager has a stale group list, auto- apply \`sudo setfacl -m u:\$USER:rw /var/run/docker.sock\` so the service can start without waiting for the next login. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 34 +++++++++++++++++++ setup/container.ts | 81 +++++++++++++++++++++++++++++++++++----------- setup/service.ts | 27 ++++++++++++++-- 3 files changed, 121 insertions(+), 21 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index bbe6326..8ef87d8 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -71,6 +71,33 @@ function runStep(name: string, extra: string[] = []): Promise { }); } +/** + * After installing Docker, this process's supplementary groups are still + * frozen from login — subsequent steps that talk to /var/run/docker.sock + * (onecli install, service start, …) fail with EACCES even though the + * daemon is up. Detect that and re-exec the whole driver under `sg docker` + * so the rest of the run inherits the docker group without a re-login. + */ +function maybeReexecUnderSg(): void { + if (process.env.NANOCLAW_REEXEC_SG === '1') return; // already re-exec'd + if (process.platform !== 'linux') return; + const info = spawnSync('docker', ['info'], { encoding: 'utf-8' }); + if (info.status === 0) return; + const err = `${info.stderr ?? ''}\n${info.stdout ?? ''}`; + if (!/permission denied/i.test(err)) return; + if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return; + + console.log( + '\n[setup:auto] Docker socket not accessible in current group — ' + + 're-executing under `sg docker` to pick up new group membership.', + ); + const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { + stdio: 'inherit', + env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, + }); + process.exit(res.status ?? 1); +} + function anthropicSecretExists(): boolean { try { const res = spawnSync('onecli', ['secrets', 'list'], { @@ -132,11 +159,18 @@ async function main(): Promise { 'Install Docker Desktop or start it manually, then retry.', ); } + if (res.fields.ERROR === 'docker_group_not_active') { + fail( + 'Docker was just installed but your shell is not yet in the `docker` group.', + 'Log out and back in (or run `newgrp docker` in a new shell), then retry `pnpm run setup:auto`.', + ); + } fail( 'container build/test failed', 'For stale build cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', ); } + maybeReexecUnderSg(); } if (!skip.has('onecli')) { diff --git a/setup/container.ts b/setup/container.ts index aadd04c..a2e6433 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -2,7 +2,7 @@ * Step: container — Build container image and verify with test run. * Replaces 03-setup-container.sh */ -import { execSync } from 'child_process'; +import { execSync, spawnSync } from 'child_process'; import path from 'path'; import { setTimeout as sleep } from 'timers/promises'; @@ -10,20 +10,28 @@ import { log } from '../src/log.js'; import { commandExists, getPlatform } from './platform.js'; import { emitStatus } from './status.js'; +type DockerStatus = 'ok' | 'no-permission' | 'no-daemon' | 'other'; + +function dockerStatus(): DockerStatus { + const res = spawnSync('docker', ['info'], { encoding: 'utf-8' }); + if (res.status === 0) return 'ok'; + const err = `${res.stderr ?? ''}\n${res.stdout ?? ''}`; + if (/permission denied/i.test(err)) return 'no-permission'; + if (/cannot connect|is the docker daemon running|no such file/i.test(err)) return 'no-daemon'; + return 'other'; +} + function dockerRunning(): boolean { - try { - execSync('docker info', { stdio: 'ignore' }); - return true; - } catch { - return false; - } + return dockerStatus() === 'ok'; } /** - * Try to start Docker if it's installed but idle. Poll for up to 60s. - * Returns true once `docker info` succeeds, false if we gave up. + * Try to start Docker if it's installed but idle. Poll up to 60s for the + * daemon to come up — but bail immediately if the socket is reachable and + * only blocked by a group-permission error, since that won't resolve by + * waiting (the caller handles the sg re-exec for that case). */ -async function tryStartDocker(): Promise { +async function tryStartDocker(): Promise { const platform = getPlatform(); log.info('Docker not running — attempting to start', { platform }); @@ -34,22 +42,27 @@ async function tryStartDocker(): Promise { // Inherit stdio so sudo can prompt for a password if needed. execSync('sudo systemctl start docker', { stdio: 'inherit' }); } else { - return false; + return 'other'; } } catch (err) { log.warn('Start command failed', { err }); - return false; + return 'other'; } for (let i = 0; i < 30; i++) { await sleep(2000); - if (dockerRunning()) { + const s = dockerStatus(); + if (s === 'ok') { log.info('Docker is up'); - return true; + return 'ok'; + } + if (s === 'no-permission') { + log.info('Docker daemon is up but socket is not accessible (group membership)'); + return 'no-permission'; } } log.warn('Docker did not become ready within 60s'); - return false; + return 'no-daemon'; } function parseArgs(args: string[]): { runtime: string } { @@ -84,6 +97,15 @@ export async function run(args: string[]): Promise { process.exit(4); } + if (!commandExists('docker')) { + log.info('Docker not found — running setup/install-docker.sh'); + try { + execSync('bash setup/install-docker.sh', { cwd: projectRoot, stdio: 'inherit' }); + } catch (err) { + log.warn('install-docker.sh failed', { err }); + } + } + if (!commandExists('docker')) { emitStatus('SETUP_CONTAINER', { RUNTIME: runtime, @@ -97,16 +119,37 @@ export async function run(args: string[]): Promise { process.exit(2); } - if (!dockerRunning()) { - const started = await tryStartDocker(); - if (!started) { + { + let status = dockerStatus(); + if (status !== 'ok') { + status = await tryStartDocker(); + } + + // Socket is unreachable due to group perms — current shell's supplementary + // groups are fixed at login, so `usermod -aG docker` (via install-docker.sh + // or a prior install) doesn't affect us until next login. Re-exec this + // step under `sg docker` so the child picks up docker as its primary + // group and can talk to /var/run/docker.sock without a logout. + if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) { + log.info('Re-executing container step under `sg docker`'); + const res = spawnSync( + 'sg', + ['docker', '-c', 'pnpm exec tsx setup/index.ts --step container'], + { cwd: projectRoot, stdio: 'inherit' }, + ); + process.exit(res.status ?? 1); + } + + if (status !== 'ok') { + const error = + status === 'no-permission' ? 'docker_group_not_active' : 'runtime_not_available'; emitStatus('SETUP_CONTAINER', { RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false, STATUS: 'failed', - ERROR: 'runtime_not_available', + ERROR: error, LOG: 'logs/setup.log', }); process.exit(2); diff --git a/setup/service.ts b/setup/service.ts index bc85d16..56bf393 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -11,6 +11,7 @@ import path from 'path'; import { log } from '../src/log.js'; import { + commandExists, getPlatform, getNodePath, getServiceManager, @@ -255,12 +256,34 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; fs.writeFileSync(unitPath, unit); log.info('Wrote systemd unit', { unitPath }); - // Detect stale docker group before starting (user systemd only) - const dockerGroupStale = !runningAsRoot && checkDockerGroupStale(); + // Detect stale docker group before starting (user systemd only). The user + // systemd manager is a long-running process whose group list is frozen at + // login, so `usermod -aG docker` mid-session doesn't reach it. Rather than + // require the user to log out + back in, punch a POSIX ACL onto the socket + // that grants the current user rw directly. This is temporary — the socket + // is recreated by dockerd on restart (and by then the user has relogged, so + // normal group perms apply again). + let dockerGroupStale = !runningAsRoot && checkDockerGroupStale(); if (dockerGroupStale) { log.warn( 'Docker group not active in systemd session — user was likely added to docker group mid-session', ); + if (commandExists('setfacl')) { + const user = execSync('whoami', { encoding: 'utf-8' }).trim(); + try { + execSync(`sudo setfacl -m u:${user}:rw /var/run/docker.sock`, { + stdio: 'inherit', + }); + log.info( + 'Applied temporary ACL to /var/run/docker.sock (resets on docker restart or reboot)', + ); + dockerGroupStale = false; + } catch (err) { + log.warn('Failed to apply setfacl workaround', { err }); + } + } else { + log.warn('setfacl not installed — cannot apply automatic workaround'); + } } // Kill orphaned nanoclaw processes to avoid channel connection conflicts From 52a9ab517943b43c7dd3f21f71c8a1c878c20d8a Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Tue, 21 Apr 2026 17:21:50 +0000 Subject: [PATCH 068/185] feat(add-wechat): personal WeChat channel via Tencent iLink Bot API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New channel skill for personal WeChat, using Tencent's official iLink Bot API (the same protocol @tencent-weixin/openclaw-weixin uses). Region-restricted to mainland 微信 accounts — international WeChat clients can't complete the QR flow. Skill contents: - Install steps copy the adapter from the `channels` branch (same pattern as other /add- skills) and register it in src/channels/index.ts. - Post-login wiring helper at scripts/wire-dm.ts — lists unwired WeChat messaging groups, prompts for an agent group, and inserts the messaging_group_agents row with sender policy `request_approval` by default (matches the router auto-create default so the admin gets an approval card on the next unknown-sender DM). - Channel Info documents how /new-setup Claude captures the operator's user_id (from data/wechat/auth.json.operatorUserId) and the first DM's platform_id (from the adapter's "WeChat inbound" log). Also adds WeChat as option 15 in /new-setup's channel list so setup wires into the existing /add- flow automatically. Addresses https://github.com/qwibitai/nanoclaw/issues/1901. Co-Authored-By: ythx-101 <226337373+ythx-101@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-wechat/REMOVE.md | 49 ++++++ .claude/skills/add-wechat/SKILL.md | 170 ++++++++++++++++++ .claude/skills/add-wechat/scripts/wire-dm.ts | 172 +++++++++++++++++++ .claude/skills/new-setup/SKILL.md | 1 + 4 files changed, 392 insertions(+) create mode 100644 .claude/skills/add-wechat/REMOVE.md create mode 100644 .claude/skills/add-wechat/SKILL.md create mode 100644 .claude/skills/add-wechat/scripts/wire-dm.ts diff --git a/.claude/skills/add-wechat/REMOVE.md b/.claude/skills/add-wechat/REMOVE.md new file mode 100644 index 0000000..366739e --- /dev/null +++ b/.claude/skills/add-wechat/REMOVE.md @@ -0,0 +1,49 @@ +# Remove WeChat Channel + +Undo `/add-wechat`. + +### 1. Remove credentials + +Delete WeChat lines from `.env`: + +```bash +sed -i.bak '/^WECHAT_ENABLED=/d' .env && rm -f .env.bak +cp .env data/env/env +``` + +### 2. Remove adapter and import + +```bash +rm -f src/channels/wechat.ts +sed -i.bak "/import '\.\/wechat\.js';/d" src/channels/index.ts && rm -f src/channels/index.ts.bak +``` + +### 3. Uninstall the package + +```bash +pnpm remove wechat-ilink-client +``` + +### 4. Remove saved auth + sync state + +```bash +rm -rf data/wechat +``` + +### 5. Remove DB wiring + +```sql +-- Remove any sessions first (foreign key) +DELETE FROM sessions WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat'); +DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat'); +DELETE FROM messaging_groups WHERE channel_type = 'wechat'; +``` + +### 6. Rebuild and restart + +```bash +pnpm run build +systemctl --user restart nanoclaw # Linux +# or +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` diff --git a/.claude/skills/add-wechat/SKILL.md b/.claude/skills/add-wechat/SKILL.md new file mode 100644 index 0000000..ba0294a --- /dev/null +++ b/.claude/skills/add-wechat/SKILL.md @@ -0,0 +1,170 @@ +--- +name: add-wechat +description: Add WeChat (personal) channel integration via Tencent's official iLink Bot API. Uses long-polling and QR scan — no webhook, no ToS risk, no paid token. +--- + +# Add WeChat Channel + +Adds WeChat support via **iLink Bot API** — the first-party Tencent API for personal WeChat bots (different from WeCom / Official Account). + +**Why this is different from wechaty/PadLocal:** + +- Official Tencent API — no ToS violation, no ban risk +- Free — no PadLocal token required +- No public webhook URL needed — uses long-poll +- Works with any personal WeChat account + +## Prerequisites + +- A **personal WeChat account** with the mobile app installed +- A phone to scan the QR code for login +- Node.js >= 20 (already required by NanoClaw) + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the WeChat adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/wechat.ts` exists +- `src/channels/index.ts` contains `import './wechat.js';` +- `wechat-ilink-client` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/wechat.ts > src/channels/wechat.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './wechat.js'; +``` + +### 4. Install the library (pinned) + +```bash +pnpm install wechat-ilink-client@0.1.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +Unlike most channels, WeChat requires **no pre-configured API keys**. Auth happens via QR code scan from your phone. + +### 1. Enable the channel + +Add to `.env`: + +```bash +WECHAT_ENABLED=true +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### 2. Start the service and scan the QR + +Restart NanoClaw: + +```bash +systemctl --user restart nanoclaw # Linux +# or +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` + +The adapter will print a **QR URL** to the logs and save it to `data/wechat/qr.txt`: + +```bash +tail -f logs/nanoclaw.log | grep WeChat +# or +cat data/wechat/qr.txt +``` + +Open the URL in a browser (it renders a QR code), then: + +1. Open WeChat on your phone +2. Use its built-in QR scanner (top-right "+" → Scan) +3. Approve the authorization on your phone +4. Auth credentials are saved to `data/wechat/auth.json` — do not commit this file + +The bot is now connected as your WeChat account. + +## Wire your first DM + +A successful QR login alone isn't enough — the adapter still needs to be wired to an agent group before it can respond. + +### 1. Trigger the first inbound message + +Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id`. + +### 2. Run the wire script + +```bash +pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts +``` + +Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and creates the `messaging_group_agents` row with sensible defaults (sender policy `request_approval`, session mode `shared`). + +With `request_approval`, the next DM from the stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent. + +Non-interactive: + +```bash +pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts \ + --platform-id wechat:wxid_xxxxx \ + --agent-group ag-xxxxx \ + --non-interactive +``` + +Flags: + +- `--platform-id ` — wire a specific messaging group (default: most recent unwired) +- `--agent-group ` — target agent group (default: prompt; or solo admin group in non-interactive) +- `--sender-policy public|strict|request_approval` — default `request_approval` (fires an admin approval card on unknown-sender DMs) +- `--session-mode shared|per-thread` — default `shared` + +### 3. Test + +Have the sender message the bot again — the agent should respond. + +## Operational notes + +- **Only one instance can use a given token at a time.** Don't run multiple NanoClaw instances pointing to the same `data/wechat/auth.json`. +- **Re-login on session expiry:** if you see `WeChat: session expired` in logs, delete `data/wechat/auth.json` and restart — you'll be asked to re-scan. +- **Sync cursor persistence:** `data/wechat/sync-buf.txt` holds the long-poll cursor. Deleting it replays recent history on next start; don't delete it in normal operation. +- **Account safety:** this uses the official Tencent API, so account bans for bot automation aren't a risk. That said, don't spam — normal rate limits still apply. + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, restart the service to pick up the new channel and wiring. + +## Channel Info + +- **type**: `wechat` +- **terminology**: WeChat has "contacts" (DMs) and "group chats" (rooms). Each DM or group is a separate messaging group. +- **how-to-find-id**: Send a message to the bot from the target account; the adapter auto-creates a messaging group and logs `WeChat inbound platformId=wechat:`. Use `wechat:` for DMs, `wechat:` for rooms. +- **admin-user-id**: The operator's WeChat user_id (for `init-first-agent.ts --admin-user-id`) is saved to `data/wechat/auth.json` as `operatorUserId` after the QR scan. Read it with `cat data/wechat/auth.json | jq -r .operatorUserId` and prefix with `wechat:` (i.e. `wechat:`). +- **supports-threads**: no (WeChat has no reply threads) +- **typical-use**: Long-poll — the adapter holds a persistent connection to Tencent's iLink API and receives messages in real time. No webhook URL needed. +- **default-isolation**: `shared` session mode per messaging group (DM or room). Use `strict` sender policy if you want only specific users to reach the agent; `public` opens it to anyone who messages the bot. +- **post-install-wiring**: Use the `wire-dm.ts` helper (see the "Wire your first DM" section above) if running this skill standalone. If running inside `/new-setup`, `init-first-agent.ts` handles wiring — just pass the `platform-id` and `admin-user-id` captured above. diff --git a/.claude/skills/add-wechat/scripts/wire-dm.ts b/.claude/skills/add-wechat/scripts/wire-dm.ts new file mode 100644 index 0000000..f94c88d --- /dev/null +++ b/.claude/skills/add-wechat/scripts/wire-dm.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env pnpm exec tsx +/** + * Wire a WeChat DM (or group) to an agent group. + * + * After /add-wechat installs the adapter and the user scans the QR login, + * the first inbound message from another WeChat account auto-creates a + * `messaging_groups` row. This script finds that row, asks the operator + * which agent group to wire it to, and inserts the `messaging_group_agents` + * join row with sensible defaults — the "post-login wiring" step /add-wechat + * otherwise requires manual SQL for. + * + * Usage: + * pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts + * + * Flags: + * --platform-id Wire a specific messaging group (default: most recent unwired) + * --agent-group Target agent group (default: interactive pick; or solo admin group) + * --sender-policy

public | strict (default: public) + * --session-mode shared | per-thread (default: shared) + * --non-interactive Fail instead of prompting + */ +import Database from 'better-sqlite3'; +import path from 'node:path'; +import readline from 'node:readline'; + +const DB_PATH = process.env.NANOCLAW_DB_PATH ?? path.join(process.cwd(), 'data', 'v2.db'); + +type SenderPolicy = 'public' | 'strict' | 'request_approval'; + +interface Args { + platformId?: string; + agentGroupId?: string; + senderPolicy: SenderPolicy; + sessionMode: 'shared' | 'per-thread'; + interactive: boolean; +} + +function parseArgs(argv: string[]): Args { + const args: Args = { + // Default matches the router's auto-create (`request_approval`) so the + // admin gets an approval card on the next unknown-sender DM rather than + // a silent allow. Pass `--sender-policy public` to open the channel to + // anyone, or `strict` to require explicit membership. + senderPolicy: 'request_approval', + sessionMode: 'shared', + interactive: true, + }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + const val = argv[i + 1]; + switch (flag) { + case '--platform-id': args.platformId = val; i++; break; + case '--agent-group': args.agentGroupId = val; i++; break; + case '--sender-policy': + if (val !== 'public' && val !== 'strict' && val !== 'request_approval') { + throw new Error(`bad --sender-policy: ${val} (use public | strict | request_approval)`); + } + args.senderPolicy = val; i++; break; + case '--session-mode': + if (val !== 'shared' && val !== 'per-thread') throw new Error(`bad --session-mode: ${val}`); + args.sessionMode = val; i++; break; + case '--non-interactive': args.interactive = false; break; + case '--help': case '-h': + console.log('See .claude/skills/add-wechat/scripts/wire-dm.ts header for usage.'); + process.exit(0); + } + } + return args; +} + +async function prompt(q: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => rl.question(q, (a) => { rl.close(); resolve(a.trim()); })); +} + +function generateId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const db = new Database(DB_PATH); + db.pragma('journal_mode = WAL'); + + // 1. Pick the messaging group + let platformId = args.platformId; + if (!platformId) { + const rows = db.prepare(` + SELECT mg.id, mg.platform_id, mg.name, mg.is_group, mg.created_at + FROM messaging_groups mg + LEFT JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id + WHERE mg.channel_type = 'wechat' AND mga.id IS NULL + ORDER BY mg.created_at DESC + `).all() as Array<{ id: string; platform_id: string; name: string | null; is_group: number; created_at: string }>; + + if (rows.length === 0) { + console.error('No unwired WeChat messaging groups found.'); + console.error('Send a message to the bot first (from another WeChat account), then re-run.'); + process.exit(1); + } + + if (rows.length === 1 || !args.interactive) { + platformId = rows[0].platform_id; + console.log(`Using most recent unwired group: ${platformId} (${rows[0].is_group ? 'group' : 'DM'})`); + } else { + console.log('Unwired WeChat messaging groups:'); + rows.forEach((r, i) => { + console.log(` ${i + 1}. ${r.platform_id} (${r.is_group ? 'group' : 'DM'}, ${r.created_at})`); + }); + const pick = await prompt('Pick one [1]: '); + const idx = pick === '' ? 0 : parseInt(pick, 10) - 1; + if (Number.isNaN(idx) || idx < 0 || idx >= rows.length) throw new Error('invalid choice'); + platformId = rows[idx].platform_id; + } + } + + const mg = db.prepare( + 'SELECT id, platform_id, is_group FROM messaging_groups WHERE channel_type = ? AND platform_id = ?' + ).get('wechat', platformId) as { id: string; platform_id: string; is_group: number } | undefined; + if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${platformId}`); + + // 2. Pick the agent group + let agentGroupId = args.agentGroupId; + if (!agentGroupId) { + const agents = db.prepare('SELECT id, name, is_admin FROM agent_groups ORDER BY is_admin DESC, created_at ASC') + .all() as Array<{ id: string; name: string; is_admin: number }>; + if (agents.length === 0) throw new Error('no agent groups exist — create one first'); + + const adminAgents = agents.filter((a) => a.is_admin === 1); + if (adminAgents.length === 1 && !args.interactive) { + agentGroupId = adminAgents[0].id; + console.log(`Auto-selected sole admin agent group: ${adminAgents[0].name} (${agentGroupId})`); + } else if (args.interactive) { + console.log('Agent groups:'); + agents.forEach((a, i) => { + console.log(` ${i + 1}. ${a.name} (${a.id})${a.is_admin ? ' [admin]' : ''}`); + }); + const pick = await prompt('Pick one [1]: '); + const idx = pick === '' ? 0 : parseInt(pick, 10) - 1; + if (Number.isNaN(idx) || idx < 0 || idx >= agents.length) throw new Error('invalid choice'); + agentGroupId = agents[idx].id; + } else { + throw new Error('multiple agent groups exist; pass --agent-group '); + } + } + + const ag = db.prepare('SELECT id, name FROM agent_groups WHERE id = ?').get(agentGroupId) as + { id: string; name: string } | undefined; + if (!ag) throw new Error(`no agent_group with id = ${agentGroupId}`); + + // 3. Update sender policy + wire + const tx = db.transaction(() => { + db.prepare('UPDATE messaging_groups SET unknown_sender_policy = ? WHERE id = ?') + .run(args.senderPolicy, mg.id); + + db.prepare(` + INSERT INTO messaging_group_agents + (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) + VALUES (?, ?, ?, '', 'all', ?, 10, datetime('now')) + `).run(generateId('mga'), mg.id, ag.id, args.sessionMode); + }); + tx(); + + console.log(''); + console.log(`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} policy=${args.senderPolicy} mode=${args.sessionMode}`); + db.close(); +} + +main().catch((err) => { + console.error('FAILED:', err.message); + process.exit(1); +}); diff --git a/.claude/skills/new-setup/SKILL.md b/.claude/skills/new-setup/SKILL.md index ef88c75..4a3f1b8 100644 --- a/.claude/skills/new-setup/SKILL.md +++ b/.claude/skills/new-setup/SKILL.md @@ -189,6 +189,7 @@ Print the list as a numbered plain-prose list (too many options for `AskUserQues > 12. **Webex** — `/add-webex` > 13. **Resend (email)** — `/add-resend` > 14. **Emacs** — `/add-emacs` +> 15. **WeChat** — `/add-wechat` > > Or say "skip" to leave this for later. From 1c748f1f2b16396ffd5fa60831f1d787dd5da228 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 22:18:08 +0300 Subject: [PATCH 069/185] refactor(setup): drop timezone step from setup:auto chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timezone step blocked the scripted flow on headless servers where the resolved TZ was UTC (interactive /setup confirms, setup:auto had to bail). Drop it from the chain — host TZ defaults to whatever the OS reports. Users who need an explicit override run the step on demand: `pnpm exec tsx setup/index.ts --step timezone -- --tz `. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 5 ++--- setup/auto.ts | 19 +++++-------------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 6a23558..2a98f98 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -3,14 +3,13 @@ # NanoClaw — scripted end-to-end install. # # Runs `bash setup.sh` (bootstrap: Node check, pnpm install, native module -# verify), then `pnpm run setup:auto` (environment → timezone → container → -# onecli → auth → mounts → service → verify). +# verify), then `pnpm run setup:auto` (environment → container → onecli → +# auth → mounts → service → cli-agent → verify). # # Everything that can be scripted runs unattended; the one interactive pause # is the auth step (browser sign-in or paste token/API key). # # Config via env — passed through unchanged: -# NANOCLAW_TZ IANA zone override # NANOCLAW_SKIP comma-separated setup:auto step names to skip # SECRET_NAME OneCLI secret name (default: Anthropic) # HOST_PATTERN OneCLI host pattern (default: api.anthropic.com) diff --git a/setup/auto.ts b/setup/auto.ts index 8ef87d8..3945d82 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -7,13 +7,16 @@ * module check). This driver picks up from there. * * Config via env: - * NANOCLAW_TZ IANA zone override (skip autodetect) * NANOCLAW_DISPLAY_NAME operator name for the CLI agent (default: $USER) * NANOCLAW_AGENT_NAME agent persona name (default: display name) * NANOCLAW_SKIP comma-separated step names to skip - * (environment|timezone|container|onecli|auth| + * (environment|container|onecli|auth| * mounts|service|cli-agent|verify) * + * Timezone is not configured here — it defaults to the host system's TZ. + * Run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` later + * if autodetect is wrong (e.g. headless server with TZ=UTC). + * * Anthropic credential registration runs via setup/register-claude-token.sh * (the only step that truly requires human input — browser sign-in or a * pasted token/key). Channel auth and `/manage-channels` remain separate @@ -132,24 +135,12 @@ async function main(): Promise { .map((s) => s.trim()) .filter(Boolean), ); - const tz = process.env.NANOCLAW_TZ; if (!skip.has('environment')) { const env = await runStep('environment'); if (!env.ok) fail('environment check failed'); } - if (!skip.has('timezone')) { - const res = await runStep('timezone', tz ? ['--tz', tz] : []); - if (res.fields.NEEDS_USER_INPUT === 'true') { - fail( - 'Timezone could not be autodetected.', - 'Set NANOCLAW_TZ to an IANA zone (e.g. NANOCLAW_TZ=America/New_York).', - ); - } - if (!res.ok) fail('timezone step failed'); - } - if (!skip.has('container')) { const res = await runStep('container'); if (!res.ok) { From 81838bbb345555e2f3896916773ecc4d2c02ec54 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 23:43:43 +0300 Subject: [PATCH 070/185] fix(setup): clarify silent-paste prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicitly tell the user that nothing appears on screen as they paste and that a single Enter submits. "(Input is hidden for safety.)" was ambiguous — users kept waiting for a visible confirmation. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/register-claude-token.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup/register-claude-token.sh b/setup/register-claude-token.sh index 2c0860d..9c042d9 100755 --- a/setup/register-claude-token.sh +++ b/setup/register-claude-token.sh @@ -80,7 +80,8 @@ prompt_for_pasted() { local value echo echo "Paste your sk-ant-${prefix}… credential and press Enter." - echo "(Input is hidden for safety.)" + echo "Nothing will appear on the screen as you paste — that's intentional." + echo "Paste once, then just press Enter to submit." read -r -s -p "> " value Date: Tue, 21 Apr 2026 23:46:49 +0300 Subject: [PATCH 071/185] feat(setup): prompt for display name, hardcode agent persona MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before the cli-agent step, ask the operator what the agent should call them (defaults to \$USER). The agent's own persona name is hardcoded to "Terminal Agent" — this is the scratch CLI agent, not one of the operator's real personas. NANOCLAW_DISPLAY_NAME still skips the prompt. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 3945d82..9a37a45 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -7,8 +7,9 @@ * module check). This driver picks up from there. * * Config via env: - * NANOCLAW_DISPLAY_NAME operator name for the CLI agent (default: $USER) - * NANOCLAW_AGENT_NAME agent persona name (default: display name) + * NANOCLAW_DISPLAY_NAME operator name for the CLI agent — skips the + * interactive prompt before cli-agent. If unset, + * the driver asks, defaulting to $USER. * NANOCLAW_SKIP comma-separated step names to skip * (environment|container|onecli|auth| * mounts|service|cli-agent|verify) @@ -24,6 +25,9 @@ * and `/manage-channels` after this driver completes. */ import { spawn, spawnSync } from 'child_process'; +import { createInterface } from 'readline/promises'; + +const CLI_AGENT_NAME = 'Terminal Agent'; type Fields = Record; type StepResult = { ok: boolean; fields: Fields; exitCode: number }; @@ -114,6 +118,18 @@ function anthropicSecretExists(): boolean { } } +async function askDisplayName(fallback: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question( + `\nWhat should the agent call you? [${fallback}]: `, + ); + return answer.trim() || fallback; + } finally { + rl.close(); + } +} + function runBashScript(relPath: string): Promise { return new Promise((resolve) => { const child = spawn('bash', [relPath], { stdio: 'inherit' }); @@ -223,19 +239,20 @@ async function main(): Promise { } if (!skip.has('cli-agent')) { - const displayName = - process.env.NANOCLAW_DISPLAY_NAME?.trim() || - process.env.USER?.trim() || - 'Operator'; - const agentName = process.env.NANOCLAW_AGENT_NAME?.trim(); - const args = ['--display-name', displayName]; - if (agentName) args.push('--agent-name', agentName); + const fallback = process.env.USER?.trim() || 'Operator'; + const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim(); + const displayName = preset || (await askDisplayName(fallback)); - const res = await runStep('cli-agent', args); + const res = await runStep('cli-agent', [ + '--display-name', + displayName, + '--agent-name', + CLI_AGENT_NAME, + ]); if (!res.ok) { fail( 'CLI agent wiring failed', - 'Re-run `pnpm exec tsx scripts/init-cli-agent.ts --display-name ""` to fix.', + `Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`, ); } } From 85faa3eab08171c815673cbbe72f177264abf519 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 23:49:28 +0300 Subject: [PATCH 072/185] fix(setup): rephrase display-name prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Your agents" — the name is stored on the operator's user row and applies to every future agent they wire up, not just this scratch CLI one. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup/auto.ts b/setup/auto.ts index 9a37a45..dbe8733 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -122,7 +122,7 @@ async function askDisplayName(fallback: string): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); try { const answer = await rl.question( - `\nWhat should the agent call you? [${fallback}]: `, + `\nWhat should your agents call you? [${fallback}]: `, ); return answer.trim() || fallback; } finally { From c87cd250b2989072e5fd12770cc65325b2d50797 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 23:52:51 +0300 Subject: [PATCH 073/185] feat(verify): end-to-end agent ping via CLI channel Verify now runs \`pnpm run chat ping\` silently and checks for a reply. Emits AGENT_PING=ok|no_reply|socket_error|skipped; skipped when the service isn't running or no groups are wired (those already fail the verify via other checks). Kills the child after 90s so a wedged container can't hang setup (chat.ts's own 120s timeout is too long here). setup:auto surfaces AGENT_PING!=ok in its failure summary. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 6 +++++ setup/verify.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index dbe8733..d3b8113 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -264,6 +264,12 @@ async function main(): Promise { if (res.fields.CREDENTIALS !== 'configured') { console.log(' • Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`'); } + if (res.fields.AGENT_PING && res.fields.AGENT_PING !== 'ok' && res.fields.AGENT_PING !== 'skipped') { + console.log( + ` • CLI agent did not reply (status: ${res.fields.AGENT_PING}). ` + + 'Check `logs/nanoclaw.log` and `groups/*/logs/container-*.log`, then try `pnpm run chat hi`.', + ); + } if (!res.fields.CONFIGURED_CHANNELS) { console.log( ' • Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …', diff --git a/setup/verify.ts b/setup/verify.ts index 6dd6a44..4be9c3f 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -4,7 +4,7 @@ * * Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks. */ -import { execSync } from 'child_process'; +import { execSync, spawn } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -175,12 +175,22 @@ export async function run(_args: string[]): Promise { mountAllowlist = 'configured'; } + // 7. End-to-end: ping the CLI agent and confirm it replies. Only run if + // everything upstream looks healthy, since a broken socket would just hang. + let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'skipped' = 'skipped'; + if (service === 'running' && registeredGroups > 0) { + log.info('Pinging CLI agent'); + agentPing = await pingCliAgent(); + log.info('Agent ping result', { agentPing }); + } + // Determine overall status const status = service === 'running' && credentials !== 'missing' && anyChannelConfigured && - registeredGroups > 0 + registeredGroups > 0 && + (agentPing === 'ok' || agentPing === 'skipped') ? 'success' : 'failed'; @@ -194,9 +204,55 @@ export async function run(_args: string[]): Promise { CHANNEL_AUTH: JSON.stringify(channelAuth), REGISTERED_GROUPS: registeredGroups, MOUNT_ALLOWLIST: mountAllowlist, + AGENT_PING: agentPing, STATUS: status, LOG: 'logs/setup.log', }); if (status === 'failed') process.exit(1); } + +/** + * Send a one-word message through the CLI channel and check for a reply. + * Silent by default — stdout/stderr of the child are captured but not + * forwarded. Kills the child after 90s so verify can't hang on a wedged + * agent (chat.ts's own timeout is 120s, which is too long for setup). + */ +function pingCliAgent(): Promise<'ok' | 'no_reply' | 'socket_error'> { + return new Promise((resolve) => { + const child = spawn('pnpm', ['run', 'chat', 'ping'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill('SIGKILL'); + resolve('no_reply'); + }, 90_000); + + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf-8'); + }); + child.on('close', (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + // chat.ts: exit 0 on reply, 2 on socket error, 3 on no reply. + if (code === 2) { + resolve('socket_error'); + } else if (code === 0 && stdout.trim().length > 0) { + resolve('ok'); + } else { + resolve('no_reply'); + } + }); + child.on('error', () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve('socket_error'); + }); + }); +} From 9c7e1d02af92a0b7ef3b5f6079a1dfb2883edd2e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 00:04:14 +0300 Subject: [PATCH 074/185] feat(setup): optional Telegram wiring in setup:auto After cli-agent, prompt the user to connect a messaging app. For now only Telegram is offered; "skip" falls through to the existing CLI flow. setup/add-telegram.sh runs the scriptable half of /add-telegram: fetch the channels branch, copy the adapter + pair-telegram files, append the self-registration import, install @chat-adapter/telegram@4.26.0 (pinned to match the skill), rebuild, collect TELEGRAM_BOT_TOKEN via silent paste, write .env + data/env/env, and kick the service so the new adapter is live. Idempotent throughout. setup:auto then runs the existing `pair-telegram` step with --intent main. The step emits the 4-digit code in its status stream, which is already forwarded to stdout by runStep. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 2 +- setup/add-telegram.sh | 134 ++++++++++++++++++++++++++++++++++++++++++ setup/auto.ts | 45 +++++++++++++- 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100755 setup/add-telegram.sh diff --git a/nanoclaw.sh b/nanoclaw.sh index 2a98f98..2dc0f04 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -4,7 +4,7 @@ # # Runs `bash setup.sh` (bootstrap: Node check, pnpm install, native module # verify), then `pnpm run setup:auto` (environment → container → onecli → -# auth → mounts → service → cli-agent → verify). +# auth → mounts → service → cli-agent → channel → verify). # # Everything that can be scripted runs unattended; the one interactive pause # is the auth step (browser sign-in or paste token/API key). diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh new file mode 100755 index 0000000..c822994 --- /dev/null +++ b/setup/add-telegram.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Install the Telegram adapter (Phase A of the /add-telegram skill), collect +# the bot token, write .env + data/env/env, and restart the service so the +# new adapter is live. Idempotent. +# +# Pair-telegram (the interactive code-sending step) is run separately by the +# caller (setup/auto.ts) so it can stream status blocks to the user. + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Keep in sync with .claude/skills/add-telegram/SKILL.md. +ADAPTER_VERSION="@chat-adapter/telegram@4.26.0" +CHANNELS_BRANCH="origin/channels" + +need_install() { + [[ ! -f src/channels/telegram.ts ]] && return 0 + [[ ! -f setup/pair-telegram.ts ]] && return 0 + ! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0 + return 1 +} + +if need_install; then + echo "[add-telegram] Fetching channels branch…" + git fetch origin channels >/dev/null 2>&1 + + echo "[add-telegram] Copying adapter files from $CHANNELS_BRANCH…" + for f in \ + src/channels/telegram.ts \ + src/channels/telegram-pairing.ts \ + src/channels/telegram-pairing.test.ts \ + src/channels/telegram-markdown-sanitize.ts \ + src/channels/telegram-markdown-sanitize.test.ts \ + setup/pair-telegram.ts + do + git show "$CHANNELS_BRANCH:$f" > "$f" + done + + # Append self-registration import if missing. + if ! grep -q "^import './telegram.js';" src/channels/index.ts; then + echo "import './telegram.js';" >> src/channels/index.ts + fi + + # Register pair-telegram step if not already in the STEPS map. + # Uses node (not sed) since sed's in-place + escape semantics differ + # between BSD (macOS) and GNU. + node -e ' + const fs = require("fs"); + const p = "setup/index.ts"; + let s = fs.readFileSync(p, "utf-8"); + if (!s.includes("\047pair-telegram\047")) { + s = s.replace( + /(register: \(\) => import\(\x27\.\/register\.js\x27\),)/, + "$1\n \x27pair-telegram\x27: () => import(\x27./pair-telegram.js\x27)," + ); + fs.writeFileSync(p, s); + } + ' + + echo "[add-telegram] Installing $ADAPTER_VERSION…" + pnpm install "$ADAPTER_VERSION" + + echo "[add-telegram] Building…" + pnpm run build >/dev/null +else + echo "[add-telegram] Adapter files already installed — skipping install phase." +fi + +# Token collection. +if grep -q '^TELEGRAM_BOT_TOKEN=.' .env 2>/dev/null; then + echo "[add-telegram] TELEGRAM_BOT_TOKEN already set in .env — skipping token prompt." +else + cat <<'EOF' + +── Create a Telegram bot ────────────────────────────────────── + + 1. Open Telegram and message @BotFather + 2. Send: /newbot + 3. Follow the prompts (bot name, username ending in "bot") + 4. Copy the token it gives you (format: :) + +Optional but recommended for groups: + 5. @BotFather → /mybots → your bot → Bot Settings → Group Privacy → OFF + +EOF + echo "Paste your TELEGRAM_BOT_TOKEN and press Enter." + echo "Nothing will appear on the screen as you paste — that's intentional." + echo "Paste once, then just press Enter to submit." + read -r -s -p "> " TOKEN &2 + exit 1 + fi + + # Telegram bot tokens: :<35+ base64url-ish chars>. + if [[ ! "$TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then + echo "[add-telegram] Token format looks wrong (expected :). Aborting." >&2 + exit 1 + fi + + touch .env + if grep -q '^TELEGRAM_BOT_TOKEN=' .env; then + awk -v tok="$TOKEN" '/^TELEGRAM_BOT_TOKEN=/{print "TELEGRAM_BOT_TOKEN=" tok; next} {print}' \ + .env > .env.tmp && mv .env.tmp .env + else + echo "TELEGRAM_BOT_TOKEN=$TOKEN" >> .env + fi +fi + +# Container reads from data/env/env (the host mounts it). +mkdir -p data/env +cp .env data/env/env + +echo "[add-telegram] Restarting service so the new adapter picks up the token…" +case "$(uname -s)" in + Darwin) + launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >/dev/null 2>&1 || true + ;; + Linux) + systemctl --user restart nanoclaw >/dev/null 2>&1 \ + || sudo systemctl restart nanoclaw >/dev/null 2>&1 \ + || true + ;; +esac + +# Give the Telegram adapter a moment to finish starting before pair-telegram +# begins polling for the user's code message. +sleep 5 + +echo "[add-telegram] Install + credentials complete." diff --git a/setup/auto.ts b/setup/auto.ts index d3b8113..12a3070 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -11,8 +11,8 @@ * interactive prompt before cli-agent. If unset, * the driver asks, defaulting to $USER. * NANOCLAW_SKIP comma-separated step names to skip - * (environment|container|onecli|auth| - * mounts|service|cli-agent|verify) + * (environment|container|onecli|auth|mounts| + * service|cli-agent|channel|verify) * * Timezone is not configured here — it defaults to the host system's TZ. * Run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` later @@ -130,6 +130,19 @@ async function askDisplayName(fallback: string): Promise { } } +async function askChannelChoice(): Promise<'telegram' | 'skip'> { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + console.log('\nConnect a messaging app so you can chat from your phone?'); + console.log(' 1) Telegram'); + console.log(' 2) Skip — just use the CLI for now'); + const answer = (await rl.question('Choose [1/2]: ')).trim(); + return answer === '1' ? 'telegram' : 'skip'; + } finally { + rl.close(); + } +} + function runBashScript(relPath: string): Promise { return new Promise((resolve) => { const child = spawn('bash', [relPath], { stdio: 'inherit' }); @@ -257,6 +270,34 @@ async function main(): Promise { } } + if (!skip.has('channel')) { + const choice = await askChannelChoice(); + if (choice === 'telegram') { + const installCode = await runBashScript('setup/add-telegram.sh'); + if (installCode !== 0) { + fail( + 'Telegram install failed.', + 'Re-run `bash setup/add-telegram.sh`, then `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', + ); + } + + console.log( + '\n[setup:auto] Pairing Telegram. A 4-digit code will appear below.\n' + + ' From Telegram, send just those 4 digits to your bot\n' + + ' (DM the bot for a personal chat, or prefix with your\n' + + ' bot handle in a group with privacy on).\n', + ); + + const pair = await runStep('pair-telegram', ['--intent', 'main']); + if (!pair.ok) { + fail( + 'Telegram pairing failed.', + 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', + ); + } + } + } + if (!skip.has('verify')) { const res = await runStep('verify'); if (!res.ok) { From 92c28a956de32c631612c5550a0f1677f07bdd77 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 00:11:35 +0300 Subject: [PATCH 075/185] feat(setup): run init-first-agent after Telegram pairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pair-telegram only identifies the chat and operator — it returns PLATFORM_ID and ADMIN_USER_ID but doesn't create the agent group, grant owner, or send the welcome. scripts/init-first-agent.ts does that, matching the pattern the /new-setup skill already uses for channel wiring. Also prompts for the agent's own name (default: Nano), overridable via NANOCLAW_AGENT_NAME. displayName is hoisted out of the cli-agent block so both cli-agent and channel wiring share the value. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 75 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 12a3070..e76d9cf 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -7,9 +7,11 @@ * module check). This driver picks up from there. * * Config via env: - * NANOCLAW_DISPLAY_NAME operator name for the CLI agent — skips the - * interactive prompt before cli-agent. If unset, - * the driver asks, defaulting to $USER. + * NANOCLAW_DISPLAY_NAME how the agents address the operator — skips the + * prompt. Defaults to $USER. + * NANOCLAW_AGENT_NAME name for the messaging-channel agent (Telegram, + * etc.) — skips the prompt. Defaults to "Nano". + * (The CLI scratch agent is always "Terminal Agent".) * NANOCLAW_SKIP comma-separated step names to skip * (environment|container|onecli|auth|mounts| * service|cli-agent|channel|verify) @@ -28,6 +30,7 @@ import { spawn, spawnSync } from 'child_process'; import { createInterface } from 'readline/promises'; const CLI_AGENT_NAME = 'Terminal Agent'; +const DEFAULT_AGENT_NAME = 'Nano'; type Fields = Record; type StepResult = { ok: boolean; fields: Fields; exitCode: number }; @@ -130,6 +133,18 @@ async function askDisplayName(fallback: string): Promise { } } +async function askAgentName(fallback: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question( + `\nWhat should your agent be called? [${fallback}]: `, + ); + return answer.trim() || fallback; + } finally { + rl.close(); + } +} + async function askChannelChoice(): Promise<'telegram' | 'skip'> { const rl = createInterface({ input: process.stdin, output: process.stdout }); try { @@ -150,6 +165,15 @@ function runBashScript(relPath: string): Promise { }); } +function runTsxScript(relPath: string, args: string[] = []): Promise { + return new Promise((resolve) => { + const child = spawn('pnpm', ['exec', 'tsx', relPath, ...args], { + stdio: 'inherit', + }); + child.on('close', (code) => resolve(code ?? 1)); + }); +} + function fail(msg: string, hint?: string): never { console.error(`\n[setup:auto] ${msg}`); if (hint) console.error(` ${hint}`); @@ -251,21 +275,26 @@ async function main(): Promise { } } - if (!skip.has('cli-agent')) { + // Resolved once, reused by cli-agent + channel wiring. + let displayName: string | undefined; + const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel'); + if (needsDisplayName) { const fallback = process.env.USER?.trim() || 'Operator'; const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim(); - const displayName = preset || (await askDisplayName(fallback)); + displayName = preset || (await askDisplayName(fallback)); + } + if (!skip.has('cli-agent')) { const res = await runStep('cli-agent', [ '--display-name', - displayName, + displayName!, '--agent-name', CLI_AGENT_NAME, ]); if (!res.ok) { fail( 'CLI agent wiring failed', - `Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`, + `Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`, ); } } @@ -295,6 +324,38 @@ async function main(): Promise { 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', ); } + + const platformId = pair.fields.PLATFORM_ID; + const adminUserId = pair.fields.ADMIN_USER_ID; + if (!platformId || !adminUserId) { + fail( + 'pair-telegram succeeded but did not return PLATFORM_ID and ADMIN_USER_ID.', + 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main` and capture the success block.', + ); + } + + const agentName = + process.env.NANOCLAW_AGENT_NAME?.trim() || + (await askAgentName(DEFAULT_AGENT_NAME)); + + console.log('\n── wiring first agent ──────────────────────────'); + const initCode = await runTsxScript('scripts/init-first-agent.ts', [ + '--channel', 'telegram', + '--user-id', adminUserId, + '--platform-id', platformId, + '--display-name', displayName!, + '--agent-name', agentName, + ]); + if (initCode !== 0) { + fail( + 'Wiring the Telegram agent failed.', + `Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${adminUserId}" --platform-id "${platformId}" --display-name "${displayName!}" --agent-name "${agentName}"\`.`, + ); + } + + console.log( + `\n[setup:auto] Telegram is wired. ${agentName} will DM you a welcome shortly.`, + ); } } From e7d798b00da16f2925ed50402b232d76935780f7 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 00:17:42 +0300 Subject: [PATCH 076/185] feat(setup): validate Telegram token via getMe and deep-link to bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the token is in .env, call https://api.telegram.org/bot/getMe — if ok, extract the bot's username and \`open tg://resolve?domain=\` so the Telegram desktop app lands on the bot chat. When pair-telegram prints the 4-digit code a moment later, the user just types it into the already- open chat instead of hunting for their bot. Falls back to https://t.me/ if the tg:// scheme isn't registered, and just warns-and-continues if getMe fails (network hiccup shouldn't block setup). Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-telegram.sh | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index c822994..8183c33 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -111,10 +111,46 @@ EOF fi fi +# Validate the token via getMe so a typo surfaces before we restart the +# service, and capture the bot's username for the deep link. +TELEGRAM_BOT_TOKEN_VALUE="$(grep '^TELEGRAM_BOT_TOKEN=' .env | head -1 | cut -d= -f2-)" +BOT_USERNAME="" +if [[ -n "$TELEGRAM_BOT_TOKEN_VALUE" ]]; then + INFO=$(curl -fsS --max-time 8 \ + "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN_VALUE}/getMe" 2>/dev/null || true) + if echo "$INFO" | grep -q '"ok":true'; then + # Crude JSON parse — the response is always a flat object here. + BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p') + if [[ -n "$BOT_USERNAME" ]]; then + echo "[add-telegram] Token validated — bot is @${BOT_USERNAME}." + fi + else + echo "[add-telegram] Warning: getMe did not return ok. Continuing, but the token may be wrong." + fi +fi + # Container reads from data/env/env (the host mounts it). mkdir -p data/env cp .env data/env/env +# Deep-link into the bot's chat in the installed Telegram app so the user +# is already on the right screen when pair-telegram prints the code. +if [[ -n "$BOT_USERNAME" ]]; then + case "$(uname -s)" in + Darwin) + open "tg://resolve?domain=${BOT_USERNAME}" >/dev/null 2>&1 \ + || open "https://t.me/${BOT_USERNAME}" >/dev/null 2>&1 \ + || true + ;; + Linux) + xdg-open "tg://resolve?domain=${BOT_USERNAME}" >/dev/null 2>&1 \ + || xdg-open "https://t.me/${BOT_USERNAME}" >/dev/null 2>&1 \ + || true + ;; + esac + echo "[add-telegram] Opened Telegram → @${BOT_USERNAME}. Keep it open for the pairing code." +fi + echo "[add-telegram] Restarting service so the new adapter picks up the token…" case "$(uname -s)" in Darwin) From 5a472c4155ec81424b8ee8ebbecdcc9468e43090 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 00:19:23 +0300 Subject: [PATCH 077/185] fix(setup): print bot URL alongside the deep-link attempt Headless / SSH / WSL users won't have \`open\` or \`xdg-open\` wired up, so the deep-link fails silently and they have no clue where to go. Always print https://t.me/ so the URL is at least clickable or copy-pasteable from the terminal. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-telegram.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 8183c33..13ffaa9 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -134,21 +134,24 @@ mkdir -p data/env cp .env data/env/env # Deep-link into the bot's chat in the installed Telegram app so the user -# is already on the right screen when pair-telegram prints the code. +# is already on the right screen when pair-telegram prints the code. Also +# always print the URL so headless / remote-SSH users can open it manually. if [[ -n "$BOT_USERNAME" ]]; then + BOT_URL="https://t.me/${BOT_USERNAME}" case "$(uname -s)" in Darwin) open "tg://resolve?domain=${BOT_USERNAME}" >/dev/null 2>&1 \ - || open "https://t.me/${BOT_USERNAME}" >/dev/null 2>&1 \ + || open "$BOT_URL" >/dev/null 2>&1 \ || true ;; Linux) xdg-open "tg://resolve?domain=${BOT_USERNAME}" >/dev/null 2>&1 \ - || xdg-open "https://t.me/${BOT_USERNAME}" >/dev/null 2>&1 \ + || xdg-open "$BOT_URL" >/dev/null 2>&1 \ || true ;; esac - echo "[add-telegram] Opened Telegram → @${BOT_USERNAME}. Keep it open for the pairing code." + echo "[add-telegram] Bot chat: ${BOT_URL}" + echo "[add-telegram] (If Telegram didn't open automatically, click the link above.)" fi echo "[add-telegram] Restarting service so the new adapter picks up the token…" From 356a4d0a9fd3fe7da7444065a56a603131f016cf Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 00:22:53 +0300 Subject: [PATCH 078/185] feat(setup): render Telegram pairing code in a focused banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pair-telegram step emits PAIR_TELEGRAM_ISSUED / _NEW_CODE / _ATTEMPT blocks meant for /setup skill parsing — dumping them raw in setup:auto left the operator squinting at key/value clutter. Intercept the stream line-by-line, suppress the block framing, and print just the 4-digit code inside a box with a short instruction. Wrong-code attempts and the final success block also get short human lines. parseStatus still runs on the full buffered output at close so PLATFORM_ID / ADMIN_USER_ID flow through unchanged to init-first-agent. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 122 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 8 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index e76d9cf..096368c 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -81,6 +81,119 @@ function runStep(name: string, extra: string[] = []): Promise { }); } +/** + * Variant of runStep for `pair-telegram`. The step emits machine-readable + * status blocks (PAIR_TELEGRAM_ISSUED, PAIR_TELEGRAM_ATTEMPT, etc.) meant + * for the /setup skill to parse and relay. Running it directly leaves the + * operator staring at noisy blocks — this filters them and renders a + * focused banner around the 4-digit code instead. + */ +function runPairTelegram(intent: string): Promise { + return new Promise((resolve) => { + console.log('\n── pair-telegram ───────────────────────────────'); + const args = [ + 'exec', 'tsx', 'setup/index.ts', + '--step', 'pair-telegram', + '--', '--intent', intent, + ]; + const child = spawn('pnpm', args, { stdio: ['inherit', 'pipe', 'inherit'] }); + + let buf = ''; + let partial = ''; + let inBlock = false; + let blockType = ''; + let blockFields: Record = {}; + + function handleLine(line: string): void { + if (line.startsWith('=== NANOCLAW SETUP:')) { + inBlock = true; + blockType = line.replace('=== NANOCLAW SETUP:', '').replace('===', '').trim(); + blockFields = {}; + return; + } + if (line.startsWith('=== END ===')) { + inBlock = false; + renderBlock(blockType, blockFields); + return; + } + if (inBlock) { + const idx = line.indexOf(':'); + if (idx > -1) { + blockFields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + return; + } + process.stdout.write(line + '\n'); + } + + function renderBlock(type: string, fields: Record): void { + switch (type) { + case 'PAIR_TELEGRAM_ISSUED': + printCodeBanner(fields.CODE ?? '????'); + break; + case 'PAIR_TELEGRAM_NEW_CODE': + console.log('\n Previous code invalidated. New code:'); + printCodeBanner(fields.CODE ?? '????'); + break; + case 'PAIR_TELEGRAM_ATTEMPT': + console.log( + ` Got "${fields.RECEIVED_CODE ?? '?'}" — doesn't match. A new code is on its way.`, + ); + break; + case 'PAIR_TELEGRAM': + if (fields.STATUS === 'success') { + console.log('\n ✓ Telegram paired.'); + } else if (fields.STATUS === 'failed') { + console.log(`\n ✗ Pairing failed: ${fields.ERROR ?? 'unknown'}`); + } + break; + default: { + // Forward unknown blocks verbatim (forward-compat). + const lines = [`=== NANOCLAW SETUP: ${type} ===`]; + for (const [k, v] of Object.entries(fields)) lines.push(`${k}: ${v}`); + lines.push('=== END ==='); + process.stdout.write(lines.join('\n') + '\n'); + } + } + } + + child.stdout.on('data', (chunk: Buffer) => { + const s = chunk.toString('utf-8'); + buf += s; + partial += s; + const lines = partial.split('\n'); + partial = lines.pop() ?? ''; + for (const line of lines) handleLine(line); + }); + child.on('close', (code) => { + if (partial) handleLine(partial); + const fields = parseStatus(buf); + resolve({ + ok: code === 0 && fields.STATUS === 'success', + fields, + exitCode: code ?? 1, + }); + }); + }); +} + +function printCodeBanner(code: string): void { + // Double-space between digits for readability in a 4-digit code. + const digits = code.trim().split('').join(' '); + const content = [ + '', + ` PAIRING CODE: ${digits}`, + '', + ' Send these digits from Telegram to your bot.', + '', + ]; + const width = Math.max(...content.map((l) => l.length)); + const top = ' ╔' + '═'.repeat(width + 2) + '╗'; + const bot = ' ╚' + '═'.repeat(width + 2) + '╝'; + const mid = content.map((l) => ' ║ ' + l.padEnd(width) + ' ║'); + console.log(['', top, ...mid, bot, ''].join('\n')); +} + /** * After installing Docker, this process's supplementary groups are still * frozen from login — subsequent steps that talk to /var/run/docker.sock @@ -310,14 +423,7 @@ async function main(): Promise { ); } - console.log( - '\n[setup:auto] Pairing Telegram. A 4-digit code will appear below.\n' + - ' From Telegram, send just those 4 digits to your bot\n' + - ' (DM the bot for a personal chat, or prefix with your\n' + - ' bot handle in a group with privacy on).\n', - ); - - const pair = await runStep('pair-telegram', ['--intent', 'main']); + const pair = await runPairTelegram('main'); if (!pair.ok) { fail( 'Telegram pairing failed.', From e24ecbf8b083be9ecf5cacd8e460c64ae7e0b9c7 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 00:27:43 +0300 Subject: [PATCH 079/185] refactor(setup): own pair-telegram.ts in this branch with clean output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously setup:auto parsed pair-telegram's machine-readable status blocks and rendered a banner on top. Fork the script instead: check in setup/pair-telegram.ts with a focused 4-digit banner, a short wrong-attempt line, and a single final PAIR_TELEGRAM status block (kept so the parent driver still picks up PLATFORM_ID and PAIRED_USER_ID via parseStatus). Drop pair-telegram.ts from add-telegram.sh's copy list so the local version isn't overwritten on re-runs. The other adapter files (telegram.ts, telegram-pairing.ts, etc.) still come from the channels branch. Also fix a latent bug: auto.ts was reading ADMIN_USER_ID from the success block, but the actual field name is PAIRED_USER_ID — init-first-agent would have been called with --user-id "". Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-telegram.sh | 6 +- setup/auto.ts | 125 ++--------------------------------------- setup/pair-telegram.ts | 124 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 122 deletions(-) create mode 100644 setup/pair-telegram.ts diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 13ffaa9..262502d 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -17,7 +17,6 @@ CHANNELS_BRANCH="origin/channels" need_install() { [[ ! -f src/channels/telegram.ts ]] && return 0 - [[ ! -f setup/pair-telegram.ts ]] && return 0 ! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0 return 1 } @@ -26,14 +25,15 @@ if need_install; then echo "[add-telegram] Fetching channels branch…" git fetch origin channels >/dev/null 2>&1 + # pair-telegram.ts is maintained in this branch (setup-auto), so it's NOT + # in this list — do not overwrite the local version with the channels copy. echo "[add-telegram] Copying adapter files from $CHANNELS_BRANCH…" for f in \ src/channels/telegram.ts \ src/channels/telegram-pairing.ts \ src/channels/telegram-pairing.test.ts \ src/channels/telegram-markdown-sanitize.ts \ - src/channels/telegram-markdown-sanitize.test.ts \ - setup/pair-telegram.ts + src/channels/telegram-markdown-sanitize.test.ts do git show "$CHANNELS_BRANCH:$f" > "$f" done diff --git a/setup/auto.ts b/setup/auto.ts index 096368c..d1358ca 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -81,119 +81,6 @@ function runStep(name: string, extra: string[] = []): Promise { }); } -/** - * Variant of runStep for `pair-telegram`. The step emits machine-readable - * status blocks (PAIR_TELEGRAM_ISSUED, PAIR_TELEGRAM_ATTEMPT, etc.) meant - * for the /setup skill to parse and relay. Running it directly leaves the - * operator staring at noisy blocks — this filters them and renders a - * focused banner around the 4-digit code instead. - */ -function runPairTelegram(intent: string): Promise { - return new Promise((resolve) => { - console.log('\n── pair-telegram ───────────────────────────────'); - const args = [ - 'exec', 'tsx', 'setup/index.ts', - '--step', 'pair-telegram', - '--', '--intent', intent, - ]; - const child = spawn('pnpm', args, { stdio: ['inherit', 'pipe', 'inherit'] }); - - let buf = ''; - let partial = ''; - let inBlock = false; - let blockType = ''; - let blockFields: Record = {}; - - function handleLine(line: string): void { - if (line.startsWith('=== NANOCLAW SETUP:')) { - inBlock = true; - blockType = line.replace('=== NANOCLAW SETUP:', '').replace('===', '').trim(); - blockFields = {}; - return; - } - if (line.startsWith('=== END ===')) { - inBlock = false; - renderBlock(blockType, blockFields); - return; - } - if (inBlock) { - const idx = line.indexOf(':'); - if (idx > -1) { - blockFields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); - } - return; - } - process.stdout.write(line + '\n'); - } - - function renderBlock(type: string, fields: Record): void { - switch (type) { - case 'PAIR_TELEGRAM_ISSUED': - printCodeBanner(fields.CODE ?? '????'); - break; - case 'PAIR_TELEGRAM_NEW_CODE': - console.log('\n Previous code invalidated. New code:'); - printCodeBanner(fields.CODE ?? '????'); - break; - case 'PAIR_TELEGRAM_ATTEMPT': - console.log( - ` Got "${fields.RECEIVED_CODE ?? '?'}" — doesn't match. A new code is on its way.`, - ); - break; - case 'PAIR_TELEGRAM': - if (fields.STATUS === 'success') { - console.log('\n ✓ Telegram paired.'); - } else if (fields.STATUS === 'failed') { - console.log(`\n ✗ Pairing failed: ${fields.ERROR ?? 'unknown'}`); - } - break; - default: { - // Forward unknown blocks verbatim (forward-compat). - const lines = [`=== NANOCLAW SETUP: ${type} ===`]; - for (const [k, v] of Object.entries(fields)) lines.push(`${k}: ${v}`); - lines.push('=== END ==='); - process.stdout.write(lines.join('\n') + '\n'); - } - } - } - - child.stdout.on('data', (chunk: Buffer) => { - const s = chunk.toString('utf-8'); - buf += s; - partial += s; - const lines = partial.split('\n'); - partial = lines.pop() ?? ''; - for (const line of lines) handleLine(line); - }); - child.on('close', (code) => { - if (partial) handleLine(partial); - const fields = parseStatus(buf); - resolve({ - ok: code === 0 && fields.STATUS === 'success', - fields, - exitCode: code ?? 1, - }); - }); - }); -} - -function printCodeBanner(code: string): void { - // Double-space between digits for readability in a 4-digit code. - const digits = code.trim().split('').join(' '); - const content = [ - '', - ` PAIRING CODE: ${digits}`, - '', - ' Send these digits from Telegram to your bot.', - '', - ]; - const width = Math.max(...content.map((l) => l.length)); - const top = ' ╔' + '═'.repeat(width + 2) + '╗'; - const bot = ' ╚' + '═'.repeat(width + 2) + '╝'; - const mid = content.map((l) => ' ║ ' + l.padEnd(width) + ' ║'); - console.log(['', top, ...mid, bot, ''].join('\n')); -} - /** * After installing Docker, this process's supplementary groups are still * frozen from login — subsequent steps that talk to /var/run/docker.sock @@ -423,7 +310,7 @@ async function main(): Promise { ); } - const pair = await runPairTelegram('main'); + const pair = await runStep('pair-telegram', ['--intent', 'main']); if (!pair.ok) { fail( 'Telegram pairing failed.', @@ -432,10 +319,10 @@ async function main(): Promise { } const platformId = pair.fields.PLATFORM_ID; - const adminUserId = pair.fields.ADMIN_USER_ID; - if (!platformId || !adminUserId) { + const pairedUserId = pair.fields.PAIRED_USER_ID; + if (!platformId || !pairedUserId) { fail( - 'pair-telegram succeeded but did not return PLATFORM_ID and ADMIN_USER_ID.', + 'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.', 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main` and capture the success block.', ); } @@ -447,7 +334,7 @@ async function main(): Promise { console.log('\n── wiring first agent ──────────────────────────'); const initCode = await runTsxScript('scripts/init-first-agent.ts', [ '--channel', 'telegram', - '--user-id', adminUserId, + '--user-id', pairedUserId, '--platform-id', platformId, '--display-name', displayName!, '--agent-name', agentName, @@ -455,7 +342,7 @@ async function main(): Promise { if (initCode !== 0) { fail( 'Wiring the Telegram agent failed.', - `Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${adminUserId}" --platform-id "${platformId}" --display-name "${displayName!}" --agent-name "${agentName}"\`.`, + `Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${pairedUserId}" --platform-id "${platformId}" --display-name "${displayName!}" --agent-name "${agentName}"\`.`, ); } diff --git a/setup/pair-telegram.ts b/setup/pair-telegram.ts new file mode 100644 index 0000000..cf7259b --- /dev/null +++ b/setup/pair-telegram.ts @@ -0,0 +1,124 @@ +/** + * Step: pair-telegram — issue a one-time pairing code and wait for the + * operator to send the code from the chat they want to register. + * + * Used exclusively by `setup:auto` / `bash nanoclaw.sh` on this branch. Human- + * facing output is a focused banner for the code (no parseable block), plus a + * short line for wrong attempts / regenerations. A single machine-readable + * PAIR_TELEGRAM status block is still emitted at the end so the parent driver + * can pick up PLATFORM_ID / PAIRED_USER_ID / IS_GROUP. + * + * Depends on src/channels/telegram-pairing.js, which setup/add-telegram.sh + * copies in from the `channels` branch before this step runs. setup/ is + * excluded from the host tsconfig, so this file's import resolves only at + * runtime — tsc won't complain on branches that haven't run add-telegram yet. + */ +import path from 'path'; + +import { + createPairing, + waitForPairing, + type PairingIntent, +} from '../src/channels/telegram-pairing.js'; +import { DATA_DIR } from '../src/config.js'; +import { initDb } from '../src/db/connection.js'; +import { runMigrations } from '../src/db/migrations/index.js'; + +import { emitStatus } from './status.js'; + +function parseArgs(args: string[]): PairingIntent { + let intent: PairingIntent = 'main'; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--intent') { + const raw = args[++i] || 'main'; + if (raw === 'main') { + intent = 'main'; + } else if (raw.startsWith('wire-to:')) { + intent = { kind: 'wire-to', folder: raw.slice('wire-to:'.length) }; + } else if (raw.startsWith('new-agent:')) { + intent = { kind: 'new-agent', folder: raw.slice('new-agent:'.length) }; + } else { + throw new Error(`Unknown intent: ${raw}`); + } + } + } + return intent; +} + +function intentToString(intent: PairingIntent): string { + if (intent === 'main') return 'main'; + return `${intent.kind}:${intent.folder}`; +} + +function printCodeBanner(code: string): void { + const digits = code.split('').join(' '); + const content = [ + '', + ` PAIRING CODE: ${digits}`, + '', + ' Send these digits from Telegram to your bot.', + '', + ]; + const width = Math.max(...content.map((l) => l.length)); + const top = ' ╔' + '═'.repeat(width + 2) + '╗'; + const bot = ' ╚' + '═'.repeat(width + 2) + '╝'; + const mid = content.map((l) => ' ║ ' + l.padEnd(width) + ' ║'); + console.log(['', top, ...mid, bot, ''].join('\n')); +} + +export async function run(args: string[]): Promise { + const intent = parseArgs(args); + + // Pairing stores state under DATA_DIR; the DB isn't strictly needed for the + // pairing primitive itself, but the inbound interceptor running inside the + // live service needs migrations applied. Touch it here so a fresh install + // doesn't fail on the first code match. + const db = initDb(path.join(DATA_DIR, 'v2.db')); + runMigrations(db); + + const MAX_REGENERATIONS = 5; + let record = await createPairing(intent); + printCodeBanner(record.code); + + for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) { + try { + const consumed = await waitForPairing(record.code, { + onAttempt: (a) => { + console.log( + ` Got "${a.candidate}" — doesn't match. A new code is on its way.`, + ); + }, + }); + + console.log('\n ✓ Telegram paired.\n'); + emitStatus('PAIR_TELEGRAM', { + STATUS: 'success', + CODE: record.code, + INTENT: intentToString(consumed.intent), + PLATFORM_ID: consumed.consumed!.platformId, + IS_GROUP: consumed.consumed!.isGroup, + PAIRED_USER_ID: consumed.consumed!.adminUserId + ? `telegram:${consumed.consumed!.adminUserId}` + : '', + }); + return; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const invalidated = /invalidated by wrong code/.test(message); + if (invalidated && regen < MAX_REGENERATIONS) { + record = await createPairing(intent); + console.log('\n Previous code invalidated. New code:'); + printCodeBanner(record.code); + continue; + } + const reason = invalidated ? 'max-regenerations-exceeded' : message; + console.error(`\n ✗ Pairing failed: ${reason}`); + emitStatus('PAIR_TELEGRAM', { + STATUS: 'failed', + CODE: record.code, + ERROR: reason, + }); + process.exit(2); + } + } +} From 6e0d742a7fdc0335d3ced3c2951bcda4e7ffd473 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 01:09:26 +0300 Subject: [PATCH 080/185] feat(setup): brand setup:auto with @clack/prompts + brand palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the scripted setup flow in a branded, friendly UI. Each step runs under a clack spinner with elapsed time; child stdout/stderr is captured quietly and dumped only on failure. Interactive children (token paste, Anthropic OAuth) bypass the spinner and inherit the TTY. - intro: NanoClaw wordmark + brand-cyan accent chip, truecolor with kleur fallback and NO_COLOR / non-TTY awareness - pair-telegram: emits PAIR_TELEGRAM_CODE / _ATTEMPT status blocks only; auto.ts renders clack notes + "received X — doesn't match" checkpoints - streaming status-block parser handles mid-step events without waiting for the child to exit - terminal-block detection now finds any block with a STATUS field (handles MOUNTS emitting CONFIGURE_MOUNTS, etc.) and treats 'skipped' as a success variant with an optional friendlier label Also fixes a latent bash bug where `$VAR…` (unbraced followed by a multi-byte Unicode character) pulled ellipsis bytes into the variable name lookup and tripped `set -u`. Braced `${VAR}` in add-telegram.sh and register-claude-token.sh. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 5 +- pnpm-lock.yaml | 76 +++++ setup/add-telegram.sh | 4 +- setup/auto.ts | 596 ++++++++++++++++++++++++--------- setup/pair-telegram.ts | 50 ++- setup/register-claude-token.sh | 2 +- 6 files changed, 541 insertions(+), 192 deletions(-) diff --git a/package.json b/package.json index a7f8804..536714f 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,13 @@ "test:watch": "vitest" }, "dependencies": { + "@chat-adapter/telegram": "4.26.0", + "@clack/prompts": "^1.2.0", "@onecli-sh/sdk": "^0.3.1", "better-sqlite3": "11.10.0", "chat": "^4.24.0", - "cron-parser": "5.5.0" + "cron-parser": "5.5.0", + "kleur": "^4.1.5" }, "devDependencies": { "@eslint/js": "^9.35.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1aa197..4f284a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@chat-adapter/telegram': + specifier: 4.26.0 + version: 4.26.0 + '@clack/prompts': + specifier: ^1.2.0 + version: 1.2.0 '@onecli-sh/sdk': specifier: ^0.3.1 version: 0.3.1 @@ -20,6 +26,9 @@ importers: cron-parser: specifier: 5.5.0 version: 5.5.0 + kleur: + specifier: ^4.1.5 + version: 4.1.5 devDependencies: '@eslint/js': specifier: ^9.35.0 @@ -60,6 +69,18 @@ importers: packages: + '@chat-adapter/shared@4.26.0': + resolution: {integrity: sha512-YD0MGktFXrArUqTBsyPfL5vkdD1WBS58PAWO0oVrMQAMmPxpAXfWGjBtZCkf3y8R8Svb0uVuVXiMZSForaEnMQ==} + + '@chat-adapter/telegram@4.26.0': + resolution: {integrity: sha512-PE2HoCQ4648VNKZTuHFanQNoYzM/niNoSbDyYlPq6VOoB5qsoo1ctR8TERyl1EfPBNexWZpSWYrrnQPr15LUfA==} + + '@clack/core@1.2.0': + resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==} + + '@clack/prompts@1.2.0': + resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==} + '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} @@ -748,6 +769,15 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@1.2.1: + resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==} + + fast-string-width@1.1.0: + resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==} + + fast-wrap-ansi@0.1.6: + resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -866,6 +896,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1239,6 +1273,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1462,6 +1499,31 @@ packages: snapshots: + '@chat-adapter/shared@4.26.0': + dependencies: + chat: 4.26.0 + transitivePeerDependencies: + - supports-color + + '@chat-adapter/telegram@4.26.0': + dependencies: + '@chat-adapter/shared': 4.26.0 + chat: 4.26.0 + transitivePeerDependencies: + - supports-color + + '@clack/core@1.2.0': + dependencies: + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + + '@clack/prompts@1.2.0': + dependencies: + '@clack/core': 1.2.0 + fast-string-width: 1.1.0 + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + '@emnapi/core@1.9.2': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2105,6 +2167,16 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@1.2.1: {} + + fast-string-width@1.1.0: + dependencies: + fast-string-truncated-width: 1.2.1 + + fast-wrap-ansi@0.1.6: + dependencies: + fast-string-width: 1.1.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -2191,6 +2263,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@4.1.5: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -2735,6 +2809,8 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + sisteransi@1.0.5: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 262502d..4d540af 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -27,7 +27,7 @@ if need_install; then # pair-telegram.ts is maintained in this branch (setup-auto), so it's NOT # in this list — do not overwrite the local version with the channels copy. - echo "[add-telegram] Copying adapter files from $CHANNELS_BRANCH…" + echo "[add-telegram] Copying adapter files from ${CHANNELS_BRANCH}…" for f in \ src/channels/telegram.ts \ src/channels/telegram-pairing.ts \ @@ -59,7 +59,7 @@ if need_install; then } ' - echo "[add-telegram] Installing $ADAPTER_VERSION…" + echo "[add-telegram] Installing ${ADAPTER_VERSION}…" pnpm install "$ADAPTER_VERSION" echo "[add-telegram] Building…" diff --git a/setup/auto.ts b/setup/auto.ts index d1358ca..482fcea 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -20,67 +20,249 @@ * Run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` later * if autodetect is wrong (e.g. headless server with TZ=UTC). * - * Anthropic credential registration runs via setup/register-claude-token.sh - * (the only step that truly requires human input — browser sign-in or a - * pasted token/key). Channel auth and `/manage-channels` remain separate - * because they're platform-specific and typically handled via `/add-` - * and `/manage-channels` after this driver completes. + * UI is rendered with @clack/prompts: spinners wrap each step, child output + * is captured quietly and only dumped on failure. Interactive children + * (register-claude-token.sh, add-telegram.sh) bypass the spinner and run + * with inherited stdio — clack resumes cleanly on the next step. */ import { spawn, spawnSync } from 'child_process'; -import { createInterface } from 'readline/promises'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; const CLI_AGENT_NAME = 'Terminal Agent'; const DEFAULT_AGENT_NAME = 'Nano'; -type Fields = Record; -type StepResult = { ok: boolean; fields: Fields; exitCode: number }; +/** + * Brand palette, pulled from assets/nanoclaw-logo.png: + * brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body + * brand navy ≈ #171B3B — the dark logo background + outlines + * Gated on TTY + NO_COLOR so piped / CI output stays plain. Falls back to + * kleur's 16-color cyan when the terminal isn't truecolor. + */ +const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; +const TRUECOLOR = + USE_ANSI && + (process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit'); -function parseStatus(stdout: string): Fields { - const out: Fields = {}; - let inBlock = false; - for (const line of stdout.split('\n')) { - if (line.startsWith('=== NANOCLAW SETUP:')) { - inBlock = true; - continue; +const brand = (s: string): string => { + if (!USE_ANSI) return s; + if (TRUECOLOR) return `\x1b[38;2;43;183;206m${s}\x1b[0m`; + return k.cyan(s); +}; +const brandBold = (s: string): string => { + if (!USE_ANSI) return s; + if (TRUECOLOR) return `\x1b[1;38;2;43;183;206m${s}\x1b[0m`; + return k.bold(k.cyan(s)); +}; +const brandChip = (s: string): string => { + if (!USE_ANSI) return s; + if (TRUECOLOR) { + return `\x1b[48;2;43;183;206m\x1b[38;2;23;27;59m\x1b[1m${s}\x1b[0m`; + } + return k.bgCyan(k.black(k.bold(s))); +}; + +type Fields = Record; +type Block = { type: string; fields: Fields }; +type StepResult = { + ok: boolean; + exitCode: number; + blocks: Block[]; + transcript: string; + /** The last block matching `stepName.toUpperCase()` if any. */ + terminal: Block | null; +}; + +/** + * Streaming parser for `=== NANOCLAW SETUP: TYPE ===` blocks. Emits each + * block as it closes so the UI can react mid-stream (e.g. render a pairing + * code card as soon as pair-telegram emits it, rather than after the step + * has finished). + */ +class StatusStream { + private lineBuf = ''; + private current: Block | null = null; + readonly blocks: Block[] = []; + transcript = ''; + + constructor(private readonly onBlock: (block: Block) => void) {} + + write(chunk: string): void { + this.transcript += chunk; + this.lineBuf += chunk; + let idx: number; + while ((idx = this.lineBuf.indexOf('\n')) !== -1) { + const line = this.lineBuf.slice(0, idx); + this.lineBuf = this.lineBuf.slice(idx + 1); + this.processLine(line); + } + } + + private processLine(line: string): void { + const start = line.match(/^=== NANOCLAW SETUP: (\S+) ===/); + if (start) { + this.current = { type: start[1], fields: {} }; + return; } if (line.startsWith('=== END ===')) { - inBlock = false; - continue; + if (this.current) { + this.blocks.push(this.current); + this.onBlock(this.current); + this.current = null; + } + return; } - if (!inBlock) continue; - const idx = line.indexOf(':'); - if (idx === -1) continue; - const key = line.slice(0, idx).trim(); - const value = line.slice(idx + 1).trim(); - if (key) out[key] = value; + if (!this.current) return; + const colon = line.indexOf(':'); + if (colon === -1) return; + const key = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + if (key) this.current.fields[key] = value; } - return out; } -function runStep(name: string, extra: string[] = []): Promise { +/** + * Spawn a setup step as a child process, swallowing stdout/stderr into a + * buffer. The provided onBlock callback fires per status block as they + * parse. Returns when the child exits. + */ +function spawnStep( + stepName: string, + extra: string[], + onBlock: (block: Block) => void, +): Promise { return new Promise((resolve) => { - console.log(`\n── ${name} ────────────────────────────────────`); - const args = ['exec', 'tsx', 'setup/index.ts', '--step', name]; + const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName]; if (extra.length > 0) args.push('--', ...extra); - const child = spawn('pnpm', args, { stdio: ['inherit', 'pipe', 'inherit'] }); - let buf = ''; - child.stdout.on('data', (chunk: Buffer) => { - const s = chunk.toString('utf-8'); - buf += s; - process.stdout.write(s); + const child = spawn('pnpm', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const stream = new StatusStream(onBlock); + + child.stdout.on('data', (chunk: Buffer) => stream.write(chunk.toString('utf-8'))); + child.stderr.on('data', (chunk: Buffer) => { + stream.transcript += chunk.toString('utf-8'); }); + child.on('close', (code) => { - const fields = parseStatus(buf); + // Step block types don't always mirror step names (e.g. `mounts` emits + // CONFIGURE_MOUNTS, `container` emits SETUP_CONTAINER). Any block with + // a STATUS field is a terminal block; the last one wins. + const terminal = + [...stream.blocks].reverse().find((b) => b.fields.STATUS) ?? null; + const status = terminal?.fields.STATUS; + const ok = code === 0 && (status === 'success' || status === 'skipped'); resolve({ - ok: code === 0 && fields.STATUS === 'success', - fields, + ok, exitCode: code ?? 1, + blocks: stream.blocks, + transcript: stream.transcript, + terminal, }); }); }); } +type SpinnerLabels = { + running: string; + done: string; + skipped?: string; + failed?: string; +}; + +/** Run a step under a clack spinner. Child output is captured; shown only on failure. */ +async function runQuietStep( + stepName: string, + labels: SpinnerLabels, + extra: string[] = [], +): Promise { + return runUnderSpinner(labels, () => spawnStep(stepName, extra, () => {})); +} + +/** Run an arbitrary child under a spinner, capturing its stdout/stderr. */ +async function runQuietChild( + cmd: string, + args: string[], + labels: SpinnerLabels, +): Promise<{ ok: boolean; exitCode: number; transcript: string }> { + return runUnderSpinner(labels, () => spawnQuiet(cmd, args)); +} + +async function runUnderSpinner< + T extends { ok: boolean; transcript: string; terminal?: Block | null }, +>( + labels: SpinnerLabels, + work: () => Promise, +): Promise { + const s = p.spinner(); + const start = Date.now(); + s.start(labels.running); + const tick = setInterval(() => { + const elapsed = Math.round((Date.now() - start) / 1000); + s.message(`${labels.running} ${k.dim(`(${elapsed}s)`)}`); + }, 1000); + + const result = await work(); + + clearInterval(tick); + const elapsed = Math.round((Date.now() - start) / 1000); + if (result.ok) { + const isSkipped = result.terminal?.fields.STATUS === 'skipped'; + const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; + s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`); + } else { + const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed'); + s.stop(`${failMsg} ${k.dim(`(${elapsed}s)`)}`, 1); + dumpTranscriptOnFailure(result.transcript); + } + return result; +} + +function spawnQuiet( + cmd: string, + args: string[], +): Promise<{ ok: boolean; exitCode: number; transcript: string }> { + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let transcript = ''; + child.stdout.on('data', (c: Buffer) => { transcript += c.toString('utf-8'); }); + child.stderr.on('data', (c: Buffer) => { transcript += c.toString('utf-8'); }); + child.on('close', (code) => { + resolve({ ok: code === 0, exitCode: code ?? 1, transcript }); + }); + }); +} + +function dumpTranscriptOnFailure(transcript: string): void { + const lines = transcript.split('\n').filter((l) => { + if (l.startsWith('=== NANOCLAW SETUP:')) return false; + if (l.startsWith('=== END ===')) return false; + return true; + }); + const tail = lines.slice(-40).join('\n').trimEnd(); + if (tail) { + console.log(); + console.log(k.dim(tail)); + console.log(); + } +} + +function fail(msg: string, hint?: string): never { + p.log.error(msg); + if (hint) p.log.message(k.dim(hint)); + p.log.message(k.dim('Logs: logs/setup.log')); + p.cancel('Setup aborted.'); + process.exit(1); +} + +function ensureAnswer(value: T | symbol): T { + if (p.isCancel(value)) { + p.cancel('Setup cancelled.'); + process.exit(0); + } + return value as T; +} + /** * After installing Docker, this process's supplementary groups are still * frozen from login — subsequent steps that talk to /var/run/docker.sock @@ -89,7 +271,7 @@ function runStep(name: string, extra: string[] = []): Promise { * so the rest of the run inherits the docker group without a re-login. */ function maybeReexecUnderSg(): void { - if (process.env.NANOCLAW_REEXEC_SG === '1') return; // already re-exec'd + if (process.env.NANOCLAW_REEXEC_SG === '1') return; if (process.platform !== 'linux') return; const info = spawnSync('docker', ['info'], { encoding: 'utf-8' }); if (info.status === 0) return; @@ -97,10 +279,7 @@ function maybeReexecUnderSg(): void { if (!/permission denied/i.test(err)) return; if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return; - console.log( - '\n[setup:auto] Docker socket not accessible in current group — ' + - 're-executing under `sg docker` to pick up new group membership.', - ); + p.log.warn('Docker socket not accessible in current group — re-executing under `sg docker`.'); const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { stdio: 'inherit', env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, @@ -121,67 +300,121 @@ function anthropicSecretExists(): boolean { } } -async function askDisplayName(fallback: string): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - const answer = await rl.question( - `\nWhat should your agents call you? [${fallback}]: `, - ); - return answer.trim() || fallback; - } finally { - rl.close(); +function runInheritScript(cmd: string, args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: 'inherit' }); + child.on('close', (code) => resolve(code ?? 1)); + }); +} + +function formatCodeCard(code: string): string { + const spaced = code.split('').join(' '); + return [ + '', + ` ${brandBold(spaced)}`, + '', + k.dim(' Send these digits from Telegram to your bot.'), + ].join('\n'); +} + +async function runPairTelegram(): Promise { + const s = p.spinner(); + s.start('Creating pairing code…'); + let spinnerActive = true; + + const stopSpinner = (msg: string, code?: number) => { + if (spinnerActive) { + s.stop(msg, code); + spinnerActive = false; + } + }; + + const result = await spawnStep('pair-telegram', ['--intent', 'main'], (block) => { + if (block.type === 'PAIR_TELEGRAM_CODE') { + const reason = block.fields.REASON ?? 'initial'; + if (reason === 'initial') { + stopSpinner('Pairing code ready.'); + } else { + stopSpinner('Previous code invalidated. New code below.'); + } + p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Pairing code'); + s.start('Waiting for the code from Telegram…'); + spinnerActive = true; + } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { + stopSpinner(`Received "${block.fields.CANDIDATE ?? '?'}" — doesn't match.`); + s.start('Waiting for the correct code…'); + spinnerActive = true; + } else if (block.type === 'PAIR_TELEGRAM') { + if (block.fields.STATUS === 'success') { + stopSpinner('Telegram paired.'); + } else { + stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1); + } + } + }); + + // Safety net: if the child died without emitting a terminal block, make + // sure we don't leave the spinner running. + if (spinnerActive) { + stopSpinner(result.ok ? 'Done.' : 'Pairing exited unexpectedly.', result.ok ? 0 : 1); + if (!result.ok) dumpTranscriptOnFailure(result.transcript); } + return result; +} + +async function askDisplayName(fallback: string): Promise { + const answer = ensureAnswer( + await p.text({ + message: 'What should your agents call you?', + placeholder: fallback, + defaultValue: fallback, + }), + ); + return (answer as string).trim() || fallback; } async function askAgentName(fallback: string): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - const answer = await rl.question( - `\nWhat should your agent be called? [${fallback}]: `, - ); - return answer.trim() || fallback; - } finally { - rl.close(); - } + const answer = ensureAnswer( + await p.text({ + message: 'What should your messaging agent be called?', + placeholder: fallback, + defaultValue: fallback, + }), + ); + return (answer as string).trim() || fallback; } async function askChannelChoice(): Promise<'telegram' | 'skip'> { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - console.log('\nConnect a messaging app so you can chat from your phone?'); - console.log(' 1) Telegram'); - console.log(' 2) Skip — just use the CLI for now'); - const answer = (await rl.question('Choose [1/2]: ')).trim(); - return answer === '1' ? 'telegram' : 'skip'; - } finally { - rl.close(); + const choice = ensureAnswer( + await p.select({ + message: 'Connect a messaging app so you can chat from your phone?', + options: [ + { value: 'telegram', label: 'Telegram', hint: 'recommended' }, + { value: 'skip', label: 'Skip — use the CLI only' }, + ], + }), + ); + return choice as 'telegram' | 'skip'; +} + +function printIntro(): void { + const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; + const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; + + if (isReexec) { + p.intro(`${brandChip(' setup:auto ')} ${wordmark} ${k.dim('· resuming under docker group')}`); + return; } -} -function runBashScript(relPath: string): Promise { - return new Promise((resolve) => { - const child = spawn('bash', [relPath], { stdio: 'inherit' }); - child.on('close', (code) => resolve(code ?? 1)); - }); -} - -function runTsxScript(relPath: string, args: string[] = []): Promise { - return new Promise((resolve) => { - const child = spawn('pnpm', ['exec', 'tsx', relPath, ...args], { - stdio: 'inherit', - }); - child.on('close', (code) => resolve(code ?? 1)); - }); -} - -function fail(msg: string, hint?: string): never { - console.error(`\n[setup:auto] ${msg}`); - if (hint) console.error(` ${hint}`); - console.error(' Logs: logs/setup.log'); - process.exit(1); + console.log(); + console.log(` ${wordmark}`); + console.log(` ${k.dim('end-to-end scripted setup of your personal assistant')}`); + p.intro(`${brandChip(' setup:auto ')}`); } async function main(): Promise { + printIntro(); + const skip = new Set( (process.env.NANOCLAW_SKIP ?? '') .split(',') @@ -190,92 +423,113 @@ async function main(): Promise { ); if (!skip.has('environment')) { - const env = await runStep('environment'); - if (!env.ok) fail('environment check failed'); + const res = await runQuietStep( + 'environment', + { running: 'Checking environment…', done: 'Environment OK.' }, + ); + if (!res.ok) fail('Environment check failed.'); } if (!skip.has('container')) { - const res = await runStep('container'); + const res = await runQuietStep('container', { + running: 'Building the agent container image…', + done: 'Container image ready.', + failed: 'Container build failed.', + }); if (!res.ok) { - if (res.fields.ERROR === 'runtime_not_available') { + const err = res.terminal?.fields.ERROR; + if (err === 'runtime_not_available') { fail( 'Docker is not available and could not be started automatically.', 'Install Docker Desktop or start it manually, then retry.', ); } - if (res.fields.ERROR === 'docker_group_not_active') { + if (err === 'docker_group_not_active') { fail( 'Docker was just installed but your shell is not yet in the `docker` group.', - 'Log out and back in (or run `newgrp docker` in a new shell), then retry `pnpm run setup:auto`.', + 'Log out and back in (or run `newgrp docker` in a new shell), then retry.', ); } fail( - 'container build/test failed', - 'For stale build cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', + 'Container build/test failed.', + 'For stale cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', ); } maybeReexecUnderSg(); } if (!skip.has('onecli')) { - const res = await runStep('onecli'); + const res = await runQuietStep('onecli', { + running: 'Installing OneCLI credential vault…', + done: 'OneCLI installed.', + }); if (!res.ok) { - if (res.fields.ERROR === 'onecli_not_on_path_after_install') { + const err = res.terminal?.fields.ERROR; + if (err === 'onecli_not_on_path_after_install') { fail( 'OneCLI installed but not on PATH.', 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', ); } fail( - `OneCLI install failed (${res.fields.ERROR ?? 'unknown'})`, - 'Check that curl + a writable ~/.local/bin are available; re-run `pnpm run setup:auto`.', + `OneCLI install failed (${err ?? 'unknown'}).`, + 'Check that curl + a writable ~/.local/bin are available, then retry.', ); } } if (!skip.has('auth')) { if (anthropicSecretExists()) { - console.log( - '\n── auth ────────────────────────────────────\n' + - '[setup:auto] OneCLI already has an Anthropic secret — skipping.', - ); + p.log.success('OneCLI already has an Anthropic secret — skipping.'); } else { - console.log('\n── auth ────────────────────────────────────'); - const code = await runBashScript('setup/register-claude-token.sh'); + p.log.step('Registering your Anthropic credential…'); + console.log( + k.dim(' (browser sign-in or paste a token/key — this part is interactive)'), + ); + console.log(); + const code = await runInheritScript('bash', ['setup/register-claude-token.sh']); + console.log(); if (code !== 0) { fail( 'Anthropic credential registration failed or was aborted.', 'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.', ); } + p.log.success('Anthropic credential registered with OneCLI.'); } } if (!skip.has('mounts')) { - const res = await runStep('mounts', ['--empty']); - if (!res.ok && res.fields.STATUS !== 'skipped') { - fail('mount allowlist step failed'); - } + const res = await runQuietStep('mounts', { + running: 'Writing mount allowlist…', + done: 'Mount allowlist in place.', + skipped: 'Mount allowlist already configured.', + }, ['--empty']); + if (!res.ok) fail('Mount allowlist step failed.'); } if (!skip.has('service')) { - const res = await runStep('service'); + const res = await runQuietStep('service', { + running: 'Installing the background service…', + done: 'Service installed and running.', + }); if (!res.ok) { fail( - 'service install failed', + 'Service install failed.', 'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.', ); } - if (res.fields.DOCKER_GROUP_STALE === 'true') { - console.warn( - '\n[setup:auto] Docker group stale in systemd session. Run:\n' + - ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + - ' systemctl --user restart nanoclaw', + if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') { + p.log.warn('Docker group stale in systemd session.'); + p.log.message( + k.dim( + ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + + ' systemctl --user restart nanoclaw', + ), ); } } - // Resolved once, reused by cli-agent + channel wiring. let displayName: string | undefined; const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel'); if (needsDisplayName) { @@ -285,15 +539,17 @@ async function main(): Promise { } if (!skip.has('cli-agent')) { - const res = await runStep('cli-agent', [ - '--display-name', - displayName!, - '--agent-name', - CLI_AGENT_NAME, - ]); + const res = await runQuietStep( + 'cli-agent', + { + running: 'Wiring the terminal agent…', + done: 'Terminal agent wired (try `pnpm run chat hi`).', + }, + ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME], + ); if (!res.ok) { fail( - 'CLI agent wiring failed', + 'CLI agent wiring failed.', `Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`, ); } @@ -302,15 +558,19 @@ async function main(): Promise { if (!skip.has('channel')) { const choice = await askChannelChoice(); if (choice === 'telegram') { - const installCode = await runBashScript('setup/add-telegram.sh'); + p.log.step('Installing the Telegram adapter and collecting your bot token…'); + console.log(); + const installCode = await runInheritScript('bash', ['setup/add-telegram.sh']); + console.log(); if (installCode !== 0) { fail( 'Telegram install failed.', - 'Re-run `bash setup/add-telegram.sh`, then `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', + 'Re-run `bash setup/add-telegram.sh`, then retry `pnpm run setup:auto`.', ); } + p.log.success('Telegram adapter installed.'); - const pair = await runStep('pair-telegram', ['--intent', 'main']); + const pair = await runPairTelegram(); if (!pair.ok) { fail( 'Telegram pairing failed.', @@ -318,8 +578,8 @@ async function main(): Promise { ); } - const platformId = pair.fields.PLATFORM_ID; - const pairedUserId = pair.fields.PAIRED_USER_ID; + const platformId = pair.terminal?.fields.PLATFORM_ID; + const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID; if (!platformId || !pairedUserId) { fail( 'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.', @@ -331,54 +591,72 @@ async function main(): Promise { process.env.NANOCLAW_AGENT_NAME?.trim() || (await askAgentName(DEFAULT_AGENT_NAME)); - console.log('\n── wiring first agent ──────────────────────────'); - const initCode = await runTsxScript('scripts/init-first-agent.ts', [ - '--channel', 'telegram', - '--user-id', pairedUserId, - '--platform-id', platformId, - '--display-name', displayName!, - '--agent-name', agentName, - ]); - if (initCode !== 0) { + const init = await runQuietChild( + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'telegram', + '--user-id', pairedUserId, + '--platform-id', platformId, + '--display-name', displayName!, + '--agent-name', agentName, + ], + { + running: `Wiring ${agentName} to your Telegram chat…`, + done: `${agentName} is wired — welcome DM incoming.`, + }, + ); + if (!init.ok) { fail( 'Wiring the Telegram agent failed.', `Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${pairedUserId}" --platform-id "${platformId}" --display-name "${displayName!}" --agent-name "${agentName}"\`.`, ); } - - console.log( - `\n[setup:auto] Telegram is wired. ${agentName} will DM you a welcome shortly.`, - ); + } else { + p.log.info('No messaging channel wired — you can add one later with `/add-`.'); } } if (!skip.has('verify')) { - const res = await runStep('verify'); + const res = await runQuietStep('verify', { + running: 'Verifying the install…', + done: 'Install verified.', + failed: 'Verification found issues.', + }); if (!res.ok) { - console.log('\n[setup:auto] Scripted steps done. Remaining (interactive):'); - if (res.fields.CREDENTIALS !== 'configured') { - console.log(' • Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`'); + const notes: string[] = []; + if (res.terminal?.fields.CREDENTIALS !== 'configured') { + notes.push('• Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`.'); } - if (res.fields.AGENT_PING && res.fields.AGENT_PING !== 'ok' && res.fields.AGENT_PING !== 'skipped') { - console.log( - ` • CLI agent did not reply (status: ${res.fields.AGENT_PING}). ` + + const agentPing = res.terminal?.fields.AGENT_PING; + if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') { + notes.push( + `• CLI agent did not reply (status: ${agentPing}). ` + 'Check `logs/nanoclaw.log` and `groups/*/logs/container-*.log`, then try `pnpm run chat hi`.', ); } - if (!res.fields.CONFIGURED_CHANNELS) { - console.log( - ' • Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …', - ); - console.log(' (CLI channel is already wired: `pnpm run chat hi`)'); + if (!res.terminal?.fields.CONFIGURED_CHANNELS) { + notes.push('• Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …'); } + if (notes.length > 0) { + p.note(notes.join('\n'), 'What’s left'); + } + p.outro(k.yellow('Scripted steps done — some pieces still need you.')); return; } } - console.log('\n[setup:auto] Complete.'); + const nextSteps = [ + `${k.cyan('Chat from the CLI:')} pnpm run chat hi`, + `${k.cyan('Tail host logs:')} tail -f logs/nanoclaw.log`, + `${k.cyan('Open Claude Code:')} claude`, + ].join('\n'); + p.note(nextSteps, 'Next steps'); + p.outro(k.green('Setup complete.')); } main().catch((err) => { - console.error(err); + p.log.error(err instanceof Error ? err.message : String(err)); + p.cancel('Setup aborted.'); process.exit(1); }); diff --git a/setup/pair-telegram.ts b/setup/pair-telegram.ts index cf7259b..f3f9bf8 100644 --- a/setup/pair-telegram.ts +++ b/setup/pair-telegram.ts @@ -2,11 +2,16 @@ * Step: pair-telegram — issue a one-time pairing code and wait for the * operator to send the code from the chat they want to register. * - * Used exclusively by `setup:auto` / `bash nanoclaw.sh` on this branch. Human- - * facing output is a focused banner for the code (no parseable block), plus a - * short line for wrong attempts / regenerations. A single machine-readable - * PAIR_TELEGRAM status block is still emitted at the end so the parent driver - * can pick up PLATFORM_ID / PAIRED_USER_ID / IS_GROUP. + * Emits machine-readable status blocks only. The parent driver + * (`setup:auto`) renders the code / attempt / success UI with clack. Running + * this step directly will look sparse — that's intentional. + * + * Blocks emitted: + * PAIR_TELEGRAM_CODE { CODE, REASON=initial|regenerated } + * PAIR_TELEGRAM_ATTEMPT { CANDIDATE } + * PAIR_TELEGRAM (final) { STATUS=success, CODE, INTENT, PLATFORM_ID, + * IS_GROUP, PAIRED_USER_ID } + * or { STATUS=failed, CODE, ERROR } * * Depends on src/channels/telegram-pairing.js, which setup/add-telegram.sh * copies in from the `channels` branch before this step runs. setup/ is @@ -50,22 +55,6 @@ function intentToString(intent: PairingIntent): string { return `${intent.kind}:${intent.folder}`; } -function printCodeBanner(code: string): void { - const digits = code.split('').join(' '); - const content = [ - '', - ` PAIRING CODE: ${digits}`, - '', - ' Send these digits from Telegram to your bot.', - '', - ]; - const width = Math.max(...content.map((l) => l.length)); - const top = ' ╔' + '═'.repeat(width + 2) + '╗'; - const bot = ' ╚' + '═'.repeat(width + 2) + '╝'; - const mid = content.map((l) => ' ║ ' + l.padEnd(width) + ' ║'); - console.log(['', top, ...mid, bot, ''].join('\n')); -} - export async function run(args: string[]): Promise { const intent = parseArgs(args); @@ -78,19 +67,21 @@ export async function run(args: string[]): Promise { const MAX_REGENERATIONS = 5; let record = await createPairing(intent); - printCodeBanner(record.code); + emitStatus('PAIR_TELEGRAM_CODE', { + CODE: record.code, + REASON: 'initial', + }); for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) { try { const consumed = await waitForPairing(record.code, { onAttempt: (a) => { - console.log( - ` Got "${a.candidate}" — doesn't match. A new code is on its way.`, - ); + emitStatus('PAIR_TELEGRAM_ATTEMPT', { + CANDIDATE: a.candidate, + }); }, }); - console.log('\n ✓ Telegram paired.\n'); emitStatus('PAIR_TELEGRAM', { STATUS: 'success', CODE: record.code, @@ -107,12 +98,13 @@ export async function run(args: string[]): Promise { const invalidated = /invalidated by wrong code/.test(message); if (invalidated && regen < MAX_REGENERATIONS) { record = await createPairing(intent); - console.log('\n Previous code invalidated. New code:'); - printCodeBanner(record.code); + emitStatus('PAIR_TELEGRAM_CODE', { + CODE: record.code, + REASON: 'regenerated', + }); continue; } const reason = invalidated ? 'max-regenerations-exceeded' : message; - console.error(`\n ✗ Pairing failed: ${reason}`); emitStatus('PAIR_TELEGRAM', { STATUS: 'failed', CODE: record.code, diff --git a/setup/register-claude-token.sh b/setup/register-claude-token.sh index 9c042d9..8bcab73 100755 --- a/setup/register-claude-token.sh +++ b/setup/register-claude-token.sh @@ -117,7 +117,7 @@ esac echo echo "Got token: ${TOKEN:0:16}…${TOKEN: -4}" -echo "Registering with OneCLI as '$SECRET_NAME' (host pattern: $HOST_PATTERN)…" +echo "Registering with OneCLI as '${SECRET_NAME}' (host pattern: ${HOST_PATTERN})…" onecli secrets create \ --name "$SECRET_NAME" \ From 5269edada4ddcb23c27ac201c7b5eb2c8a60fdc5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 02:02:13 +0300 Subject: [PATCH 081/185] feat(setup): three-level output (clack UI / progression log / raw per-step) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents and implements the output contract from docs/setup-flow.md: Level 1: clack UI — branded, concise, product content Level 2: logs/setup.log — append-only, linear, structured entries for humans + AI agents reviewing a run Level 3: logs/setup-steps/NN-name.log — full raw stdout+stderr per step Every scripted sub-step, including bootstrap, emits at all three levels. Bootstrap now runs under a bash-side clack-alike spinner with live elapsed time; its apt/pnpm output is captured to 01-bootstrap.log and summarised as a progression entry. setup.sh's legacy log() routes to the raw log instead of contaminating the progression log. Telegram install becomes fully branded: setup/auto.ts owns the BotFather instructions (clack note), token paste (clack password with format validation), and getMe check (clack spinner). add-telegram.sh drops to a non-interactive installer that reads TELEGRAM_BOT_TOKEN from env, logs to stderr, and emits a single ADD_TELEGRAM status block on stdout. The Anthropic credential flow is the one intentional break — register- claude-token.sh still inherits the TTY for claude setup-token's browser dance; it logs as an 'interactive' progression entry with the method. setup/logs.ts centralises the level 2/3 formatting: reset, header, step, userInput, complete, abort, stepRawLog. User answers (display name, agent name, channel choice, telegram_token preview) log as their own entries so the setup path is reconstructable from the progression log alone. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/setup-flow.md | 226 ++++++++++++++++++++++++++ nanoclaw.sh | 162 +++++++++++++++++-- setup.sh | 12 +- setup/add-telegram.sh | 171 ++++++++++---------- setup/auto.ts | 359 ++++++++++++++++++++++++++++++++++++------ setup/logs.ts | 130 +++++++++++++++ 6 files changed, 909 insertions(+), 151 deletions(-) create mode 100644 docs/setup-flow.md create mode 100644 setup/logs.ts diff --git a/docs/setup-flow.md b/docs/setup-flow.md new file mode 100644 index 0000000..800411c --- /dev/null +++ b/docs/setup-flow.md @@ -0,0 +1,226 @@ +# Setup flow + +This document is the contract for NanoClaw's end-to-end scripted setup +(`bash nanoclaw.sh` → `pnpm run setup:auto`). Read it before adding a new +step, fixing a regression, or changing how output is rendered. + +## The three output levels + +Every setup step produces output at **three distinct levels**. They have +different audiences, go to different places, and are formatted differently. +Don't conflate them. + +| Level | Audience | Destination | Format | +|---|---|---|---| +| 1. User-facing | The operator running setup | Terminal (via clack) | Branded, concise, informational — "product content" | +| 2. Progression | Future debuggers, AI agents reviewing a failed run, release support | `logs/setup.log` (one file, append-only) | Structured per-step blocks, linear chronology, human + machine readable | +| 3. Raw | Whoever is deep-debugging a specific step | `logs/setup-steps/NN-step-name.log` (one file per step) | Full raw child stdout + stderr, verbatim | + +Think of it as: the user sees a **summary**, the progression log is an +**index with key facts**, the raw logs are the **evidence**. + +### Level 1: user-facing (clack) + +Rendered by `setup/auto.ts` via `@clack/prompts`. This is our *product +surface* for setup — every line should read as if we designed it for a +stranger on day one. + +- Clack spinners for in-progress work. Show elapsed time. +- `p.log.success` / `p.log.step` / `p.log.warn` for permanent status + markers. +- `p.note` for multi-line information (pairing code, next steps). +- `p.text` / `p.select` / `p.password` for prompts. +- Brand palette: `brand()` / `brandBold()` / `brandChip()` helpers in + `setup/auto.ts`. Truecolor when the terminal supports it, 16-color + cyan fallback otherwise, plain text when piped / `NO_COLOR`. + +Rules: +- **No discontinuity.** Every sub-step belongs to the same visual flow. + The only exception is Anthropic credential registration (see below). +- **No raw child output.** Never `stdio: 'inherit'` a child whose output + wasn't written by us. Capture it and show it on failure only. +- **No debug-style prefixes** (`[add-telegram] …`, `INFO …`, timestamps). + Those belong in levels 2 and 3. +- **No emoji** unless the clack glyph requires it. + +### Level 2: progression log + +`logs/setup.log` — one file per setup run, append-only, cumulative across +a multi-run install (if a run fails midway and is re-attempted, the new +entries append). It's the thing you'd ask an operator to paste when they +report a setup bug, and the thing an AI agent would read to understand +what happened. + +Entry format: + +``` +=== [2026-04-22T22:14:12Z] bootstrap [45.1s] → success === + platform: linux + is_wsl: false + node_version: 22.22.2 + deps_ok: true + native_ok: true + raw: logs/setup-steps/01-bootstrap.log + +=== [2026-04-22T22:14:57Z] environment [2.3s] → success === + docker: running + apple_container: not_found + raw: logs/setup-steps/02-environment.log + +=== [2026-04-22T22:15:00Z] container [92.4s] → success === + runtime: docker + image: nanoclaw-agent:latest + build_ok: true + raw: logs/setup-steps/03-container.log +``` + +Design constraints: +- Start-time timestamp (UTC, ISO-8601) on the opening line so a `grep` + gives you the sequence. +- Duration in seconds with one decimal — fast steps read as "0.5s", not + "0ms". +- Status is one of: `success`, `skipped`, `failed`, `aborted`. +- Fields are step-specific but **must** be short scalar values. No JSON, + no multi-line. If a value is long, put it in the raw log and reference + it. +- Always emit a `raw:` pointer, even on success — makes debugging the + second failure easier. +- **User choices** are their own entries, not nested inside a step: + + ``` + === [2026-04-22T22:17:44Z] user-input → display_name === + value: gav + + === [2026-04-22T22:17:51Z] user-input → channel_choice === + value: telegram + ``` + + These matter because the path through the setup flow depends on them. + +The log opens with a header block identifying the run, and closes with +a completion block: + +``` +## 2026-04-22T22:14:12Z · setup:auto started + user: exedev + cwd: /home/exedev/nanoclaw + branch: branded-setup + commit: 6e0d742 + +… (step entries) … + +## 2026-04-22T22:18:54Z · completed (total 4m42s) +``` + +On failure the completion block names the failing step and its error: + +``` +## 2026-04-22T22:16:40Z · aborted at container (err=cache_miss) +``` + +### Level 3: raw per-step logs + +`logs/setup-steps/NN-step-name.log` — one file per step, numbered in +execution order (zero-padded 2-digit prefix for natural sorting). Full +verbatim stdout + stderr from the child process. Truncated and rewritten +on each run (not appended). + +Contents are whatever the step emits: apt output, docker build layers, +pnpm install spam, `curl` bodies, etc. This is the evidence plane — +"what did the shell actually see?" Nothing is filtered. + +## Contract for a new step + +When you add a step (either a TS step in `setup/.ts` or a bash +installer invoked from `auto.ts`), it must: + +1. **Receive a raw-log path** from the caller. Write all stdout + stderr + there. Don't write to the terminal directly. +2. **Emit a single terminal status block** at the end, containing + `STATUS: success|skipped|failed` and any step-specific fields: + + ``` + === NANOCLAW SETUP: STEP_NAME === + STATUS: success + KEY: value + KEY: value + === END === + ``` + + Field names are `UPPER_SNAKE_CASE`. Values are short scalars. + +3. If it's a long-running step, optionally emit **sub-status blocks** + mid-stream. `auto.ts` parses them live and can render intermediate + UI (as `pair-telegram` does with `PAIR_TELEGRAM_CODE` / + `PAIR_TELEGRAM_ATTEMPT`). + +4. **Exit non-zero** on hard failure so `auto.ts` can distinguish + "step ran to completion and reported failed" from "step crashed". + +The driver handles the rest: spinner in level 1, structured append to +level 2, raw capture to level 3. + +## The Anthropic exception + +Anthropic credential registration (`setup/register-claude-token.sh`) is +the **one** permitted break in the visual flow. Why: + +- `claude setup-token` opens a browser, runs its own OAuth prompt, and + prints the token. It owns the TTY via `script(1)`. +- We don't want to re-implement the OAuth device flow ourselves. +- We don't want to intercept / mirror the token (it appears in the + user's terminal already — mirroring it adds attack surface). + +So during this step: +- The clack flow explicitly pauses (a `p.log.step` marker says "this + part is interactive, you're handing off to Anthropic"). +- The child inherits stdio fully. +- When control returns, clack resumes on the next line with a success + marker. + +The level-2 log still gets an entry (`auth [interactive] → success` +with the method — subscription / oauth-token / api-key). Level-3 captures +are optional here; mirroring `script -q` output is tricky and the risk of +leaking the token to disk outweighs the debugging value. + +## File reference + +| File | Role | +|---|---| +| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. | +| `setup.sh` | Phase 1 bootstrap: Node, pnpm, native-module verify. Emits its own `BOOTSTRAP` status block (historically printed to stdout; now goes to the bootstrap raw log). | +| `setup/auto.ts` | Phase 2 driver. Orchestrates the clack UI, step execution, user prompts, and writes to all three log levels for every step it spawns. | +| `setup/logs.ts` | The logging primitives (`logStep`, `logUserInput`, `logComplete`, `stepRawLog`, `initSetupLog`). Single source of truth for level 2/3 formatting and file paths. | +| `setup/.ts` | Individual step implementations. Must emit one terminal status block; must not write directly to the terminal. | +| `setup/register-claude-token.sh` | The Anthropic exception. Inherits stdio, prints its own UI, returns a status to the driver. | +| `setup/add-telegram.sh` | Non-interactive adapter installer. Reads `TELEGRAM_BOT_TOKEN` from env; never prompts. User-facing bits live in `auto.ts`. | +| `setup/pair-telegram.ts` | Emits `PAIR_TELEGRAM_CODE` / `PAIR_TELEGRAM_ATTEMPT` / `PAIR_TELEGRAM` status blocks. Never prints UI. The driver renders it via clack notes. | + +## Common pitfalls + +- **Printing debug output from inside a step.** Tempting during + development; forbidden in checked-in code. All runtime messaging goes + through status blocks (level 2) or raw log writes (level 3). +- **Adding a `console.log` that "just this once" goes to the terminal.** + It breaks the clack flow — the spinner line gets torn. Use + `log.info` / `log.error` from `src/log.ts` (writes to the raw log) + instead. +- **`stdio: 'inherit'` for a non-exception child.** See Anthropic above. + Anything else needs `pipe` + explicit capture. +- **Tee-ing to stderr.** Clack's spinner owns the terminal during a step. + Even stderr writes tear the frame. Pipe everything, then choose what + to surface. +- **UTF-8 in bash `$VAR…` positions.** Bash's lexer can pull the first + byte of a multi-byte character into the variable name and trip + `set -u`. Always brace: `${VAR}…`. + +## Future work (not yet implemented) + +- **Progression log rotation.** Today's implementation truncates on each + run. Future: roll prior runs to `logs/setup.log.1`, `.2`, etc. +- **Raw log rotation for multi-run installs.** Currently each run + overwrites. Fine for now; revisit if support needs to compare + successive attempts. +- **Structured output from `register-claude-token.sh`.** The interactive + step emits no machine-readable status today. Future could add a + post-interaction status block with the method used. diff --git a/nanoclaw.sh b/nanoclaw.sh index 2dc0f04..17df82c 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -2,12 +2,15 @@ # # NanoClaw — scripted end-to-end install. # -# Runs `bash setup.sh` (bootstrap: Node check, pnpm install, native module -# verify), then `pnpm run setup:auto` (environment → container → onecli → -# auth → mounts → service → cli-agent → channel → verify). +# Phase 1: bootstrap (Node + pnpm + native module verify). Runs bash-side +# since tsx isn't available until pnpm install completes. +# Phase 2: setup:auto (all remaining steps under clack). # -# Everything that can be scripted runs unattended; the one interactive pause -# is the auth step (browser sign-in or paste token/API key). +# Both phases obey the same three-level output contract (see +# docs/setup-flow.md): +# 1. User-facing — concise status line with elapsed time +# 2. Progression log — logs/setup.log (header + one entry per phase/step) +# 3. Raw per-step log — logs/setup-steps/NN-name.log (full verbatim output) # # Config via env — passed through unchanged: # NANOCLAW_SKIP comma-separated setup:auto step names to skip @@ -19,28 +22,163 @@ set -euo pipefail PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$PROJECT_ROOT" +LOGS_DIR="$PROJECT_ROOT/logs" +STEPS_DIR="$LOGS_DIR/setup-steps" +PROGRESS_LOG="$LOGS_DIR/setup.log" + +# ─── log helpers ──────────────────────────────────────────────────────── + +ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +write_header() { + local ts + ts=$(ts_utc) + local branch commit + branch=$(git branch --show-current 2>/dev/null || echo unknown) + commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown) + { + echo "## ${ts} · setup:auto started" + echo " invocation: nanoclaw.sh" + echo " user: $(whoami)" + echo " cwd: ${PROJECT_ROOT}" + echo " branch: ${branch}" + echo " commit: ${commit}" + echo "" + } > "$PROGRESS_LOG" +} + +# grep_field FIELD FILE — first value of FIELD: from a status block. +grep_field() { + grep "^$1:" "$2" 2>/dev/null | head -1 | sed "s/^$1: *//" || true +} + +write_bootstrap_entry() { + local status=$1 dur=$2 raw=$3 + local ts + ts=$(ts_utc) + local platform is_wsl node_version deps_ok native_ok has_build_tools + platform=$(grep_field PLATFORM "$raw") + is_wsl=$(grep_field IS_WSL "$raw") + node_version=$(grep_field NODE_VERSION "$raw" | head -1) + deps_ok=$(grep_field DEPS_OK "$raw") + native_ok=$(grep_field NATIVE_OK "$raw") + has_build_tools=$(grep_field HAS_BUILD_TOOLS "$raw") + { + echo "=== [${ts}] bootstrap [${dur}s] → ${status} ===" + [ -n "$platform" ] && echo " platform: ${platform}" + [ -n "$is_wsl" ] && echo " is_wsl: ${is_wsl}" + [ -n "$node_version" ] && echo " node_version: ${node_version}" + [ -n "$deps_ok" ] && echo " deps_ok: ${deps_ok}" + [ -n "$native_ok" ] && echo " native_ok: ${native_ok}" + [ -n "$has_build_tools" ] && echo " has_build_tools: ${has_build_tools}" + # Emit the raw path relative to PROJECT_ROOT so the progression log + # is portable and matches the TS-side format (logs/setup-steps/NN-…). + echo " raw: ${raw#${PROJECT_ROOT}/}" + echo "" + } >> "$PROGRESS_LOG" +} + +write_abort_entry() { + local step=$1 error=$2 + local ts + ts=$(ts_utc) + echo "## ${ts} · aborted at ${step} (${error})" >> "$PROGRESS_LOG" +} + +# ─── bash-side "clack-alike" status line ──────────────────────────────── + +use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; } +dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; } +gray() { use_ansi && printf '\033[90m%s\033[0m' "$1" || printf '%s' "$1"; } +red() { use_ansi && printf '\033[31m%s\033[0m' "$1" || printf '%s' "$1"; } +clear_line() { use_ansi && printf '\r\033[2K' || printf '\n'; } + +spinner_start() { printf '%s %s…' "$(gray '◒')" "$1"; } +spinner_update() { clear_line; printf '%s %s… %s' "$(gray '◒')" "$1" "$(dim "(${2}s)")"; } +spinner_success() { clear_line; printf '%s %s %s\n' "$(gray '◇')" "$1" "$(dim "(${2}s)")"; } +spinner_failure() { clear_line; printf '%s %s %s\n' "$(red '✗')" "$1" "$(dim "(${2}s)")"; } + +# ─── fresh-run setup ──────────────────────────────────────────────────── + +rm -rf "$STEPS_DIR" +rm -f "$PROGRESS_LOG" +mkdir -p "$STEPS_DIR" "$LOGS_DIR" +write_header + cat <<'EOF' ═══════════════════════════════════════════════════════════════ NanoClaw scripted setup ═══════════════════════════════════════════════════════════════ -Phase 1: bootstrap (Node + pnpm + native modules) +Phase 1 · bootstrap EOF -if ! bash setup.sh; then +# ─── phase 1: bootstrap ───────────────────────────────────────────────── + +BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log" +BOOTSTRAP_LABEL="Bootstrapping Node, pnpm, native modules" +BOOTSTRAP_START=$(date +%s) + +spinner_start "$BOOTSTRAP_LABEL" + +# Run in the background so we can tick elapsed time. Capture exit code via +# a tmpfile (subshell $? is lost after the while loop finishes). +BOOTSTRAP_EXIT_FILE=$(mktemp -t nanoclaw-bootstrap-exit.XXXXXX) +( + # setup.sh's legacy `log()` writes to a file; point it at the raw log + # so its verbose entries land alongside the stdout we're capturing. + export NANOCLAW_BOOTSTRAP_LOG="$BOOTSTRAP_RAW" + if bash setup.sh > "$BOOTSTRAP_RAW" 2>&1; then + echo 0 > "$BOOTSTRAP_EXIT_FILE" + else + echo $? > "$BOOTSTRAP_EXIT_FILE" + fi +) & +BOOTSTRAP_PID=$! + +while kill -0 "$BOOTSTRAP_PID" 2>/dev/null; do + sleep 1 + if kill -0 "$BOOTSTRAP_PID" 2>/dev/null; then + spinner_update "$BOOTSTRAP_LABEL" "$(( $(date +%s) - BOOTSTRAP_START ))" + fi +done +# `wait` surfaces the child's exit code; we've already captured it. +wait "$BOOTSTRAP_PID" 2>/dev/null || true + +BOOTSTRAP_RC=$(cat "$BOOTSTRAP_EXIT_FILE") +rm -f "$BOOTSTRAP_EXIT_FILE" +BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START )) + +if [ "$BOOTSTRAP_RC" -eq 0 ]; then + spinner_success "Bootstrap complete" "$BOOTSTRAP_DUR" + write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" +else + spinner_failure "Bootstrap failed" "$BOOTSTRAP_DUR" + write_bootstrap_entry failed "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" + write_abort_entry bootstrap "exit-${BOOTSTRAP_RC}" + echo - echo "[nanoclaw.sh] Bootstrap failed. Inspect logs/setup.log and retry." >&2 + echo "$(dim '── last 40 lines of ')$(dim "$BOOTSTRAP_RAW")$(dim ' ──')" + tail -40 "$BOOTSTRAP_RAW" + echo + echo "Full raw log: $BOOTSTRAP_RAW" + echo "Progression: $PROGRESS_LOG" exit 1 fi +echo cat <<'EOF' - -═══════════════════════════════════════════════════════════════ - Phase 2: setup:auto -═══════════════════════════════════════════════════════════════ +Phase 2 · setup:auto EOF +# ─── phase 2: clack driver ────────────────────────────────────────────── + +# NANOCLAW_BOOTSTRAPPED=1 tells setup/auto.ts that the progression log has +# already been initialized (header + bootstrap entry), so it should append +# rather than wipe. +export NANOCLAW_BOOTSTRAPPED=1 + # exec so signals (Ctrl-C) propagate directly to the child. exec pnpm run setup:auto diff --git a/setup.sh b/setup.sh index e163df8..ae5da27 100755 --- a/setup.sh +++ b/setup.sh @@ -6,9 +6,17 @@ set -euo pipefail # This is the only bash script in the setup flow. PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LOG_FILE="$PROJECT_ROOT/logs/setup.log" -mkdir -p "$PROJECT_ROOT/logs" +# Where verbose bootstrap logs go. nanoclaw.sh captures setup.sh's stdout to +# the per-step raw log, but legacy code in this script + install-node.sh +# also calls `log` which writes to a file. Route those to the raw log so +# they don't contaminate the progression log (logs/setup.log). +# Default: write to the raw bootstrap log if nanoclaw.sh pointed us there, +# else fall back to a dedicated bootstrap log (keeps standalone `bash +# setup.sh` invocations working). +LOG_FILE="${NANOCLAW_BOOTSTRAP_LOG:-${PROJECT_ROOT}/logs/bootstrap.log}" + +mkdir -p "$(dirname "$LOG_FILE")" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [bootstrap] $*" >> "$LOG_FILE"; } diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 4d540af..5036bd4 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -1,12 +1,15 @@ #!/usr/bin/env bash -set -euo pipefail - -# Install the Telegram adapter (Phase A of the /add-telegram skill), collect -# the bot token, write .env + data/env/env, and restart the service so the -# new adapter is live. Idempotent. # -# Pair-telegram (the interactive code-sending step) is run separately by the -# caller (setup/auto.ts) so it can stream status blocks to the user. +# Install the Telegram adapter, persist the bot token to .env + data/env/env, +# restart the service, and open the bot's chat page in the local Telegram +# client. Non-interactive — the operator-facing "Create a bot" instructions +# and token paste live in setup/auto.ts. The token comes in via the +# TELEGRAM_BOT_TOKEN env var. +# +# Emits exactly one status block on stdout (ADD_TELEGRAM) at the end. All +# chatty progress messages go to stderr so setup:auto's raw-log capture +# sees the full story without cluttering the final block for the parser. +set -euo pipefail PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$PROJECT_ROOT" @@ -15,19 +18,49 @@ cd "$PROJECT_ROOT" ADAPTER_VERSION="@chat-adapter/telegram@4.26.0" CHANNELS_BRANCH="origin/channels" +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + local username=${BOT_USERNAME:-} + echo "=== NANOCLAW SETUP: ADD_TELEGRAM ===" + echo "STATUS: ${status}" + echo "ADAPTER_VERSION: ${ADAPTER_VERSION}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$username" ] && echo "BOT_USERNAME: ${username}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-telegram] $*" >&2; } + +if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then + emit_status failed "TELEGRAM_BOT_TOKEN env var not set" + exit 1 +fi + +if ! [[ "$TELEGRAM_BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then + emit_status failed "token format invalid (expected :)" + exit 1 +fi + need_install() { - [[ ! -f src/channels/telegram.ts ]] && return 0 + [ ! -f src/channels/telegram.ts ] && return 0 ! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0 return 1 } +ADAPTER_ALREADY_INSTALLED=true if need_install; then - echo "[add-telegram] Fetching channels branch…" - git fetch origin channels >/dev/null 2>&1 + ADAPTER_ALREADY_INSTALLED=false + log "Fetching channels branch…" + git fetch origin channels >&2 2>/dev/null || { + emit_status failed "git fetch origin channels failed" + exit 1 + } # pair-telegram.ts is maintained in this branch (setup-auto), so it's NOT # in this list — do not overwrite the local version with the channels copy. - echo "[add-telegram] Copying adapter files from ${CHANNELS_BRANCH}…" + log "Copying adapter files from ${CHANNELS_BRANCH}…" for f in \ src/channels/telegram.ts \ src/channels/telegram-pairing.ts \ @@ -35,7 +68,7 @@ if need_install; then src/channels/telegram-markdown-sanitize.ts \ src/channels/telegram-markdown-sanitize.test.ts do - git show "$CHANNELS_BRANCH:$f" > "$f" + git show "${CHANNELS_BRANCH}:$f" > "$f" done # Append self-registration import if missing. @@ -59,109 +92,71 @@ if need_install; then } ' - echo "[add-telegram] Installing ${ADAPTER_VERSION}…" - pnpm install "$ADAPTER_VERSION" + log "Installing ${ADAPTER_VERSION}…" + pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${ADAPTER_VERSION} failed" + exit 1 + } - echo "[add-telegram] Building…" - pnpm run build >/dev/null + log "Building…" + pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 + } else - echo "[add-telegram] Adapter files already installed — skipping install phase." + log "Adapter files already installed — skipping install phase." fi -# Token collection. -if grep -q '^TELEGRAM_BOT_TOKEN=.' .env 2>/dev/null; then - echo "[add-telegram] TELEGRAM_BOT_TOKEN already set in .env — skipping token prompt." +# Persist token. auto.ts validates before this point, so a bad token here +# would be an internal bug rather than operator input. +touch .env +if grep -q '^TELEGRAM_BOT_TOKEN=' .env; then + awk -v tok="$TELEGRAM_BOT_TOKEN" \ + '/^TELEGRAM_BOT_TOKEN=/{print "TELEGRAM_BOT_TOKEN=" tok; next} {print}' \ + .env > .env.tmp && mv .env.tmp .env else - cat <<'EOF' - -── Create a Telegram bot ────────────────────────────────────── - - 1. Open Telegram and message @BotFather - 2. Send: /newbot - 3. Follow the prompts (bot name, username ending in "bot") - 4. Copy the token it gives you (format: :) - -Optional but recommended for groups: - 5. @BotFather → /mybots → your bot → Bot Settings → Group Privacy → OFF - -EOF - echo "Paste your TELEGRAM_BOT_TOKEN and press Enter." - echo "Nothing will appear on the screen as you paste — that's intentional." - echo "Paste once, then just press Enter to submit." - read -r -s -p "> " TOKEN &2 - exit 1 - fi - - # Telegram bot tokens: :<35+ base64url-ish chars>. - if [[ ! "$TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then - echo "[add-telegram] Token format looks wrong (expected :). Aborting." >&2 - exit 1 - fi - - touch .env - if grep -q '^TELEGRAM_BOT_TOKEN=' .env; then - awk -v tok="$TOKEN" '/^TELEGRAM_BOT_TOKEN=/{print "TELEGRAM_BOT_TOKEN=" tok; next} {print}' \ - .env > .env.tmp && mv .env.tmp .env - else - echo "TELEGRAM_BOT_TOKEN=$TOKEN" >> .env - fi + echo "TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}" >> .env fi -# Validate the token via getMe so a typo surfaces before we restart the -# service, and capture the bot's username for the deep link. -TELEGRAM_BOT_TOKEN_VALUE="$(grep '^TELEGRAM_BOT_TOKEN=' .env | head -1 | cut -d= -f2-)" +# Look up the bot username (auto.ts already validated; we re-query here so +# standalone invocations still work). +INFO=$(curl -fsS --max-time 8 \ + "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || true) BOT_USERNAME="" -if [[ -n "$TELEGRAM_BOT_TOKEN_VALUE" ]]; then - INFO=$(curl -fsS --max-time 8 \ - "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN_VALUE}/getMe" 2>/dev/null || true) - if echo "$INFO" | grep -q '"ok":true'; then - # Crude JSON parse — the response is always a flat object here. - BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p') - if [[ -n "$BOT_USERNAME" ]]; then - echo "[add-telegram] Token validated — bot is @${BOT_USERNAME}." - fi - else - echo "[add-telegram] Warning: getMe did not return ok. Continuing, but the token may be wrong." - fi +if echo "$INFO" | grep -q '"ok":true'; then + BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p') fi # Container reads from data/env/env (the host mounts it). mkdir -p data/env cp .env data/env/env -# Deep-link into the bot's chat in the installed Telegram app so the user -# is already on the right screen when pair-telegram prints the code. Also -# always print the URL so headless / remote-SSH users can open it manually. -if [[ -n "$BOT_USERNAME" ]]; then - BOT_URL="https://t.me/${BOT_USERNAME}" +# Deep-link into the bot's chat so the user is already on the right screen +# when pair-telegram prints the code. Silent best-effort — runs under a +# spinner, any output (from `open` / `xdg-open`) goes to the raw log. +if [ -n "$BOT_USERNAME" ]; then case "$(uname -s)" in Darwin) - open "tg://resolve?domain=${BOT_USERNAME}" >/dev/null 2>&1 \ - || open "$BOT_URL" >/dev/null 2>&1 \ + open "tg://resolve?domain=${BOT_USERNAME}" >&2 2>/dev/null \ + || open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \ || true ;; Linux) - xdg-open "tg://resolve?domain=${BOT_USERNAME}" >/dev/null 2>&1 \ - || xdg-open "$BOT_URL" >/dev/null 2>&1 \ + xdg-open "tg://resolve?domain=${BOT_USERNAME}" >&2 2>/dev/null \ + || xdg-open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \ || true ;; esac - echo "[add-telegram] Bot chat: ${BOT_URL}" - echo "[add-telegram] (If Telegram didn't open automatically, click the link above.)" fi -echo "[add-telegram] Restarting service so the new adapter picks up the token…" +log "Restarting service so the new adapter picks up the token…" case "$(uname -s)" in Darwin) - launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >/dev/null 2>&1 || true + launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true ;; Linux) - systemctl --user restart nanoclaw >/dev/null 2>&1 \ - || sudo systemctl restart nanoclaw >/dev/null 2>&1 \ + systemctl --user restart nanoclaw >&2 2>/dev/null \ + || sudo systemctl restart nanoclaw >&2 2>/dev/null \ || true ;; esac @@ -170,4 +165,4 @@ esac # begins polling for the user's code message. sleep 5 -echo "[add-telegram] Install + credentials complete." +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts index 482fcea..c16b6e5 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -26,13 +26,19 @@ * with inherited stdio — clack resumes cleanly on the next step. */ import { spawn, spawnSync } from 'child_process'; +import fs from 'fs'; import * as p from '@clack/prompts'; import k from 'kleur'; +import * as setupLog from './logs.js'; + const CLI_AGENT_NAME = 'Terminal Agent'; const DEFAULT_AGENT_NAME = 'Nano'; +const RUN_START = Date.now(); +let failingStep = 'setup'; + /** * Brand palette, pulled from assets/nanoclaw-logo.png: * brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body @@ -123,14 +129,16 @@ class StatusStream { } /** - * Spawn a setup step as a child process, swallowing stdout/stderr into a - * buffer. The provided onBlock callback fires per status block as they - * parse. Returns when the child exits. + * Spawn a setup step as a child process. Output is tee'd to the provided + * raw log file (level 3) and parsed for status blocks (level 2 summary). + * The onBlock callback fires per status block as they close so the UI can + * react mid-stream. */ function spawnStep( stepName: string, extra: string[], onBlock: (block: Block) => void, + rawLogPath: string, ): Promise { return new Promise((resolve) => { const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName]; @@ -138,13 +146,20 @@ function spawnStep( const child = spawn('pnpm', args, { stdio: ['ignore', 'pipe', 'pipe'] }); const stream = new StatusStream(onBlock); + const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); + raw.write(`# ${stepName} — ${new Date().toISOString()}\n\n`); - child.stdout.on('data', (chunk: Buffer) => stream.write(chunk.toString('utf-8'))); + child.stdout.on('data', (chunk: Buffer) => { + stream.write(chunk.toString('utf-8')); + raw.write(chunk); + }); child.stderr.on('data', (chunk: Buffer) => { stream.transcript += chunk.toString('utf-8'); + raw.write(chunk); }); child.on('close', (code) => { + raw.end(); // Step block types don't always mirror step names (e.g. `mounts` emits // CONFIGURE_MOUNTS, `container` emits SETUP_CONTAINER). Any block with // a STATUS field is a terminal block; the last one wins. @@ -170,22 +185,90 @@ type SpinnerLabels = { failed?: string; }; -/** Run a step under a clack spinner. Child output is captured; shown only on failure. */ +/** Run a step under a clack spinner. Teed to a per-step raw log + progression entry at the end. */ async function runQuietStep( stepName: string, labels: SpinnerLabels, extra: string[] = [], -): Promise { - return runUnderSpinner(labels, () => spawnStep(stepName, extra, () => {})); +): Promise { + failingStep = stepName; + const rawLog = setupLog.stepRawLog(stepName); + const start = Date.now(); + const result = await runUnderSpinner(labels, () => + spawnStep(stepName, extra, () => {}, rawLog), + ); + const durationMs = Date.now() - start; + writeStepEntry(stepName, result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; } -/** Run an arbitrary child under a spinner, capturing its stdout/stderr. */ +/** Run an arbitrary child under a spinner. Same raw-log + progression treatment as runQuietStep. */ async function runQuietChild( + logName: string, cmd: string, args: string[], labels: SpinnerLabels, -): Promise<{ ok: boolean; exitCode: number; transcript: string }> { - return runUnderSpinner(labels, () => spawnQuiet(cmd, args)); + opts?: { + /** Extra fields to merge into the progression entry (on top of any status-block fields). */ + extraFields?: Record; + /** Environment overrides to pass to the child process. */ + env?: NodeJS.ProcessEnv; + }, +): Promise<{ + ok: boolean; + exitCode: number; + transcript: string; + terminal: Block | null; + rawLog: string; + durationMs: number; +}> { + failingStep = logName; + const rawLog = setupLog.stepRawLog(logName); + const start = Date.now(); + const result = await runUnderSpinner(labels, () => + spawnQuiet(cmd, args, rawLog, opts?.env), + ); + const durationMs = Date.now() - start; + + const blockFields = summariseTerminalFields(result.terminal); + const fields = { ...blockFields, ...(opts?.extraFields ?? {}) }; + const rawStatus = result.terminal?.fields.STATUS; + const status: 'success' | 'skipped' | 'failed' = !result.ok + ? 'failed' + : rawStatus === 'skipped' + ? 'skipped' + : 'success'; + setupLog.step(logName, status, durationMs, fields, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** Turn a step's terminal-block fields into a concise progression-log entry. */ +function writeStepEntry( + stepName: string, + result: StepResult, + durationMs: number, + rawLog: string, +): void { + const rawStatus = result.terminal?.fields.STATUS; + const logStatus: 'success' | 'skipped' | 'failed' = !result.ok + ? 'failed' + : rawStatus === 'skipped' + ? 'skipped' + : 'success'; + const fields = summariseTerminalFields(result.terminal); + setupLog.step(stepName, logStatus, durationMs, fields, rawLog); +} + +/** Strip STATUS + LOG (redundant) and any oversize values from the terminal block's fields. */ +function summariseTerminalFields(block: Block | null): Record { + if (!block) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(block.fields)) { + if (k === 'STATUS' || k === 'LOG') continue; + if (v.length > 120) continue; // keep it skimmable; full value lives in the raw log + out[k] = v; + } + return out; } async function runUnderSpinner< @@ -221,14 +304,34 @@ async function runUnderSpinner< function spawnQuiet( cmd: string, args: string[], -): Promise<{ ok: boolean; exitCode: number; transcript: string }> { + rawLogPath: string, + envOverride?: NodeJS.ProcessEnv, +): Promise<{ ok: boolean; exitCode: number; transcript: string; terminal: Block | null; blocks: Block[] }> { return new Promise((resolve) => { - const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn(cmd, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: envOverride ? { ...process.env, ...envOverride } : process.env, + }); let transcript = ''; - child.stdout.on('data', (c: Buffer) => { transcript += c.toString('utf-8'); }); - child.stderr.on('data', (c: Buffer) => { transcript += c.toString('utf-8'); }); + const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); + raw.write(`# ${[cmd, ...args].join(' ')} — ${new Date().toISOString()}\n\n`); + const blocks: Block[] = []; + const stream = new StatusStream((b) => blocks.push(b)); + child.stdout.on('data', (c: Buffer) => { + const s = c.toString('utf-8'); + transcript += s; + stream.write(s); + raw.write(c); + }); + child.stderr.on('data', (c: Buffer) => { + transcript += c.toString('utf-8'); + raw.write(c); + }); child.on('close', (code) => { - resolve({ ok: code === 0, exitCode: code ?? 1, transcript }); + raw.end(); + const terminal = + [...blocks].reverse().find((b) => b.fields.STATUS) ?? null; + resolve({ ok: code === 0, exitCode: code ?? 1, transcript, terminal, blocks }); }); }); } @@ -248,15 +351,17 @@ function dumpTranscriptOnFailure(transcript: string): void { } function fail(msg: string, hint?: string): never { + setupLog.abort(failingStep, msg); p.log.error(msg); if (hint) p.log.message(k.dim(hint)); - p.log.message(k.dim('Logs: logs/setup.log')); + p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/')); p.cancel('Setup aborted.'); process.exit(1); } function ensureAnswer(value: T | symbol): T { if (p.isCancel(value)) { + setupLog.abort(failingStep, 'user-cancelled'); p.cancel('Setup cancelled.'); process.exit(0); } @@ -317,7 +422,10 @@ function formatCodeCard(code: string): string { ].join('\n'); } -async function runPairTelegram(): Promise { +async function runPairTelegram(): Promise { + failingStep = 'pair-telegram'; + const rawLog = setupLog.stepRawLog('pair-telegram'); + const start = Date.now(); const s = p.spinner(); s.start('Creating pairing code…'); let spinnerActive = true; @@ -329,29 +437,35 @@ async function runPairTelegram(): Promise { } }; - const result = await spawnStep('pair-telegram', ['--intent', 'main'], (block) => { - if (block.type === 'PAIR_TELEGRAM_CODE') { - const reason = block.fields.REASON ?? 'initial'; - if (reason === 'initial') { - stopSpinner('Pairing code ready.'); - } else { - stopSpinner('Previous code invalidated. New code below.'); + const result = await spawnStep( + 'pair-telegram', + ['--intent', 'main'], + (block) => { + if (block.type === 'PAIR_TELEGRAM_CODE') { + const reason = block.fields.REASON ?? 'initial'; + if (reason === 'initial') { + stopSpinner('Pairing code ready.'); + } else { + stopSpinner('Previous code invalidated. New code below.'); + } + p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Pairing code'); + s.start('Waiting for the code from Telegram…'); + spinnerActive = true; + } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { + stopSpinner(`Received "${block.fields.CANDIDATE ?? '?'}" — doesn't match.`); + s.start('Waiting for the correct code…'); + spinnerActive = true; + } else if (block.type === 'PAIR_TELEGRAM') { + if (block.fields.STATUS === 'success') { + stopSpinner('Telegram paired.'); + } else { + stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1); + } } - p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Pairing code'); - s.start('Waiting for the code from Telegram…'); - spinnerActive = true; - } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { - stopSpinner(`Received "${block.fields.CANDIDATE ?? '?'}" — doesn't match.`); - s.start('Waiting for the correct code…'); - spinnerActive = true; - } else if (block.type === 'PAIR_TELEGRAM') { - if (block.fields.STATUS === 'success') { - stopSpinner('Telegram paired.'); - } else { - stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1); - } - } - }); + }, + rawLog, + ); + const durationMs = Date.now() - start; // Safety net: if the child died without emitting a terminal block, make // sure we don't leave the spinner running. @@ -359,7 +473,9 @@ async function runPairTelegram(): Promise { stopSpinner(result.ok ? 'Done.' : 'Pairing exited unexpectedly.', result.ok ? 0 : 1); if (!result.ok) dumpTranscriptOnFailure(result.transcript); } - return result; + + writeStepEntry('pair-telegram', result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; } async function askDisplayName(fallback: string): Promise { @@ -370,7 +486,9 @@ async function askDisplayName(fallback: string): Promise { defaultValue: fallback, }), ); - return (answer as string).trim() || fallback; + const value = (answer as string).trim() || fallback; + setupLog.userInput('display_name', value); + return value; } async function askAgentName(fallback: string): Promise { @@ -381,7 +499,9 @@ async function askAgentName(fallback: string): Promise { defaultValue: fallback, }), ); - return (answer as string).trim() || fallback; + const value = (answer as string).trim() || fallback; + setupLog.userInput('agent_name', value); + return value; } async function askChannelChoice(): Promise<'telegram' | 'skip'> { @@ -394,9 +514,94 @@ async function askChannelChoice(): Promise<'telegram' | 'skip'> { ], }), ); + setupLog.userInput('channel_choice', String(choice)); return choice as 'telegram' | 'skip'; } +async function collectTelegramToken(): Promise { + p.note( + [ + '1. Open Telegram and message @BotFather', + '2. Send: /newbot', + '3. Follow the prompts (name + username ending in "bot")', + '4. Copy the token it gives you (format: :)', + '', + k.dim('Optional, but recommended for groups:'), + k.dim(' @BotFather → /mybots → Bot Settings → Group Privacy → OFF'), + ].join('\n'), + 'Create a Telegram bot', + ); + + const answer = ensureAnswer( + await p.password({ + message: 'Paste your bot token', + validate: (v) => { + if (!v || !v.trim()) return 'Token is required'; + if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { + return 'Format looks wrong — expected :'; + } + return undefined; + }, + }), + ); + const token = (answer as string).trim(); + setupLog.userInput( + 'telegram_token', + `${token.slice(0, 12)}…${token.slice(-4)}`, + ); + return token; +} + +async function validateTelegramToken(token: string): Promise { + failingStep = 'telegram-validate'; + const s = p.spinner(); + const start = Date.now(); + s.start('Validating token with Telegram…'); + try { + const res = await fetch(`https://api.telegram.org/bot${token}/getMe`); + const data = (await res.json()) as { + ok?: boolean; + result?: { username?: string; id?: number }; + description?: string; + }; + const elapsed = Math.round((Date.now() - start) / 1000); + if (data.ok && data.result?.username) { + const username = data.result.username; + s.stop(`Bot is @${username}. ${k.dim(`(${elapsed}s)`)}`); + setupLog.step( + 'telegram-validate', + 'success', + Date.now() - start, + { BOT_USERNAME: username, BOT_ID: data.result.id ?? '' }, + ); + return username; + } + const reason = data.description ?? 'token rejected by Telegram'; + s.stop(`Telegram rejected the token: ${reason}`, 1); + setupLog.step( + 'telegram-validate', + 'failed', + Date.now() - start, + { ERROR: reason }, + ); + fail( + 'Telegram rejected the token.', + 'Double-check the token (copy it again from @BotFather) and retry.', + ); + } catch (err) { + const elapsed = Math.round((Date.now() - start) / 1000); + s.stop(`Could not reach Telegram. ${k.dim(`(${elapsed}s)`)}`, 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('telegram-validate', 'failed', Date.now() - start, { + ERROR: message, + }); + fail( + 'Telegram API unreachable.', + 'Check your network connection and retry.', + ); + } +} + function printIntro(): void { const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; @@ -414,6 +619,7 @@ function printIntro(): void { async function main(): Promise { printIntro(); + initProgressionLog(); const skip = new Set( (process.env.NANOCLAW_SKIP ?? '') @@ -479,22 +685,28 @@ async function main(): Promise { } if (!skip.has('auth')) { + failingStep = 'auth'; if (anthropicSecretExists()) { p.log.success('OneCLI already has an Anthropic secret — skipping.'); + setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); } else { p.log.step('Registering your Anthropic credential…'); console.log( k.dim(' (browser sign-in or paste a token/key — this part is interactive)'), ); console.log(); + const start = Date.now(); const code = await runInheritScript('bash', ['setup/register-claude-token.sh']); + const durationMs = Date.now() - start; console.log(); if (code !== 0) { + setupLog.step('auth', 'failed', durationMs, { EXIT_CODE: code }); fail( 'Anthropic credential registration failed or was aborted.', 'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.', ); } + setupLog.step('auth', 'interactive', durationMs, { METHOD: 'register-claude-token.sh' }); p.log.success('Anthropic credential registered with OneCLI.'); } } @@ -558,17 +770,28 @@ async function main(): Promise { if (!skip.has('channel')) { const choice = await askChannelChoice(); if (choice === 'telegram') { - p.log.step('Installing the Telegram adapter and collecting your bot token…'); - console.log(); - const installCode = await runInheritScript('bash', ['setup/add-telegram.sh']); - console.log(); - if (installCode !== 0) { + const token = await collectTelegramToken(); + const botUsername = await validateTelegramToken(token); + + const install = await runQuietChild( + 'telegram-install', + 'bash', + ['setup/add-telegram.sh'], + { + running: `Installing Telegram adapter and wiring @${botUsername}…`, + done: `Telegram adapter ready.`, + }, + { + env: { TELEGRAM_BOT_TOKEN: token }, + extraFields: { BOT_USERNAME: botUsername }, + }, + ); + if (!install.ok) { fail( 'Telegram install failed.', - 'Re-run `bash setup/add-telegram.sh`, then retry `pnpm run setup:auto`.', + 'Check the raw log under logs/setup-steps/, then retry `pnpm run setup:auto`.', ); } - p.log.success('Telegram adapter installed.'); const pair = await runPairTelegram(); if (!pair.ok) { @@ -592,6 +815,7 @@ async function main(): Promise { (await askAgentName(DEFAULT_AGENT_NAME)); const init = await runQuietChild( + 'init-first-agent', 'pnpm', [ 'exec', 'tsx', 'scripts/init-first-agent.ts', @@ -605,6 +829,9 @@ async function main(): Promise { running: `Wiring ${agentName} to your Telegram chat…`, done: `${agentName} is wired — welcome DM incoming.`, }, + { + extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId }, + }, ); if (!init.ok) { fail( @@ -652,9 +879,43 @@ async function main(): Promise { `${k.cyan('Open Claude Code:')} claude`, ].join('\n'); p.note(nextSteps, 'Next steps'); + setupLog.complete(Date.now() - RUN_START); p.outro(k.green('Setup complete.')); } +/** + * Bootstrap (nanoclaw.sh) normally initializes logs/setup.log and writes + * the bootstrap entry before we even boot. If someone runs `pnpm run + * setup:auto` directly, start a fresh progression log here so we don't + * append to a stale one from a previous run. + */ +function initProgressionLog(): void { + if (process.env.NANOCLAW_BOOTSTRAPPED === '1') return; + let commit = ''; + try { + commit = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { + encoding: 'utf-8', + }).stdout.trim(); + } catch { + // git not available or not a repo — skip + } + let branch = ''; + try { + branch = spawnSync('git', ['branch', '--show-current'], { + encoding: 'utf-8', + }).stdout.trim(); + } catch { + // skip + } + setupLog.reset({ + invocation: 'setup:auto (standalone)', + user: process.env.USER ?? 'unknown', + cwd: process.cwd(), + branch: branch || 'unknown', + commit: commit || 'unknown', + }); +} + main().catch((err) => { p.log.error(err instanceof Error ? err.message : String(err)); p.cancel('Setup aborted.'); diff --git a/setup/logs.ts b/setup/logs.ts new file mode 100644 index 0000000..127f969 --- /dev/null +++ b/setup/logs.ts @@ -0,0 +1,130 @@ +/** + * Three-level setup logging primitives. See docs/setup-flow.md for the + * contract and design rationale. + * + * Level 1: clack UI in setup/auto.ts (not here) + * Level 2: logs/setup.log — structured, append-only progression log + * Level 3: logs/setup-steps/NN-name.log — raw stdout+stderr per step + * + * Usage from auto.ts: + * + * import * as setupLog from './logs.js'; + * + * const rawLog = setupLog.stepRawLog('container'); + * const { ok, durationMs, terminal } = + * await spawnIntoRawLog('...', rawLog); + * setupLog.step('container', ok ? 'success' : 'failed', durationMs, + * { RUNTIME: 'docker', BUILD_OK: terminal.fields.BUILD_OK }, + * rawLog); + * + * nanoclaw.sh emits the bootstrap entry directly via a bash helper so + * the format stays consistent without needing IPC between bash and tsx. + */ +import fs from 'fs'; +import path from 'path'; + +const LOGS_DIR = 'logs'; +const STEPS_DIR = path.join(LOGS_DIR, 'setup-steps'); +const PROGRESS_LOG = path.join(LOGS_DIR, 'setup.log'); + +export const progressLogPath = PROGRESS_LOG; +export const stepsDir = STEPS_DIR; + +/** Wipe prior logs and write a header. Called once per fresh run (by nanoclaw.sh or as a fallback by auto.ts if invoked standalone). */ +export function reset(meta: Record): void { + if (fs.existsSync(STEPS_DIR)) { + fs.rmSync(STEPS_DIR, { recursive: true, force: true }); + } + fs.mkdirSync(STEPS_DIR, { recursive: true }); + if (fs.existsSync(PROGRESS_LOG)) fs.unlinkSync(PROGRESS_LOG); + header(meta); +} + +/** Append a run-start header to the progression log. Idempotent: creates the file if missing. */ +export function header(meta: Record): void { + fs.mkdirSync(LOGS_DIR, { recursive: true }); + const ts = new Date().toISOString(); + const lines = [`## ${ts} · setup:auto started`]; + for (const [k, v] of Object.entries(meta)) { + lines.push(` ${k}: ${v}`); + } + lines.push(''); + fs.appendFileSync(PROGRESS_LOG, lines.join('\n') + '\n'); +} + +/** Append one step entry to the progression log. */ +export function step( + name: string, + status: 'success' | 'skipped' | 'failed' | 'aborted' | 'interactive', + durationMs: number, + fields: Record, + rawRel?: string, +): void { + fs.mkdirSync(LOGS_DIR, { recursive: true }); + const ts = new Date().toISOString(); + const dur = formatDuration(durationMs); + const lines = [`=== [${ts}] ${name} [${dur}] → ${status} ===`]; + for (const [k, v] of Object.entries(fields)) { + if (v === undefined || v === null || v === '') continue; + lines.push(` ${k.toLowerCase()}: ${String(v)}`); + } + if (rawRel) lines.push(` raw: ${rawRel}`); + lines.push(''); + fs.appendFileSync(PROGRESS_LOG, lines.join('\n') + '\n'); +} + +/** A user answered a prompt. Logs as its own entry because the setup path depends on it. */ +export function userInput(key: string, value: string): void { + fs.mkdirSync(LOGS_DIR, { recursive: true }); + const ts = new Date().toISOString(); + fs.appendFileSync( + PROGRESS_LOG, + `=== [${ts}] user-input → ${key} ===\n value: ${value}\n\n`, + ); +} + +/** Append the success footer. */ +export function complete(totalMs: number): void { + const ts = new Date().toISOString(); + fs.appendFileSync( + PROGRESS_LOG, + `## ${ts} · completed (total ${formatDurationTotal(totalMs)})\n`, + ); +} + +/** Append the failure footer. Keep error short — full context lives in the failing step's raw log. */ +export function abort(stepName: string, error: string): void { + const ts = new Date().toISOString(); + fs.appendFileSync( + PROGRESS_LOG, + `## ${ts} · aborted at ${stepName} (${error})\n`, + ); +} + +/** + * Return the next raw-log path for a given step name. Numbering is derived + * from the count of existing NN-*.log files in STEPS_DIR, so bootstrap's + * pre-existing 01-bootstrap.log (written by nanoclaw.sh before this module + * is loaded) counts toward the sequence. + */ +export function stepRawLog(name: string): string { + fs.mkdirSync(STEPS_DIR, { recursive: true }); + const existing = fs + .readdirSync(STEPS_DIR) + .filter((n) => /^\d+-.+\.log$/.test(n)); + const nextIdx = existing.length + 1; + const num = String(nextIdx).padStart(2, '0'); + const safeName = name.replace(/[^a-z0-9-]/gi, '-').toLowerCase(); + return path.join(STEPS_DIR, `${num}-${safeName}.log`); +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function formatDurationTotal(ms: number): string { + const mins = Math.floor(ms / 60000); + const secs = Math.round((ms % 60000) / 1000); + return mins > 0 ? `${mins}m${secs}s` : `${secs}s`; +} From 416fe018550cbb3f83826cc1a3d67dc165920720 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 02:13:22 +0300 Subject: [PATCH 082/185] refactor(setup): drop CLI-bonus wiring from init-first-agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit init-first-agent used to double-wire the CLI channel to every new DM agent as a convenience for `pnpm run chat`, gated by --no-cli-bonus. With the /new-setup-2 flow gone and a dedicated scratch CLI agent created earlier in setup:auto, that bonus just stomps on CLI routing the user already set up. Remove the CLI_CHANNEL/CLI_PLATFORM_ID constants, ensureCliMessagingGroup, the --no-cli-bonus flag, and the cli-bonus wiring block. Pass the paired user's identity through to the welcome delivery so the sender resolver sees the real owner (e.g. telegram:) instead of cli:local. Extend the CLI channel's admin-transport payload to accept optional sender/senderId overrides — falls back to the old cli/cli:local defaults when omitted, so existing callers are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/init-first-agent.ts | 74 +++++++++++-------------------------- src/channels/cli.ts | 6 ++- 2 files changed, 26 insertions(+), 54 deletions(-) diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index 29ca6d4..c634851 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -1,16 +1,13 @@ /** * Init the first (or Nth) NanoClaw v2 agent for a DM channel. * - * Wires a real DM channel (discord, telegram, etc.) to a new agent group - * (and the local CLI channel as a convenience bonus), then hands a welcome - * message to the running service via its CLI socket. The service routes - * that message into the DM session, which wakes the container synchronously — - * the agent processes the welcome and DMs the operator through the normal - * delivery path. + * Wires a real DM channel (discord, telegram, etc.) to a new agent group, + * then hands a welcome message to the running service via the CLI socket + * (admin transport). The service routes that message into the DM session, + * which wakes the container synchronously — the agent processes the welcome + * and DMs the operator through the normal delivery path. * - * For the CLI-only scratch agent used during `/new-setup`, see - * `scripts/init-cli-agent.ts` — that's a distinct flow and doesn't run - * through here. + * CLI channel wiring is handled separately by `scripts/init-cli-agent.ts`. * * Creates/reuses: user, owner grant (if none), agent group + filesystem, * messaging group(s), wiring. @@ -27,8 +24,7 @@ * --platform-id discord:@me:1491573333382523708 \ * --display-name "Gavriel" \ * [--agent-name "Andy"] \ - * [--welcome "System instruction: ..."] \ - * [--no-cli-bonus] + * [--welcome "System instruction: ..."] * * For direct-addressable channels (telegram, whatsapp, etc.), --platform-id * is typically the same as the handle in --user-id, with the channel prefix. @@ -53,7 +49,6 @@ import { initGroupFilesystem } from '../src/group-init.js'; import type { AgentGroup, MessagingGroup } from '../src/types.js'; interface Args { - noCliBonus: boolean; channel: string; userId: string; platformId: string; @@ -65,18 +60,12 @@ interface Args { const DEFAULT_WELCOME = 'System instruction: run /welcome to introduce yourself to the user on this new channel.'; -const CLI_CHANNEL = 'cli'; -const CLI_PLATFORM_ID = 'local'; - function parseArgs(argv: string[]): Args { - const out: Partial = { noCliBonus: false }; + const out: Partial = {}; for (let i = 0; i < argv.length; i++) { const key = argv[i]; const val = argv[i + 1]; switch (key) { - case '--no-cli-bonus': - out.noCliBonus = true; - break; case '--channel': out.channel = (val ?? '').toLowerCase(); i++; @@ -115,7 +104,6 @@ function parseArgs(argv: string[]): Args { } return { - noCliBonus: out.noCliBonus ?? false, channel: out.channel!, userId: out.userId!, platformId: out.platformId!, @@ -137,24 +125,6 @@ function generateId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -function ensureCliMessagingGroup(now: string): MessagingGroup { - let cliMg = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID); - if (cliMg) return cliMg; - - cliMg = { - id: generateId('mg'), - channel_type: CLI_CHANNEL, - platform_id: CLI_PLATFORM_ID, - name: 'Local CLI', - is_group: 0, - unknown_sender_policy: 'public', - created_at: now, - }; - createMessagingGroup(cliMg); - console.log(`Created CLI messaging group: ${cliMg.id}`); - return cliMg; -} - function wireIfMissing(mg: MessagingGroup, ag: AgentGroup, now: string, label: string): void { const existing = getMessagingGroupAgentByPair(mg.id, ag.id); if (existing) { @@ -252,29 +222,23 @@ async function main(): Promise { console.log(`Reusing messaging group: ${dmMg.id} (${platformId})`); } - // 4. Wire DM (auto-creates companion agent_destinations row) and, - // unless suppressed, also wire the CLI channel so `pnpm run chat` works - // against the new agent immediately. `/new-setup-2` sets --no-cli-bonus - // so the scratch CLI agent from `/new-setup` keeps owning CLI routing. + // 4. Wire DM messaging group to the agent. wireIfMissing(dmMg, ag, now, 'dm'); - if (!args.noCliBonus) { - const cliMg = ensureCliMessagingGroup(now); - wireIfMissing(cliMg, ag, now, 'cli-bonus'); - } // 5. Welcome delivery over the CLI socket. Router picks up the line, // writes the message into the DM session's inbound.db, and wakes the - // container synchronously — no sweep wait. - await sendWelcomeViaCliSocket(dmMg, args.welcome); + // container synchronously — no sweep wait. The paired user's identity is + // passed so the sender resolver sees the real owner, not cli:local. + await sendWelcomeViaCliSocket(dmMg, args.welcome, { + senderId: userId, + sender: args.displayName, + }); console.log(''); console.log('Init complete.'); console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`); console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`); console.log(` channel: ${args.channel} ${dmMg.platform_id}`); - if (!args.noCliBonus) { - console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``); - } console.log(''); console.log('Welcome DM queued — the agent will greet you shortly.'); } @@ -288,7 +252,11 @@ async function main(): Promise { * Throws if the socket isn't reachable — this script requires the service * to be running. */ -async function sendWelcomeViaCliSocket(dmMg: MessagingGroup, welcome: string): Promise { +async function sendWelcomeViaCliSocket( + dmMg: MessagingGroup, + welcome: string, + identity: { senderId: string; sender: string }, +): Promise { const sockPath = path.join(DATA_DIR, 'cli.sock'); await new Promise((resolve, reject) => { @@ -318,6 +286,8 @@ async function sendWelcomeViaCliSocket(dmMg: MessagingGroup, welcome: string): P const payload = JSON.stringify({ text: welcome, + senderId: identity.senderId, + sender: identity.sender, to: { channelType: dmMg.channel_type, platformId: dmMg.platform_id, diff --git a/src/channels/cli.ts b/src/channels/cli.ts index b738186..ad78bea 100644 --- a/src/channels/cli.ts +++ b/src/channels/cli.ts @@ -183,6 +183,8 @@ function createAdapter(): ChannelAdapter { text?: unknown; to?: unknown; reply_to?: unknown; + sender?: unknown; + senderId?: unknown; }; try { payload = JSON.parse(line); @@ -209,8 +211,8 @@ function createAdapter(): ChannelAdapter { timestamp: new Date().toISOString(), content: JSON.stringify({ text: payload.text, - sender: 'cli', - senderId: `cli:${PLATFORM_ID}`, + sender: typeof payload.sender === 'string' ? payload.sender : 'cli', + senderId: typeof payload.senderId === 'string' ? payload.senderId : `cli:${PLATFORM_ID}`, }), }, replyTo: replyTo ?? undefined, From 9b7d4d50e409fb240f6a1a80c2bfe5d43330ae9e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 02:26:50 +0300 Subject: [PATCH 083/185] refactor(setup): split auto.ts into runner + theme + telegram channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auto.ts had grown to 923 lines with ~10 interleaved responsibilities. Split into three focused modules, keeping auto.ts as a pure step sequencer: - setup/lib/runner.ts (325 lines) — spawn + stream-parse + spinner-wrap primitives. Exports: spawnStep, spawnQuiet, runQuietStep, runQuietChild, runUnderSpinner (internal), StatusStream, types (Fields, Block, StepResult, SpinnerLabels, QuietChildResult), writeStepEntry, summariseTerminalFields, dumpTranscriptOnFailure, fail(), ensureAnswer(). - setup/lib/theme.ts (39 lines) — brand palette (brand, brandBold, brandChip) with USE_ANSI / TRUECOLOR gating, so both auto.ts and channel flows can render the NanoClaw cyan without duplicating the detection. - setup/channels/telegram.ts (277 lines) — runTelegramChannel(displayName) owns the full flow: BotFather instructions, token paste + validation (via getMe), install script, pair-telegram streaming UI (code card + attempt checkpoints), agent-name prompt, init-first-agent wiring. auto.ts drops to 376 lines. main() reads as a clean sequence of `if (!skip.has(X)) await Xstep(...)` blocks. fail() now takes the step name explicitly — no module-level failingStep state. Every call site is grep-friendly and self-contained (fail('container', msg, hint)). Typechecks clean. Smoke-tested end-to-end: intro, mounts step, progression log, and outro all render the same as before the split. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 813 ++++++------------------------------- setup/channels/telegram.ts | 277 +++++++++++++ setup/lib/runner.ts | 325 +++++++++++++++ setup/lib/theme.ts | 39 ++ 4 files changed, 774 insertions(+), 680 deletions(-) create mode 100644 setup/channels/telegram.ts create mode 100644 setup/lib/runner.ts create mode 100644 setup/lib/theme.ts diff --git a/setup/auto.ts b/setup/auto.ts index c16b6e5..bb23650 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -1,621 +1,37 @@ /** - * Non-interactive setup driver. Chains the deterministic setup steps so a - * scripted install can go from a fresh checkout to a running service without - * the `/setup` skill. + * Non-interactive setup driver — the step sequencer for `pnpm run setup:auto`. * - * Prerequisite: `bash setup.sh` has run (Node >= 20, pnpm install, native - * module check). This driver picks up from there. + * Responsibility: orchestrate the sequence of steps end-to-end and route + * between them. The runner, spawning, status parsing, spinner, abort, and + * prompt primitives live in `setup/lib/runner.ts`; theming in + * `setup/lib/theme.ts`; Telegram's full flow in `setup/channels/telegram.ts`. * * Config via env: * NANOCLAW_DISPLAY_NAME how the agents address the operator — skips the * prompt. Defaults to $USER. - * NANOCLAW_AGENT_NAME name for the messaging-channel agent (Telegram, - * etc.) — skips the prompt. Defaults to "Nano". - * (The CLI scratch agent is always "Terminal Agent".) + * NANOCLAW_AGENT_NAME messaging-channel agent name (consumed by the + * channel flow). The CLI scratch agent is always + * "Terminal Agent". * NANOCLAW_SKIP comma-separated step names to skip * (environment|container|onecli|auth|mounts| * service|cli-agent|channel|verify) * - * Timezone is not configured here — it defaults to the host system's TZ. - * Run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` later - * if autodetect is wrong (e.g. headless server with TZ=UTC). - * - * UI is rendered with @clack/prompts: spinners wrap each step, child output - * is captured quietly and only dumped on failure. Interactive children - * (register-claude-token.sh, add-telegram.sh) bypass the spinner and run - * with inherited stdio — clack resumes cleanly on the next step. + * Timezone defaults to the host system's TZ. Run + * pnpm exec tsx setup/index.ts --step timezone -- --tz + * later if autodetect is wrong. */ import { spawn, spawnSync } from 'child_process'; -import fs from 'fs'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { runTelegramChannel } from './channels/telegram.js'; import * as setupLog from './logs.js'; +import { ensureAnswer, fail, runQuietStep } from './lib/runner.js'; +import { brandBold, brandChip } from './lib/theme.js'; const CLI_AGENT_NAME = 'Terminal Agent'; -const DEFAULT_AGENT_NAME = 'Nano'; - const RUN_START = Date.now(); -let failingStep = 'setup'; - -/** - * Brand palette, pulled from assets/nanoclaw-logo.png: - * brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body - * brand navy ≈ #171B3B — the dark logo background + outlines - * Gated on TTY + NO_COLOR so piped / CI output stays plain. Falls back to - * kleur's 16-color cyan when the terminal isn't truecolor. - */ -const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; -const TRUECOLOR = - USE_ANSI && - (process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit'); - -const brand = (s: string): string => { - if (!USE_ANSI) return s; - if (TRUECOLOR) return `\x1b[38;2;43;183;206m${s}\x1b[0m`; - return k.cyan(s); -}; -const brandBold = (s: string): string => { - if (!USE_ANSI) return s; - if (TRUECOLOR) return `\x1b[1;38;2;43;183;206m${s}\x1b[0m`; - return k.bold(k.cyan(s)); -}; -const brandChip = (s: string): string => { - if (!USE_ANSI) return s; - if (TRUECOLOR) { - return `\x1b[48;2;43;183;206m\x1b[38;2;23;27;59m\x1b[1m${s}\x1b[0m`; - } - return k.bgCyan(k.black(k.bold(s))); -}; - -type Fields = Record; -type Block = { type: string; fields: Fields }; -type StepResult = { - ok: boolean; - exitCode: number; - blocks: Block[]; - transcript: string; - /** The last block matching `stepName.toUpperCase()` if any. */ - terminal: Block | null; -}; - -/** - * Streaming parser for `=== NANOCLAW SETUP: TYPE ===` blocks. Emits each - * block as it closes so the UI can react mid-stream (e.g. render a pairing - * code card as soon as pair-telegram emits it, rather than after the step - * has finished). - */ -class StatusStream { - private lineBuf = ''; - private current: Block | null = null; - readonly blocks: Block[] = []; - transcript = ''; - - constructor(private readonly onBlock: (block: Block) => void) {} - - write(chunk: string): void { - this.transcript += chunk; - this.lineBuf += chunk; - let idx: number; - while ((idx = this.lineBuf.indexOf('\n')) !== -1) { - const line = this.lineBuf.slice(0, idx); - this.lineBuf = this.lineBuf.slice(idx + 1); - this.processLine(line); - } - } - - private processLine(line: string): void { - const start = line.match(/^=== NANOCLAW SETUP: (\S+) ===/); - if (start) { - this.current = { type: start[1], fields: {} }; - return; - } - if (line.startsWith('=== END ===')) { - if (this.current) { - this.blocks.push(this.current); - this.onBlock(this.current); - this.current = null; - } - return; - } - if (!this.current) return; - const colon = line.indexOf(':'); - if (colon === -1) return; - const key = line.slice(0, colon).trim(); - const value = line.slice(colon + 1).trim(); - if (key) this.current.fields[key] = value; - } -} - -/** - * Spawn a setup step as a child process. Output is tee'd to the provided - * raw log file (level 3) and parsed for status blocks (level 2 summary). - * The onBlock callback fires per status block as they close so the UI can - * react mid-stream. - */ -function spawnStep( - stepName: string, - extra: string[], - onBlock: (block: Block) => void, - rawLogPath: string, -): Promise { - return new Promise((resolve) => { - const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName]; - if (extra.length > 0) args.push('--', ...extra); - - const child = spawn('pnpm', args, { stdio: ['ignore', 'pipe', 'pipe'] }); - const stream = new StatusStream(onBlock); - const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); - raw.write(`# ${stepName} — ${new Date().toISOString()}\n\n`); - - child.stdout.on('data', (chunk: Buffer) => { - stream.write(chunk.toString('utf-8')); - raw.write(chunk); - }); - child.stderr.on('data', (chunk: Buffer) => { - stream.transcript += chunk.toString('utf-8'); - raw.write(chunk); - }); - - child.on('close', (code) => { - raw.end(); - // Step block types don't always mirror step names (e.g. `mounts` emits - // CONFIGURE_MOUNTS, `container` emits SETUP_CONTAINER). Any block with - // a STATUS field is a terminal block; the last one wins. - const terminal = - [...stream.blocks].reverse().find((b) => b.fields.STATUS) ?? null; - const status = terminal?.fields.STATUS; - const ok = code === 0 && (status === 'success' || status === 'skipped'); - resolve({ - ok, - exitCode: code ?? 1, - blocks: stream.blocks, - transcript: stream.transcript, - terminal, - }); - }); - }); -} - -type SpinnerLabels = { - running: string; - done: string; - skipped?: string; - failed?: string; -}; - -/** Run a step under a clack spinner. Teed to a per-step raw log + progression entry at the end. */ -async function runQuietStep( - stepName: string, - labels: SpinnerLabels, - extra: string[] = [], -): Promise { - failingStep = stepName; - const rawLog = setupLog.stepRawLog(stepName); - const start = Date.now(); - const result = await runUnderSpinner(labels, () => - spawnStep(stepName, extra, () => {}, rawLog), - ); - const durationMs = Date.now() - start; - writeStepEntry(stepName, result, durationMs, rawLog); - return { ...result, rawLog, durationMs }; -} - -/** Run an arbitrary child under a spinner. Same raw-log + progression treatment as runQuietStep. */ -async function runQuietChild( - logName: string, - cmd: string, - args: string[], - labels: SpinnerLabels, - opts?: { - /** Extra fields to merge into the progression entry (on top of any status-block fields). */ - extraFields?: Record; - /** Environment overrides to pass to the child process. */ - env?: NodeJS.ProcessEnv; - }, -): Promise<{ - ok: boolean; - exitCode: number; - transcript: string; - terminal: Block | null; - rawLog: string; - durationMs: number; -}> { - failingStep = logName; - const rawLog = setupLog.stepRawLog(logName); - const start = Date.now(); - const result = await runUnderSpinner(labels, () => - spawnQuiet(cmd, args, rawLog, opts?.env), - ); - const durationMs = Date.now() - start; - - const blockFields = summariseTerminalFields(result.terminal); - const fields = { ...blockFields, ...(opts?.extraFields ?? {}) }; - const rawStatus = result.terminal?.fields.STATUS; - const status: 'success' | 'skipped' | 'failed' = !result.ok - ? 'failed' - : rawStatus === 'skipped' - ? 'skipped' - : 'success'; - setupLog.step(logName, status, durationMs, fields, rawLog); - return { ...result, rawLog, durationMs }; -} - -/** Turn a step's terminal-block fields into a concise progression-log entry. */ -function writeStepEntry( - stepName: string, - result: StepResult, - durationMs: number, - rawLog: string, -): void { - const rawStatus = result.terminal?.fields.STATUS; - const logStatus: 'success' | 'skipped' | 'failed' = !result.ok - ? 'failed' - : rawStatus === 'skipped' - ? 'skipped' - : 'success'; - const fields = summariseTerminalFields(result.terminal); - setupLog.step(stepName, logStatus, durationMs, fields, rawLog); -} - -/** Strip STATUS + LOG (redundant) and any oversize values from the terminal block's fields. */ -function summariseTerminalFields(block: Block | null): Record { - if (!block) return {}; - const out: Record = {}; - for (const [k, v] of Object.entries(block.fields)) { - if (k === 'STATUS' || k === 'LOG') continue; - if (v.length > 120) continue; // keep it skimmable; full value lives in the raw log - out[k] = v; - } - return out; -} - -async function runUnderSpinner< - T extends { ok: boolean; transcript: string; terminal?: Block | null }, ->( - labels: SpinnerLabels, - work: () => Promise, -): Promise { - const s = p.spinner(); - const start = Date.now(); - s.start(labels.running); - const tick = setInterval(() => { - const elapsed = Math.round((Date.now() - start) / 1000); - s.message(`${labels.running} ${k.dim(`(${elapsed}s)`)}`); - }, 1000); - - const result = await work(); - - clearInterval(tick); - const elapsed = Math.round((Date.now() - start) / 1000); - if (result.ok) { - const isSkipped = result.terminal?.fields.STATUS === 'skipped'; - const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; - s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`); - } else { - const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed'); - s.stop(`${failMsg} ${k.dim(`(${elapsed}s)`)}`, 1); - dumpTranscriptOnFailure(result.transcript); - } - return result; -} - -function spawnQuiet( - cmd: string, - args: string[], - rawLogPath: string, - envOverride?: NodeJS.ProcessEnv, -): Promise<{ ok: boolean; exitCode: number; transcript: string; terminal: Block | null; blocks: Block[] }> { - return new Promise((resolve) => { - const child = spawn(cmd, args, { - stdio: ['ignore', 'pipe', 'pipe'], - env: envOverride ? { ...process.env, ...envOverride } : process.env, - }); - let transcript = ''; - const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); - raw.write(`# ${[cmd, ...args].join(' ')} — ${new Date().toISOString()}\n\n`); - const blocks: Block[] = []; - const stream = new StatusStream((b) => blocks.push(b)); - child.stdout.on('data', (c: Buffer) => { - const s = c.toString('utf-8'); - transcript += s; - stream.write(s); - raw.write(c); - }); - child.stderr.on('data', (c: Buffer) => { - transcript += c.toString('utf-8'); - raw.write(c); - }); - child.on('close', (code) => { - raw.end(); - const terminal = - [...blocks].reverse().find((b) => b.fields.STATUS) ?? null; - resolve({ ok: code === 0, exitCode: code ?? 1, transcript, terminal, blocks }); - }); - }); -} - -function dumpTranscriptOnFailure(transcript: string): void { - const lines = transcript.split('\n').filter((l) => { - if (l.startsWith('=== NANOCLAW SETUP:')) return false; - if (l.startsWith('=== END ===')) return false; - return true; - }); - const tail = lines.slice(-40).join('\n').trimEnd(); - if (tail) { - console.log(); - console.log(k.dim(tail)); - console.log(); - } -} - -function fail(msg: string, hint?: string): never { - setupLog.abort(failingStep, msg); - p.log.error(msg); - if (hint) p.log.message(k.dim(hint)); - p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/')); - p.cancel('Setup aborted.'); - process.exit(1); -} - -function ensureAnswer(value: T | symbol): T { - if (p.isCancel(value)) { - setupLog.abort(failingStep, 'user-cancelled'); - p.cancel('Setup cancelled.'); - process.exit(0); - } - return value as T; -} - -/** - * After installing Docker, this process's supplementary groups are still - * frozen from login — subsequent steps that talk to /var/run/docker.sock - * (onecli install, service start, …) fail with EACCES even though the - * daemon is up. Detect that and re-exec the whole driver under `sg docker` - * so the rest of the run inherits the docker group without a re-login. - */ -function maybeReexecUnderSg(): void { - if (process.env.NANOCLAW_REEXEC_SG === '1') return; - if (process.platform !== 'linux') return; - const info = spawnSync('docker', ['info'], { encoding: 'utf-8' }); - if (info.status === 0) return; - const err = `${info.stderr ?? ''}\n${info.stdout ?? ''}`; - if (!/permission denied/i.test(err)) return; - if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return; - - p.log.warn('Docker socket not accessible in current group — re-executing under `sg docker`.'); - const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { - stdio: 'inherit', - env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, - }); - process.exit(res.status ?? 1); -} - -function anthropicSecretExists(): boolean { - try { - const res = spawnSync('onecli', ['secrets', 'list'], { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - if (res.status !== 0) return false; - return /anthropic/i.test(res.stdout ?? ''); - } catch { - return false; - } -} - -function runInheritScript(cmd: string, args: string[]): Promise { - return new Promise((resolve) => { - const child = spawn(cmd, args, { stdio: 'inherit' }); - child.on('close', (code) => resolve(code ?? 1)); - }); -} - -function formatCodeCard(code: string): string { - const spaced = code.split('').join(' '); - return [ - '', - ` ${brandBold(spaced)}`, - '', - k.dim(' Send these digits from Telegram to your bot.'), - ].join('\n'); -} - -async function runPairTelegram(): Promise { - failingStep = 'pair-telegram'; - const rawLog = setupLog.stepRawLog('pair-telegram'); - const start = Date.now(); - const s = p.spinner(); - s.start('Creating pairing code…'); - let spinnerActive = true; - - const stopSpinner = (msg: string, code?: number) => { - if (spinnerActive) { - s.stop(msg, code); - spinnerActive = false; - } - }; - - const result = await spawnStep( - 'pair-telegram', - ['--intent', 'main'], - (block) => { - if (block.type === 'PAIR_TELEGRAM_CODE') { - const reason = block.fields.REASON ?? 'initial'; - if (reason === 'initial') { - stopSpinner('Pairing code ready.'); - } else { - stopSpinner('Previous code invalidated. New code below.'); - } - p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Pairing code'); - s.start('Waiting for the code from Telegram…'); - spinnerActive = true; - } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { - stopSpinner(`Received "${block.fields.CANDIDATE ?? '?'}" — doesn't match.`); - s.start('Waiting for the correct code…'); - spinnerActive = true; - } else if (block.type === 'PAIR_TELEGRAM') { - if (block.fields.STATUS === 'success') { - stopSpinner('Telegram paired.'); - } else { - stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1); - } - } - }, - rawLog, - ); - const durationMs = Date.now() - start; - - // Safety net: if the child died without emitting a terminal block, make - // sure we don't leave the spinner running. - if (spinnerActive) { - stopSpinner(result.ok ? 'Done.' : 'Pairing exited unexpectedly.', result.ok ? 0 : 1); - if (!result.ok) dumpTranscriptOnFailure(result.transcript); - } - - writeStepEntry('pair-telegram', result, durationMs, rawLog); - return { ...result, rawLog, durationMs }; -} - -async function askDisplayName(fallback: string): Promise { - const answer = ensureAnswer( - await p.text({ - message: 'What should your agents call you?', - placeholder: fallback, - defaultValue: fallback, - }), - ); - const value = (answer as string).trim() || fallback; - setupLog.userInput('display_name', value); - return value; -} - -async function askAgentName(fallback: string): Promise { - const answer = ensureAnswer( - await p.text({ - message: 'What should your messaging agent be called?', - placeholder: fallback, - defaultValue: fallback, - }), - ); - const value = (answer as string).trim() || fallback; - setupLog.userInput('agent_name', value); - return value; -} - -async function askChannelChoice(): Promise<'telegram' | 'skip'> { - const choice = ensureAnswer( - await p.select({ - message: 'Connect a messaging app so you can chat from your phone?', - options: [ - { value: 'telegram', label: 'Telegram', hint: 'recommended' }, - { value: 'skip', label: 'Skip — use the CLI only' }, - ], - }), - ); - setupLog.userInput('channel_choice', String(choice)); - return choice as 'telegram' | 'skip'; -} - -async function collectTelegramToken(): Promise { - p.note( - [ - '1. Open Telegram and message @BotFather', - '2. Send: /newbot', - '3. Follow the prompts (name + username ending in "bot")', - '4. Copy the token it gives you (format: :)', - '', - k.dim('Optional, but recommended for groups:'), - k.dim(' @BotFather → /mybots → Bot Settings → Group Privacy → OFF'), - ].join('\n'), - 'Create a Telegram bot', - ); - - const answer = ensureAnswer( - await p.password({ - message: 'Paste your bot token', - validate: (v) => { - if (!v || !v.trim()) return 'Token is required'; - if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { - return 'Format looks wrong — expected :'; - } - return undefined; - }, - }), - ); - const token = (answer as string).trim(); - setupLog.userInput( - 'telegram_token', - `${token.slice(0, 12)}…${token.slice(-4)}`, - ); - return token; -} - -async function validateTelegramToken(token: string): Promise { - failingStep = 'telegram-validate'; - const s = p.spinner(); - const start = Date.now(); - s.start('Validating token with Telegram…'); - try { - const res = await fetch(`https://api.telegram.org/bot${token}/getMe`); - const data = (await res.json()) as { - ok?: boolean; - result?: { username?: string; id?: number }; - description?: string; - }; - const elapsed = Math.round((Date.now() - start) / 1000); - if (data.ok && data.result?.username) { - const username = data.result.username; - s.stop(`Bot is @${username}. ${k.dim(`(${elapsed}s)`)}`); - setupLog.step( - 'telegram-validate', - 'success', - Date.now() - start, - { BOT_USERNAME: username, BOT_ID: data.result.id ?? '' }, - ); - return username; - } - const reason = data.description ?? 'token rejected by Telegram'; - s.stop(`Telegram rejected the token: ${reason}`, 1); - setupLog.step( - 'telegram-validate', - 'failed', - Date.now() - start, - { ERROR: reason }, - ); - fail( - 'Telegram rejected the token.', - 'Double-check the token (copy it again from @BotFather) and retry.', - ); - } catch (err) { - const elapsed = Math.round((Date.now() - start) / 1000); - s.stop(`Could not reach Telegram. ${k.dim(`(${elapsed}s)`)}`, 1); - const message = err instanceof Error ? err.message : String(err); - setupLog.step('telegram-validate', 'failed', Date.now() - start, { - ERROR: message, - }); - fail( - 'Telegram API unreachable.', - 'Check your network connection and retry.', - ); - } -} - -function printIntro(): void { - const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; - const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; - - if (isReexec) { - p.intro(`${brandChip(' setup:auto ')} ${wordmark} ${k.dim('· resuming under docker group')}`); - return; - } - - console.log(); - console.log(` ${wordmark}`); - console.log(` ${k.dim('end-to-end scripted setup of your personal assistant')}`); - p.intro(`${brandChip(' setup:auto ')}`); -} async function main(): Promise { printIntro(); @@ -629,11 +45,11 @@ async function main(): Promise { ); if (!skip.has('environment')) { - const res = await runQuietStep( - 'environment', - { running: 'Checking environment…', done: 'Environment OK.' }, - ); - if (!res.ok) fail('Environment check failed.'); + const res = await runQuietStep('environment', { + running: 'Checking environment…', + done: 'Environment OK.', + }); + if (!res.ok) fail('environment', 'Environment check failed.'); } if (!skip.has('container')) { @@ -646,17 +62,20 @@ async function main(): Promise { const err = res.terminal?.fields.ERROR; if (err === 'runtime_not_available') { fail( + 'container', 'Docker is not available and could not be started automatically.', 'Install Docker Desktop or start it manually, then retry.', ); } if (err === 'docker_group_not_active') { fail( + 'container', 'Docker was just installed but your shell is not yet in the `docker` group.', 'Log out and back in (or run `newgrp docker` in a new shell), then retry.', ); } fail( + 'container', 'Container build/test failed.', 'For stale cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', ); @@ -673,11 +92,13 @@ async function main(): Promise { const err = res.terminal?.fields.ERROR; if (err === 'onecli_not_on_path_after_install') { fail( + 'onecli', 'OneCLI installed but not on PATH.', 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', ); } fail( + 'onecli', `OneCLI install failed (${err ?? 'unknown'}).`, 'Check that curl + a writable ~/.local/bin are available, then retry.', ); @@ -685,7 +106,6 @@ async function main(): Promise { } if (!skip.has('auth')) { - failingStep = 'auth'; if (anthropicSecretExists()) { p.log.success('OneCLI already has an Anthropic secret — skipping.'); setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); @@ -702,22 +122,29 @@ async function main(): Promise { if (code !== 0) { setupLog.step('auth', 'failed', durationMs, { EXIT_CODE: code }); fail( + 'auth', 'Anthropic credential registration failed or was aborted.', 'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.', ); } - setupLog.step('auth', 'interactive', durationMs, { METHOD: 'register-claude-token.sh' }); + setupLog.step('auth', 'interactive', durationMs, { + METHOD: 'register-claude-token.sh', + }); p.log.success('Anthropic credential registered with OneCLI.'); } } if (!skip.has('mounts')) { - const res = await runQuietStep('mounts', { - running: 'Writing mount allowlist…', - done: 'Mount allowlist in place.', - skipped: 'Mount allowlist already configured.', - }, ['--empty']); - if (!res.ok) fail('Mount allowlist step failed.'); + const res = await runQuietStep( + 'mounts', + { + running: 'Writing mount allowlist…', + done: 'Mount allowlist in place.', + skipped: 'Mount allowlist already configured.', + }, + ['--empty'], + ); + if (!res.ok) fail('mounts', 'Mount allowlist step failed.'); } if (!skip.has('service')) { @@ -727,6 +154,7 @@ async function main(): Promise { }); if (!res.ok) { fail( + 'service', 'Service install failed.', 'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.', ); @@ -761,6 +189,7 @@ async function main(): Promise { ); if (!res.ok) { fail( + 'cli-agent', 'CLI agent wiring failed.', `Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`, ); @@ -770,75 +199,7 @@ async function main(): Promise { if (!skip.has('channel')) { const choice = await askChannelChoice(); if (choice === 'telegram') { - const token = await collectTelegramToken(); - const botUsername = await validateTelegramToken(token); - - const install = await runQuietChild( - 'telegram-install', - 'bash', - ['setup/add-telegram.sh'], - { - running: `Installing Telegram adapter and wiring @${botUsername}…`, - done: `Telegram adapter ready.`, - }, - { - env: { TELEGRAM_BOT_TOKEN: token }, - extraFields: { BOT_USERNAME: botUsername }, - }, - ); - if (!install.ok) { - fail( - 'Telegram install failed.', - 'Check the raw log under logs/setup-steps/, then retry `pnpm run setup:auto`.', - ); - } - - const pair = await runPairTelegram(); - if (!pair.ok) { - fail( - 'Telegram pairing failed.', - 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', - ); - } - - const platformId = pair.terminal?.fields.PLATFORM_ID; - const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID; - if (!platformId || !pairedUserId) { - fail( - 'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.', - 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main` and capture the success block.', - ); - } - - const agentName = - process.env.NANOCLAW_AGENT_NAME?.trim() || - (await askAgentName(DEFAULT_AGENT_NAME)); - - const init = await runQuietChild( - 'init-first-agent', - 'pnpm', - [ - 'exec', 'tsx', 'scripts/init-first-agent.ts', - '--channel', 'telegram', - '--user-id', pairedUserId, - '--platform-id', platformId, - '--display-name', displayName!, - '--agent-name', agentName, - ], - { - running: `Wiring ${agentName} to your Telegram chat…`, - done: `${agentName} is wired — welcome DM incoming.`, - }, - { - extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId }, - }, - ); - if (!init.ok) { - fail( - 'Wiring the Telegram agent failed.', - `Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${pairedUserId}" --platform-id "${platformId}" --display-name "${displayName!}" --agent-name "${agentName}"\`.`, - ); - } + await runTelegramChannel(displayName!); } else { p.log.info('No messaging channel wired — you can add one later with `/add-`.'); } @@ -883,6 +244,98 @@ async function main(): Promise { p.outro(k.green('Setup complete.')); } +// ─── prompts owned by the sequencer ──────────────────────────────────── + +async function askDisplayName(fallback: string): Promise { + const answer = ensureAnswer( + await p.text({ + message: 'What should your agents call you?', + placeholder: fallback, + defaultValue: fallback, + }), + ); + const value = (answer as string).trim() || fallback; + setupLog.userInput('display_name', value); + return value; +} + +async function askChannelChoice(): Promise<'telegram' | 'skip'> { + const choice = ensureAnswer( + await p.select({ + message: 'Connect a messaging app so you can chat from your phone?', + options: [ + { value: 'telegram', label: 'Telegram', hint: 'recommended' }, + { value: 'skip', label: 'Skip — use the CLI only' }, + ], + }), + ); + setupLog.userInput('channel_choice', String(choice)); + return choice as 'telegram' | 'skip'; +} + +// ─── interactive / env helpers ───────────────────────────────────────── + +function anthropicSecretExists(): boolean { + try { + const res = spawnSync('onecli', ['secrets', 'list'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (res.status !== 0) return false; + return /anthropic/i.test(res.stdout ?? ''); + } catch { + return false; + } +} + +function runInheritScript(cmd: string, args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: 'inherit' }); + child.on('close', (code) => resolve(code ?? 1)); + }); +} + +/** + * After installing Docker, this process's supplementary groups are still + * frozen from login — subsequent steps that talk to /var/run/docker.sock + * (onecli install, service start, …) fail with EACCES even though the + * daemon is up. Detect that and re-exec the whole driver under `sg docker` + * so the rest of the run inherits the docker group without a re-login. + */ +function maybeReexecUnderSg(): void { + if (process.env.NANOCLAW_REEXEC_SG === '1') return; + if (process.platform !== 'linux') return; + const info = spawnSync('docker', ['info'], { encoding: 'utf-8' }); + if (info.status === 0) return; + const err = `${info.stderr ?? ''}\n${info.stdout ?? ''}`; + if (!/permission denied/i.test(err)) return; + if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return; + + p.log.warn('Docker socket not accessible in current group — re-executing under `sg docker`.'); + const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { + stdio: 'inherit', + env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, + }); + process.exit(res.status ?? 1); +} + +// ─── intro + progression-log init ────────────────────────────────────── + +function printIntro(): void { + const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; + const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; + + if (isReexec) { + p.intro(`${brandChip(' setup:auto ')} ${wordmark} ${k.dim('· resuming under docker group')}`); + return; + } + + console.log(); + console.log(` ${wordmark}`); + console.log(` ${k.dim('end-to-end scripted setup of your personal assistant')}`); + p.intro(`${brandChip(' setup:auto ')}`); +} + /** * Bootstrap (nanoclaw.sh) normally initializes logs/setup.log and writes * the bootstrap entry before we even boot. If someone runs `pnpm run diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts new file mode 100644 index 0000000..d3e3f89 --- /dev/null +++ b/setup/channels/telegram.ts @@ -0,0 +1,277 @@ +/** + * Telegram channel flow for setup:auto. + * + * `runTelegramChannel(displayName)` owns the full branch from the + * BotFather instructions through the welcome DM: + * + * 1. BotFather instructions (clack note) + * 2. Paste the bot token (clack password) — format-validated + * 3. getMe via the Bot API to resolve the bot's username + * 4. Install the adapter (setup/add-telegram.sh, non-interactive) + * 5. Run the pair-telegram step, rendering code events as clack notes + * 6. Ask for the messaging-agent name (defaulting to "Nano") + * 7. Wire the agent via scripts/init-first-agent.ts + * + * All output obeys the three-level contract: clack UI for the user, + * structured entries in logs/setup.log, full raw output in per-step files + * under logs/setup-steps/. See docs/setup-flow.md. + */ +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; +import { + type Block, + type StepResult, + dumpTranscriptOnFailure, + ensureAnswer, + fail, + runQuietChild, + spawnStep, + writeStepEntry, +} from '../lib/runner.js'; +import { brandBold } from '../lib/theme.js'; + +const DEFAULT_AGENT_NAME = 'Nano'; + +export async function runTelegramChannel(displayName: string): Promise { + const token = await collectTelegramToken(); + const botUsername = await validateTelegramToken(token); + + const install = await runQuietChild( + 'telegram-install', + 'bash', + ['setup/add-telegram.sh'], + { + running: `Installing Telegram adapter and wiring @${botUsername}…`, + done: 'Telegram adapter ready.', + }, + { + env: { TELEGRAM_BOT_TOKEN: token }, + extraFields: { BOT_USERNAME: botUsername }, + }, + ); + if (!install.ok) { + fail( + 'telegram-install', + 'Telegram install failed.', + 'Check the raw log under logs/setup-steps/, then retry `pnpm run setup:auto`.', + ); + } + + const pair = await runPairTelegram(); + if (!pair.ok) { + fail( + 'pair-telegram', + 'Telegram pairing failed.', + 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', + ); + } + + const platformId = pair.terminal?.fields.PLATFORM_ID; + const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID; + if (!platformId || !pairedUserId) { + fail( + 'pair-telegram', + 'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.', + 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main` and capture the success block.', + ); + } + + const agentName = await resolveAgentName(); + + const init = await runQuietChild( + 'init-first-agent', + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'telegram', + '--user-id', pairedUserId, + '--platform-id', platformId, + '--display-name', displayName, + '--agent-name', agentName, + ], + { + running: `Wiring ${agentName} to your Telegram chat…`, + done: `${agentName} is wired — welcome DM incoming.`, + }, + { + extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId }, + }, + ); + if (!init.ok) { + fail( + 'init-first-agent', + 'Wiring the Telegram agent failed.', + `Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${pairedUserId}" --platform-id "${platformId}" --display-name "${displayName}" --agent-name "${agentName}"\`.`, + ); + } +} + +async function collectTelegramToken(): Promise { + p.note( + [ + '1. Open Telegram and message @BotFather', + '2. Send: /newbot', + '3. Follow the prompts (name + username ending in "bot")', + '4. Copy the token it gives you (format: :)', + '', + k.dim('Optional, but recommended for groups:'), + k.dim(' @BotFather → /mybots → Bot Settings → Group Privacy → OFF'), + ].join('\n'), + 'Create a Telegram bot', + ); + + const answer = ensureAnswer( + await p.password({ + message: 'Paste your bot token', + validate: (v) => { + if (!v || !v.trim()) return 'Token is required'; + if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { + return 'Format looks wrong — expected :'; + } + return undefined; + }, + }), + ); + const token = (answer as string).trim(); + setupLog.userInput( + 'telegram_token', + `${token.slice(0, 12)}…${token.slice(-4)}`, + ); + return token; +} + +async function validateTelegramToken(token: string): Promise { + const s = p.spinner(); + const start = Date.now(); + s.start('Validating token with Telegram…'); + try { + const res = await fetch(`https://api.telegram.org/bot${token}/getMe`); + const data = (await res.json()) as { + ok?: boolean; + result?: { username?: string; id?: number }; + description?: string; + }; + const elapsedS = Math.round((Date.now() - start) / 1000); + if (data.ok && data.result?.username) { + const username = data.result.username; + s.stop(`Bot is @${username}. ${k.dim(`(${elapsedS}s)`)}`); + setupLog.step('telegram-validate', 'success', Date.now() - start, { + BOT_USERNAME: username, + BOT_ID: data.result.id ?? '', + }); + return username; + } + const reason = data.description ?? 'token rejected by Telegram'; + s.stop(`Telegram rejected the token: ${reason}`, 1); + setupLog.step('telegram-validate', 'failed', Date.now() - start, { + ERROR: reason, + }); + fail( + 'telegram-validate', + 'Telegram rejected the token.', + 'Double-check the token (copy it again from @BotFather) and retry.', + ); + } catch (err) { + const elapsedS = Math.round((Date.now() - start) / 1000); + s.stop(`Could not reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('telegram-validate', 'failed', Date.now() - start, { + ERROR: message, + }); + fail( + 'telegram-validate', + 'Telegram API unreachable.', + 'Check your network connection and retry.', + ); + } +} + +async function runPairTelegram(): Promise< + StepResult & { rawLog: string; durationMs: number } +> { + const rawLog = setupLog.stepRawLog('pair-telegram'); + const start = Date.now(); + const s = p.spinner(); + s.start('Creating pairing code…'); + let spinnerActive = true; + + const stopSpinner = (msg: string, code?: number) => { + if (spinnerActive) { + s.stop(msg, code); + spinnerActive = false; + } + }; + + const result = await spawnStep( + 'pair-telegram', + ['--intent', 'main'], + (block: Block) => { + if (block.type === 'PAIR_TELEGRAM_CODE') { + const reason = block.fields.REASON ?? 'initial'; + if (reason === 'initial') { + stopSpinner('Pairing code ready.'); + } else { + stopSpinner('Previous code invalidated. New code below.'); + } + p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Pairing code'); + s.start('Waiting for the code from Telegram…'); + spinnerActive = true; + } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { + stopSpinner(`Received "${block.fields.CANDIDATE ?? '?'}" — doesn't match.`); + s.start('Waiting for the correct code…'); + spinnerActive = true; + } else if (block.type === 'PAIR_TELEGRAM') { + if (block.fields.STATUS === 'success') { + stopSpinner('Telegram paired.'); + } else { + stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1); + } + } + }, + rawLog, + ); + const durationMs = Date.now() - start; + + // Safety net: if the child died without emitting a terminal block, make + // sure we don't leave the spinner running. + if (spinnerActive) { + stopSpinner( + result.ok ? 'Done.' : 'Pairing exited unexpectedly.', + result.ok ? 0 : 1, + ); + if (!result.ok) dumpTranscriptOnFailure(result.transcript); + } + + writeStepEntry('pair-telegram', result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; +} + +function formatCodeCard(code: string): string { + const spaced = code.split('').join(' '); + return [ + '', + ` ${brandBold(spaced)}`, + '', + k.dim(' Send these digits from Telegram to your bot.'), + ].join('\n'); +} + +async function resolveAgentName(): Promise { + const preset = process.env.NANOCLAW_AGENT_NAME?.trim(); + if (preset) { + setupLog.userInput('agent_name', preset); + return preset; + } + const answer = ensureAnswer( + await p.text({ + message: 'What should your messaging agent be called?', + placeholder: DEFAULT_AGENT_NAME, + defaultValue: DEFAULT_AGENT_NAME, + }), + ); + const value = (answer as string).trim() || DEFAULT_AGENT_NAME; + setupLog.userInput('agent_name', value); + return value; +} diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts new file mode 100644 index 0000000..59b3da6 --- /dev/null +++ b/setup/lib/runner.ts @@ -0,0 +1,325 @@ +/** + * Step runner + abort helpers for setup:auto. + * + * Responsibilities: + * - Stream-parse setup-step status blocks (`=== NANOCLAW SETUP: … ===`) + * - Spawn children with output tee'd to a per-step raw log (level 3) + * - Wrap each run in a clack spinner with live elapsed time (level 1) + * - Append a structured entry to the progression log (level 2) via + * `setup/logs.ts` when the run ends + * - Abort helpers (`fail`, `ensureAnswer`) used by step orchestrators + * + * See docs/setup-flow.md for the three-level output contract. + */ +import { spawn } from 'child_process'; +import fs from 'fs'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; + +export type Fields = Record; +export type Block = { type: string; fields: Fields }; + +export type StepResult = { + ok: boolean; + exitCode: number; + blocks: Block[]; + transcript: string; + /** The last block with a STATUS field (the terminal/result block). */ + terminal: Block | null; +}; + +export type QuietChildResult = { + ok: boolean; + exitCode: number; + transcript: string; + terminal: Block | null; + blocks: Block[]; +}; + +export type SpinnerLabels = { + running: string; + done: string; + skipped?: string; + failed?: string; +}; + +/** + * Streaming parser for `=== NANOCLAW SETUP: TYPE ===` blocks. Emits each + * block as it closes so the UI can react mid-stream (e.g. render a pairing + * code card as soon as pair-telegram emits it, rather than after the step + * has finished). + */ +export class StatusStream { + private lineBuf = ''; + private current: Block | null = null; + readonly blocks: Block[] = []; + transcript = ''; + + constructor(private readonly onBlock: (block: Block) => void) {} + + write(chunk: string): void { + this.transcript += chunk; + this.lineBuf += chunk; + let idx: number; + while ((idx = this.lineBuf.indexOf('\n')) !== -1) { + const line = this.lineBuf.slice(0, idx); + this.lineBuf = this.lineBuf.slice(idx + 1); + this.processLine(line); + } + } + + private processLine(line: string): void { + const start = line.match(/^=== NANOCLAW SETUP: (\S+) ===/); + if (start) { + this.current = { type: start[1], fields: {} }; + return; + } + if (line.startsWith('=== END ===')) { + if (this.current) { + this.blocks.push(this.current); + this.onBlock(this.current); + this.current = null; + } + return; + } + if (!this.current) return; + const colon = line.indexOf(':'); + if (colon === -1) return; + const key = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + if (key) this.current.fields[key] = value; + } +} + +/** + * Spawn a setup step as a child process. Output is tee'd to the provided + * raw log file (level 3) and parsed for status blocks (level 2 summary). + * The onBlock callback fires per status block as they close so the UI can + * react mid-stream. + */ +export function spawnStep( + stepName: string, + extra: string[], + onBlock: (block: Block) => void, + rawLogPath: string, +): Promise { + return new Promise((resolve) => { + const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName]; + if (extra.length > 0) args.push('--', ...extra); + + const child = spawn('pnpm', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const stream = new StatusStream(onBlock); + const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); + raw.write(`# ${stepName} — ${new Date().toISOString()}\n\n`); + + child.stdout.on('data', (chunk: Buffer) => { + stream.write(chunk.toString('utf-8')); + raw.write(chunk); + }); + child.stderr.on('data', (chunk: Buffer) => { + stream.transcript += chunk.toString('utf-8'); + raw.write(chunk); + }); + + child.on('close', (code) => { + raw.end(); + const terminal = + [...stream.blocks].reverse().find((b) => b.fields.STATUS) ?? null; + const status = terminal?.fields.STATUS; + const ok = code === 0 && (status === 'success' || status === 'skipped'); + resolve({ + ok, + exitCode: code ?? 1, + blocks: stream.blocks, + transcript: stream.transcript, + terminal, + }); + }); + }); +} + +export function spawnQuiet( + cmd: string, + args: string[], + rawLogPath: string, + envOverride?: NodeJS.ProcessEnv, +): Promise { + return new Promise((resolve) => { + const child = spawn(cmd, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: envOverride ? { ...process.env, ...envOverride } : process.env, + }); + let transcript = ''; + const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); + raw.write(`# ${[cmd, ...args].join(' ')} — ${new Date().toISOString()}\n\n`); + const blocks: Block[] = []; + const stream = new StatusStream((b) => blocks.push(b)); + child.stdout.on('data', (c: Buffer) => { + const s = c.toString('utf-8'); + transcript += s; + stream.write(s); + raw.write(c); + }); + child.stderr.on('data', (c: Buffer) => { + transcript += c.toString('utf-8'); + raw.write(c); + }); + child.on('close', (code) => { + raw.end(); + const terminal = + [...blocks].reverse().find((b) => b.fields.STATUS) ?? null; + resolve({ ok: code === 0, exitCode: code ?? 1, transcript, terminal, blocks }); + }); + }); +} + +/** Run a step under a clack spinner. Teed to a per-step raw log + progression entry at the end. */ +export async function runQuietStep( + stepName: string, + labels: SpinnerLabels, + extra: string[] = [], +): Promise { + const rawLog = setupLog.stepRawLog(stepName); + const start = Date.now(); + const result = await runUnderSpinner(labels, () => + spawnStep(stepName, extra, () => {}, rawLog), + ); + const durationMs = Date.now() - start; + writeStepEntry(stepName, result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** Run an arbitrary child under a spinner. Same raw-log + progression treatment as runQuietStep. */ +export async function runQuietChild( + logName: string, + cmd: string, + args: string[], + labels: SpinnerLabels, + opts?: { + /** Extra fields to merge into the progression entry (on top of any status-block fields). */ + extraFields?: Record; + /** Environment overrides to pass to the child process. */ + env?: NodeJS.ProcessEnv; + }, +): Promise { + const rawLog = setupLog.stepRawLog(logName); + const start = Date.now(); + const result = await runUnderSpinner(labels, () => + spawnQuiet(cmd, args, rawLog, opts?.env), + ); + const durationMs = Date.now() - start; + + const blockFields = summariseTerminalFields(result.terminal); + const fields = { ...blockFields, ...(opts?.extraFields ?? {}) }; + const rawStatus = result.terminal?.fields.STATUS; + const status: 'success' | 'skipped' | 'failed' = !result.ok + ? 'failed' + : rawStatus === 'skipped' + ? 'skipped' + : 'success'; + setupLog.step(logName, status, durationMs, fields, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** Turn a step's terminal-block fields into a concise progression-log entry. */ +export function writeStepEntry( + stepName: string, + result: StepResult, + durationMs: number, + rawLog: string, +): void { + const rawStatus = result.terminal?.fields.STATUS; + const logStatus: 'success' | 'skipped' | 'failed' = !result.ok + ? 'failed' + : rawStatus === 'skipped' + ? 'skipped' + : 'success'; + const fields = summariseTerminalFields(result.terminal); + setupLog.step(stepName, logStatus, durationMs, fields, rawLog); +} + +/** Strip STATUS + LOG (redundant) and any oversize values from the terminal block's fields. */ +export function summariseTerminalFields(block: Block | null): Record { + if (!block) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(block.fields)) { + if (k === 'STATUS' || k === 'LOG') continue; + if (v.length > 120) continue; // keep it skimmable; full value lives in the raw log + out[k] = v; + } + return out; +} + +async function runUnderSpinner< + T extends { ok: boolean; transcript: string; terminal?: Block | null }, +>( + labels: SpinnerLabels, + work: () => Promise, +): Promise { + const s = p.spinner(); + const start = Date.now(); + s.start(labels.running); + const tick = setInterval(() => { + const elapsed = Math.round((Date.now() - start) / 1000); + s.message(`${labels.running} ${k.dim(`(${elapsed}s)`)}`); + }, 1000); + + const result = await work(); + + clearInterval(tick); + const elapsed = Math.round((Date.now() - start) / 1000); + if (result.ok) { + const isSkipped = result.terminal?.fields.STATUS === 'skipped'; + const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; + s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`); + } else { + const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed'); + s.stop(`${failMsg} ${k.dim(`(${elapsed}s)`)}`, 1); + dumpTranscriptOnFailure(result.transcript); + } + return result; +} + +export function dumpTranscriptOnFailure(transcript: string): void { + const lines = transcript.split('\n').filter((l) => { + if (l.startsWith('=== NANOCLAW SETUP:')) return false; + if (l.startsWith('=== END ===')) return false; + return true; + }); + const tail = lines.slice(-40).join('\n').trimEnd(); + if (tail) { + console.log(); + console.log(k.dim(tail)); + console.log(); + } +} + +/** + * Abort the setup run with a user-facing error, logging the abort to the + * progression log. Takes the step name explicitly so callers are clear + * about which step they're failing from — no hidden module state. + */ +export function fail(stepName: string, msg: string, hint?: string): never { + setupLog.abort(stepName, msg); + p.log.error(msg); + if (hint) p.log.message(k.dim(hint)); + p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/')); + p.cancel('Setup aborted.'); + process.exit(1); +} + +/** + * Unwrap a clack prompt result. If the user cancelled (Ctrl-C / Esc), exit + * gracefully. Cancel is exit 0 — it's not an abort worth logging to the + * progression log, since the operator initiated it deliberately. + */ +export function ensureAnswer(value: T | symbol): T { + if (p.isCancel(value)) { + p.cancel('Setup cancelled.'); + process.exit(0); + } + return value as T; +} diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts new file mode 100644 index 0000000..9bd18a5 --- /dev/null +++ b/setup/lib/theme.ts @@ -0,0 +1,39 @@ +/** + * NanoClaw brand palette for the terminal. + * + * Colors pulled from assets/nanoclaw-logo.png: + * brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body + * brand navy ≈ #171B3B — the dark logo background + outlines + * + * Rendering gates: + * - No TTY (piped / redirected) → plain text, no ANSI + * - NO_COLOR set → plain text, no ANSI + * - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan) + * - Otherwise → kleur's 16-color cyan (closest fallback) + */ +import k from 'kleur'; + +const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; +const TRUECOLOR = + USE_ANSI && + (process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit'); + +export function brand(s: string): string { + if (!USE_ANSI) return s; + if (TRUECOLOR) return `\x1b[38;2;43;183;206m${s}\x1b[0m`; + return k.cyan(s); +} + +export function brandBold(s: string): string { + if (!USE_ANSI) return s; + if (TRUECOLOR) return `\x1b[1;38;2;43;183;206m${s}\x1b[0m`; + return k.bold(k.cyan(s)); +} + +export function brandChip(s: string): string { + if (!USE_ANSI) return s; + if (TRUECOLOR) { + return `\x1b[48;2;43;183;206m\x1b[38;2;23;27;59m\x1b[1m${s}\x1b[0m`; + } + return k.bgCyan(k.black(k.bold(s))); +} From 7d2081660bec24db1e2c0bf577c2028b6c03ad73 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 02:57:20 +0300 Subject: [PATCH 084/185] feat(setup): rewrite copy for first-time users + split auth flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content pass: every user-facing line is rewritten from the perspective of someone trying NanoClaw for the first time. Phase labels and devops framing are gone. Examples: "Environment OK" → "Your system looks good." "Container image ready" → "Sandbox ready." "OneCLI installed" → "OneCLI vault ready." "Anthropic credential" → "Claude account" "Mount allowlist in place" → "Access rules set." "Service installed/running" → "NanoClaw is running." "Wiring the terminal agent" → "Setting up your terminal chat…" "Setup complete" → "You're ready! Enjoy NanoClaw." Long-running steps get a one-sentence "why" that teaches a NanoClaw differentiator while the user waits: bootstrap → "NanoClaw is small and runs entirely on your machine. Yours to modify." container → "Your assistant lives in its own sandbox. It can only see what you explicitly share." onecli → "Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox." OneCLI is now named explicitly and framed as "your agent's vault" in the install step, the paste-auth save step, the subscription-auth banner, and their associated failure hints. Auth split (option b: explicit step name on fail): the auth-method choice moves from the bash menu in register-claude-token.sh into a clack select. Only the subscription path still breaks out to the interactive TTY for `claude setup-token`; paste-based OAuth tokens and API keys stay in clack via p.password() and register directly via `onecli secrets create`. register-claude-token.sh is scoped down to the subscription flow only. nanoclaw.sh: dropped the "Phase 1 / Phase 2" labels. The wordmark and subtitle now print bash-side so setup:auto skips repeating them and the flow reads as one continuous sequence. Bootstrap label is "Installing the basics" with a dim gutter-line "why" preamble. pnpm's `> nanoclaw@X setup:auto` preamble is suppressed via --silent. Em-dash pass on user-facing copy: every em-dash that functions as an em-dash in a user-visible string is replaced with period, semicolon, comma, or parens. Code comments and JSDoc are untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 77 +++++---- setup/auto.ts | 286 +++++++++++++++++++++++---------- setup/channels/telegram.ts | 78 ++++----- setup/register-claude-token.sh | 154 +++++++----------- 4 files changed, 346 insertions(+), 249 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 17df82c..e94e383 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -1,15 +1,18 @@ #!/usr/bin/env bash # -# NanoClaw — scripted end-to-end install. +# NanoClaw — end-to-end setup entry point. # -# Phase 1: bootstrap (Node + pnpm + native module verify). Runs bash-side -# since tsx isn't available until pnpm install completes. -# Phase 2: setup:auto (all remaining steps under clack). +# Runs two parts from the user's perspective as one continuous flow: +# - bash-side: install the basics (Node + pnpm + native modules) under a +# bash-rendered clack-alike spinner. Can't use setup/auto.ts here since +# tsx isn't available until pnpm install completes. +# - hand off to `pnpm run setup:auto`, which renders the rest with +# @clack/prompts. The wordmark is printed once here so setup:auto can +# skip it and the flow reads as a single sequence. # -# Both phases obey the same three-level output contract (see -# docs/setup-flow.md): +# Obeys the three-level output contract (see docs/setup-flow.md): # 1. User-facing — concise status line with elapsed time -# 2. Progression log — logs/setup.log (header + one entry per phase/step) +# 2. Progression log — logs/setup.log (header + one entry per step) # 3. Raw per-step log — logs/setup-steps/NN-name.log (full verbatim output) # # Config via env — passed through unchanged: @@ -91,6 +94,19 @@ use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; } dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; } gray() { use_ansi && printf '\033[90m%s\033[0m' "$1" || printf '%s' "$1"; } red() { use_ansi && printf '\033[31m%s\033[0m' "$1" || printf '%s' "$1"; } +bold() { use_ansi && printf '\033[1m%s\033[0m' "$1" || printf '%s' "$1"; } +# brand cyan (≈ #2BB7CE) — truecolor when supported, 16-color cyan fallback. +brand_bold() { + if use_ansi; then + if [ "${COLORTERM:-}" = "truecolor" ] || [ "${COLORTERM:-}" = "24bit" ]; then + printf '\033[1;38;2;43;183;206m%s\033[0m' "$1" + else + printf '\033[1;36m%s\033[0m' "$1" + fi + else + printf '%s' "$1" + fi +} clear_line() { use_ansi && printf '\r\033[2K' || printf '\n'; } spinner_start() { printf '%s %s…' "$(gray '◒')" "$1"; } @@ -105,21 +121,20 @@ rm -f "$PROGRESS_LOG" mkdir -p "$STEPS_DIR" "$LOGS_DIR" write_header -cat <<'EOF' -═══════════════════════════════════════════════════════════════ - NanoClaw scripted setup -═══════════════════════════════════════════════════════════════ +# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1 +# and skip printing these again, so the flow stays visually continuous. +printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" +printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')" -Phase 1 · bootstrap - -EOF - -# ─── phase 1: bootstrap ───────────────────────────────────────────────── +# ─── first step: install the basics (Node + pnpm + native modules) ───── BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log" -BOOTSTRAP_LABEL="Bootstrapping Node, pnpm, native modules" +BOOTSTRAP_LABEL="Installing the basics" BOOTSTRAP_START=$(date +%s) +# One-line "why" that teaches a differentiator while the user waits. +printf '%s %s\n' "$(gray '│')" \ + "$(dim "NanoClaw is small and runs entirely on your machine. Yours to modify.")" spinner_start "$BOOTSTRAP_LABEL" # Run in the background so we can tick elapsed time. Capture exit code via @@ -151,10 +166,10 @@ rm -f "$BOOTSTRAP_EXIT_FILE" BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START )) if [ "$BOOTSTRAP_RC" -eq 0 ]; then - spinner_success "Bootstrap complete" "$BOOTSTRAP_DUR" + spinner_success "Basics installed" "$BOOTSTRAP_DUR" write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" else - spinner_failure "Bootstrap failed" "$BOOTSTRAP_DUR" + spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR" write_bootstrap_entry failed "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" write_abort_entry bootstrap "exit-${BOOTSTRAP_RC}" @@ -162,23 +177,19 @@ else echo "$(dim '── last 40 lines of ')$(dim "$BOOTSTRAP_RAW")$(dim ' ──')" tail -40 "$BOOTSTRAP_RAW" echo - echo "Full raw log: $BOOTSTRAP_RAW" - echo "Progression: $PROGRESS_LOG" + echo "$(dim "Full raw log: $BOOTSTRAP_RAW")" + echo "$(dim "Progression: $PROGRESS_LOG")" exit 1 fi -echo -cat <<'EOF' -Phase 2 · setup:auto +# ─── hand off to setup:auto ──────────────────────────────────────────── -EOF - -# ─── phase 2: clack driver ────────────────────────────────────────────── - -# NANOCLAW_BOOTSTRAPPED=1 tells setup/auto.ts that the progression log has -# already been initialized (header + bootstrap entry), so it should append -# rather than wipe. +# NANOCLAW_BOOTSTRAPPED=1 tells setup/auto.ts to skip the wordmark (we +# already printed it) and to append to the progression log rather than +# wipe it. export NANOCLAW_BOOTSTRAPPED=1 -# exec so signals (Ctrl-C) propagate directly to the child. -exec pnpm run setup:auto +# --silent suppresses pnpm's `> nanoclaw@1.2.52 setup:auto / > tsx setup/auto.ts` +# preamble so the flow continues visually from "Basics installed" straight +# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly. +exec pnpm --silent run setup:auto diff --git a/setup/auto.ts b/setup/auto.ts index bb23650..a0068bb 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -27,7 +27,7 @@ import k from 'kleur'; import { runTelegramChannel } from './channels/telegram.js'; import * as setupLog from './logs.js'; -import { ensureAnswer, fail, runQuietStep } from './lib/runner.js'; +import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { brandBold, brandChip } from './lib/theme.js'; const CLI_AGENT_NAME = 'Terminal Agent'; @@ -46,121 +46,116 @@ async function main(): Promise { if (!skip.has('environment')) { const res = await runQuietStep('environment', { - running: 'Checking environment…', - done: 'Environment OK.', + running: 'Checking your system…', + done: 'Your system looks good.', }); - if (!res.ok) fail('environment', 'Environment check failed.'); + if (!res.ok) { + fail( + 'environment', + "Your system doesn't look quite right.", + 'See logs/setup-steps/ for details, then retry.', + ); + } } if (!skip.has('container')) { + p.log.message( + k.dim( + 'Your assistant lives in its own sandbox. It can only see what you explicitly share.', + ), + ); const res = await runQuietStep('container', { - running: 'Building the agent container image…', - done: 'Container image ready.', - failed: 'Container build failed.', + running: 'Preparing the sandbox your assistant runs in…', + done: 'Sandbox ready.', + failed: "Couldn't prepare the sandbox.", }); if (!res.ok) { const err = res.terminal?.fields.ERROR; if (err === 'runtime_not_available') { fail( 'container', - 'Docker is not available and could not be started automatically.', - 'Install Docker Desktop or start it manually, then retry.', + "Docker isn't available.", + 'Install Docker Desktop (or start it if already installed), then retry.', ); } if (err === 'docker_group_not_active') { fail( 'container', - 'Docker was just installed but your shell is not yet in the `docker` group.', + "Docker was just installed but your shell doesn't know yet.", 'Log out and back in (or run `newgrp docker` in a new shell), then retry.', ); } fail( 'container', - 'Container build/test failed.', - 'For stale cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', + "Couldn't build the sandbox.", + 'If Docker has a stale cache, try: `docker builder prune -f`, then retry.', ); } maybeReexecUnderSg(); } if (!skip.has('onecli')) { + p.log.message( + k.dim( + 'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.', + ), + ); const res = await runQuietStep('onecli', { - running: 'Installing OneCLI credential vault…', - done: 'OneCLI installed.', + running: "Setting up OneCLI, your agent's vault…", + done: 'OneCLI vault ready.', }); if (!res.ok) { const err = res.terminal?.fields.ERROR; if (err === 'onecli_not_on_path_after_install') { fail( 'onecli', - 'OneCLI installed but not on PATH.', + 'OneCLI was installed but your shell needs to refresh to see it.', 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', ); } fail( 'onecli', - `OneCLI install failed (${err ?? 'unknown'}).`, - 'Check that curl + a writable ~/.local/bin are available, then retry.', + `Couldn't set up OneCLI (${err ?? 'unknown error'}).`, + 'Make sure curl is installed and ~/.local/bin is writable, then retry.', ); } } if (!skip.has('auth')) { - if (anthropicSecretExists()) { - p.log.success('OneCLI already has an Anthropic secret — skipping.'); - setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); - } else { - p.log.step('Registering your Anthropic credential…'); - console.log( - k.dim(' (browser sign-in or paste a token/key — this part is interactive)'), - ); - console.log(); - const start = Date.now(); - const code = await runInheritScript('bash', ['setup/register-claude-token.sh']); - const durationMs = Date.now() - start; - console.log(); - if (code !== 0) { - setupLog.step('auth', 'failed', durationMs, { EXIT_CODE: code }); - fail( - 'auth', - 'Anthropic credential registration failed or was aborted.', - 'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.', - ); - } - setupLog.step('auth', 'interactive', durationMs, { - METHOD: 'register-claude-token.sh', - }); - p.log.success('Anthropic credential registered with OneCLI.'); - } + await runAuthStep(); } if (!skip.has('mounts')) { const res = await runQuietStep( 'mounts', { - running: 'Writing mount allowlist…', - done: 'Mount allowlist in place.', - skipped: 'Mount allowlist already configured.', + running: "Setting your assistant's access rules…", + done: 'Access rules set.', + skipped: 'Access rules already set.', }, ['--empty'], ); - if (!res.ok) fail('mounts', 'Mount allowlist step failed.'); + if (!res.ok) { + fail('mounts', "Couldn't write access rules."); + } } if (!skip.has('service')) { const res = await runQuietStep('service', { - running: 'Installing the background service…', - done: 'Service installed and running.', + running: 'Starting NanoClaw in the background…', + done: 'NanoClaw is running.', }); if (!res.ok) { fail( 'service', - 'Service install failed.', - 'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.', + "Couldn't start NanoClaw.", + 'See logs/nanoclaw.error.log for details.', ); } if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') { - p.log.warn('Docker group stale in systemd session.'); + p.log.warn( + "NanoClaw's permissions need a tweak before it can reach Docker.", + ); p.log.message( k.dim( ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + @@ -182,16 +177,16 @@ async function main(): Promise { const res = await runQuietStep( 'cli-agent', { - running: 'Wiring the terminal agent…', - done: 'Terminal agent wired (try `pnpm run chat hi`).', + running: 'Setting up your terminal chat…', + done: 'Terminal chat ready. Try `pnpm run chat hi`.', }, ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME], ); if (!res.ok) { fail( 'cli-agent', - 'CLI agent wiring failed.', - `Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`, + "Couldn't set up the terminal chat.", + `You can retry later with \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\`.`, ); } } @@ -201,47 +196,165 @@ async function main(): Promise { if (choice === 'telegram') { await runTelegramChannel(displayName!); } else { - p.log.info('No messaging channel wired — you can add one later with `/add-`.'); + p.log.info( + "No messaging app for now. You can add one later (like Telegram, Slack, or Discord).", + ); } } if (!skip.has('verify')) { const res = await runQuietStep('verify', { - running: 'Verifying the install…', - done: 'Install verified.', - failed: 'Verification found issues.', + running: 'Making sure everything works together…', + done: "Everything's connected.", + failed: 'A few things still need your attention.', }); if (!res.ok) { const notes: string[] = []; if (res.terminal?.fields.CREDENTIALS !== 'configured') { - notes.push('• Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`.'); + notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.'); } const agentPing = res.terminal?.fields.AGENT_PING; if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') { notes.push( - `• CLI agent did not reply (status: ${agentPing}). ` + - 'Check `logs/nanoclaw.log` and `groups/*/logs/container-*.log`, then try `pnpm run chat hi`.', + "• Your assistant didn't reply to a test message. " + + 'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', ); } if (!res.terminal?.fields.CONFIGURED_CHANNELS) { - notes.push('• Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …'); + notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.'); } if (notes.length > 0) { - p.note(notes.join('\n'), 'What’s left'); + p.note(notes.join('\n'), "What's left"); } - p.outro(k.yellow('Scripted steps done — some pieces still need you.')); + p.outro(k.yellow('Almost there. A few things still need your attention.')); return; } } - const nextSteps = [ - `${k.cyan('Chat from the CLI:')} pnpm run chat hi`, - `${k.cyan('Tail host logs:')} tail -f logs/nanoclaw.log`, - `${k.cyan('Open Claude Code:')} claude`, - ].join('\n'); - p.note(nextSteps, 'Next steps'); + const rows: [string, string][] = [ + ['Chat in the terminal:', 'pnpm run chat hi'], + ["See what's happening:", 'tail -f logs/nanoclaw.log'], + ['Open Claude Code:', 'claude'], + ]; + const labelWidth = Math.max(...rows.map(([l]) => l.length)); + const nextSteps = rows + .map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`) + .join('\n'); + p.note(nextSteps, 'Try these'); setupLog.complete(Date.now() - RUN_START); - p.outro(k.green('Setup complete.')); + p.outro(k.green("You're ready! Enjoy NanoClaw.")); +} + +// ─── auth step (select → branch) ──────────────────────────────────────── + +async function runAuthStep(): Promise { + if (anthropicSecretExists()) { + p.log.success('Your Claude account is already connected.'); + setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); + return; + } + + const method = ensureAnswer( + await p.select({ + message: 'How would you like to connect to Claude?', + options: [ + { + value: 'subscription', + label: 'Sign in with my Claude subscription', + hint: 'recommended if you have Pro or Max', + }, + { + value: 'oauth', + label: 'Paste an OAuth token I already have', + hint: 'sk-ant-oat…', + }, + { + value: 'api', + label: 'Paste an Anthropic API key', + hint: 'pay-per-use via console.anthropic.com', + }, + ], + }), + ) as 'subscription' | 'oauth' | 'api'; + setupLog.userInput('auth_method', method); + + if (method === 'subscription') { + await runSubscriptionAuth(); + } else { + await runPasteAuth(method); + } +} + +async function runSubscriptionAuth(): Promise { + p.log.step("Opening the Claude sign-in flow…"); + console.log( + k.dim(' (a browser will open for sign-in; this part is interactive)'), + ); + console.log(); + const start = Date.now(); + const code = await runInheritScript('bash', [ + 'setup/register-claude-token.sh', + ]); + const durationMs = Date.now() - start; + console.log(); + if (code !== 0) { + setupLog.step('auth', 'failed', durationMs, { + EXIT_CODE: code, + METHOD: 'subscription', + }); + fail( + 'auth', + "Couldn't complete the Claude sign-in.", + 'Re-run setup and try again, or choose a paste option instead.', + ); + } + setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' }); + p.log.success('Claude account connected.'); +} + +async function runPasteAuth(method: 'oauth' | 'api'): Promise { + const label = method === 'oauth' ? 'OAuth token' : 'API key'; + const prefix = method === 'oauth' ? 'sk-ant-oat' : 'sk-ant-api'; + + const answer = ensureAnswer( + await p.password({ + message: `Paste your ${label}`, + validate: (v) => { + if (!v || !v.trim()) return 'Required'; + if (!v.trim().startsWith(prefix)) { + return `Should start with ${prefix}…`; + } + return undefined; + }, + }), + ); + const token = (answer as string).trim(); + + const res = await runQuietChild( + 'auth', + 'onecli', + [ + 'secrets', 'create', + '--name', 'Anthropic', + '--type', 'anthropic', + '--value', token, + '--host-pattern', 'api.anthropic.com', + ], + { + running: `Saving your ${label} to your OneCLI vault…`, + done: 'Claude account connected.', + }, + { + extraFields: { METHOD: method }, + }, + ); + if (!res.ok) { + fail( + 'auth', + `Couldn't save your ${label} to the vault.`, + 'Make sure OneCLI is running (`onecli version`), then retry.', + ); + } } // ─── prompts owned by the sequencer ──────────────────────────────────── @@ -249,7 +362,7 @@ async function main(): Promise { async function askDisplayName(fallback: string): Promise { const answer = ensureAnswer( await p.text({ - message: 'What should your agents call you?', + message: 'What should your assistant call you?', placeholder: fallback, defaultValue: fallback, }), @@ -262,10 +375,10 @@ async function askDisplayName(fallback: string): Promise { async function askChannelChoice(): Promise<'telegram' | 'skip'> { const choice = ensureAnswer( await p.select({ - message: 'Connect a messaging app so you can chat from your phone?', + message: 'Want to chat with your assistant from your phone?', options: [ - { value: 'telegram', label: 'Telegram', hint: 'recommended' }, - { value: 'skip', label: 'Skip — use the CLI only' }, + { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, + { value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" }, ], }), ); @@ -311,7 +424,7 @@ function maybeReexecUnderSg(): void { if (!/permission denied/i.test(err)) return; if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return; - p.log.warn('Docker socket not accessible in current group — re-executing under `sg docker`.'); + p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.'); const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { stdio: 'inherit', env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, @@ -323,17 +436,28 @@ function maybeReexecUnderSg(): void { function printIntro(): void { const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; + const isBootstrapped = process.env.NANOCLAW_BOOTSTRAPPED === '1'; const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; if (isReexec) { - p.intro(`${brandChip(' setup:auto ')} ${wordmark} ${k.dim('· resuming under docker group')}`); + p.intro( + `${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`, + ); + return; + } + + // When we were called via nanoclaw.sh, the wordmark + subtitle were + // already printed in bash. Just open the clack gutter with a short, + // neutral intro so the flow continues without duplication. + if (isBootstrapped) { + p.intro(k.dim("Let's get you set up.")); return; } console.log(); console.log(` ${wordmark}`); - console.log(` ${k.dim('end-to-end scripted setup of your personal assistant')}`); - p.intro(`${brandChip(' setup:auto ')}`); + console.log(` ${k.dim('Setting up your personal AI assistant')}`); + p.intro(k.dim("Let's get you set up.")); } /** diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index d3e3f89..348cd05 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -43,8 +43,8 @@ export async function runTelegramChannel(displayName: string): Promise { 'bash', ['setup/add-telegram.sh'], { - running: `Installing Telegram adapter and wiring @${botUsername}…`, - done: 'Telegram adapter ready.', + running: `Connecting Telegram to @${botUsername}…`, + done: 'Telegram connected.', }, { env: { TELEGRAM_BOT_TOKEN: token }, @@ -54,8 +54,8 @@ export async function runTelegramChannel(displayName: string): Promise { if (!install.ok) { fail( 'telegram-install', - 'Telegram install failed.', - 'Check the raw log under logs/setup-steps/, then retry `pnpm run setup:auto`.', + "Couldn't connect Telegram.", + 'See logs/setup-steps/ for details, then retry setup.', ); } @@ -63,8 +63,8 @@ export async function runTelegramChannel(displayName: string): Promise { if (!pair.ok) { fail( 'pair-telegram', - 'Telegram pairing failed.', - 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', + "Couldn't pair with Telegram.", + 'Re-run setup to try again.', ); } @@ -73,8 +73,8 @@ export async function runTelegramChannel(displayName: string): Promise { if (!platformId || !pairedUserId) { fail( 'pair-telegram', - 'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.', - 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main` and capture the success block.', + 'Pairing completed but came back incomplete.', + 'Re-run setup to try again.', ); } @@ -92,8 +92,8 @@ export async function runTelegramChannel(displayName: string): Promise { '--agent-name', agentName, ], { - running: `Wiring ${agentName} to your Telegram chat…`, - done: `${agentName} is wired — welcome DM incoming.`, + running: `Connecting ${agentName} to your Telegram chat…`, + done: `${agentName} is ready. Check Telegram for a welcome message.`, }, { extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId }, @@ -102,8 +102,8 @@ export async function runTelegramChannel(displayName: string): Promise { if (!init.ok) { fail( 'init-first-agent', - 'Wiring the Telegram agent failed.', - `Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${pairedUserId}" --platform-id "${platformId}" --display-name "${displayName}" --agent-name "${agentName}"\`.`, + `Couldn't finish connecting ${agentName}.`, + 'You can retry later with `/manage-channels`.', ); } } @@ -111,24 +111,26 @@ export async function runTelegramChannel(displayName: string): Promise { async function collectTelegramToken(): Promise { p.note( [ - '1. Open Telegram and message @BotFather', - '2. Send: /newbot', - '3. Follow the prompts (name + username ending in "bot")', - '4. Copy the token it gives you (format: :)', + "Your assistant talks to you through a Telegram bot you create.", + "Here's how:", '', - k.dim('Optional, but recommended for groups:'), - k.dim(' @BotFather → /mybots → Bot Settings → Group Privacy → OFF'), + ' 1. Open Telegram and message @BotFather', + ' 2. Send /newbot and follow the prompts', + ' 3. Copy the token it gives you (it looks like :)', + '', + k.dim('Planning to add your assistant to group chats? In @BotFather:'), + k.dim(' /mybots → your bot → Bot Settings → Group Privacy → OFF'), ].join('\n'), - 'Create a Telegram bot', + 'Set up your Telegram bot', ); const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', validate: (v) => { - if (!v || !v.trim()) return 'Token is required'; + if (!v || !v.trim()) return "Token is required"; if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { - return 'Format looks wrong — expected :'; + return "That doesn't look right. It should be :"; } return undefined; }, @@ -145,7 +147,7 @@ async function collectTelegramToken(): Promise { async function validateTelegramToken(token: string): Promise { const s = p.spinner(); const start = Date.now(); - s.start('Validating token with Telegram…'); + s.start('Checking your bot token…'); try { const res = await fetch(`https://api.telegram.org/bot${token}/getMe`); const data = (await res.json()) as { @@ -156,7 +158,7 @@ async function validateTelegramToken(token: string): Promise { const elapsedS = Math.round((Date.now() - start) / 1000); if (data.ok && data.result?.username) { const username = data.result.username; - s.stop(`Bot is @${username}. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`); setupLog.step('telegram-validate', 'success', Date.now() - start, { BOT_USERNAME: username, BOT_ID: data.result.id ?? '', @@ -164,26 +166,26 @@ async function validateTelegramToken(token: string): Promise { return username; } const reason = data.description ?? 'token rejected by Telegram'; - s.stop(`Telegram rejected the token: ${reason}`, 1); + s.stop(`Telegram didn't accept that token: ${reason}`, 1); setupLog.step('telegram-validate', 'failed', Date.now() - start, { ERROR: reason, }); fail( 'telegram-validate', - 'Telegram rejected the token.', - 'Double-check the token (copy it again from @BotFather) and retry.', + "Telegram didn't accept that token.", + 'Copy the token again from @BotFather and try setup once more.', ); } catch (err) { const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Could not reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('telegram-validate', 'failed', Date.now() - start, { ERROR: message, }); fail( 'telegram-validate', - 'Telegram API unreachable.', - 'Check your network connection and retry.', + "Couldn't reach Telegram.", + 'Check your internet connection and retry setup.', ); } } @@ -194,7 +196,7 @@ async function runPairTelegram(): Promise< const rawLog = setupLog.stepRawLog('pair-telegram'); const start = Date.now(); const s = p.spinner(); - s.start('Creating pairing code…'); + s.start('Generating a secret code for your bot…'); let spinnerActive = true; const stopSpinner = (msg: string, code?: number) => { @@ -211,15 +213,15 @@ async function runPairTelegram(): Promise< if (block.type === 'PAIR_TELEGRAM_CODE') { const reason = block.fields.REASON ?? 'initial'; if (reason === 'initial') { - stopSpinner('Pairing code ready.'); + stopSpinner('Your secret code is ready.'); } else { - stopSpinner('Previous code invalidated. New code below.'); + stopSpinner("Old code expired. Here's a fresh one."); } - p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Pairing code'); - s.start('Waiting for the code from Telegram…'); + p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); + s.start('Waiting for you to send the code from Telegram…'); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { - stopSpinner(`Received "${block.fields.CANDIDATE ?? '?'}" — doesn't match.`); + stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`); s.start('Waiting for the correct code…'); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM') { @@ -238,7 +240,7 @@ async function runPairTelegram(): Promise< // sure we don't leave the spinner running. if (spinnerActive) { stopSpinner( - result.ok ? 'Done.' : 'Pairing exited unexpectedly.', + result.ok ? 'Done.' : 'Pairing ended unexpectedly.', result.ok ? 0 : 1, ); if (!result.ok) dumpTranscriptOnFailure(result.transcript); @@ -254,7 +256,7 @@ function formatCodeCard(code: string): string { '', ` ${brandBold(spaced)}`, '', - k.dim(' Send these digits from Telegram to your bot.'), + k.dim(' Send this code to your bot from Telegram.'), ].join('\n'); } @@ -266,7 +268,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your messaging agent be called?', + message: 'What should your assistant be called?', placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/register-claude-token.sh b/setup/register-claude-token.sh index 8bcab73..e0707bf 100755 --- a/setup/register-claude-token.sh +++ b/setup/register-claude-token.sh @@ -1,128 +1,88 @@ #!/usr/bin/env bash set -euo pipefail -# Prefer bash 4+ (for `read -e -i` readline preload). macOS ships 3.2 in -# /bin/bash, but Homebrew users usually have 5.x first on PATH. The readline -# preload is optional — on 3.x we fall back to a plain confirmation prompt. - -# Register an Anthropic credential with OneCLI. Three paths: -# 1) Claude subscription — run `claude setup-token` (browser sign-in) -# and capture the resulting OAuth token. -# 2) Paste an existing sk-ant-oat… OAuth token you already have. -# 3) Paste an Anthropic API key (sk-ant-api…). +# Register a Claude subscription OAuth token with OneCLI — the *only* auth +# path that needs a TTY break in the flow. Paste-based paths (existing +# OAuth token / API key) are handled in-process by setup/auto.ts using +# clack prompts, then onecli secrets create is invoked directly from TS. +# +# Flow: +# 1. Run `claude setup-token` under a PTY (via script(1)) so the browser +# OAuth dance works and its token is captured into a tempfile. +# 2. Regex the sk-ant-oat…AA token out of the ANSI-stripped capture. +# 3. Register it with OneCLI. # # Env overrides: # SECRET_NAME OneCLI secret name (default: Anthropic) # HOST_PATTERN OneCLI host pattern (default: api.anthropic.com) +# Prefer bash 4+ (for `read -e -i` readline preload). macOS ships 3.2 in +# /bin/bash, but Homebrew users usually have 5.x first on PATH. The +# readline preload is optional — on 3.x we fall back to a plain prompt. + SECRET_NAME="${SECRET_NAME:-Anthropic}" HOST_PATTERN="${HOST_PATTERN:-api.anthropic.com}" command -v onecli >/dev/null \ || { echo "onecli not found. Install it first (see /setup §4)." >&2; exit 1; } +command -v claude >/dev/null \ + || { echo "claude CLI not found. Install from https://claude.ai/download" >&2; exit 1; } +command -v script >/dev/null \ + || { echo "script(1) is required for PTY capture." >&2; exit 1; } -TOKEN="" +tmpfile=$(mktemp -t claude-setup-token.XXXXXX) +trap 'rm -f "$tmpfile"' EXIT -capture_via_claude_setup_token() { - command -v claude >/dev/null \ - || { echo "claude CLI not found. Install from https://claude.ai/download" >&2; exit 1; } - command -v script >/dev/null \ - || { echo "script(1) is required for PTY capture." >&2; exit 1; } +cat <<'EOF' +A browser window will open for you to sign in with your Claude account. +When you finish, we'll save the token to your OneCLI vault automatically. - local tmpfile - tmpfile=$(mktemp -t claude-setup-token.XXXXXX) - trap 'rm -f "$tmpfile"' RETURN - - cat <<'EOF' -A browser window will open for sign-in. Token is captured automatically. -Press Enter to run, or edit the command first. +Press Enter to continue, or edit the command first. EOF - local cmd="claude setup-token" - if [[ ${BASH_VERSINFO[0]:-0} -ge 4 ]]; then - # bash 4+: pre-fill the readline buffer so Enter literally submits. - read -r -e -i "$cmd" -p "$ " cmd /dev/null | grep -q util-linux; then - script -q -c "$cmd" "$tmpfile" - else - # BSD script: command is argv after the file, so let it word-split. - # shellcheck disable=SC2086 - script -q "$tmpfile" $cmd - fi +# `script` arg order differs between BSD (macOS) and util-linux. +if script --version 2>/dev/null | grep -q util-linux; then + script -q -c "$cmd" "$tmpfile" +else + # BSD script: command is argv after the file, so let it word-split. + # shellcheck disable=SC2086 + script -q "$tmpfile" $cmd +fi - # Strip ANSI codes + newlines (TTY wraps the token mid-string), then match - # the sk-ant-oat…AA token. perl because BSD grep caps {n,m} at 255. - TOKEN=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \ - | tr -d '\n\r' \ - | perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \ - | tail -1 || true) +# Strip ANSI codes + newlines (TTY wraps the token mid-string), then match +# the sk-ant-oat…AA token. perl because BSD grep caps {n,m} at 255. +token=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \ + | tr -d '\n\r' \ + | perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \ + | tail -1 || true) - if [[ -z "$TOKEN" ]]; then - local keep - keep=$(mktemp -t claude-setup-token-log.XXXXXX) - cp "$tmpfile" "$keep" - echo >&2 - echo "No sk-ant-oat…AA token found. Raw log: $keep" >&2 - exit 1 - fi -} - -prompt_for_pasted() { - local prefix="$1" # "oat" or "api" - local value - echo - echo "Paste your sk-ant-${prefix}… credential and press Enter." - echo "Nothing will appear on the screen as you paste — that's intentional." - echo "Paste once, then just press Enter to submit." - read -r -s -p "> " value &2 - exit 1 - fi - if [[ ! "$value" =~ ^sk-ant-${prefix} ]]; then - echo "Value does not start with sk-ant-${prefix}. Aborting." >&2 - exit 1 - fi - TOKEN="$value" -} - -cat <&2; exit 1 ;; -esac +if [ -z "$token" ]; then + keep=$(mktemp -t claude-setup-token-log.XXXXXX) + cp "$tmpfile" "$keep" + echo >&2 + echo "No sk-ant-oat…AA token found. Raw log: $keep" >&2 + exit 1 +fi echo -echo "Got token: ${TOKEN:0:16}…${TOKEN: -4}" -echo "Registering with OneCLI as '${SECRET_NAME}' (host pattern: ${HOST_PATTERN})…" +echo "Got token: ${token:0:16}…${token: -4}" +echo "Saving it to your OneCLI vault as '${SECRET_NAME}' (host: ${HOST_PATTERN})…" onecli secrets create \ --name "$SECRET_NAME" \ --type anthropic \ - --value "$TOKEN" \ + --value "$token" \ --host-pattern "$HOST_PATTERN" echo "Done." From a263da3e538008005951eaa0193f9e2b15de49ec Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 09:17:19 +0300 Subject: [PATCH 085/185] feat(setup): prompt to install Homebrew on factory macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install-node.sh and install-docker.sh both require brew on macOS. On a fresh Mac there's no brew, so the bootstrap spinner would bail with a cryptic "Homebrew not installed" error. Move the prompt to nanoclaw.sh as a pre-flight, so the user sees a clear ask — including the heads-up that Xcode Command Line Tools come along for the ride (~5-10 min) — before the spinner starts and brew's own sudo/CLT prompts appear. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/nanoclaw.sh b/nanoclaw.sh index e94e383..a1a22af 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -126,6 +126,54 @@ write_header printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')" +# ─── pre-flight: Homebrew on macOS ───────────────────────────────────── +# setup/install-node.sh and setup/install-docker.sh both require `brew` on +# macOS. On a factory Mac there's no brew, and those helpers would fail +# later inside the bootstrap spinner with a cryptic error. Prompt here, +# before the spinner starts, so the user knows what's about to happen and +# brew's own interactive sudo/CLT prompts stay readable. +if [ "$(uname -s)" = "Darwin" ] && ! command -v brew >/dev/null 2>&1; then + printf ' %s\n' \ + "$(dim "Homebrew isn't installed. NanoClaw uses it to install Node and Docker on your Mac.")" + printf ' %s\n\n' \ + "$(dim "This also installs Apple's Command Line Tools, which can take 5-10 minutes.")" + read -r -p " $(bold 'Install Homebrew now?') [Y/n] " BREW_ANS /dev/null 2>&1; then + printf '\n %s %s\n' "$(red '✗')" "Homebrew install didn't complete." + printf ' %s\n\n' \ + "$(dim 'Install manually from https://brew.sh and re-run: bash nanoclaw.sh')" + exit 1 + fi + printf '\n' + ;; + *) + printf '\n %s\n\n' \ + "$(dim 'NanoClaw needs Homebrew. Install it from https://brew.sh and re-run.')" + exit 1 + ;; + esac +fi + # ─── first step: install the basics (Node + pnpm + native modules) ───── BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log" From 9b6e5b24a1ba80ce9bb0d4993cd530bf7f761e12 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 10:45:05 +0300 Subject: [PATCH 086/185] feat(setup): optional Discord wiring in setup:auto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the Telegram flow but without a pairing step — Discord exposes enough via the bot token that we only need one paste from the operator, with every other identity field derived: GET /users/@me → bot username (sanity check) GET /oauth2/applications/@me → application id, verify_key (public key), owner {id, username} POST /users/@me/channels → DM channel id After confirming "Is @ your Discord account?" the flow invites the bot to a server (OAuth URL + open + confirm, gating so the welcome DM can actually reach the operator), installs the adapter, opens the DM channel, and hands off to init-first-agent with --channel discord --platform-id discord:@me:. The existing init-first-agent welcome-over-CLI-socket path delivers the greeting through the normal adapter pipeline — no Discord-specific code in the welcome logic. Fallbacks: if the app is team-owned (no owner object) or the operator declines the confirmation, a Dev Mode walkthrough + user-id paste prompt takes over. Adds: - setup/add-discord.sh (non-interactive installer, mirror of add-telegram.sh minus pair-step registration) - setup/channels/discord.ts (operator-facing flow) - setup/auto.ts: Discord option in askChannelChoice + dispatch Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-discord.sh | 122 ++++++++++ setup/auto.ts | 10 +- setup/channels/discord.ts | 455 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 584 insertions(+), 3 deletions(-) create mode 100755 setup/add-discord.sh create mode 100644 setup/channels/discord.ts diff --git a/setup/add-discord.sh b/setup/add-discord.sh new file mode 100755 index 0000000..1cd247a --- /dev/null +++ b/setup/add-discord.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# +# Install the Discord adapter, persist DISCORD_BOT_TOKEN / APPLICATION_ID / +# PUBLIC_KEY to .env + data/env/env, and restart the service. Non-interactive — +# the operator-facing "Create a bot" walkthrough, owner confirmation, and +# server-invite step live in setup/channels/discord.ts. Credentials come in via +# env vars: DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY. +# +# Emits exactly one status block on stdout (ADD_DISCORD) at the end. All chatty +# progress messages go to stderr so setup:auto's raw-log capture sees the full +# story without cluttering the final block for the parser. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Keep in sync with .claude/skills/add-discord/SKILL.md. +ADAPTER_VERSION="@chat-adapter/discord@4.26.0" +CHANNELS_BRANCH="origin/channels" + +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + echo "=== NANOCLAW SETUP: ADD_DISCORD ===" + echo "STATUS: ${status}" + echo "ADAPTER_VERSION: ${ADAPTER_VERSION}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-discord] $*" >&2; } + +if [ -z "${DISCORD_BOT_TOKEN:-}" ]; then + emit_status failed "DISCORD_BOT_TOKEN env var not set" + exit 1 +fi +if [ -z "${DISCORD_APPLICATION_ID:-}" ]; then + emit_status failed "DISCORD_APPLICATION_ID env var not set" + exit 1 +fi +if [ -z "${DISCORD_PUBLIC_KEY:-}" ]; then + emit_status failed "DISCORD_PUBLIC_KEY env var not set" + exit 1 +fi + +need_install() { + [ ! -f src/channels/discord.ts ] && return 0 + ! grep -q "^import './discord.js';" src/channels/index.ts 2>/dev/null && return 0 + return 1 +} + +ADAPTER_ALREADY_INSTALLED=true +if need_install; then + ADAPTER_ALREADY_INSTALLED=false + log "Fetching channels branch…" + git fetch origin channels >&2 2>/dev/null || { + emit_status failed "git fetch origin channels failed" + exit 1 + } + + log "Copying adapter from ${CHANNELS_BRANCH}…" + git show "${CHANNELS_BRANCH}:src/channels/discord.ts" > src/channels/discord.ts + + # Append self-registration import if missing. + if ! grep -q "^import './discord.js';" src/channels/index.ts; then + echo "import './discord.js';" >> src/channels/index.ts + fi + + log "Installing ${ADAPTER_VERSION}…" + pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${ADAPTER_VERSION} failed" + exit 1 + } + + log "Building…" + pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 + } +else + log "Adapter files already installed — skipping install phase." +fi + +# Persist credentials. auto.ts validates before this point, so bad values here +# would be an internal bug rather than operator input. +touch .env +upsert_env() { + local key=$1 value=$2 + if grep -q "^${key}=" .env; then + awk -v k="$key" -v v="$value" \ + 'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \ + .env > .env.tmp && mv .env.tmp .env + else + echo "${key}=${value}" >> .env + fi +} +upsert_env DISCORD_BOT_TOKEN "$DISCORD_BOT_TOKEN" +upsert_env DISCORD_APPLICATION_ID "$DISCORD_APPLICATION_ID" +upsert_env DISCORD_PUBLIC_KEY "$DISCORD_PUBLIC_KEY" + +# Container reads from data/env/env (the host mounts it). +mkdir -p data/env +cp .env data/env/env + +log "Restarting service so the new adapter picks up the credentials…" +case "$(uname -s)" in + Darwin) + launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true + ;; + Linux) + systemctl --user restart nanoclaw >&2 2>/dev/null \ + || sudo systemctl restart nanoclaw >&2 2>/dev/null \ + || true + ;; +esac + +# Give the Discord adapter a moment to finish gateway handshake before +# init-first-agent attempts delivery. +sleep 5 + +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts index a0068bb..97f38ac 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -25,6 +25,7 @@ import { spawn, spawnSync } from 'child_process'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { runDiscordChannel } from './channels/discord.js'; import { runTelegramChannel } from './channels/telegram.js'; import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; @@ -195,9 +196,11 @@ async function main(): Promise { const choice = await askChannelChoice(); if (choice === 'telegram') { await runTelegramChannel(displayName!); + } else if (choice === 'discord') { + await runDiscordChannel(displayName!); } else { p.log.info( - "No messaging app for now. You can add one later (like Telegram, Slack, or Discord).", + "No messaging app for now. You can add one later (like Telegram, Discord, or Slack).", ); } } @@ -372,18 +375,19 @@ async function askDisplayName(fallback: string): Promise { return value; } -async function askChannelChoice(): Promise<'telegram' | 'skip'> { +async function askChannelChoice(): Promise<'telegram' | 'discord' | 'skip'> { const choice = ensureAnswer( await p.select({ message: 'Want to chat with your assistant from your phone?', options: [ { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, + { value: 'discord', label: 'Yes, connect Discord' }, { value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" }, ], }), ); setupLog.userInput('channel_choice', String(choice)); - return choice as 'telegram' | 'skip'; + return choice as 'telegram' | 'discord' | 'skip'; } // ─── interactive / env helpers ───────────────────────────────────────── diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts new file mode 100644 index 0000000..cfc8155 --- /dev/null +++ b/setup/channels/discord.ts @@ -0,0 +1,455 @@ +/** + * Discord channel flow for setup:auto. + * + * `runDiscordChannel(displayName)` owns the full branch from "do you have a + * bot?" through the welcome DM: + * + * 1. Ask if they have a bot already; walk them through Dev Portal creation + * if not + * 2. Paste the bot token (clack password) — format-validated + * 3. GET /users/@me to confirm the token and resolve bot username + * 4. GET /oauth2/applications/@me to derive application_id, verify_key + * (public key), and owner — no separate paste needed in the common case + * 5. Confirm owner identity (falls back to a manual user-id prompt with + * Developer Mode instructions if declined or if the app is team-owned) + * 6. Print the OAuth invite URL, open it, wait for "I've added the bot" + * 7. Install the adapter via setup/add-discord.sh (non-interactive) + * 8. POST /users/@me/channels to open the DM channel (yields dm channel id) + * 9. Ask for the messaging-agent name (defaulting to "Nano") + * 10. Wire the agent via scripts/init-first-agent.ts, which sends the welcome + * DM through the normal delivery path + * + * All output obeys the three-level contract: clack UI for the user, structured + * entries in logs/setup.log, full raw output in per-step files under + * logs/setup-steps/. See docs/setup-flow.md. + */ +import { spawn } from 'child_process'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; +import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; + +const DEFAULT_AGENT_NAME = 'Nano'; +const DISCORD_API = 'https://discord.com/api/v10'; + +// Send Messages (0x800) + Add Reactions (0x40) + Attach Files (0x8000) +// + Read Message History (0x10000) = 100416. +// Matches the permissions set documented in .claude/skills/add-discord/SKILL.md. +const INVITE_PERMISSIONS = '100416'; + +interface AppInfo { + applicationId: string; + publicKey: string; + owner: { id: string; username: string } | null; +} + +export async function runDiscordChannel(displayName: string): Promise { + if (!(await askHasBotToken())) { + await walkThroughBotCreation(); + } + + const token = await collectDiscordToken(); + const botUsername = await validateDiscordToken(token); + const app = await fetchApplicationInfo(token); + + const ownerUserId = await resolveOwnerUserId(app.owner); + + await promptInviteBot(app.applicationId, botUsername); + + const install = await runQuietChild( + 'discord-install', + 'bash', + ['setup/add-discord.sh'], + { + running: `Connecting Discord to @${botUsername}…`, + done: 'Discord connected.', + }, + { + env: { + DISCORD_BOT_TOKEN: token, + DISCORD_APPLICATION_ID: app.applicationId, + DISCORD_PUBLIC_KEY: app.publicKey, + }, + extraFields: { + BOT_USERNAME: botUsername, + APPLICATION_ID: app.applicationId, + }, + }, + ); + if (!install.ok) { + fail( + 'discord-install', + "Couldn't connect Discord.", + 'See logs/setup-steps/ for details, then retry setup.', + ); + } + + const dmChannelId = await openDmChannel(token, ownerUserId); + const platformId = `discord:@me:${dmChannelId}`; + + const agentName = await resolveAgentName(); + + const init = await runQuietChild( + 'init-first-agent', + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'discord', + '--user-id', `discord:${ownerUserId}`, + '--platform-id', platformId, + '--display-name', displayName, + '--agent-name', agentName, + ], + { + running: `Connecting ${agentName} to your Discord DMs…`, + done: `${agentName} is ready. Check Discord for a welcome message.`, + }, + { + extraFields: { + CHANNEL: 'discord', + AGENT_NAME: agentName, + PLATFORM_ID: platformId, + }, + }, + ); + if (!init.ok) { + fail( + 'init-first-agent', + `Couldn't finish connecting ${agentName}.`, + 'Most likely the bot and you don\'t share a server yet — invite the bot, then retry later with `/manage-channels`.', + ); + } +} + +async function askHasBotToken(): Promise { + const answer = ensureAnswer( + await p.select({ + message: 'Do you already have a Discord bot?', + options: [ + { value: 'yes', label: 'Yes, I have a bot token ready' }, + { value: 'no', label: "No, walk me through creating one" }, + ], + }), + ); + return answer === 'yes'; +} + +async function walkThroughBotCreation(): Promise { + const url = 'https://discord.com/developers/applications'; + p.note( + [ + "You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.", + '', + ' 1. Click "New Application", give it a name (e.g. "NanoClaw")', + ' 2. In the "Bot" tab, click "Reset Token" and copy the token', + ' 3. On the same tab, enable "Message Content Intent"', + ' (under Privileged Gateway Intents)', + '', + k.dim(`Opening ${url} …`), + ].join('\n'), + 'Create a Discord bot', + ); + openUrl(url); + + ensureAnswer( + await p.confirm({ + message: "Got your bot token?", + initialValue: true, + }), + ); +} + +async function collectDiscordToken(): Promise { + const answer = ensureAnswer( + await p.password({ + message: 'Paste your bot token', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'Token is required'; + // Discord bot tokens are base64url segments separated by dots. + // Be lenient on length; the real check is /users/@me. + if (!/^[A-Za-z0-9._-]{50,}$/.test(t)) { + return "That doesn't look like a Discord bot token"; + } + return undefined; + }, + }), + ); + const token = (answer as string).trim(); + setupLog.userInput( + 'discord_token', + `${token.slice(0, 10)}…${token.slice(-4)}`, + ); + return token; +} + +async function validateDiscordToken(token: string): Promise { + const s = p.spinner(); + const start = Date.now(); + s.start('Checking your bot token…'); + try { + const res = await fetch(`${DISCORD_API}/users/@me`, { + headers: { Authorization: `Bot ${token}` }, + }); + const data = (await res.json()) as { + id?: string; + username?: string; + message?: string; + }; + const elapsedS = Math.round((Date.now() - start) / 1000); + if (res.ok && data.username) { + s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`); + setupLog.step('discord-validate', 'success', Date.now() - start, { + BOT_USERNAME: data.username, + BOT_ID: data.id ?? '', + }); + return data.username; + } + const reason = data.message ?? `HTTP ${res.status}`; + s.stop(`Discord didn't accept that token: ${reason}`, 1); + setupLog.step('discord-validate', 'failed', Date.now() - start, { + ERROR: reason, + }); + fail( + 'discord-validate', + "Discord didn't accept that token.", + 'Copy the token again from the Developer Portal and retry setup.', + ); + } catch (err) { + const elapsedS = Math.round((Date.now() - start) / 1000); + s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('discord-validate', 'failed', Date.now() - start, { + ERROR: message, + }); + fail( + 'discord-validate', + "Couldn't reach Discord.", + 'Check your internet connection and retry setup.', + ); + } +} + +async function fetchApplicationInfo(token: string): Promise { + const s = p.spinner(); + const start = Date.now(); + s.start('Looking up your bot application…'); + try { + const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, { + headers: { Authorization: `Bot ${token}` }, + }); + const data = (await res.json()) as { + id?: string; + verify_key?: string; + owner?: { id: string; username: string } | null; + team?: unknown; + message?: string; + }; + const elapsedS = Math.round((Date.now() - start) / 1000); + if (!res.ok || !data.id || !data.verify_key) { + const reason = data.message ?? `HTTP ${res.status}`; + s.stop(`Couldn't read application info: ${reason}`, 1); + setupLog.step('discord-app-info', 'failed', Date.now() - start, { + ERROR: reason, + }); + fail( + 'discord-app-info', + "Couldn't read your Discord application details.", + 'Re-run setup. If it keeps failing, check the bot token has the right scopes.', + ); + } + s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`); + // owner is populated for solo applications; team-owned apps return a + // team object instead and we'll fall back to a manual user-id prompt. + const owner = + data.owner && data.owner.id && data.owner.username + ? { id: data.owner.id, username: data.owner.username } + : null; + setupLog.step('discord-app-info', 'success', Date.now() - start, { + APPLICATION_ID: data.id, + OWNER_USERNAME: owner?.username ?? '', + TEAM_OWNED: data.team ? 'true' : 'false', + }); + return { + applicationId: data.id, + publicKey: data.verify_key, + owner, + }; + } catch (err) { + const elapsedS = Math.round((Date.now() - start) / 1000); + s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('discord-app-info', 'failed', Date.now() - start, { + ERROR: message, + }); + fail( + 'discord-app-info', + "Couldn't reach Discord.", + 'Check your internet connection and retry setup.', + ); + } +} + +async function resolveOwnerUserId( + owner: { id: string; username: string } | null, +): Promise { + if (owner) { + const confirmed = ensureAnswer( + await p.confirm({ + message: `Is @${owner.username} your Discord account?`, + initialValue: true, + }), + ); + if (confirmed === true) { + setupLog.userInput('discord_owner_confirmed', owner.username); + return owner.id; + } + } else { + p.log.info( + "Your bot is owned by a Developer Team, so we need your Discord user ID directly.", + ); + } + return await promptForUserIdWithDevMode(); +} + +async function promptForUserIdWithDevMode(): Promise { + p.note( + [ + "To get your Discord user ID:", + '', + ' 1. Open Discord → Settings (⚙️) → Advanced', + ' 2. Turn on "Developer Mode"', + ' 3. Right-click your own name/avatar → "Copy User ID"', + ].join('\n'), + 'Find your Discord user ID', + ); + const answer = ensureAnswer( + await p.text({ + message: 'Paste your Discord user ID', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'User ID is required'; + if (!/^\d{17,20}$/.test(t)) { + return "That doesn't look like a Discord user ID (17-20 digits)"; + } + return undefined; + }, + }), + ); + const id = (answer as string).trim(); + setupLog.userInput('discord_user_id', id); + return id; +} + +async function promptInviteBot( + applicationId: string, + botUsername: string, +): Promise { + const url = + `https://discord.com/api/oauth2/authorize` + + `?client_id=${applicationId}` + + `&scope=bot` + + `&permissions=${INVITE_PERMISSIONS}`; + + p.note( + [ + `@${botUsername} needs to share a server with you before it can DM you.`, + '', + ' 1. Pick any server you\'re in (a personal one is fine)', + ' 2. Click "Authorize"', + '', + k.dim(`Opening ${url}`), + ].join('\n'), + 'Add bot to a server', + ); + openUrl(url); + + ensureAnswer( + await p.confirm({ + message: "I've added the bot to a server", + initialValue: true, + }), + ); +} + +async function openDmChannel(token: string, userId: string): Promise { + const s = p.spinner(); + const start = Date.now(); + s.start('Opening a DM channel…'); + try { + const res = await fetch(`${DISCORD_API}/users/@me/channels`, { + method: 'POST', + headers: { + Authorization: `Bot ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ recipient_id: userId }), + }); + const data = (await res.json()) as { id?: string; message?: string }; + const elapsedS = Math.round((Date.now() - start) / 1000); + if (!res.ok || !data.id) { + const reason = data.message ?? `HTTP ${res.status}`; + s.stop(`Couldn't open a DM channel: ${reason}`, 1); + setupLog.step('discord-open-dm', 'failed', Date.now() - start, { + ERROR: reason, + }); + fail( + 'discord-open-dm', + "Couldn't open a DM channel with you.", + 'Make sure the bot is in a server you\'re also in, then retry setup.', + ); + } + s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`); + setupLog.step('discord-open-dm', 'success', Date.now() - start, { + DM_CHANNEL_ID: data.id, + }); + return data.id; + } catch (err) { + const elapsedS = Math.round((Date.now() - start) / 1000); + s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('discord-open-dm', 'failed', Date.now() - start, { + ERROR: message, + }); + fail( + 'discord-open-dm', + "Couldn't reach Discord.", + 'Check your internet connection and retry setup.', + ); + } +} + +async function resolveAgentName(): Promise { + const preset = process.env.NANOCLAW_AGENT_NAME?.trim(); + if (preset) { + setupLog.userInput('agent_name', preset); + return preset; + } + const answer = ensureAnswer( + await p.text({ + message: 'What should your assistant be called?', + placeholder: DEFAULT_AGENT_NAME, + defaultValue: DEFAULT_AGENT_NAME, + }), + ); + const value = (answer as string).trim() || DEFAULT_AGENT_NAME; + setupLog.userInput('agent_name', value); + return value; +} + +/** Best-effort open of a URL in the user's default browser. Silent on failure. */ +function openUrl(url: string): void { + try { + const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open'; + const child = spawn(cmd, [url], { stdio: 'ignore', detached: true }); + child.on('error', () => { + // Headless / no browser / unknown command — the URL is already + // printed in the note above, so the user can copy-paste. + }); + child.unref(); + } catch { + // swallow — URL is visible in the note. + } +} From 72b7a72cbbe55489d6c5813a701e9777fef9ea54 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 11:06:15 +0300 Subject: [PATCH 087/185] feat(setup): ping agent before chat, detect stale service, auto-install Claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-trip confirmation before first chat. After cli-agent wires up the Terminal Agent, send `chat ping` through the CLI socket under a spinner with 30s timeout (shared helper in setup/lib/agent-ping.ts, also used by verify). Only after a real reply do we show "Your assistant is ready." and enter the chat loop. Ping failures surface a targeted note (socket_error vs no_reply) and skip the prompt — so users never type into the void. Checkout-mismatch detection. verify resolves the running service PID's script path via `ps -p -o command=` and compares to projectRoot. If the service is running from a sibling clone (common for developers with multiple checkouts), SERVICE comes back as running_other_checkout instead of running, AGENT_PING is skipped, and the failure note tells the user exactly which bootout + bootstrap pair to run. Native Claude Code install on demand. Only the subscription auth path needs `claude`; the paste-token and paste-API-key paths don't. So register-claude-token.sh now runs setup/install-claude.sh when `claude` is missing (curl -fsSL https://claude.ai/install.sh | bash), then prepends ~/.local/bin to PATH in-process so the rest of the script can see the fresh binary. Gutter-safe wrapping. wrapForGutter + dimWrap in lib/theme.ts hard-wrap text to `process.stdout.columns - gutter` on word boundaries, measuring visible length (ANSI-stripped). dimWrap applies the dim envelope per line because clack resets styling at each line break when rendering multi-line log content — a single outer dim() only colors the first line. Applied to the long "why" notes before container + onecli, the channel-skip info, the ping-failure note, and the checkout-mismatch remediation. Wordmark anchoring. printIntro always includes the NanoClaw wordmark in the clack intro line, whether or not nanoclaw.sh already printed one in bash. Worth ~1 line of redundancy so the brand stays visible at the top of the clack session after bootstrap output scrolls out. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 160 +++++++++++++++++++++++++++------ setup/install-claude.sh | 50 +++++++++++ setup/lib/agent-ping.ts | 50 +++++++++++ setup/lib/theme.ts | 63 +++++++++++++ setup/register-claude-token.sh | 22 ++++- setup/verify.ts | 122 ++++++++++++++----------- 6 files changed, 389 insertions(+), 78 deletions(-) create mode 100755 setup/install-claude.sh create mode 100644 setup/lib/agent-ping.ts diff --git a/setup/auto.ts b/setup/auto.ts index 97f38ac..49be3f3 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -14,7 +14,7 @@ * "Terminal Agent". * NANOCLAW_SKIP comma-separated step names to skip * (environment|container|onecli|auth|mounts| - * service|cli-agent|channel|verify) + * service|cli-agent|channel|verify|first-chat) * * Timezone defaults to the host system's TZ. Run * pnpm exec tsx setup/index.ts --step timezone -- --tz @@ -27,9 +27,10 @@ import k from 'kleur'; import { runDiscordChannel } from './channels/discord.js'; import { runTelegramChannel } from './channels/telegram.js'; +import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; -import { brandBold, brandChip } from './lib/theme.js'; +import { brandBold, brandChip, dimWrap, wrapForGutter } from './lib/theme.js'; const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); @@ -61,8 +62,9 @@ async function main(): Promise { if (!skip.has('container')) { p.log.message( - k.dim( + dimWrap( 'Your assistant lives in its own sandbox. It can only see what you explicitly share.', + 4, ), ); const res = await runQuietStep('container', { @@ -97,8 +99,9 @@ async function main(): Promise { if (!skip.has('onecli')) { p.log.message( - k.dim( + dimWrap( 'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.', + 4, ), ); const res = await runQuietStep('onecli', { @@ -178,18 +181,26 @@ async function main(): Promise { const res = await runQuietStep( 'cli-agent', { - running: 'Setting up your terminal chat…', - done: 'Terminal chat ready. Try `pnpm run chat hi`.', + running: 'Bringing your assistant online…', + done: 'Assistant wired up.', }, ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME], ); if (!res.ok) { fail( 'cli-agent', - "Couldn't set up the terminal chat.", + "Couldn't bring your assistant online.", `You can retry later with \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\`.`, ); } + if (!skip.has('first-chat')) { + const ping = await confirmAssistantResponds(); + if (ping === 'ok') { + await runFirstChat(); + } else { + renderPingFailureNote(ping); + } + } } if (!skip.has('channel')) { @@ -200,7 +211,10 @@ async function main(): Promise { await runDiscordChannel(displayName!); } else { p.log.info( - "No messaging app for now. You can add one later (like Telegram, Discord, or Slack).", + wrapForGutter( + 'No messaging app for now. You can add one later (like Telegram, Discord, or Slack).', + 4, + ), ); } } @@ -216,12 +230,27 @@ async function main(): Promise { if (res.terminal?.fields.CREDENTIALS !== 'configured') { notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.'); } - const agentPing = res.terminal?.fields.AGENT_PING; - if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') { + const service = res.terminal?.fields.SERVICE; + if (service === 'running_other_checkout') { notes.push( - "• Your assistant didn't reply to a test message. " + - 'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', + wrapForGutter( + [ + '• Your NanoClaw service is running from a different folder on this machine.', + ' Point it at this checkout with:', + ' launchctl bootout gui/$(id -u)/com.nanoclaw', + ' launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.plist', + ].join('\n'), + 6, + ), ); + } else { + const agentPing = res.terminal?.fields.AGENT_PING; + if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') { + notes.push( + "• Your assistant didn't reply to a test message. " + + 'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', + ); + } } if (!res.terminal?.fields.CONFIGURED_CHANNELS) { notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.'); @@ -248,6 +277,95 @@ async function main(): Promise { p.outro(k.green("You're ready! Enjoy NanoClaw.")); } +// ─── first-chat step ─────────────────────────────────────────────────── + +/** + * Round-trip ping against the CLI socket before we ask the user to chat. + * Renders its own spinner with elapsed time because a cold-start container + * boot can take 30–60s — the elapsed counter is the difference between + * "patient" and "is this hung?". Returns the raw result so the caller can + * branch between the chat loop (ok) and a diagnostic note (anything else). + */ +async function confirmAssistantResponds(): Promise { + const s = p.spinner(); + const start = Date.now(); + const label = 'Waking your assistant…'; + s.start(label); + const tick = setInterval(() => { + const elapsed = Math.round((Date.now() - start) / 1000); + s.message(`${label} ${k.dim(`(${elapsed}s)`)}`); + }, 1000); + + const result = await pingCliAgent(); + + clearInterval(tick); + const elapsed = Math.round((Date.now() - start) / 1000); + if (result === 'ok') { + s.stop(`Your assistant is ready. ${k.dim(`(${elapsed}s)`)}`); + } else { + const msg = + result === 'socket_error' + ? "Couldn't reach the NanoClaw service." + : "Your assistant didn't reply in time."; + s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`, 1); + } + return result; +} + +function renderPingFailureNote(result: PingResult): void { + const body = + result === 'socket_error' + ? [ + wrapForGutter( + "The NanoClaw service isn't listening on its local socket. Try restarting it, then chat with `pnpm run chat hi`:", + 6, + ), + '', + k.dim(' macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw'), + k.dim(' Linux: systemctl --user restart nanoclaw'), + ].join('\n') + : wrapForGutter( + 'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', + 6, + ); + p.note(body, 'Skipping the first chat'); +} + +/** + * Chat loop. Each message is piped through `pnpm run chat`, which uses + * the same Unix-socket path the ping just exercised, so output streams + * back inline as the agent replies. An empty input ends the loop. + */ +async function runFirstChat(): Promise { + while (true) { + const answer = ensureAnswer( + await p.text({ + message: 'Say something to your assistant', + placeholder: 'press Enter with nothing to continue', + }), + ); + const text = ((answer as string | undefined) ?? '').trim(); + if (!text) return; + await sendChatMessage(text); + } +} + +function sendChatMessage(message: string): Promise { + return new Promise((resolve) => { + // `pnpm --silent` suppresses the `> nanoclaw@… chat` preamble so the + // agent's reply reads as a clean block under the prompt. Splitting on + // whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv + // with spaces on the far side. + const child = spawn( + 'pnpm', + ['--silent', 'run', 'chat', ...message.split(/\s+/)], + { stdio: ['ignore', 'inherit', 'inherit'] }, + ); + child.on('close', () => resolve()); + child.on('error', () => resolve()); + }); +} + // ─── auth step (select → branch) ──────────────────────────────────────── async function runAuthStep(): Promise { @@ -440,7 +558,6 @@ function maybeReexecUnderSg(): void { function printIntro(): void { const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; - const isBootstrapped = process.env.NANOCLAW_BOOTSTRAPPED === '1'; const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; if (isReexec) { @@ -450,18 +567,11 @@ function printIntro(): void { return; } - // When we were called via nanoclaw.sh, the wordmark + subtitle were - // already printed in bash. Just open the clack gutter with a short, - // neutral intro so the flow continues without duplication. - if (isBootstrapped) { - p.intro(k.dim("Let's get you set up.")); - return; - } - - console.log(); - console.log(` ${wordmark}`); - console.log(` ${k.dim('Setting up your personal AI assistant')}`); - p.intro(k.dim("Let's get you set up.")); + // Always include the wordmark inside the clack intro line. When bash ran + // first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark + // above us; the small repeat is worth it to keep the brand anchored at + // the visible top of the clack session once the bash output scrolls away. + p.intro(`${wordmark} ${k.dim("Let's get you set up.")}`); } /** diff --git a/setup/install-claude.sh b/setup/install-claude.sh new file mode 100755 index 0000000..485f0b4 --- /dev/null +++ b/setup/install-claude.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Install the Claude Code CLI on the host via the official native installer. +# Invoked from setup/register-claude-token.sh when the user picks the +# subscription auth path and `claude` is missing. The other two auth paths +# (paste OAuth token, paste API key) don't need the CLI, so this runs on +# demand rather than up front. +# +# The native installer is Node-independent (downloads a prebuilt binary to +# ~/.local/bin) and is the path Anthropic documents. This matches the +# pattern used by install-docker.sh / install-node.sh: the script itself is +# the allowlisted unit; the curl | bash pipe lives inside it. + +set -euo pipefail + +echo "=== NANOCLAW SETUP: INSTALL_CLAUDE ===" + +if command -v claude >/dev/null 2>&1; then + echo "STATUS: already-installed" + echo "CLAUDE_VERSION: $(claude --version 2>/dev/null || echo unknown)" + echo "=== END ===" + exit 0 +fi + +if ! command -v curl >/dev/null 2>&1; then + echo "STATUS: failed" + echo "ERROR: curl not available." + echo "=== END ===" + exit 1 +fi + +echo "STEP: claude-native-install" +curl -fsSL https://claude.ai/install.sh | bash + +# Native installer writes to ~/.local/bin and appends a PATH line to the +# user's rc file; that doesn't help this session, so put it on PATH now. +if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" +fi +hash -r 2>/dev/null || true + +if ! command -v claude >/dev/null 2>&1; then + echo "STATUS: failed" + echo "ERROR: claude not found on PATH after install." + echo "=== END ===" + exit 1 +fi + +echo "STATUS: installed" +echo "CLAUDE_VERSION: $(claude --version 2>/dev/null || echo unknown)" +echo "=== END ===" diff --git a/setup/lib/agent-ping.ts b/setup/lib/agent-ping.ts new file mode 100644 index 0000000..8c5127f --- /dev/null +++ b/setup/lib/agent-ping.ts @@ -0,0 +1,50 @@ +/** + * Round-trip check against the CLI Unix socket. + * + * Shared by `setup/verify.ts` (end-of-run health check) and `setup/auto.ts` + * (confirm the freshly-wired agent actually responds before prompting the + * user to chat with it). + * + * Exit-code contract follows `scripts/chat.ts`: + * 0 → got a reply on stdout + * 2 → socket unreachable (service not running or wrong checkout) + * 3 → no reply before chat.ts's own 120s hard stop + * This wrapper also guards with its own timeout in case chat.ts hangs. + */ +import { spawn } from 'child_process'; + +export type PingResult = 'ok' | 'no_reply' | 'socket_error'; + +export function pingCliAgent(timeoutMs = 30_000): Promise { + return new Promise((resolve) => { + const child = spawn('pnpm', ['run', 'chat', 'ping'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill('SIGKILL'); + resolve('no_reply'); + }, timeoutMs); + + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf-8'); + }); + child.on('close', (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code === 2) resolve('socket_error'); + else if (code === 0 && stdout.trim().length > 0) resolve('ok'); + else resolve('no_reply'); + }); + child.on('error', () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve('socket_error'); + }); + }); +} diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index 9bd18a5..0a08eae 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -37,3 +37,66 @@ export function brandChip(s: string): string { } return k.bgCyan(k.black(k.bold(s))); } + +/** + * Wrap text so it fits inside clack's gutter without the terminal's soft + * wrap breaking the `│ …` bar on long lines. Works on a single string with + * embedded `\n`s; each logical line is wrapped independently. + * + * The `gutter` argument is the total horizontal overhead clack adds for + * the component the text lives in (e.g. 4 for `p.log.*`'s `│ ` prefix; + * 6-ish for `p.note`'s box). Caller picks it; we just subtract from + * `process.stdout.columns` and hard-wrap at word boundaries. + */ +export function wrapForGutter(text: string, gutter: number): string { + const cols = process.stdout.columns ?? 80; + const width = Math.max(30, cols - gutter); + return text + .split('\n') + .map((line) => wrapLine(line, width)) + .join('\n'); +} + +/** + * Wrap + dim together. Needed instead of `k.dim(wrapForGutter(...))` + * because clack resets styling at each line break when rendering + * multi-line log content — a single outer dim envelope only colors the + * first line. Applying dim per-line gives each wrapped row its own + * `\x1b[2m…\x1b[0m` envelope so the whole block reads as one block. + */ +export function dimWrap(text: string, gutter: number): string { + return wrapForGutter(text, gutter) + .split('\n') + .map((line) => k.dim(line)) + .join('\n'); +} + +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +function visibleLength(s: string): number { + return s.replace(ANSI_RE, '').length; +} + +function wrapLine(line: string, width: number): string { + if (visibleLength(line) <= width) return line; + const words = line.split(' '); + const rows: string[] = []; + let cur = ''; + let curLen = 0; + for (const word of words) { + const wLen = visibleLength(word); + if (curLen === 0) { + cur = word; + curLen = wLen; + } else if (curLen + 1 + wLen <= width) { + cur += ' ' + word; + curLen += 1 + wLen; + } else { + rows.push(cur); + cur = word; + curLen = wLen; + } + } + if (cur) rows.push(cur); + return rows.join('\n'); +} diff --git a/setup/register-claude-token.sh b/setup/register-claude-token.sh index e0707bf..e0adfc6 100755 --- a/setup/register-claude-token.sh +++ b/setup/register-claude-token.sh @@ -25,8 +25,26 @@ HOST_PATTERN="${HOST_PATTERN:-api.anthropic.com}" command -v onecli >/dev/null \ || { echo "onecli not found. Install it first (see /setup §4)." >&2; exit 1; } -command -v claude >/dev/null \ - || { echo "claude CLI not found. Install from https://claude.ai/download" >&2; exit 1; } + +if ! command -v claude >/dev/null 2>&1; then + echo "Claude Code CLI not found — installing it now (needed for subscription sign-in)…" + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if ! bash "$SCRIPT_DIR/install-claude.sh"; then + echo >&2 + echo "Couldn't install the Claude Code CLI automatically." >&2 + echo "Install it manually with" >&2 + echo " curl -fsSL https://claude.ai/install.sh | bash" >&2 + echo "and re-run setup." >&2 + exit 1 + fi + # install-claude.sh PATH additions are scoped to its own subshell; redo + # them here so the rest of this script can see the fresh `claude` binary. + if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" + fi + hash -r 2>/dev/null || true +fi + command -v script >/dev/null \ || { echo "script(1) is required for PTY capture." >&2; exit 1; } diff --git a/setup/verify.ts b/setup/verify.ts index 4be9c3f..ab0b80e 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -4,7 +4,7 @@ * * Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks. */ -import { execSync, spawn } from 'child_process'; +import { execSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -14,6 +14,7 @@ import Database from 'better-sqlite3'; import { DATA_DIR } from '../src/config.js'; import { readEnvFile } from '../src/env.js'; import { log } from '../src/log.js'; +import { pingCliAgent } from './lib/agent-ping.js'; import { getPlatform, getServiceManager, @@ -29,19 +30,35 @@ export async function run(_args: string[]): Promise { log.info('Starting verification'); - // 1. Check service status - let service = 'not_found'; + // 1. Check service status + detect checkout mismatch. + // + // Why the mismatch matters: the host binds `/cli.sock` relative + // to the project root it was started from. If the running service is from + // a sibling checkout (common for developers with multiple clones), this + // repo's `data/cli.sock` won't exist — AGENT_PING would return a + // misleading `socket_error`. Surface the mismatch directly instead. + let service: + | 'not_found' + | 'stopped' + | 'running' + | 'running_other_checkout' = 'not_found'; + let runningFromPath: string | null = null; const mgr = getServiceManager(); if (mgr === 'launchd') { try { const output = execSync('launchctl list', { encoding: 'utf-8' }); - if (output.includes('com.nanoclaw')) { - // Check if it has a PID (actually running) - const line = output.split('\n').find((l) => l.includes('com.nanoclaw')); - if (line) { - const pidField = line.trim().split(/\s+/)[0]; - service = pidField !== '-' && pidField ? 'running' : 'stopped'; + const line = output.split('\n').find((l) => l.includes('com.nanoclaw')); + if (line) { + const pidField = line.trim().split(/\s+/)[0]; + if (pidField !== '-' && pidField) { + service = 'running'; + const pid = Number(pidField); + if (Number.isInteger(pid) && pid > 0) { + runningFromPath = resolveBinaryScript(pid); + } + } else { + service = 'stopped'; } } } catch { @@ -52,6 +69,18 @@ export async function run(_args: string[]): Promise { try { execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' }); service = 'running'; + try { + const pidStr = execSync( + `${prefix} show nanoclaw -p MainPID --value`, + { encoding: 'utf-8' }, + ).trim(); + const pid = Number(pidStr); + if (Number.isInteger(pid) && pid > 0) { + runningFromPath = resolveBinaryScript(pid); + } + } catch { + // couldn't read MainPID; leave runningFromPath null + } } catch { try { const output = execSync(`${prefix} list-unit-files`, { @@ -74,13 +103,23 @@ export async function run(_args: string[]): Promise { if (raw && Number.isInteger(pid) && pid > 0) { process.kill(pid, 0); service = 'running'; + runningFromPath = resolveBinaryScript(pid); } } catch { service = 'stopped'; } } } - log.info('Service status', { service }); + + if ( + service === 'running' && + runningFromPath && + !isPathInside(runningFromPath, projectRoot) + ) { + service = 'running_other_checkout'; + } + + log.info('Service status', { service, runningFromPath }); // 2. Check container runtime let containerRuntime = 'none'; @@ -213,46 +252,27 @@ export async function run(_args: string[]): Promise { } /** - * Send a one-word message through the CLI channel and check for a reply. - * Silent by default — stdout/stderr of the child are captured but not - * forwarded. Kills the child after 90s so verify can't hang on a wedged - * agent (chat.ts's own timeout is 120s, which is too long for setup). + * Given a PID, resolve the script path the process is executing (i.e. the + * first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any + * error — callers should treat null as "couldn't tell" and skip the + * mismatch check rather than flag a false positive. */ -function pingCliAgent(): Promise<'ok' | 'no_reply' | 'socket_error'> { - return new Promise((resolve) => { - const child = spawn('pnpm', ['run', 'chat', 'ping'], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stdout = ''; - let settled = false; - const timer = setTimeout(() => { - if (settled) return; - settled = true; - child.kill('SIGKILL'); - resolve('no_reply'); - }, 90_000); - - child.stdout.on('data', (chunk: Buffer) => { - stdout += chunk.toString('utf-8'); - }); - child.on('close', (code) => { - if (settled) return; - settled = true; - clearTimeout(timer); - // chat.ts: exit 0 on reply, 2 on socket error, 3 on no reply. - if (code === 2) { - resolve('socket_error'); - } else if (code === 0 && stdout.trim().length > 0) { - resolve('ok'); - } else { - resolve('no_reply'); - } - }); - child.on('error', () => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve('socket_error'); - }); - }); +function resolveBinaryScript(pid: number): string | null { + try { + // BSD ps (macOS) and util-linux both honour `-o command=` (full argv, + // no header). Node argv: "node /path/to/dist/index.js ...". + const out = execSync(`ps -p ${pid} -o command=`, { + encoding: 'utf-8', + }).trim(); + const tokens = out.split(/\s+/); + const script = tokens.find((t) => /\.(js|mjs|cjs|ts)$/.test(t)); + return script ?? null; + } catch { + return null; + } +} + +function isPathInside(candidate: string, parent: string): boolean { + const rel = path.relative(parent, candidate); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); } From dfcbab5364f7e3ae9f383965606811b0e5b56cf8 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 12:39:48 +0300 Subject: [PATCH 088/185] feat(setup): optional WhatsApp wiring + cross-channel UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp (community/Baileys) joins the setup:auto channel picker, with the same clack-native UX discipline as Telegram and Discord: - setup/channels/whatsapp.ts — driver. Collects auth method (QR terminal or pairing code), runs the auth step, renders QR blocks in-place with ANSI cursor-rewind on rotation so the terminal doesn't fill up with stale codes, reads creds.me.id for the bot phone, restarts the service, asks for the operator's personal phone (defaulting to the authed number), writes ASSISTANT_HAS_OWN_NUMBER=true when they differ (dedicated mode), and hands off to init-first-agent. - setup/whatsapp-auth.ts — forked standalone auth step. Channels-branch version had a browser-QR path with an HTTP server + QR renderer; stripped entirely (headless/SSH users hit dead ends too often, and the extra deps complicate install). The remaining terminal QR emits raw QR strings in WHATSAPP_AUTH_QR blocks so the parent driver owns the rendering. Pairing-code path retained. Status blocks now use the runner's vocabulary (success/skipped/failed) so spawnStep sets ok correctly; WhatsApp-specific UI text ("WhatsApp linked", "You chat") lives in the driver. - setup/add-whatsapp.sh — non-interactive installer, mirror of add-telegram.sh. Fetches the adapter + groups step from the channels branch (whatsapp-auth.ts stays local, pair-telegram.ts pattern), installs pinned baileys/qrcode/pino, registers the steps in setup/index.ts's STEPS map. No service restart (adapter factory returns null until creds exist). Cross-channel fixes bundled: - scripts/init-first-agent.ts: always addMember(user, agentGroup) for the target user so subsequent wirings (not the first) pass the access gate. Telegram wiring first → Discord/WhatsApp second was dropping every inbound with accessReason='not_member' because only the first user gets owner. namespacedPlatformId also passes through JID-format raws (contains '@') so WhatsApp's bare @s.whatsapp.net matches what the adapter stores. - setup/service.ts: launchctl unload-then-load instead of bare load (bare load errors 'already loaded' when a prior plist was cached, keeping launchd on the OLD ProgramArguments even after the file on disk changed). systemctl start → restart (start is a no-op on an active unit, swallowing unit-file edits). - setup/add-telegram.sh: removed the in-script open "tg://resolve" block. The driver (setup/channels/telegram.ts) now owns the deep-link, gated on a p.confirm so the browser can't steal focus unexpectedly. - setup/channels/discord.ts + setup/channels/telegram.ts: every browser open goes through confirmThenOpen (new shared helper in setup/lib/browser.ts) — operator presses Enter before their browser takes focus. Telegram switched from tg://resolve?domain= to https://t.me/ which works everywhere. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/init-first-agent.ts | 22 +- setup/add-telegram.sh | 24 +- setup/add-whatsapp.sh | 114 +++++++++ setup/auto.ts | 10 +- setup/channels/discord.ts | 25 +- setup/channels/telegram.ts | 26 +- setup/channels/whatsapp.ts | 464 ++++++++++++++++++++++++++++++++++++ setup/index.ts | 2 + setup/lib/browser.ts | 51 ++++ setup/service.ts | 30 ++- setup/whatsapp-auth.ts | 221 +++++++++++++++++ 11 files changed, 939 insertions(+), 50 deletions(-) create mode 100755 setup/add-whatsapp.sh create mode 100644 setup/channels/whatsapp.ts create mode 100644 setup/lib/browser.ts create mode 100644 setup/whatsapp-auth.ts diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index c634851..c4dfdc2 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -43,6 +43,7 @@ import { } from '../src/db/messaging-groups.js'; import { runMigrations } from '../src/db/migrations/index.js'; import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.js'; +import { addMember } from '../src/modules/permissions/db/agent-group-members.js'; import { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles.js'; import { upsertUser } from '../src/modules/permissions/db/users.js'; import { initGroupFilesystem } from '../src/group-init.js'; @@ -118,7 +119,13 @@ function namespacedUserId(channel: string, raw: string): string { } function namespacedPlatformId(channel: string, raw: string): string { - return raw.startsWith(`${channel}:`) ? raw : `${channel}:${raw}`; + if (raw.startsWith(`${channel}:`)) return raw; + // Adapters using native JID format (WhatsApp: @s.whatsapp.net, + // @g.us) store platform_id without a channel prefix. The '@' is + // the discriminator — telegram/discord platform_ids don't contain it + // except after a channel prefix, which is already handled above. + if (raw.includes('@')) return raw; + return `${channel}:${raw}`; } function generateId(prefix: string): string { @@ -202,6 +209,19 @@ async function main(): Promise { 'When the user first reaches out (or you receive a system welcome prompt), introduce yourself briefly and invite them to chat. Keep replies concise.', }); + // 2b. Grant the user access to this agent group. Owner role is only + // assigned to the first user (above); subsequent DMs need explicit + // membership or the strict unknown_sender_policy on the DM messaging + // group will drop every message with accessReason='not_member'. addMember + // is INSERT OR IGNORE — idempotent when the global owner already has + // access by virtue of their role. + addMember({ + user_id: userId, + agent_group_id: ag.id, + added_by: null, + added_at: now, + }); + // 3. DM messaging group. const platformId = namespacedPlatformId(args.channel, args.platformId); let dmMg = getMessagingGroupByPlatform(args.channel, platformId); diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 5036bd4..361960f 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -119,7 +119,8 @@ else fi # Look up the bot username (auto.ts already validated; we re-query here so -# standalone invocations still work). +# standalone invocations still work — BOT_USERNAME is emitted in the status +# block for parent drivers to display). INFO=$(curl -fsS --max-time 8 \ "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || true) BOT_USERNAME="" @@ -131,23 +132,10 @@ fi mkdir -p data/env cp .env data/env/env -# Deep-link into the bot's chat so the user is already on the right screen -# when pair-telegram prints the code. Silent best-effort — runs under a -# spinner, any output (from `open` / `xdg-open`) goes to the raw log. -if [ -n "$BOT_USERNAME" ]; then - case "$(uname -s)" in - Darwin) - open "tg://resolve?domain=${BOT_USERNAME}" >&2 2>/dev/null \ - || open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \ - || true - ;; - Linux) - xdg-open "tg://resolve?domain=${BOT_USERNAME}" >&2 2>/dev/null \ - || xdg-open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \ - || true - ;; - esac -fi +# Browser/app deep-link is done by the parent driver (setup/channels/telegram.ts) +# BEFORE this script runs — gated on a clack confirm so focus-stealing doesn't +# surprise the user. Keeping it out of here means this script stays pure +# non-interactive install. log "Restarting service so the new adapter picks up the token…" case "$(uname -s)" in diff --git a/setup/add-whatsapp.sh b/setup/add-whatsapp.sh new file mode 100755 index 0000000..d04d372 --- /dev/null +++ b/setup/add-whatsapp.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# +# Install the native WhatsApp (Baileys) adapter and its whatsapp-auth + groups +# setup steps. No credentials in env — WhatsApp uses linked-device auth, run +# by the whatsapp-auth step as a separate process. The adapter's factory +# returns null until store/auth/creds.json exists, so it's safe to install +# this before auth runs; the driver restarts the service *after* auth +# succeeds. +# +# Emits exactly one status block on stdout (ADD_WHATSAPP) at the end. All +# chatty progress messages go to stderr so setup:auto's raw-log capture sees +# the full story without cluttering the final block for the parser. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Keep in sync with .claude/skills/add-whatsapp/SKILL.md. +BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16" +QRCODE_VERSION="qrcode@1.5.4" +QRCODE_TYPES_VERSION="@types/qrcode@1.5.6" +PINO_VERSION="pino@9.6.0" +CHANNELS_BRANCH="origin/channels" + +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + echo "=== NANOCLAW SETUP: ADD_WHATSAPP ===" + echo "STATUS: ${status}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-whatsapp] $*" >&2; } + +need_install() { + [ ! -f src/channels/whatsapp.ts ] && return 0 + [ ! -f setup/groups.ts ] && return 0 + ! grep -q "^import './whatsapp.js';" src/channels/index.ts 2>/dev/null && return 0 + ! grep -q "'whatsapp-auth':" setup/index.ts 2>/dev/null && return 0 + ! grep -q "^ groups:" setup/index.ts 2>/dev/null && return 0 + return 1 +} + +ADAPTER_ALREADY_INSTALLED=true +if need_install; then + ADAPTER_ALREADY_INSTALLED=false + log "Fetching channels branch…" + git fetch origin channels >&2 2>/dev/null || { + emit_status failed "git fetch origin channels failed" + exit 1 + } + + # whatsapp-auth.ts is maintained in this branch (setup-auto) — do not copy + # from channels. Matches the pair-telegram.ts pattern. + log "Copying adapter + group step from ${CHANNELS_BRANCH}…" + git show "${CHANNELS_BRANCH}:src/channels/whatsapp.ts" > src/channels/whatsapp.ts + git show "${CHANNELS_BRANCH}:setup/groups.ts" > setup/groups.ts + + # Append self-registration import if missing. + if ! grep -q "^import './whatsapp.js';" src/channels/index.ts; then + echo "import './whatsapp.js';" >> src/channels/index.ts + fi + + # Register the setup steps in setup/index.ts's STEPS map. node (not sed) — + # sed's in-place + escape semantics differ between BSD (macOS) and GNU. + node -e ' + const fs = require("fs"); + const p = "setup/index.ts"; + let s = fs.readFileSync(p, "utf-8"); + let changed = false; + if (!s.includes("\047whatsapp-auth\047:")) { + s = s.replace( + /(register: \(\) => import\(\x27\.\/register\.js\x27\),)/, + "$1\n \x27whatsapp-auth\x27: () => import(\x27./whatsapp-auth.js\x27)," + ); + changed = true; + } + if (!/^\s*groups:\s/m.test(s)) { + s = s.replace( + /(register: \(\) => import\(\x27\.\/register\.js\x27\),)/, + "$1\n groups: () => import(\x27./groups.js\x27)," + ); + changed = true; + } + if (changed) fs.writeFileSync(p, s); + ' + + log "Installing Baileys + QR + pino (pinned)…" + pnpm install \ + "${BAILEYS_VERSION}" \ + "${QRCODE_VERSION}" \ + "${QRCODE_TYPES_VERSION}" \ + "${PINO_VERSION}" \ + >&2 2>/dev/null || { + emit_status failed "pnpm install failed" + exit 1 + } + + log "Building…" + pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 + } +else + log "Adapter + setup steps already installed — skipping install phase." +fi + +# No service restart here — the adapter factory returns null without +# store/auth/creds.json, so restarting now would no-op. The driver restarts +# the service AFTER whatsapp-auth completes so the adapter picks up creds. + +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts index 49be3f3..2191e9a 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -27,6 +27,7 @@ import k from 'kleur'; import { runDiscordChannel } from './channels/discord.js'; import { runTelegramChannel } from './channels/telegram.js'; +import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; @@ -209,10 +210,12 @@ async function main(): Promise { await runTelegramChannel(displayName!); } else if (choice === 'discord') { await runDiscordChannel(displayName!); + } else if (choice === 'whatsapp') { + await runWhatsAppChannel(displayName!); } else { p.log.info( wrapForGutter( - 'No messaging app for now. You can add one later (like Telegram, Discord, or Slack).', + 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, or Slack).', 4, ), ); @@ -493,19 +496,20 @@ async function askDisplayName(fallback: string): Promise { return value; } -async function askChannelChoice(): Promise<'telegram' | 'discord' | 'skip'> { +async function askChannelChoice(): Promise<'telegram' | 'discord' | 'whatsapp' | 'skip'> { const choice = ensureAnswer( await p.select({ message: 'Want to chat with your assistant from your phone?', options: [ { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, { value: 'discord', label: 'Yes, connect Discord' }, + { value: 'whatsapp', label: 'Yes, connect WhatsApp' }, { value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" }, ], }), ); setupLog.userInput('channel_choice', String(choice)); - return choice as 'telegram' | 'discord' | 'skip'; + return choice as 'telegram' | 'discord' | 'whatsapp' | 'skip'; } // ─── interactive / env helpers ───────────────────────────────────────── diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index cfc8155..f384902 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -23,12 +23,11 @@ * entries in logs/setup.log, full raw output in per-step files under * logs/setup-steps/. See docs/setup-flow.md. */ -import { spawn } from 'child_process'; - import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { confirmThenOpen } from '../lib/browser.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -147,11 +146,11 @@ async function walkThroughBotCreation(): Promise { ' 3. On the same tab, enable "Message Content Intent"', ' (under Privileged Gateway Intents)', '', - k.dim(`Opening ${url} …`), + k.dim(url), ].join('\n'), 'Create a Discord bot', ); - openUrl(url); + await confirmThenOpen(url, 'Press Enter to open the Developer Portal'); ensureAnswer( await p.confirm({ @@ -360,11 +359,11 @@ async function promptInviteBot( ' 1. Pick any server you\'re in (a personal one is fine)', ' 2. Click "Authorize"', '', - k.dim(`Opening ${url}`), + k.dim(url), ].join('\n'), 'Add bot to a server', ); - openUrl(url); + await confirmThenOpen(url, 'Press Enter to open the invite page'); ensureAnswer( await p.confirm({ @@ -439,17 +438,3 @@ async function resolveAgentName(): Promise { return value; } -/** Best-effort open of a URL in the user's default browser. Silent on failure. */ -function openUrl(url: string): void { - try { - const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open'; - const child = spawn(cmd, [url], { stdio: 'ignore', detached: true }); - child.on('error', () => { - // Headless / no browser / unknown command — the URL is already - // printed in the note above, so the user can copy-paste. - }); - child.unref(); - } catch { - // swallow — URL is visible in the note. - } -} diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 348cd05..7fe5d26 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -7,10 +7,11 @@ * 1. BotFather instructions (clack note) * 2. Paste the bot token (clack password) — format-validated * 3. getMe via the Bot API to resolve the bot's username - * 4. Install the adapter (setup/add-telegram.sh, non-interactive) - * 5. Run the pair-telegram step, rendering code events as clack notes - * 6. Ask for the messaging-agent name (defaulting to "Nano") - * 7. Wire the agent via scripts/init-first-agent.ts + * 4. Confirm + deep-link into the bot's Telegram chat (tg://resolve) + * 5. Install the adapter (setup/add-telegram.sh, non-interactive) + * 6. Run the pair-telegram step, rendering code events as clack notes + * 7. Ask for the messaging-agent name (defaulting to "Nano") + * 8. Wire the agent via scripts/init-first-agent.ts * * All output obeys the three-level contract: clack UI for the user, * structured entries in logs/setup.log, full raw output in per-step files @@ -20,6 +21,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { confirmThenOpen } from '../lib/browser.js'; import { type Block, type StepResult, @@ -38,6 +40,22 @@ export async function runTelegramChannel(displayName: string): Promise { const token = await collectTelegramToken(); const botUsername = await validateTelegramToken(token); + // Deep-link the user into the bot's chat so they're on the right screen + // by the time pair-telegram prints the code. https://t.me/ works + // everywhere: browsers show an "Open in Telegram" button when the app is + // installed, or the bot's web profile if not. tg://resolve?domain= is + // more direct but silently fails when the scheme isn't registered. + const botUrl = `https://t.me/${botUsername}`; + p.note( + [ + `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, + '', + k.dim(botUrl), + ].join('\n'), + 'Open Telegram', + ); + await confirmThenOpen(botUrl, 'Press Enter to open Telegram'); + const install = await runQuietChild( 'telegram-install', 'bash', diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts new file mode 100644 index 0000000..4d8290f --- /dev/null +++ b/setup/channels/whatsapp.ts @@ -0,0 +1,464 @@ +/** + * WhatsApp (community/Baileys) channel flow for setup:auto. + * + * `runWhatsAppChannel(displayName)` owns the full branch from auth-method + * picker through the welcome DM: + * + * 1. Ask how to authenticate (QR code in terminal, default, or pairing code) + * 2. If pairing-code: collect the phone number + * 3. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh + * 4. Run the whatsapp-auth step, rendering status blocks as clack UI: + * - WHATSAPP_AUTH_QR (repeating): render the QR as terminal block art + * inside a clack note. On rotation we clear the previous QR in-place + * via ANSI escapes so the terminal doesn't fill up with stale codes. + * - WHATSAPP_AUTH_PAIRING_CODE (one-shot): centred code card. + * 5. Read store/auth/creds.json → extract the authenticated (bot) phone + * 6. Kick the service so the adapter picks up the new credentials + * 7. Ask the operator for the phone they'll chat from (defaults to the + * authed number). Different number ⇒ dedicated mode ⇒ also writes + * ASSISTANT_HAS_OWN_NUMBER=true so outbound replies aren't prefixed + * 8. Ask for the messaging-agent name (defaulting to "Nano") + * 9. Wire the agent via scripts/init-first-agent.ts; the existing welcome + * DM path delivers the greeting through the adapter + * + * All output obeys the three-level contract: clack UI for the user, structured + * entries in logs/setup.log, full raw output in per-step files under + * logs/setup-steps/. See docs/setup-flow.md. + */ +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; +import { + type Block, + type StepResult, + dumpTranscriptOnFailure, + ensureAnswer, + fail, + runQuietChild, + spawnStep, + writeStepEntry, +} from '../lib/runner.js'; +import { brandBold } from '../lib/theme.js'; + +const DEFAULT_AGENT_NAME = 'Nano'; +const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); + +type AuthMethod = 'qr' | 'pairing-code'; + +export async function runWhatsAppChannel(displayName: string): Promise { + const method = await askAuthMethod(); + const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined; + + const install = await runQuietChild( + 'whatsapp-install', + 'bash', + ['setup/add-whatsapp.sh'], + { + running: 'Installing the WhatsApp adapter…', + done: 'WhatsApp adapter installed.', + skipped: 'WhatsApp adapter already installed.', + }, + ); + if (!install.ok) { + fail( + 'whatsapp-install', + "Couldn't install the WhatsApp adapter.", + 'See logs/setup-steps/ for details, then retry setup.', + ); + } + + const auth = await runWhatsAppAuth(method, phone); + if (!auth.ok) { + const reason = auth.terminal?.fields.ERROR ?? 'unknown'; + fail( + 'whatsapp-auth', + `WhatsApp authentication failed (${reason}).`, + reason === 'qr_timeout' || reason === 'timeout' + ? 'The code expired. Re-run setup to get a fresh one.' + : 'Re-run setup to try again.', + ); + } + + const botPhone = readAuthedPhone(); + if (!botPhone) { + fail( + 'whatsapp-auth', + "Authenticated but couldn't read your WhatsApp number from the saved credentials.", + 'Re-run setup to try again.', + ); + } + + await restartService(); + + const chatPhone = await askChatPhone(botPhone); + const isDedicated = chatPhone !== botPhone; + if (isDedicated) { + writeAssistantHasOwnNumber(); + } + + const agentName = await resolveAgentName(); + + const platformId = `${chatPhone}@s.whatsapp.net`; + + const init = await runQuietChild( + 'init-first-agent', + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'whatsapp', + '--user-id', platformId, + '--platform-id', platformId, + '--display-name', displayName, + '--agent-name', agentName, + ], + { + running: `Connecting ${agentName} to WhatsApp…`, + done: isDedicated + ? `${agentName} is ready. Check WhatsApp for a welcome message.` + : `${agentName} is ready. Look in your "You" chat on WhatsApp for the welcome.`, + }, + { + extraFields: { + CHANNEL: 'whatsapp', + AGENT_NAME: agentName, + PLATFORM_ID: platformId, + MODE: isDedicated ? 'dedicated' : 'shared', + }, + }, + ); + if (!init.ok) { + fail( + 'init-first-agent', + `Couldn't finish connecting ${agentName}.`, + 'You can retry later with `/manage-channels`.', + ); + } +} + +async function askAuthMethod(): Promise { + const choice = ensureAnswer( + await p.select({ + message: 'How would you like to authenticate with WhatsApp?', + options: [ + { + value: 'qr', + label: 'Scan a QR code in this terminal', + hint: 'recommended', + }, + { + value: 'pairing-code', + label: 'Enter a pairing code on your phone', + hint: 'no camera needed', + }, + ], + }), + ) as AuthMethod; + setupLog.userInput('whatsapp_auth_method', choice); + return choice; +} + +async function askPhoneNumber(): Promise { + p.note( + [ + "Enter your phone number the way WhatsApp expects it:", + '', + ' • Digits only — no +, spaces, or dashes', + ' • Country code first, then the rest of the number', + '', + k.dim('Example: 14155551234 (country code 1, then 4155551234)'), + ].join('\n'), + 'Your phone number', + ); + const answer = ensureAnswer( + await p.text({ + message: 'Phone number', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'Phone number is required'; + if (!/^\d{8,15}$/.test(t)) { + return "That doesn't look right. Digits only, country code included."; + } + return undefined; + }, + }), + ); + const phone = (answer as string).trim(); + setupLog.userInput('whatsapp_phone', phone); + return phone; +} + +async function runWhatsAppAuth( + method: AuthMethod, + phone: string | undefined, +): Promise { + const rawLog = setupLog.stepRawLog('whatsapp-auth'); + const start = Date.now(); + const s = p.spinner(); + s.start('Starting WhatsApp authentication…'); + let spinnerActive = true; + + const stopSpinner = (msg: string, code?: number) => { + if (spinnerActive) { + s.stop(msg, code); + spinnerActive = false; + } + }; + + // Tracks the QR render so we can overwrite it in-place on rotation. null + // before the first QR is printed. + let qrLinesPrinted = 0; + + const extra = + method === 'pairing-code' && phone + ? ['--method', 'pairing-code', '--phone', phone] + : ['--method', 'qr']; + + const result = await spawnStep( + 'whatsapp-auth', + extra, + (block: Block) => { + if (block.type === 'WHATSAPP_AUTH_QR') { + const qr = block.fields.QR ?? ''; + if (!qr) return; + // Fire-and-forget — await inside spawnStep's sync onBlock is fine + // since spawnStep's own logic keeps running in parallel. + void renderQr(qr).then((lines) => { + if (qrLinesPrinted === 0) { + stopSpinner('QR code ready — scan with WhatsApp.'); + } else { + // Cursor up N lines + clear from there to end of screen. Wipes + // the previous QR + caption so the new one renders in place. + process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`); + } + process.stdout.write(lines.join('\n') + '\n'); + qrLinesPrinted = lines.length; + }); + } else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') { + const code = block.fields.CODE ?? '????'; + stopSpinner('Your pairing code is ready.'); + p.note(formatPairingCard(code), 'Pairing code'); + s.start('Waiting for you to enter the code…'); + spinnerActive = true; + } else if (block.type === 'WHATSAPP_AUTH') { + const status = block.fields.STATUS; + if (status === 'skipped') { + stopSpinner('WhatsApp is already authenticated.'); + } else if (status === 'success') { + // Erase the QR block if one was on screen — it's served its purpose. + if (qrLinesPrinted > 0) { + process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`); + qrLinesPrinted = 0; + } + // In QR flow the spinner was stopped when the first QR landed. + // Fall back to a plain success line so the user sees confirmation. + if (spinnerActive) { + stopSpinner('WhatsApp linked.'); + } else { + p.log.success('WhatsApp linked.'); + } + } else if (status === 'failed') { + if (qrLinesPrinted > 0) { + process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`); + qrLinesPrinted = 0; + } + const err = block.fields.ERROR ?? 'unknown'; + if (spinnerActive) { + stopSpinner(`Authentication failed: ${err}`, 1); + } else { + p.log.error(`Authentication failed: ${err}`); + } + } + } + }, + rawLog, + ); + const durationMs = Date.now() - start; + + // Safety net — if the step died without emitting a terminal block, don't + // leave the spinner running. + if (spinnerActive) { + stopSpinner( + result.ok ? 'Done.' : 'Authentication ended unexpectedly.', + result.ok ? 0 : 1, + ); + if (!result.ok) dumpTranscriptOnFailure(result.transcript); + } + + writeStepEntry('whatsapp-auth', result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** + * Render the raw QR string to an array of terminal lines (block-art QR + + * a caption). Returned as an array so the caller can count lines for the + * in-place rewrite on rotation. Uses the small-mode QR to keep the height + * manageable on 24-row terminals. + */ +async function renderQr(qr: string): Promise { + try { + const QRCode = await import('qrcode'); + const qrText = await QRCode.toString(qr, { type: 'terminal', small: true }); + const caption = k.dim( + ' Open WhatsApp → Settings → Linked Devices → Link a Device → scan.', + ); + return [...qrText.trimEnd().split('\n'), '', caption]; + } catch { + return ['QR code (raw): ' + qr]; + } +} + +function formatPairingCard(code: string): string { + // WhatsApp pairing codes are 8 characters; render with two-wide gap so the + // digits read clearly in the terminal. + const spaced = code.split('').join(' '); + return [ + '', + ` ${brandBold(spaced)}`, + '', + k.dim(' Open WhatsApp → Settings → Linked Devices → Link a Device'), + k.dim(' → "Link with phone number instead" → enter this code.'), + k.dim(' It expires in ~60 seconds.'), + ].join('\n'); +} + +/** + * Pull the authenticated WhatsApp phone out of store/auth/creds.json. + * `creds.me.id` looks like `14155551234:@s.whatsapp.net` — we want + * just the leading digit run. + */ +function readAuthedPhone(): string { + try { + const raw = fs.readFileSync(AUTH_CREDS_PATH, 'utf-8'); + const creds = JSON.parse(raw) as { me?: { id?: string } }; + const id = creds.me?.id; + if (!id) return ''; + return id.split(':')[0].split('@')[0]; + } catch { + return ''; + } +} + +async function restartService(): Promise { + const s = p.spinner(); + s.start('Restarting NanoClaw so it sees your WhatsApp credentials…'); + const start = Date.now(); + const platform = process.platform; + try { + if (platform === 'darwin') { + spawnSync( + 'launchctl', + ['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/com.nanoclaw`], + { stdio: 'ignore' }, + ); + } else if (platform === 'linux') { + const user = spawnSync( + 'systemctl', + ['--user', 'restart', 'nanoclaw'], + { stdio: 'ignore' }, + ); + if (user.status !== 0) { + spawnSync('sudo', ['systemctl', 'restart', 'nanoclaw'], { + stdio: 'ignore', + }); + } + } + // Give the adapter a moment to reconnect before init-first-agent's + // welcome DM hits the delivery path. + await new Promise((r) => setTimeout(r, 5000)); + const elapsed = Math.round((Date.now() - start) / 1000); + s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`); + setupLog.step('whatsapp-restart', 'success', Date.now() - start, { + PLATFORM: platform, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + s.stop(`Restart may have failed: ${message}`, 1); + setupLog.step('whatsapp-restart', 'failed', Date.now() - start, { + ERROR: message, + }); + // Non-fatal — the user can restart manually if init-first-agent fails. + } +} + +async function askChatPhone(authedPhone: string): Promise { + p.note( + [ + `Authenticated with ${k.cyan('+' + authedPhone)}.`, + '', + "What's the phone number you'll chat with your agent from?", + '', + k.dim( + 'Same number = messages will land in your "You" / self-chat on WhatsApp\n' + + "(you won't be able to reply to yourself — use a different number for a\n" + + 'two-way chat).', + ), + ].join('\n'), + 'Your chat number', + ); + const answer = ensureAnswer( + await p.text({ + message: 'Your personal phone number', + placeholder: authedPhone, + defaultValue: authedPhone, + validate: (v) => { + const t = (v ?? authedPhone).trim(); + if (!/^\d{8,15}$/.test(t)) { + return 'Digits only, country code included.'; + } + return undefined; + }, + }), + ); + const phone = ((answer as string) || authedPhone).trim(); + setupLog.userInput('whatsapp_chat_phone', phone); + return phone; +} + +/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env and data/env/env. */ +function writeAssistantHasOwnNumber(): void { + const envPath = path.join(process.cwd(), '.env'); + let contents = ''; + try { + contents = fs.readFileSync(envPath, 'utf-8'); + } catch { + contents = ''; + } + if (/^ASSISTANT_HAS_OWN_NUMBER=/m.test(contents)) { + contents = contents.replace( + /^ASSISTANT_HAS_OWN_NUMBER=.*$/m, + 'ASSISTANT_HAS_OWN_NUMBER=true', + ); + } else { + if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n'; + contents += 'ASSISTANT_HAS_OWN_NUMBER=true\n'; + } + fs.writeFileSync(envPath, contents); + + // Container reads from data/env/env. + const containerEnvDir = path.join(process.cwd(), 'data', 'env'); + fs.mkdirSync(containerEnvDir, { recursive: true }); + fs.copyFileSync(envPath, path.join(containerEnvDir, 'env')); +} + +async function resolveAgentName(): Promise { + const preset = process.env.NANOCLAW_AGENT_NAME?.trim(); + if (preset) { + setupLog.userInput('agent_name', preset); + return preset; + } + const answer = ensureAnswer( + await p.text({ + message: 'What should your assistant be called?', + placeholder: DEFAULT_AGENT_NAME, + defaultValue: DEFAULT_AGENT_NAME, + }), + ); + const value = (answer as string).trim() || DEFAULT_AGENT_NAME; + setupLog.userInput('agent_name', value); + return value; +} diff --git a/setup/index.ts b/setup/index.ts index 2112cd1..25d1934 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -14,6 +14,8 @@ const STEPS: Record< environment: () => import('./environment.js'), container: () => import('./container.js'), register: () => import('./register.js'), + groups: () => import('./groups.js'), + 'whatsapp-auth': () => import('./whatsapp-auth.js'), mounts: () => import('./mounts.js'), service: () => import('./service.js'), verify: () => import('./verify.js'), diff --git a/setup/lib/browser.ts b/setup/lib/browser.ts new file mode 100644 index 0000000..9d801fa --- /dev/null +++ b/setup/lib/browser.ts @@ -0,0 +1,51 @@ +/** + * Browser-open helpers shared across channel setup flows. + * + * `openUrl` is best-effort — silent on failure, so headless/SSH/WSL + * environments where `open`/`xdg-open` isn't wired up don't crash the + * setup. The URL should always be visible in the clack note that calls + * this so the user can copy-paste if the auto-open doesn't land. + * + * `confirmThenOpen` pauses for the operator before triggering the open — + * the browser tends to steal focus when it pops, and a split-second + * "wait what just happened" moment is worse than letting the user hit + * Enter when they're ready. + */ +import { spawn } from 'child_process'; + +import * as p from '@clack/prompts'; + +import { ensureAnswer } from './runner.js'; + +/** Best-effort open of a URL in the user's default browser. Silent on failure. */ +export function openUrl(url: string): void { + try { + const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open'; + const child = spawn(cmd, [url], { stdio: 'ignore', detached: true }); + child.on('error', () => { + // Headless / no browser / unknown command — URL is printed in the + // calling note so the user can copy-paste. + }); + child.unref(); + } catch { + // swallow — URL is visible in the note. + } +} + +/** + * Gate a browser-open on a confirm so the user is ready for their browser + * to take focus. Proceeds on cancel as well — the user can always copy the + * URL from the note that precedes the prompt. + */ +export async function confirmThenOpen( + url: string, + message = 'Press Enter to open your browser', +): Promise { + ensureAnswer( + await p.confirm({ + message, + initialValue: true, + }), + ); + openUrl(url); +} diff --git a/setup/service.ts b/setup/service.ts index 56bf393..f5ad855 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -116,13 +116,30 @@ function setupLaunchd( fs.writeFileSync(plistPath, plist); log.info('Wrote launchd plist', { plistPath }); + // Unload first to force launchd to drop any cached plist and re-read from + // disk. Bare `launchctl load` on an already-loaded plist errors with + // "already loaded" and keeps the ORIGINAL plist's ProgramArguments / + // WorkingDirectory in memory — even if the file on disk changed. That + // bit us when the plist target shifted between installs: kickstart kept + // relaunching the old binary and the CLI socket landed in the wrong dir. + // unload succeeds whether or not the service was previously loaded; the + // failure case is "Could not find specified service" which is harmless. + try { + execSync(`launchctl unload ${JSON.stringify(plistPath)}`, { + stdio: 'ignore', + }); + log.info('launchctl unload succeeded'); + } catch { + log.info('launchctl unload noop (plist was not previously loaded)'); + } + try { execSync(`launchctl load ${JSON.stringify(plistPath)}`, { stdio: 'ignore', }); log.info('launchctl load succeeded'); - } catch { - log.warn('launchctl load failed (may already be loaded)'); + } catch (err) { + log.error('launchctl load failed', { err }); } // Verify @@ -316,10 +333,15 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; log.error('systemctl enable failed', { err }); } + // restart (not start) so a previously-running instance picks up edits to + // the unit file. `start` on an active unit is a no-op, which would leave + // the old ExecStart / WorkingDirectory in effect even after daemon-reload. + // `restart` on a stopped unit is equivalent to `start`, so this is safe + // as a first-install path too. try { - execSync(`${systemctlPrefix} start nanoclaw`, { stdio: 'ignore' }); + execSync(`${systemctlPrefix} restart nanoclaw`, { stdio: 'ignore' }); } catch (err) { - log.error('systemctl start failed', { err }); + log.error('systemctl restart failed', { err }); } // Verify diff --git a/setup/whatsapp-auth.ts b/setup/whatsapp-auth.ts new file mode 100644 index 0000000..47bfc6e --- /dev/null +++ b/setup/whatsapp-auth.ts @@ -0,0 +1,221 @@ +/** + * Step: whatsapp-auth — standalone WhatsApp (Baileys) authentication. + * + * Forked from the channels-branch version so setup:auto's driver can render + * the terminal UX itself (inside clack) instead of the step dumping a raw QR + * to stdout. The browser method has been dropped — one less moving part and + * it kept biting headless/SSH users. + * + * Methods: + * --method qr (default) Emit each rotating QR as a status block + * with the raw QR string. Driver renders. + * --method pairing-code --phone Request a pairing code. Emitted in a + * status block once the Baileys call returns. + * + * Block schema (parent parses these): + * WHATSAPP_AUTH_QR { QR: "" } — repeats + * WHATSAPP_AUTH_PAIRING_CODE { CODE: "XXXX-XXXX" } — one-shot + * WHATSAPP_AUTH { STATUS: success } — terminal + * { STATUS: skipped, AUTH_DIR, REASON } + * { STATUS: failed, ERROR: } + * + * STATUS values are kept in the runner's vocabulary (success/skipped/failed) + * so `spawnStep` recognises them and sets `ok` correctly; WhatsApp-specific + * UI text (e.g. "WhatsApp linked") lives in the driver's block handler. + * + * On success, credentials land in store/auth/ and the process exits 0. + */ +import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'module'; +// Named import (not default) — pino's d.ts under NodeNext resolves the +// default export to `typeof pino` (namespace), which isn't callable. The +// named `pino` export resolves to the callable function. +import { pino } from 'pino'; + +import { + makeWASocket, + Browsers, + DisconnectReason, + fetchLatestWaWebVersion, + makeCacheableSignalKeyStore, + useMultiFileAuthState, +} from '@whiskeysockets/baileys'; +import { emitStatus } from './status.js'; + +const AUTH_DIR = path.join(process.cwd(), 'store', 'auth'); +const PAIRING_CODE_FILE = path.join(process.cwd(), 'store', 'pairing-code.txt'); +const baileysLogger = pino({ level: 'silent' }); + +// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1). +// Fixed in Baileys 7.x but not backported. Without this patch pairing codes +// fail with "couldn't link device" because WhatsApp receives an invalid +// platform id. createRequire because proto is not a named ESM export. +const _require = createRequire(import.meta.url); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const { proto } = _require('@whiskeysockets/baileys') as { proto: any }; +try { + const _generics = _require( + '@whiskeysockets/baileys/lib/Utils/generics', + ) as Record; + _generics.getPlatformId = (browser: string): string => { + const platformType = + proto.DeviceProps.PlatformType[ + browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType + ]; + return platformType ? platformType.toString() : '1'; + }; +} catch { + // If CJS require fails, QR auth still works; only pairing code may be affected. +} + +type AuthMethod = 'qr' | 'pairing-code'; + +function parseArgs(args: string[]): { method: AuthMethod; phone?: string } { + let method: AuthMethod = 'qr'; + let phone: string | undefined; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--method': { + const raw = args[++i]; + if (raw === 'qr' || raw === 'pairing-code') { + method = raw; + } else { + console.error(`Unknown --method: ${raw} (expected 'qr' or 'pairing-code')`); + process.exit(1); + } + break; + } + case '--phone': + phone = args[++i]; + break; + } + } + + if (method === 'pairing-code' && !phone) { + console.error('--phone is required for pairing-code method'); + process.exit(1); + } + + return { method, phone }; +} + +export async function run(args: string[]): Promise { + const { method, phone } = parseArgs(args); + + if (fs.existsSync(path.join(AUTH_DIR, 'creds.json'))) { + emitStatus('WHATSAPP_AUTH', { + STATUS: 'skipped', + REASON: 'already-authenticated', + AUTH_DIR, + }); + return; + } + + fs.mkdirSync(AUTH_DIR, { recursive: true }); + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + emitStatus('WHATSAPP_AUTH', { STATUS: 'failed', ERROR: 'timeout' }); + process.exit(1); + }, 120_000); + + let succeeded = false; + function succeed(): void { + if (succeeded) return; + succeeded = true; + clearTimeout(timeout); + try { + if (fs.existsSync(PAIRING_CODE_FILE)) fs.unlinkSync(PAIRING_CODE_FILE); + } catch { + // ignore — the pairing code file is best-effort cleanup + } + emitStatus('WHATSAPP_AUTH', { STATUS: 'success' }); + resolve(); + // Give a moment for creds to flush before exiting. + setTimeout(() => process.exit(0), 1000); + } + + async function connectSocket(isReconnect = false): Promise { + const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR); + const { version } = await fetchLatestWaWebVersion({}).catch(() => ({ + version: undefined, + })); + + const sock = makeWASocket({ + version, + auth: { + creds: state.creds, + keys: makeCacheableSignalKeyStore(state.keys, baileysLogger), + }, + printQRInTerminal: false, + logger: baileysLogger, + browser: Browsers.macOS('Chrome'), + }); + + // Request pairing code only on first connect (not reconnect after 515). + if ( + !isReconnect && + method === 'pairing-code' && + phone && + !state.creds.registered + ) { + setTimeout(async () => { + try { + const code = await sock.requestPairingCode(phone); + fs.writeFileSync(PAIRING_CODE_FILE, code, 'utf-8'); + emitStatus('WHATSAPP_AUTH_PAIRING_CODE', { CODE: code }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + emitStatus('WHATSAPP_AUTH', { STATUS: 'failed', ERROR: message }); + process.exit(1); + } + }, 3000); + } + + sock.ev.on('connection.update', (update) => { + const { connection, lastDisconnect, qr } = update; + + // QR method: emit each rotation as a block. Parent renders. + if (qr && method === 'qr') { + emitStatus('WHATSAPP_AUTH_QR', { QR: qr }); + } + + if (connection === 'open') { + succeed(); + sock.end(undefined); + } + + if (connection === 'close') { + const reason = ( + lastDisconnect?.error as { output?: { statusCode?: number } } + )?.output?.statusCode; + if (reason === DisconnectReason.loggedOut) { + clearTimeout(timeout); + emitStatus('WHATSAPP_AUTH', { + STATUS: 'failed', + ERROR: 'logged_out', + }); + process.exit(1); + } else if (reason === DisconnectReason.timedOut) { + clearTimeout(timeout); + emitStatus('WHATSAPP_AUTH', { + STATUS: 'failed', + ERROR: 'qr_timeout', + }); + process.exit(1); + } else if (reason === 515) { + // 515 = stream error after pairing succeeds but before registration + // completes. Reconnect to finish the handshake. + connectSocket(true); + } + } + }); + + sock.ev.on('creds.update', saveCreds); + } + + connectSocket(); + }); +} From 4859d8fb2dc499c1e96bd0f4036532379818a368 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 12:42:32 +0300 Subject: [PATCH 089/185] feat(setup): Claude-assisted error recovery with resume-at-step retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a setup step fails — whether hard via fail() or soft via the "What's left" / "Skipping the first chat" notes — offer to ask Claude to diagnose. On consent, spawn `claude -p --output-format stream-json` with a scrolling 3-line action window ("Reading x", "Running y") so the 1–4 minute investigations feel active rather than hung. No hard timeout: debugging can take time, Ctrl-C is the escape hatch. The prompt is minimal: one-paragraph framing, failed step name + msg + hint, and a list of file references (not contents). Claude's Read/Grep tools fetch what they need. A per-step map in claude-assist.ts gives the most relevant files per step; the rest is README + auto.ts + logs/setup.log + the per-step raw log. Claude responds with REASON + COMMAND lines. We show the reason in a clack note, prefill the command via setup/run-suggested.sh (bash 4+ readline, 3.x fallback to Enter-to-run), and eval on the user's confirm. When the user runs a fix, fail() now offers to retry the failing step rather than aborting. setup/logs.ts tracks successfully-completed step names in-memory; fail() threads those as NANOCLAW_SKIP on a spawnSync retry, so the child picks up exactly where the parent left off — no rebuilding containers or reinstalling OneCLI. Other polish in this change: - fitToWidth + dimWrap in lib/theme.ts to prevent long spinner labels from soft-wrapping (each terminal row stacks a stale copy otherwise). - Shorter container step label ("Preparing your assistant's sandbox…") so it fits on narrow terminals. - Wordmark anchored in the clack intro line on every run. - All 25 existing fail() call sites updated to await fail(...) since fail is now async. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 60 ++++-- setup/channels/discord.ts | 16 +- setup/channels/telegram.ts | 12 +- setup/lib/claude-assist.ts | 410 +++++++++++++++++++++++++++++++++++++ setup/lib/runner.ts | 57 +++++- setup/lib/theme.ts | 22 ++ setup/logs.ts | 14 ++ setup/run-suggested.sh | 35 ++++ 8 files changed, 589 insertions(+), 37 deletions(-) create mode 100644 setup/lib/claude-assist.ts create mode 100755 setup/run-suggested.sh diff --git a/setup/auto.ts b/setup/auto.ts index 2191e9a..3be7856 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -29,9 +29,10 @@ import { runDiscordChannel } from './channels/discord.js'; import { runTelegramChannel } from './channels/telegram.js'; import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; +import { offerClaudeAssist } from './lib/claude-assist.js'; import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; -import { brandBold, brandChip, dimWrap, wrapForGutter } from './lib/theme.js'; +import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js'; const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); @@ -53,7 +54,7 @@ async function main(): Promise { done: 'Your system looks good.', }); if (!res.ok) { - fail( + await fail( 'environment', "Your system doesn't look quite right.", 'See logs/setup-steps/ for details, then retry.', @@ -69,27 +70,27 @@ async function main(): Promise { ), ); const res = await runQuietStep('container', { - running: 'Preparing the sandbox your assistant runs in…', + running: "Preparing your assistant's sandbox…", done: 'Sandbox ready.', failed: "Couldn't prepare the sandbox.", }); if (!res.ok) { const err = res.terminal?.fields.ERROR; if (err === 'runtime_not_available') { - fail( + await fail( 'container', "Docker isn't available.", 'Install Docker Desktop (or start it if already installed), then retry.', ); } if (err === 'docker_group_not_active') { - fail( + await fail( 'container', "Docker was just installed but your shell doesn't know yet.", 'Log out and back in (or run `newgrp docker` in a new shell), then retry.', ); } - fail( + await fail( 'container', "Couldn't build the sandbox.", 'If Docker has a stale cache, try: `docker builder prune -f`, then retry.', @@ -112,13 +113,13 @@ async function main(): Promise { if (!res.ok) { const err = res.terminal?.fields.ERROR; if (err === 'onecli_not_on_path_after_install') { - fail( + await fail( 'onecli', 'OneCLI was installed but your shell needs to refresh to see it.', 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', ); } - fail( + await fail( 'onecli', `Couldn't set up OneCLI (${err ?? 'unknown error'}).`, 'Make sure curl is installed and ~/.local/bin is writable, then retry.', @@ -141,7 +142,7 @@ async function main(): Promise { ['--empty'], ); if (!res.ok) { - fail('mounts', "Couldn't write access rules."); + await fail('mounts', "Couldn't write access rules."); } } @@ -151,7 +152,7 @@ async function main(): Promise { done: 'NanoClaw is running.', }); if (!res.ok) { - fail( + await fail( 'service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.', @@ -188,7 +189,7 @@ async function main(): Promise { ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME], ); if (!res.ok) { - fail( + await fail( 'cli-agent', "Couldn't bring your assistant online.", `You can retry later with \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\`.`, @@ -200,6 +201,17 @@ async function main(): Promise { await runFirstChat(); } else { renderPingFailureNote(ping); + await offerClaudeAssist({ + stepName: 'cli-agent', + msg: + ping === 'socket_error' + ? "NanoClaw service isn't listening on its CLI socket." + : "No reply from the assistant within 30 seconds.", + hint: + ping === 'socket_error' + ? 'Socket at data/cli.sock did not accept a connection.' + : 'Agent container may be failing to start or authenticate.', + }); } } } @@ -261,6 +273,18 @@ async function main(): Promise { if (notes.length > 0) { p.note(notes.join('\n'), "What's left"); } + // "What's left" is a soft failure — we don't abort like fail(), but the + // user is still stuck and a fix is exactly what claude-assist is for. + const summary = notes + .map((n) => n.replace(/^•\s*/, '').split('\n')[0].trim()) + .filter(Boolean) + .join(' · '); + await offerClaudeAssist({ + stepName: 'verify', + msg: summary || 'Verification completed with unresolved issues.', + hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`, + rawLogPath: res.rawLog, + }); p.outro(k.yellow('Almost there. A few things still need your attention.')); return; } @@ -293,24 +317,26 @@ async function confirmAssistantResponds(): Promise { const s = p.spinner(); const start = Date.now(); const label = 'Waking your assistant…'; - s.start(label); + s.start(fitToWidth(label, ' (999s)')); const tick = setInterval(() => { const elapsed = Math.round((Date.now() - start) / 1000); - s.message(`${label} ${k.dim(`(${elapsed}s)`)}`); + const suffix = ` (${elapsed}s)`; + s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`); }, 1000); const result = await pingCliAgent(); clearInterval(tick); const elapsed = Math.round((Date.now() - start) / 1000); + const suffix = ` (${elapsed}s)`; if (result === 'ok') { - s.stop(`Your assistant is ready. ${k.dim(`(${elapsed}s)`)}`); + s.stop(`${fitToWidth('Your assistant is ready.', suffix)}${k.dim(suffix)}`); } else { const msg = result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time."; - s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`, 1); + s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`, 1); } return result; } @@ -426,7 +452,7 @@ async function runSubscriptionAuth(): Promise { EXIT_CODE: code, METHOD: 'subscription', }); - fail( + await fail( 'auth', "Couldn't complete the Claude sign-in.", 'Re-run setup and try again, or choose a paste option instead.', @@ -473,7 +499,7 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise { }, ); if (!res.ok) { - fail( + await fail( 'auth', `Couldn't save your ${label} to the vault.`, 'Make sure OneCLI is running (`onecli version`), then retry.', diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index f384902..010310e 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -78,7 +78,7 @@ export async function runDiscordChannel(displayName: string): Promise { }, ); if (!install.ok) { - fail( + await fail( 'discord-install', "Couldn't connect Discord.", 'See logs/setup-steps/ for details, then retry setup.', @@ -114,7 +114,7 @@ export async function runDiscordChannel(displayName: string): Promise { }, ); if (!init.ok) { - fail( + await fail( 'init-first-agent', `Couldn't finish connecting ${agentName}.`, 'Most likely the bot and you don\'t share a server yet — invite the bot, then retry later with `/manage-channels`.', @@ -211,7 +211,7 @@ async function validateDiscordToken(token: string): Promise { setupLog.step('discord-validate', 'failed', Date.now() - start, { ERROR: reason, }); - fail( + await fail( 'discord-validate', "Discord didn't accept that token.", 'Copy the token again from the Developer Portal and retry setup.', @@ -223,7 +223,7 @@ async function validateDiscordToken(token: string): Promise { setupLog.step('discord-validate', 'failed', Date.now() - start, { ERROR: message, }); - fail( + await fail( 'discord-validate', "Couldn't reach Discord.", 'Check your internet connection and retry setup.', @@ -253,7 +253,7 @@ async function fetchApplicationInfo(token: string): Promise { setupLog.step('discord-app-info', 'failed', Date.now() - start, { ERROR: reason, }); - fail( + await fail( 'discord-app-info', "Couldn't read your Discord application details.", 'Re-run setup. If it keeps failing, check the bot token has the right scopes.', @@ -283,7 +283,7 @@ async function fetchApplicationInfo(token: string): Promise { setupLog.step('discord-app-info', 'failed', Date.now() - start, { ERROR: message, }); - fail( + await fail( 'discord-app-info', "Couldn't reach Discord.", 'Check your internet connection and retry setup.', @@ -394,7 +394,7 @@ async function openDmChannel(token: string, userId: string): Promise { setupLog.step('discord-open-dm', 'failed', Date.now() - start, { ERROR: reason, }); - fail( + await fail( 'discord-open-dm', "Couldn't open a DM channel with you.", 'Make sure the bot is in a server you\'re also in, then retry setup.', @@ -412,7 +412,7 @@ async function openDmChannel(token: string, userId: string): Promise { setupLog.step('discord-open-dm', 'failed', Date.now() - start, { ERROR: message, }); - fail( + await fail( 'discord-open-dm', "Couldn't reach Discord.", 'Check your internet connection and retry setup.', diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 7fe5d26..df253c6 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -70,7 +70,7 @@ export async function runTelegramChannel(displayName: string): Promise { }, ); if (!install.ok) { - fail( + await fail( 'telegram-install', "Couldn't connect Telegram.", 'See logs/setup-steps/ for details, then retry setup.', @@ -79,7 +79,7 @@ export async function runTelegramChannel(displayName: string): Promise { const pair = await runPairTelegram(); if (!pair.ok) { - fail( + await fail( 'pair-telegram', "Couldn't pair with Telegram.", 'Re-run setup to try again.', @@ -89,7 +89,7 @@ export async function runTelegramChannel(displayName: string): Promise { const platformId = pair.terminal?.fields.PLATFORM_ID; const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID; if (!platformId || !pairedUserId) { - fail( + await fail( 'pair-telegram', 'Pairing completed but came back incomplete.', 'Re-run setup to try again.', @@ -118,7 +118,7 @@ export async function runTelegramChannel(displayName: string): Promise { }, ); if (!init.ok) { - fail( + await fail( 'init-first-agent', `Couldn't finish connecting ${agentName}.`, 'You can retry later with `/manage-channels`.', @@ -188,7 +188,7 @@ async function validateTelegramToken(token: string): Promise { setupLog.step('telegram-validate', 'failed', Date.now() - start, { ERROR: reason, }); - fail( + await fail( 'telegram-validate', "Telegram didn't accept that token.", 'Copy the token again from @BotFather and try setup once more.', @@ -200,7 +200,7 @@ async function validateTelegramToken(token: string): Promise { setupLog.step('telegram-validate', 'failed', Date.now() - start, { ERROR: message, }); - fail( + await fail( 'telegram-validate', "Couldn't reach Telegram.", 'Check your internet connection and retry setup.', diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts new file mode 100644 index 0000000..4735be6 --- /dev/null +++ b/setup/lib/claude-assist.ts @@ -0,0 +1,410 @@ +/** + * Offer Claude-assisted debugging when a setup step fails. + * + * Flow: + * 1. Check `claude` is on PATH and has a working credential. If not, + * silently skip — pre-auth failures can't use this path. + * 2. Ask the user for consent ("Want me to ask Claude for a fix?"). + * 3. Build a minimal prompt: the one-paragraph situation, the failing + * step's name/message/hint, and a short list of *file references* + * (not contents) so Claude can Read what it needs on its own. + * 4. Spawn `claude -p --output-format text` with a 2-minute timeout and + * a spinner that shows elapsed time. + * 5. Parse `REASON:` / `COMMAND:` out of the response. Show the reason + * in a clack note, then hand off to `setup/run-suggested.sh` for + * editable pre-fill + exec. + * + * Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs. + */ +import { execSync, spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import { ensureAnswer } from './runner.js'; +import { fitToWidth } from './theme.js'; + +export interface AssistContext { + stepName: string; + msg: string; + hint?: string; + /** Absolute path to the per-step raw log, if the caller has one. */ + rawLogPath?: string; +} + +/** + * File-path hints per step. Claude reads these on its own via its Read tool + * rather than us stuffing contents into the prompt. Keys are step names as + * they appear in fail() calls; values are repo-relative paths. + */ +const STEP_FILES: Record = { + bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'], + environment: ['setup/environment.ts'], + container: [ + 'setup/container.ts', + 'setup/install-docker.sh', + 'container/Dockerfile', + ], + onecli: ['setup/onecli.ts'], + auth: [ + 'setup/auth.ts', + 'setup/register-claude-token.sh', + 'setup/install-claude.sh', + ], + mounts: ['setup/mounts.ts'], + service: ['setup/service.ts'], + 'cli-agent': ['setup/cli-agent.ts', 'scripts/init-cli-agent.ts'], + channel: ['setup/auto.ts'], + verify: ['setup/verify.ts'], + // Channel-specific sub-steps: + 'telegram-install': ['setup/add-telegram.sh', 'setup/channels/telegram.ts'], + 'telegram-validate': ['setup/channels/telegram.ts'], + 'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'], + 'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'], + 'init-first-agent': [ + 'scripts/init-first-agent.ts', + 'setup/channels/telegram.ts', + 'setup/channels/discord.ts', + ], +}; + +const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts']; + +/** + * Returns `true` if the user ran a Claude-suggested fix command; callers + * can use that signal to offer a retry instead of aborting outright. + * Returns `false` for every other outcome (skipped, declined, no command, + * Claude unreachable, user chose not to run). + */ +export async function offerClaudeAssist( + ctx: AssistContext, + projectRoot: string = process.cwd(), +): Promise { + if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false; + if (!isClaudeUsable()) return false; + + const want = ensureAnswer( + await p.confirm({ + message: 'Want me to ask Claude to diagnose this?', + initialValue: true, + }), + ); + if (!want) return false; + + const prompt = buildPrompt(ctx, projectRoot); + const response = await queryClaudeUnderSpinner(prompt, projectRoot); + if (!response) return false; + + const parsed = parseResponse(response); + if (!parsed) { + p.log.warn("Claude responded but I couldn't parse a command out of it."); + p.log.message(k.dim(response.trim().slice(0, 500))); + return false; + } + + p.note( + `${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`, + "Claude's suggestion", + ); + + const run = ensureAnswer( + await p.confirm({ + message: 'Run this command? (you can edit it before executing)', + initialValue: false, + }), + ); + if (!run) return false; + + await runSuggested(parsed.command, projectRoot); + return true; +} + +function isClaudeUsable(): boolean { + try { + execSync('command -v claude', { stdio: 'ignore' }); + } catch { + return false; + } + // Availability without auth is half the story; a real query will still + // fail if the token isn't registered. We try first and surface the error + // rather than pre-checking auth with a separate round trip. + return true; +} + +function buildPrompt(ctx: AssistContext, projectRoot: string): string { + const stepRefs = STEP_FILES[ctx.stepName] ?? []; + const references = [ + ...BIG_PICTURE_FILES, + ...stepRefs, + 'logs/setup.log', + ctx.rawLogPath + ? path.relative(projectRoot, ctx.rawLogPath) + : 'logs/setup-steps/', + ].filter((v, i, a) => a.indexOf(v) === i); + + const hintLine = ctx.hint ? `Hint shown to the user: ${ctx.hint}\n` : ''; + + return [ + "I'm trying to set up NanoClaw on my machine and ran into an issue", + 'during the setup flow. Please read the referenced files to understand', + 'the flow and the step that failed, look at the logs to see what went', + 'wrong, then suggest a single bash command I can run to fix it.', + '', + `Failed step: ${ctx.stepName}`, + `Error shown to the user: ${ctx.msg}`, + hintLine, + 'References (read as needed with your Read tool):', + ...references.map((r) => ` - ${r}`), + '', + 'Respond in EXACTLY this format, nothing before or after:', + '', + 'REASON: ', + 'COMMAND: ', + '', + 'If no safe single command can fix it, respond with:', + 'REASON: ', + 'COMMAND: none', + ].join('\n'); +} + +/** + * Fixed-height scrolling window for Claude's progress. + * + * Clack's spinner only owns one line, so long tool-use breadcrumbs wrap + * and blow out the gutter. Instead we manage a 4-line window ourselves: + * a spinner header + 3 lines showing the most recent tool actions. On + * each update we use raw ANSI (cursor up, clear line) to redraw in + * place. When the query finishes we clear the whole block and emit a + * single `p.log.success` / `p.log.error` so the flow continues in + * standard clack style. + */ +const WINDOW_SIZE = 3; +const SPINNER_FRAMES = ['◒', '◐', '◓', '◑']; +const HIDE_CURSOR = '\x1b[?25l'; +const SHOW_CURSOR = '\x1b[?25h'; + +async function queryClaudeUnderSpinner( + prompt: string, + projectRoot: string, +): Promise { + const out = process.stdout; + const start = Date.now(); + const actions: string[] = []; + let frameIdx = 0; + + const redraw = (): void => { + // Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window). + out.write(`\x1b[${WINDOW_SIZE + 1}A`); + + const elapsed = Math.round((Date.now() - start) / 1000); + const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length]; + const suffix = ` (${elapsed}s)`; + const header = fitToWidth('Asking Claude to diagnose…', suffix); + out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`); + + for (let i = 0; i < WINDOW_SIZE; i++) { + const idx = actions.length - WINDOW_SIZE + i; + const action = idx >= 0 ? actions[idx] : ''; + out.write('\x1b[2K'); + if (action) { + out.write(`${k.gray('│')} ${k.dim(`▸ ${fitToWidth(action, '')}`)}`); + } else { + out.write(k.gray('│')); + } + out.write('\n'); + } + }; + + const clearBlock = (): void => { + out.write(`\x1b[${WINDOW_SIZE + 1}A`); + for (let i = 0; i < WINDOW_SIZE + 1; i++) { + out.write('\x1b[2K\n'); + } + out.write(`\x1b[${WINDOW_SIZE + 1}A`); + }; + + // Seed the block: move cursor to a fresh line, then write (header + window) + // blank lines so `redraw()`'s cursor-up math lands correctly. Hide the + // cursor for the duration so the redraw doesn't flicker. + out.write(HIDE_CURSOR); + for (let i = 0; i < WINDOW_SIZE + 1; i++) out.write('\n'); + redraw(); + + // If the user Ctrl-C's during the query, we never reach `finish()` — + // add an exit hook so the cursor comes back regardless. + const restoreCursorOnExit = (): void => { + out.write(SHOW_CURSOR); + }; + process.once('exit', restoreCursorOnExit); + + const frameTick = setInterval(() => { + frameIdx++; + redraw(); + }, 250); + + return new Promise((resolve) => { + let lineBuf = ''; + let finalText = ''; + let stderr = ''; + let settled = false; + + const finish = ( + kind: 'ok' | 'error', + payload: string | null, + ): void => { + clearInterval(frameTick); + clearBlock(); + out.write(SHOW_CURSOR); + process.off('exit', restoreCursorOnExit); + const elapsed = Math.round((Date.now() - start) / 1000); + const suffix = ` (${elapsed}s)`; + if (kind === 'ok') { + p.log.success(`${fitToWidth('Claude replied.', suffix)}${k.dim(suffix)}`); + resolve(payload); + } else { + p.log.error( + `${fitToWidth("Claude couldn't help here.", suffix)}${k.dim(suffix)}`, + ); + const tail = stderr.trim().split('\n').slice(-3).join('\n'); + if (tail) p.log.message(k.dim(tail)); + resolve(null); + } + }; + + // No hard timeout — debugging can take a long time, and the cost of + // cutting Claude off mid-investigation is worse than letting the + // spinner run. The user can Ctrl-C if they want to abort. + const child = spawn( + 'claude', + [ + '-p', + '--output-format', + 'stream-json', + '--verbose', + '--permission-mode', + 'bypassPermissions', + ], + { cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'] }, + ); + + child.stdout.on('data', (c: Buffer) => { + lineBuf += c.toString('utf-8'); + let idx: number; + while ((idx = lineBuf.indexOf('\n')) !== -1) { + const line = lineBuf.slice(0, idx); + lineBuf = lineBuf.slice(idx + 1); + if (!line.trim()) continue; + try { + const event = JSON.parse(line) as StreamEvent; + handleStreamEvent(event, { + setAction: (a) => { + actions.push(a); + redraw(); + }, + appendText: (t) => { + finalText += t; + }, + }); + } catch { + // Malformed or non-JSON line — ignore. + } + } + }); + child.stderr.on('data', (c: Buffer) => { + stderr += c.toString('utf-8'); + }); + child.on('close', (code) => { + if (settled) return; + settled = true; + if (code === 0 && finalText.trim()) finish('ok', finalText); + else finish('error', null); + }); + child.on('error', () => { + if (settled) return; + settled = true; + finish('error', null); + }); + + child.stdin.end(prompt); + }); +} + +// Minimal shape of the stream-json events we care about. Claude emits +// many more, but we only read tool_use blocks (for breadcrumbs) and text +// blocks (to reassemble the final REASON/COMMAND answer). +interface StreamEvent { + type: string; + message?: { + content?: Array< + | { type: 'text'; text: string } + | { type: 'tool_use'; name: string; input: Record } + >; + }; +} + +function handleStreamEvent( + event: StreamEvent, + cb: { setAction: (a: string) => void; appendText: (t: string) => void }, +): void { + if (event.type !== 'assistant') return; + const blocks = event.message?.content ?? []; + for (const block of blocks) { + if (block.type === 'text') { + cb.appendText(block.text); + } else if (block.type === 'tool_use') { + cb.setAction(formatToolUse(block.name, block.input)); + } + } +} + +function formatToolUse(name: string, input: Record): string { + const truncate = (v: string, n: number): string => + v.length > n ? v.slice(0, n) + '…' : v; + if (name === 'Read') { + const f = String(input.file_path ?? ''); + return `Reading ${shortenPath(f)}`; + } + if (name === 'Bash') { + const cmd = String(input.command ?? '').replace(/\s+/g, ' ').trim(); + return `Running ${truncate(cmd, 60)}`; + } + if (name === 'Grep') return `Searching for "${truncate(String(input.pattern ?? ''), 40)}"`; + if (name === 'Glob') return `Finding ${truncate(String(input.pattern ?? ''), 40)}`; + return `Using ${name}`; +} + +function shortenPath(abs: string): string { + const root = process.cwd(); + return abs.startsWith(`${root}/`) ? abs.slice(root.length + 1) : abs; +} + +function parseResponse( + raw: string, +): { reason: string; command: string } | null { + // Accept the fields anywhere in the output — Claude sometimes wraps the + // answer in a trailing explanation we can safely ignore. + const reasonMatch = raw.match(/^\s*REASON:\s*(.+?)\s*$/m); + const commandMatch = raw.match(/^\s*COMMAND:\s*(.+?)\s*$/m); + if (!reasonMatch || !commandMatch) return null; + const command = commandMatch[1].trim(); + if (!command || command.toLowerCase() === 'none') return null; + return { reason: reasonMatch[1].trim(), command }; +} + +function runSuggested(command: string, projectRoot: string): Promise { + const script = path.join(projectRoot, 'setup/run-suggested.sh'); + if (!fs.existsSync(script)) { + p.log.error(`Missing helper: ${script}`); + return Promise.resolve(); + } + return new Promise((resolve) => { + const child = spawn('bash', [script, command], { + cwd: projectRoot, + stdio: 'inherit', + }); + child.on('close', () => resolve()); + child.on('error', () => resolve()); + }); +} diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index 59b3da6..0e33c74 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -11,13 +11,15 @@ * * See docs/setup-flow.md for the three-level output contract. */ -import { spawn } from 'child_process'; +import { spawn, spawnSync } from 'child_process'; import fs from 'fs'; import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { offerClaudeAssist } from './claude-assist.js'; +import { fitToWidth } from './theme.js'; export type Fields = Record; export type Block = { type: string; fields: Fields }; @@ -261,23 +263,25 @@ async function runUnderSpinner< ): Promise { const s = p.spinner(); const start = Date.now(); - s.start(labels.running); + s.start(fitToWidth(labels.running, ' (999s)')); const tick = setInterval(() => { const elapsed = Math.round((Date.now() - start) / 1000); - s.message(`${labels.running} ${k.dim(`(${elapsed}s)`)}`); + const suffix = ` (${elapsed}s)`; + s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`); }, 1000); const result = await work(); clearInterval(tick); const elapsed = Math.round((Date.now() - start) / 1000); + const suffix = ` (${elapsed}s)`; if (result.ok) { const isSkipped = result.terminal?.fields.STATUS === 'skipped'; const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; - s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`); + s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`); } else { const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed'); - s.stop(`${failMsg} ${k.dim(`(${elapsed}s)`)}`, 1); + s.stop(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`, 1); dumpTranscriptOnFailure(result.transcript); } return result; @@ -301,12 +305,53 @@ export function dumpTranscriptOnFailure(transcript: string): void { * Abort the setup run with a user-facing error, logging the abort to the * progression log. Takes the step name explicitly so callers are clear * about which step they're failing from — no hidden module state. + * + * Before aborting we offer Claude-assisted debugging. Callers must + * `await fail(...)` so the offer can actually run before we call + * process.exit. The return type is `Promise`; control-flow + * narrowing still works after `await`. */ -export function fail(stepName: string, msg: string, hint?: string): never { +export async function fail( + stepName: string, + msg: string, + hint?: string, + rawLogPath?: string, +): Promise { setupLog.abort(stepName, msg); p.log.error(msg); if (hint) p.log.message(k.dim(hint)); p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/')); + + const ranFix = await offerClaudeAssist({ stepName, msg, hint, rawLogPath }); + + // If the user just ran a Claude-suggested fix, offer to resume the flow + // at the step that failed instead of aborting. We re-exec via spawnSync + // and pass NANOCLAW_SKIP with every step that already completed so the + // child skips them and picks up where we left off. + if (ranFix) { + const retry = ensureAnswer( + await p.confirm({ + message: `Fix applied. Retry the ${stepName} step?`, + initialValue: true, + }), + ); + if (retry) { + const existingSkip = (process.env.NANOCLAW_SKIP ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const skipList = [ + ...new Set([...existingSkip, ...setupLog.completedStepNames()]), + ].join(','); + p.log.step(`Retrying from ${stepName}…`); + const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], { + stdio: 'inherit', + env: { ...process.env, NANOCLAW_SKIP: skipList }, + }); + process.exit(result.status ?? 0); + } + } + p.cancel('Setup aborted.'); process.exit(1); } diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index 0a08eae..6f21d15 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -77,6 +77,28 @@ function visibleLength(s: string): number { return s.replace(ANSI_RE, '').length; } +/** + * Truncate a label so the final line — base + reserved suffix — fits in + * the terminal width. Use on spinner labels that get an elapsed counter + * appended: if the total exceeds terminal width, clack's cursor-up + * redraw math breaks and each tick stacks a copy of the line instead + * of replacing it. + * + * `suffix` is the reserved space for what we'll append after `fit()` + * returns (e.g. ` (999s)` or a tool-use breadcrumb). We don't include + * it in the output — caller appends it. + */ +export function fitToWidth(base: string, suffix: string): string { + const cols = process.stdout.columns ?? 80; + // Overhead we reserve before sizing the label: + // spinner icon (1) + 2 padding spaces = 3 + // clack's animated ellipsis after the label = up to 3 (". " -> "...") + // 1-char safety margin so wide-char glyphs don't tip over the edge + // Total reserved budget = 7 cols plus the caller's suffix. + const budget = Math.max(20, cols - 7 - visibleLength(suffix)); + return base.length > budget ? base.slice(0, budget - 1) + '…' : base; +} + function wrapLine(line: string, width: number): string { if (visibleLength(line) <= width) return line; const words = line.split(' '); diff --git a/setup/logs.ts b/setup/logs.ts index 127f969..7e37beb 100644 --- a/setup/logs.ts +++ b/setup/logs.ts @@ -30,6 +30,16 @@ const PROGRESS_LOG = path.join(LOGS_DIR, 'setup.log'); export const progressLogPath = PROGRESS_LOG; export const stepsDir = STEPS_DIR; +// Track steps that finished cleanly in this run. Used by fail() to build +// a NANOCLAW_SKIP list when re-executing after a Claude-assisted fix, so +// the retry picks up at the failing step instead of redoing every step +// before it. +const completedInRun = new Set(); + +export function completedStepNames(): string[] { + return [...completedInRun]; +} + /** Wipe prior logs and write a header. Called once per fresh run (by nanoclaw.sh or as a fallback by auto.ts if invoked standalone). */ export function reset(meta: Record): void { if (fs.existsSync(STEPS_DIR)) { @@ -71,6 +81,10 @@ export function step( if (rawRel) lines.push(` raw: ${rawRel}`); lines.push(''); fs.appendFileSync(PROGRESS_LOG, lines.join('\n') + '\n'); + + if (status === 'success' || status === 'skipped') { + completedInRun.add(name); + } } /** A user answered a prompt. Logs as its own entry because the setup path depends on it. */ diff --git a/setup/run-suggested.sh b/setup/run-suggested.sh new file mode 100755 index 0000000..3cd47b5 --- /dev/null +++ b/setup/run-suggested.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Run a command suggested by claude-assist, giving the user a chance to +# edit it first. Same pattern as setup/register-claude-token.sh: bash 4+ +# pre-fills readline so Enter literally submits; bash 3.x (macOS default +# /bin/bash) shows the command and waits for Enter. +# +# This script is the allowlisted unit — the `eval` happens inside. The +# caller has already shown the command to the user and gotten confirmation. + +set -u + +CMD="${1:-}" +if [ -z "$CMD" ]; then + echo "run-suggested: no command provided" >&2 + exit 1 +fi + +echo +if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then + # Pre-fill readline; user can edit before pressing Enter. + read -r -e -i "$CMD" -p "$ " cmd &2 + exit 0 +fi + +echo +eval "$cmd" From 596035be09db76e50033c2c36a9585a73073d29e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 12:57:57 +0300 Subject: [PATCH 090/185] feat(setup): operator role prompt per channel, owner by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously init-first-agent auto-granted global owner to the first user wired through it and left every subsequent user as nothing — no role, no membership. That worked for the bootstrap path but broke the second channel's welcome DM: the access gate saw no role + no membership and dropped the message with accessReason='not_member'. Make the role explicit: - scripts/init-first-agent.ts accepts --role owner|admin|member (default: owner). Role drives the grant: owner -> global owner (agent_group_id=null) admin -> admin scoped to this agent group member -> no role row, just membership Idempotent via getUserRoles pre-check — safe on re-runs. addMember runs unconditionally (INSERT OR IGNORE) so the access gate has a row even for users who'd otherwise pass via role alone. - setup/lib/role-prompt.ts — shared askOperatorRole(channel) prompt with owner as the default pick. Self-host single-operator is the dominant case, so the user's fingers default to Enter. - Telegram / Discord / WhatsApp drivers all call askOperatorRole before resolving the agent name and pass --role through. Captured in progression log via setupLog.userInput('_role'). Summary output drops the fragile "promoted on first owner" hint in favor of a dedicated role: line ("owner (global)" / "admin (scoped to )" / "member") so re-runs make the current grant legible. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/init-first-agent.ts | 92 +++++++++++++++++++++++++++++-------- setup/channels/discord.ts | 5 ++ setup/channels/telegram.ts | 5 ++ setup/channels/whatsapp.ts | 6 +++ setup/lib/role-prompt.ts | 44 ++++++++++++++++++ 5 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 setup/lib/role-prompt.ts diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index c4dfdc2..b3d7bd0 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -24,7 +24,8 @@ * --platform-id discord:@me:1491573333382523708 \ * --display-name "Gavriel" \ * [--agent-name "Andy"] \ - * [--welcome "System instruction: ..."] + * [--welcome "System instruction: ..."] \ + * [--role owner|admin|member] # default: owner * * For direct-addressable channels (telegram, whatsapp, etc.), --platform-id * is typically the same as the handle in --user-id, with the channel prefix. @@ -44,11 +45,13 @@ import { import { runMigrations } from '../src/db/migrations/index.js'; import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.js'; import { addMember } from '../src/modules/permissions/db/agent-group-members.js'; -import { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles.js'; +import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js'; import { upsertUser } from '../src/modules/permissions/db/users.js'; import { initGroupFilesystem } from '../src/group-init.js'; import type { AgentGroup, MessagingGroup } from '../src/types.js'; +type Role = 'owner' | 'admin' | 'member'; + interface Args { channel: string; userId: string; @@ -56,11 +59,14 @@ interface Args { displayName: string; agentName: string; welcome: string; + role: Role; } const DEFAULT_WELCOME = 'System instruction: run /welcome to introduce yourself to the user on this new channel.'; +const DEFAULT_ROLE: Role = 'owner'; + function parseArgs(argv: string[]): Args { const out: Partial = {}; for (let i = 0; i < argv.length; i++) { @@ -91,6 +97,18 @@ function parseArgs(argv: string[]): Args { out.welcome = val; i++; break; + case '--role': { + const raw = (val ?? '').toLowerCase(); + if (raw !== 'owner' && raw !== 'admin' && raw !== 'member') { + console.error( + `Invalid --role: ${raw} (expected 'owner', 'admin', or 'member')`, + ); + process.exit(2); + } + out.role = raw; + i++; + break; + } } } @@ -111,6 +129,7 @@ function parseArgs(argv: string[]): Args { displayName: out.displayName!, agentName: out.agentName?.trim() || out.displayName!, welcome: out.welcome?.trim() || DEFAULT_WELCOME, + role: out.role ?? DEFAULT_ROLE, }; } @@ -173,17 +192,8 @@ async function main(): Promise { created_at: now, }); - let promotedToOwner = false; - if (!hasAnyOwner()) { - grantRole({ - user_id: userId, - role: 'owner', - agent_group_id: null, - granted_by: null, - granted_at: now, - }); - promotedToOwner = true; - } + // Owner grant is deferred until after the agent group is resolved, since + // an admin grant is scoped to that group. See step 2b. // 2. Agent group + filesystem. const folder = `dm-with-${normalizeName(args.displayName)}`; @@ -209,12 +219,46 @@ async function main(): Promise { 'When the user first reaches out (or you receive a system welcome prompt), introduce yourself briefly and invite them to chat. Keep replies concise.', }); - // 2b. Grant the user access to this agent group. Owner role is only - // assigned to the first user (above); subsequent DMs need explicit - // membership or the strict unknown_sender_policy on the DM messaging - // group will drop every message with accessReason='not_member'. addMember - // is INSERT OR IGNORE — idempotent when the global owner already has - // access by virtue of their role. + // 2b. Assign the user a role for this agent group. The caller picks via + // --role; the channel drivers default to 'owner' for the self-host case. + // - owner: global owner (agent_group_id=null). Cross-channel access. + // - admin: scoped admin for this agent group only. + // - member: no role grant, just the membership row below. + // grantRole inserts a new row per call — idempotence check against + // getUserRoles prevents duplicates on re-runs. + const existingRoles = getUserRoles(userId); + if (args.role === 'owner') { + const alreadyOwner = existingRoles.some( + (r) => r.role === 'owner' && r.agent_group_id === null, + ); + if (!alreadyOwner) { + grantRole({ + user_id: userId, + role: 'owner', + agent_group_id: null, + granted_by: null, + granted_at: now, + }); + } + } else if (args.role === 'admin') { + const alreadyAdmin = existingRoles.some( + (r) => r.role === 'admin' && r.agent_group_id === ag.id, + ); + if (!alreadyAdmin) { + grantRole({ + user_id: userId, + role: 'admin', + agent_group_id: ag.id, + granted_by: null, + granted_at: now, + }); + } + } + + // Always add a membership row so the access gate has a straightforward + // yes/no even for users without a role grant. INSERT OR IGNORE, so this + // is a no-op when the row already exists (e.g. re-runs, owners whose + // access already passes via role). addMember({ user_id: userId, agent_group_id: ag.id, @@ -254,9 +298,17 @@ async function main(): Promise { sender: args.displayName, }); + const roleLabel = + args.role === 'owner' + ? 'owner (global)' + : args.role === 'admin' + ? `admin (scoped to ${ag.id})` + : 'member'; + console.log(''); console.log('Init complete.'); - console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`); + console.log(` user: ${userId}`); + console.log(` role: ${roleLabel}`); console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`); console.log(` channel: ${args.channel} ${dmMg.platform_id}`); console.log(''); diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 010310e..f26dc23 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -28,6 +28,7 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { confirmThenOpen } from '../lib/browser.js'; +import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -88,6 +89,9 @@ export async function runDiscordChannel(displayName: string): Promise { const dmChannelId = await openDmChannel(token, ownerUserId); const platformId = `discord:@me:${dmChannelId}`; + const role = await askOperatorRole('Discord'); + setupLog.userInput('discord_role', role); + const agentName = await resolveAgentName(); const init = await runQuietChild( @@ -100,6 +104,7 @@ export async function runDiscordChannel(displayName: string): Promise { '--platform-id', platformId, '--display-name', displayName, '--agent-name', agentName, + '--role', role, ], { running: `Connecting ${agentName} to your Discord DMs…`, diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index df253c6..df97fcf 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -22,6 +22,7 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { confirmThenOpen } from '../lib/browser.js'; +import { askOperatorRole } from '../lib/role-prompt.js'; import { type Block, type StepResult, @@ -96,6 +97,9 @@ export async function runTelegramChannel(displayName: string): Promise { ); } + const role = await askOperatorRole('Telegram'); + setupLog.userInput('telegram_role', role); + const agentName = await resolveAgentName(); const init = await runQuietChild( @@ -108,6 +112,7 @@ export async function runTelegramChannel(displayName: string): Promise { '--platform-id', platformId, '--display-name', displayName, '--agent-name', agentName, + '--role', role, ], { running: `Connecting ${agentName} to your Telegram chat…`, diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 4d8290f..29c70e3 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -43,6 +43,7 @@ import { spawnStep, writeStepEntry, } from '../lib/runner.js'; +import { askOperatorRole } from '../lib/role-prompt.js'; import { brandBold } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -101,6 +102,9 @@ export async function runWhatsAppChannel(displayName: string): Promise { writeAssistantHasOwnNumber(); } + const role = await askOperatorRole('WhatsApp'); + setupLog.userInput('whatsapp_role', role); + const agentName = await resolveAgentName(); const platformId = `${chatPhone}@s.whatsapp.net`; @@ -115,6 +119,7 @@ export async function runWhatsAppChannel(displayName: string): Promise { '--platform-id', platformId, '--display-name', displayName, '--agent-name', agentName, + '--role', role, ], { running: `Connecting ${agentName} to WhatsApp…`, @@ -128,6 +133,7 @@ export async function runWhatsAppChannel(displayName: string): Promise { AGENT_NAME: agentName, PLATFORM_ID: platformId, MODE: isDedicated ? 'dedicated' : 'shared', + ROLE: role, }, }, ); diff --git a/setup/lib/role-prompt.ts b/setup/lib/role-prompt.ts new file mode 100644 index 0000000..c5ac537 --- /dev/null +++ b/setup/lib/role-prompt.ts @@ -0,0 +1,44 @@ +/** + * Shared "who's connecting this channel?" prompt used by the channel setup + * drivers before they hand off to scripts/init-first-agent.ts. + * + * Default: owner. Self-hosted NanoClaw is almost always a single-operator + * deployment, and granting the same human owner status on every channel + * they wire up matches what you'd want 99% of the time. The prompt + * surfaces admin/member for the edge cases (shared instance, collaborators + * with limited access), but hitting Enter assigns owner. + */ +import * as p from '@clack/prompts'; + +import { ensureAnswer } from './runner.js'; + +export type OperatorRole = 'owner' | 'admin' | 'member'; + +export async function askOperatorRole( + channelLabel: string, +): Promise { + const choice = ensureAnswer( + await p.select({ + message: `How should this ${channelLabel} account be registered?`, + initialValue: 'owner', + options: [ + { + value: 'owner', + label: 'Owner', + hint: 'full access — recommended for your own account', + }, + { + value: 'admin', + label: 'Admin', + hint: 'can manage the agent for this channel', + }, + { + value: 'member', + label: 'Member', + hint: 'can chat with the agent but nothing more', + }, + ], + }), + ) as OperatorRole; + return choice; +} From 8a12fa61ac00a92c065075b1895c2dd4805067ed Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 21 Apr 2026 12:05:19 +0000 Subject: [PATCH 091/185] =?UTF-8?q?refactor:=20shared=20source=20=E2=80=94?= =?UTF-8?q?=20replace=20per-group=20agent-runner=20copies=20with=20single?= =?UTF-8?q?=20RO=20mount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-group agent-runner-src copy model with a single shared read-only mount. Source and skills are now RO + shared; personality, config, working files, and Claude state stay RW + per-group. Key changes: - Mount container/agent-runner/src/ RO at /app/src (all groups share one copy) - Mount container/skills/ RO at /app/skills; per-group skill selection via symlinks in .claude-shared/skills/ based on container.json "skills" field - Mount container.json as nested RO bind on top of RW group dir - Move all NANOCLAW_* env vars to container.json (runner reads at startup) - New runner config.ts module replaces process.env reads - Move command gate (filtered/admin) from container to host router - Dockerfile: remove source COPY, split CLI installs (claude-code last), move agent-runner deps above CLIs for better layer caching - Add writeOutboundDirect for router denial responses - Design doc at docs/shared-src.md Not included (follow-up): DB migration to drop agent_provider columns, cleanup of orphaned agent-runner-src directories. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/Dockerfile | 55 ++-- container/agent-runner/src/config.ts | 55 ++++ container/agent-runner/src/db/connection.ts | 8 +- container/agent-runner/src/db/messages-in.ts | 21 +- container/agent-runner/src/formatter.ts | 11 + container/agent-runner/src/index.ts | 58 ++-- container/agent-runner/src/poll-loop.ts | 85 ++---- docs/shared-src.md | 276 +++++++++++++++++++ src/command-gate.ts | 70 +++++ src/container-config.ts | 32 ++- src/container-runner.ts | 212 +++++++++----- src/group-init.ts | 27 +- src/router.ts | 26 +- src/session-manager.ts | 28 ++ 14 files changed, 715 insertions(+), 249 deletions(-) create mode 100644 container/agent-runner/src/config.ts create mode 100644 docs/shared-src.md create mode 100644 src/command-gate.ts diff --git a/container/Dockerfile b/container/Dockerfile index be37638..c110bd6 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -3,8 +3,12 @@ # Runs Claude Agent SDK in isolated Linux VM with browser automation. # # Runtime split: -# - agent-runner (our TypeScript code): Bun +# - agent-runner (our TypeScript code): Bun, mounted RO at /app/src by host # - globally-installed Node CLIs (claude-code, agent-browser, vercel): pnpm + Node +# +# Source is never baked in — /app/src is provided by a shared read-only +# bind mount at runtime (see src/container-runner.ts). Source-only changes +# never require an image rebuild. FROM node:22-slim @@ -66,36 +70,39 @@ RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" && \ install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun && \ rm -rf /root/.bun -# ---- pnpm + global Node CLIs ------------------------------------------------- -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable - -# agent-browser has a postinstall build script — pnpm skips these by default. -# Allowlist it via .npmrc so the install doesn't silently produce a broken -# package. Pinned versions so every rebuild is reproducible. -RUN --mount=type=cache,target=/root/.cache/pnpm \ - echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \ - echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \ - pnpm install -g \ - "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ - "agent-browser@${AGENT_BROWSER_VERSION}" \ - "vercel@${VERCEL_VERSION}" - -# ---- agent-runner ------------------------------------------------------------ +# ---- agent-runner deps ------------------------------------------------------- +# Deps are cached independently of CLI versions. Source is NOT baked in — +# it's provided by the shared RO mount at runtime. WORKDIR /app -# Copy manifest + lockfile first so the install layer caches independently of -# source edits. COPY agent-runner/package.json agent-runner/bun.lock ./ RUN --mount=type=cache,target=/root/.bun/install/cache \ bun install --frozen-lockfile -# Source. Bun runs TS directly — no tsc build step. The host remounts this -# path at runtime via `src/container-runner.ts` so source edits on the host -# take effect without rebuilding the image; the baked copy is the fallback. -COPY agent-runner/ ./ +# ---- pnpm + global Node CLIs ------------------------------------------------- +# Most stable first, most frequently bumped last. Bumping claude-code +# (the most common change) only invalidates one layer. +# +# only-built-dependencies gates pnpm's supply-chain policy: +# - agent-browser has a postinstall build step. +# - @anthropic-ai/claude-code's postinstall downloads the native Claude +# binary (linux-arm64 variant on our image). Without the allowlist +# the SDK fails at spawn time with "native binary not found". +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +RUN --mount=type=cache,target=/root/.cache/pnpm \ + echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \ + echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \ + pnpm install -g "vercel@${VERCEL_VERSION}" + +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}" + +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" # ---- Entrypoint -------------------------------------------------------------- COPY entrypoint.sh /app/entrypoint.sh diff --git a/container/agent-runner/src/config.ts b/container/agent-runner/src/config.ts new file mode 100644 index 0000000..3a022ab --- /dev/null +++ b/container/agent-runner/src/config.ts @@ -0,0 +1,55 @@ +/** + * Runner config — reads /workspace/agent/container.json at startup. + * + * This file is mounted read-only inside the container. The host writes it; + * the runner only reads. All NanoClaw-specific configuration lives here + * instead of environment variables. + */ +import fs from 'fs'; + +const CONFIG_PATH = '/workspace/agent/container.json'; + +export interface RunnerConfig { + provider: string; + assistantName: string; + groupName: string; + agentGroupId: string; + maxMessagesPerPrompt: number; + mcpServers: Record }>; +} + +const DEFAULT_MAX_MESSAGES = 10; + +let _config: RunnerConfig | null = null; + +/** + * Load config from container.json. Called once at startup. + * Falls back to sensible defaults for any missing field. + */ +export function loadConfig(): RunnerConfig { + if (_config) return _config; + + let raw: Record = {}; + try { + raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); + } catch { + console.error(`[config] Failed to read ${CONFIG_PATH}, using defaults`); + } + + _config = { + provider: (raw.provider as string) || 'claude', + assistantName: (raw.assistantName as string) || '', + groupName: (raw.groupName as string) || '', + agentGroupId: (raw.agentGroupId as string) || '', + maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES, + mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {}, + }; + + return _config; +} + +/** Get the loaded config. Throws if loadConfig() hasn't been called. */ +export function getConfig(): RunnerConfig { + if (!_config) throw new Error('Config not loaded — call loadConfig() first'); + return _config; +} diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 3c0fffd..3f0e73b 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -31,8 +31,7 @@ let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH; /** Inbound DB — container opens read-only (host is the sole writer). */ export function getInboundDb(): Database { if (!_inbound) { - const dbPath = process.env.SESSION_INBOUND_DB_PATH || DEFAULT_INBOUND_PATH; - _inbound = new Database(dbPath, { readonly: true }); + _inbound = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); _inbound.exec('PRAGMA busy_timeout = 5000'); } return _inbound; @@ -41,8 +40,7 @@ export function getInboundDb(): Database { /** Outbound DB — container owns this file (sole writer). */ export function getOutboundDb(): Database { if (!_outbound) { - const dbPath = process.env.SESSION_OUTBOUND_DB_PATH || DEFAULT_OUTBOUND_PATH; - _outbound = new Database(dbPath); + _outbound = new Database(DEFAULT_OUTBOUND_PATH); _outbound.exec('PRAGMA journal_mode = DELETE'); _outbound.exec('PRAGMA busy_timeout = 5000'); _outbound.exec('PRAGMA foreign_keys = ON'); @@ -122,7 +120,7 @@ export function clearContainerToolInFlight(): void { * A file touch is cheaper and avoids cross-boundary DB write contention. */ export function touchHeartbeat(): void { - const p = process.env.SESSION_HEARTBEAT_PATH || _heartbeatPath; + const p = _heartbeatPath; const now = new Date(); try { fs.utimesSync(p, now, now); diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index a152a5e..4ecf818 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -7,6 +7,7 @@ * The container never writes to inbound.db — all status tracking goes through * processing_ack. The host reads processing_ack to sync message lifecycle. */ +import { getConfig } from '../config.js'; import { getInboundDb, getOutboundDb } from './connection.js'; export interface MessageInRow { @@ -26,14 +27,16 @@ export interface MessageInRow { content: string; } -// Cap on how many messages reach the agent in one prompt, including any -// accumulated-but-not-triggered context. Host controls the cap via the -// NANOCLAW_MAX_MESSAGES_PER_PROMPT env var; default mirrors the host's -// config.ts default of 10. -const MAX_MESSAGES_PER_PROMPT = Math.max( - 1, - parseInt(process.env.NANOCLAW_MAX_MESSAGES_PER_PROMPT || '10', 10) || 10, -); +// Cap on how many messages reach the agent in one prompt. Read from +// container.json; falls back to 10. +function getMaxMessagesPerPrompt(): number { + try { + return getConfig().maxMessagesPerPrompt; + } catch { + // Config not loaded yet (e.g. test harness) — use default + return 10; + } +} /** * Fetch pending messages that are due for processing. @@ -58,7 +61,7 @@ export function getPendingMessages(): MessageInRow[] { ORDER BY seq DESC LIMIT ?`, ) - .all(MAX_MESSAGES_PER_PROMPT) as MessageInRow[]; + .all(getMaxMessagesPerPrompt()) as MessageInRow[]; if (pending.length === 0) return []; diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index 2e90720..c0475b2 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -55,6 +55,17 @@ export function categorizeMessage(msg: MessageInRow): CommandInfo { return { category: 'passthrough', command, text, senderId }; } +/** + * Narrow check for /clear — the only command the runner handles directly. + * All other command gating (filtered, admin) is done by the host router + * before messages reach the container. + */ +export function isClearCommand(msg: MessageInRow): boolean { + const content = parseContent(msg.content); + const text = (content.text || '').trim(); + return text.toLowerCase().startsWith('/clear'); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any function extractSenderId(msg: MessageInRow, content: any): string | null { const raw: string | null = content?.senderId || content?.author?.userId || null; diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index a0b0dc8..5535417 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -4,14 +4,8 @@ * Runs inside a container. All IO goes through the session DB. * No stdin, no stdout markers, no IPC files. * - * Config: - * - SESSION_INBOUND_DB_PATH: path to host-owned inbound DB (default: /workspace/inbound.db) - * - SESSION_OUTBOUND_DB_PATH: path to container-owned outbound DB (default: /workspace/outbound.db) - * - SESSION_HEARTBEAT_PATH: heartbeat file path (default: /workspace/.heartbeat) - * - AGENT_PROVIDER: any registered provider name (default: claude). The - * set of registered providers is whatever `providers/index.ts` imports. - * - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving - * - NANOCLAW_ADMIN_USER_IDS: comma-separated user IDs allowed to run admin commands + * Config is read from /workspace/agent/container.json (mounted RO). + * Only TZ and OneCLI networking vars come from env. * * Mount structure: * /workspace/ @@ -19,14 +13,19 @@ * outbound.db ← container-owned session DB * .heartbeat ← container touches for liveness detection * outbox/ ← outbound files - * agent/ ← agent group folder (CLAUDE.md, skills, working files) - * .claude/ ← Claude SDK session data + * agent/ ← agent group folder (CLAUDE.md, container.json, working files) + * container.json ← per-group config (RO nested mount) + * global/ ← shared global memory (RO) + * /app/src/ ← shared agent-runner source (RO) + * /app/skills/ ← shared skills (RO) + * /home/node/.claude/ ← Claude SDK state + skill symlinks (RW) */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { loadConfig } from './config.js'; import { buildSystemPromptAddendum } from './destinations.js'; // Providers barrel — each enabled provider self-registers on import. // Provider skills append imports to providers/index.ts. @@ -41,21 +40,11 @@ function log(msg: string): void { const CWD = '/workspace/agent'; async function main(): Promise { - const providerName = (process.env.AGENT_PROVIDER || 'claude').toLowerCase() as ProviderName; - const assistantName = process.env.NANOCLAW_ASSISTANT_NAME; - const adminUserIds = new Set( - (process.env.NANOCLAW_ADMIN_USER_IDS || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - ); + const config = loadConfig(); + const providerName = config.provider.toLowerCase() as ProviderName; log(`Starting v2 agent-runner (provider: ${providerName})`); - // Destinations addendum is the only runtime-generated context we inject. - // Global CLAUDE.md is loaded by Claude Code from /workspace/agent/CLAUDE.md - // (which imports /workspace/global/CLAUDE.md via @-syntax) — no need to - // read it manually anymore. const instructions = buildSystemPromptAddendum(); // Discover additional directories mounted at /workspace/extra/* @@ -77,34 +66,22 @@ async function main(): Promise { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.ts'); - // Build MCP servers config: nanoclaw built-in + any additional from host + // Build MCP servers config: nanoclaw built-in + any from container.json const mcpServers: Record }> = { nanoclaw: { command: 'bun', args: ['run', mcpServerPath], - env: { - SESSION_INBOUND_DB_PATH: process.env.SESSION_INBOUND_DB_PATH || '/workspace/inbound.db', - SESSION_OUTBOUND_DB_PATH: process.env.SESSION_OUTBOUND_DB_PATH || '/workspace/outbound.db', - SESSION_HEARTBEAT_PATH: process.env.SESSION_HEARTBEAT_PATH || '/workspace/.heartbeat', - }, + env: {}, }, }; - // Merge additional MCP servers from host configuration - if (process.env.NANOCLAW_MCP_SERVERS) { - try { - const additional = JSON.parse(process.env.NANOCLAW_MCP_SERVERS) as Record }>; - for (const [name, config] of Object.entries(additional)) { - mcpServers[name] = config; - log(`Additional MCP server: ${name} (${config.command})`); - } - } catch (e) { - log(`Failed to parse NANOCLAW_MCP_SERVERS: ${e}`); - } + for (const [name, serverConfig] of Object.entries(config.mcpServers)) { + mcpServers[name] = serverConfig; + log(`Additional MCP server: ${name} (${serverConfig.command})`); } const provider = createProvider(providerName, { - assistantName, + assistantName: config.assistantName || undefined, mcpServers, env: { ...process.env }, additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined, @@ -114,7 +91,6 @@ async function main(): Promise { provider, cwd: CWD, systemContext: { instructions }, - adminUserIds, }); } diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 119b1d4..5ccb2e4 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -3,7 +3,7 @@ import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } import { writeMessageOut } from './db/messages-out.js'; import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js'; -import { formatMessages, extractRouting, categorizeMessage, stripInternalTags, type RoutingContext } from './formatter.js'; +import { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js'; import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js'; const POLL_INTERVAL_MS = 1000; @@ -23,12 +23,6 @@ export interface PollLoopConfig { systemContext?: { instructions?: string; }; - /** - * Set of user IDs allowed to run admin commands (e.g. /clear) in this - * agent group. Host populates from owners + global admins + scoped admins - * at container wake time, so role changes take effect on next spawn. - */ - adminUserIds?: Set; } /** @@ -90,74 +84,36 @@ export async function runPollLoop(config: PollLoopConfig): Promise { const routing = extractRouting(messages); - // Handle commands: categorize chat messages - const adminUserIds = config.adminUserIds ?? new Set(); - const normalMessages = []; + // Command handling: the host router gates filtered and unauthorized + // admin commands before they reach the container. The only command + // the runner handles directly is /clear (session reset). + const normalMessages: MessageInRow[] = []; const commandIds: string[] = []; for (const msg of messages) { - if (msg.kind !== 'chat' && msg.kind !== 'chat-sdk') { - normalMessages.push(msg); - continue; - } - - const cmdInfo = categorizeMessage(msg); - - if (cmdInfo.category === 'filtered') { - // Silently drop — mark completed, don't process - log(`Filtered command: ${cmdInfo.command} (msg: ${msg.id})`); + if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) { + log('Clearing session (resetting continuation)'); + continuation = undefined; + clearStoredSessionId(); + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: 'Session cleared.' }), + }); commandIds.push(msg.id); continue; } - - if (cmdInfo.category === 'admin') { - if (!cmdInfo.senderId || !adminUserIds.has(cmdInfo.senderId)) { - log(`Admin command denied: ${cmdInfo.command} from ${cmdInfo.senderId} (msg: ${msg.id})`); - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: `Permission denied: ${cmdInfo.command} requires admin access.` }), - }); - commandIds.push(msg.id); - continue; - } - // Handle admin commands directly - if (cmdInfo.command === '/clear') { - log('Clearing session (resetting continuation)'); - continuation = undefined; - clearStoredSessionId(); - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: 'Session cleared.' }), - }); - commandIds.push(msg.id); - continue; - } - - // Other admin commands — pass through to agent - normalMessages.push(msg); - continue; - } - - // passthrough or none normalMessages.push(msg); } - // Mark filtered/denied command messages as completed immediately if (commandIds.length > 0) { markCompleted(commandIds); } - // If all messages were filtered commands, skip processing if (normalMessages.length === 0) { - // Mark remaining processing IDs as completed const remainingIds = ids.filter((id) => !commandIds.includes(id)); if (remainingIds.length > 0) markCompleted(remainingIds); log(`All ${messages.length} message(s) were commands, skipping query`); @@ -289,17 +245,14 @@ async function processQuery(query: AgentQuery, routing: RoutingContext): Promise const pollHandle = setInterval(() => { if (done) return; - // Skip system messages (MCP tool responses) and admin commands (need fresh query). + // Skip system messages (MCP tool responses) and /clear (needs fresh query). // Also defer messages whose thread_id differs from the active turn's routing // — mixing threads into one streaming turn would send the reply to the wrong // thread because `routing` is captured at turn start. The next turn will pick // them up with fresh routing. const newMessages = getPendingMessages().filter((m) => { if (m.kind === 'system') return false; - if (m.kind === 'chat' || m.kind === 'chat-sdk') { - const cmd = categorizeMessage(m); - if (cmd.category === 'admin') return false; - } + if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false; if ((m.thread_id ?? null) !== (routing.threadId ?? null)) return false; return true; }); diff --git a/docs/shared-src.md b/docs/shared-src.md new file mode 100644 index 0000000..90476b6 --- /dev/null +++ b/docs/shared-src.md @@ -0,0 +1,276 @@ +# Shared Source + +Replace per-group agent-runner-src copies with a single shared read-only mount. + +## Problem + +Each agent group gets a full copy of `container/agent-runner/src/` at creation time. This copy is mounted RW at `/app/src` in the container. Consequences: + +- Bug fixes and features don't propagate to existing groups +- Owner edits to `container/agent-runner/src/` silently don't apply to existing groups +- No tooling to diff or detect drift between groups and upstream +- The RW mount lets agents write to their own runtime source without approval +- Cross-cutting changes (host + container) break down when container code is per-group +- Skills have the same copy-and-drift problem + +## Design + +**Principle: RW is per-group, RO is shared.** Every mount is either read-only and shared across all groups, or read-write and scoped to one group. Source and skills become RO + shared. Personality, config, working files, and Claude state stay RW + per-group. This makes drift impossible by construction — no group can diverge from shared code because no group has write access to it. + +### Shared source mount + +Mount `container/agent-runner/src/` into all containers at `/app/src` as **read-only**. + +``` +container/agent-runner/src/ → /app/src (RO, shared) +``` + +Source is never baked into the image. `/app/src/` exists only via this mount — running without it is an intentional startup failure (entrypoint `bun run /app/src/index.ts` → ENOENT). Source-only changes never trigger image rebuilds; edits to `.ts` files take effect on next container spawn. + +Image rebuilds are only needed for: +- Agent-runner npm dependency changes (`package.json` / `bun.lock`) +- System packages, runtime versions, global CLI version bumps +- Dockerfile/entrypoint changes + +### Shared skills mount + +Mount `container/skills/` into all containers at `/app/skills/` as **read-only**. + +Per-group skill selection via `container.json`: + +```jsonc +{ + "skills": ["welcome", "agent-browser", "self-customize"] + // or "skills": "all" (default) +} +``` + +At every spawn, the host syncs symlinks in the group's `.claude-shared/skills/` directory to match the selected set. For `"all"`, the set is recomputed from the shared skills dir on each spawn — newly-added upstream skills appear without intervention. Symlinks for skills no longer in the set are removed. + +Each symlink points to a container path: + +``` +.claude-shared/skills/welcome → /app/skills/welcome +.claude-shared/skills/agent-browser → /app/skills/agent-browser +``` + +Claude Code scans `/home/node/.claude/skills/`, follows the symlinks, loads the selected skills. Same dangling-symlink-on-host pattern as `.claude-global.md` — host tools don't resolve the target, the container mount makes it valid at read time. + +### Per-group customization surface + +What remains per-group (unchanged): + +| Resource | Location | Mechanism | +|----------|----------|-----------| +| Personality / instructions | `groups//CLAUDE.md` | Mount at `/workspace/agent` (RW, live) | +| MCP servers | `groups//container.json` | Read by runner at startup | +| apt/npm packages | `groups//container.json` | Per-group image layer | +| Skill selection | `groups//container.json` | Symlinks at spawn | +| Additional mounts | `groups//container.json` | Validated bind mounts | +| Agent provider / model | `groups//container.json` | Read by runner at startup | +| Claude Code settings | `.claude-shared/settings.json` | Mount at `/home/node/.claude` (RW) | +| Working files | `groups//` | Mount at `/workspace/agent` (RW) | + +`container.json` is mounted **read-only** inside the container (separate RO mount at `/workspace/agent/container.json`). The agent can read its own config but cannot modify it — config changes go through the self-mod approval flow on the host. The parent group dir (`/workspace/agent/`) stays RW for working files and CLAUDE.md. + +### Self-modification + +Existing config-level self-mod tools (`install_packages`, `add_mcp_server`, `request_rebuild`) mutate `container.json` and per-group images, not source. The approval flow should ask whether to apply the change to the current group or all groups — users often expect packages and MCP servers installed for one agent to be available everywhere. "All groups" writes to each group's `container.json` and rebuilds per-group images where needed. + +Source-level self-modification (not yet implemented) uses staging: edits happen against a copy of `container/agent-runner/src/`, reviewed and swapped in on approval. Owner can also edit source directly. + +### Providers + +Provider install skills (`/add-opencode`, `/add-ollama-provider`) add the provider module to the shared `container/agent-runner/src/providers/` tree. This is an instance-level change — owner/admin action, affects all groups. Which provider a group uses is per-group config (`"provider": "opencode"` in `container.json`). The shared source ships all installed provider modules; groups select. + +## Environment variables + +Env is for things read by code we don't own: glibc, Node's http agent, CLIs we shell out to. Everything NanoClaw-specific moves out of env. + +**Stays in env (read by non-nanoclaw code):** + +| Var | Reader | +|---|---| +| `TZ` | glibc, child processes | +| `HTTPS_PROXY`, `NO_PROXY` | Node http agent, curl, git, etc. (OneCLI-injected) | +| `NODE_EXTRA_CA_CERTS` | Node at startup (OneCLI-injected) | + +**Moves to `container.json` (read by runner at startup):** + +| Var | Reason | +|---|---| +| `AGENT_PROVIDER` | Per-group config; runner reads before importing provider module | +| `NANOCLAW_AGENT_GROUP_NAME` | Per-group identity | +| `NANOCLAW_ASSISTANT_NAME` | Per-group identity | +| `NANOCLAW_MAX_MESSAGES_PER_PROMPT` | Config constant; per-group override possible | + +**Deleted (admin gating moves to router):** + +`NANOCLAW_ADMIN_USER_IDS` is removed entirely — not moved to a new location. The container no longer makes authorization decisions. See **Router command gate** below. + +**Hardcoded as conventions:** + +| Var | Convention | +|---|---| +| `SESSION_INBOUND_DB_PATH` | `/workspace/inbound.db` | +| `SESSION_OUTBOUND_DB_PATH` | `/workspace/outbound.db` | +| `SESSION_HEARTBEAT_PATH` | `/workspace/.heartbeat` | +| `NANOCLAW_AGENT_GROUP_ID` | Read from `/workspace/agent/container.json` at startup | + +### Runner startup order + +The runner can no longer assume DB paths or provider identity are handed to it in env. Revised startup: + +1. Set up logging. +2. Read `/workspace/agent/container.json` (mounted RW but read-only here). +3. Open `/workspace/inbound.db` and `/workspace/outbound.db` (fixed paths). +4. Read bootstrap tables from `inbound.db` (destinations). +5. Import the provider module selected by `container.json`. +6. Enter the poll loop. + +### Router command gate + +The host router gates slash commands before writing to `messages_in`. The container still handles whatever reaches it; it just stops making authorization decisions. + +1. **Filtered commands** (`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/start`, `/remote-control`) → drop silently. Never reach the container. +2. **Admin commands** (`/clear`, `/compact`, `/context`, `/cost`, `/files`) → check sender against `user_roles` (owners + global admins + admins scoped to this agent group). + - Denied: write "Permission denied: `` requires admin access." directly to `messages_out` in the same thread. Do not write to `messages_in`. + - Allowed: pass through to container unchanged. +3. **Normal messages** → pass through unchanged. + +Admin commands that flow through continue to be handled the same way they are today: +- `/clear` — container's existing handler in `poll-loop.ts` resets session continuation and writes "Session cleared." +- `/compact`, `/context`, `/cost`, `/files` — container forwards them to Claude Code's native slash-command handler. + +Container receives only authorized messages. The runner has no admin concept, no `adminUserIds` field, no admin-gate branch — but it still recognizes `/clear` to reset session state. + +### Scope rules + +Each channel answers a single scope question: + +| Channel | Scope | What it holds | +|---|---|---| +| Env vars | Process | Things read by code we don't own (`TZ`, `HTTPS_PROXY`) | +| `container.json` | Per-group | Per-group config (MCP, packages, provider, model, skills, mounts) | +| `inbound.db` / `outbound.db` | Per-session | Messages, session state, and host-projected views of cross-group state (destinations) | +| Central DB (`data/v2.db`) | Cross-group | Users, roles, wiring, messaging groups, sessions | + +The runner reads from env (for external-convention vars), `container.json` (for its own group's config), and `inbound.db` (for messages + projected views). It never reads central DB directly — that's always host-projected through inbound.db first. + +After this change, the spawn-time `-e` flags shrink from ~10 to ~3-5 (TZ + OneCLI networking). No `NANOCLAW_*` env var survives. + +## Image layer strategy + +Single Dockerfile with aggressive layer ordering: stable layers first, frequently-bumped layers last. BuildKit's layer cache handles "upstream layers unchanged" rebuilds efficiently — a separate base image isn't justified. + +Two image tags exist at runtime: + +``` +nanoclaw-agent:latest — shared base (rebuild: dep/CLI bumps + Dockerfile changes) + └── nanoclaw-agent: — per-group apt/npm packages (rebuild: per-group via install_packages) +``` + +Layer order within the base: + +```dockerfile +FROM node:22-slim + +# System deps (apt) — rarely change +RUN apt-get install ... + +# Bun — pinned version, rarely changes +RUN ... bun + +# Agent-runner deps — cached independently of CLI versions +COPY agent-runner/package.json agent-runner/bun.lock /app/ +RUN cd /app && bun install --frozen-lockfile + +# Global CLIs — most stable first, most frequently bumped last +RUN pnpm install -g "vercel@${VERCEL_VERSION}" +RUN pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}" +RUN pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" +``` + +Bumping claude-code (the most common change) only rebuilds one layer. Agent-runner deps and other CLIs stay cached. + +Source is never baked into the image — always provided by the shared RO mount at runtime. + +### Agent-triggered version bumps + +Agents can request a claude-code version bump via a new self-mod tool (`bump_claude_code`). Same fire-and-forget pattern as `install_packages`: agent requests → owner approves → host rebuilds base image → kill all running containers. Unlike `install_packages` (per-group image), this rebuilds the shared base image and affects all groups. + +## Changes + +### `group-init.ts` + +- Remove the `agent-runner-src` copy block (lines 109–117) +- Remove the `skills/` copy block (lines 100–107) +- Skill symlinks are no longer created at init — sync is spawn-owned (see `container-runner.ts`) + +### `container-runner.ts` `buildMounts()` + +- Remove per-group `agent-runner-src` mount (lines 206–209) +- Add shared RO mount: `container/agent-runner/src/` → `/app/src` +- Add shared RO mount: `container/skills/` → `/app/skills` +- Sync skill symlinks in `.claude-shared/skills/` at spawn: write desired set from `container.json` (`"all"` = every skill in the shared dir, recomputed per spawn), remove symlinks not in the set + +### `container-runner.ts` `buildContainerArgs()` + +- Remove `-e SESSION_INBOUND_DB_PATH`, `-e SESSION_OUTBOUND_DB_PATH`, `-e SESSION_HEARTBEAT_PATH` (hardcoded conventions now) +- Remove `-e AGENT_PROVIDER` (moves to `container.json`) +- Remove `-e NANOCLAW_ASSISTANT_NAME`, `-e NANOCLAW_AGENT_GROUP_ID`, `-e NANOCLAW_AGENT_GROUP_NAME` +- Remove `-e NANOCLAW_MAX_MESSAGES_PER_PROMPT` +- Remove the `user_roles` join + `-e NANOCLAW_ADMIN_USER_IDS` block (lines 269–287) entirely. Admin gating moves to the router — no admin data passed to the container. +- Keep: `-e TZ`, OneCLI-contributed env (`HTTPS_PROXY`, `NODE_EXTRA_CA_CERTS`, `NO_PROXY`) + +### `router.ts` (new command gate) + +- Classify inbound slash commands before writing to `messages_in`: filtered / admin / normal. +- Filtered (`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/start`, `/remote-control`) → drop silently. +- Admin commands (`/clear`, `/compact`, `/context`, `/cost`, `/files`) from non-admins → write "Permission denied" directly to `messages_out`, skip `messages_in`. +- All authorized messages (admin commands from admins, and normal messages) → pass through unchanged to `messages_in`. Container handles them as today. +- The `ADMIN_COMMANDS` and `FILTERED_COMMANDS` lists move from `container/agent-runner/src/formatter.ts` to a host-side module. + +### `container/agent-runner/src/` (runner) + +- New `config.ts` module: loads `/workspace/agent/container.json` at startup, exposes a typed config singleton. All previous `process.env.NANOCLAW_*` reads go through this. +- `db/connection.ts`: use hardcoded paths `/workspace/inbound.db` and `/workspace/outbound.db`; drop `SESSION_*_DB_PATH` lookups. +- `formatter.ts`: remove `ADMIN_COMMANDS`, `FILTERED_COMMANDS`, and the `filtered` / admin-gate categorization. Keep enough to recognize `/clear` so `poll-loop.ts` can route it (e.g., a narrow `isClearCommand(msg)` helper). +- `poll-loop.ts`: remove `adminUserIds` field from config type and the admin-gate branch (lines 113–126). Keep the `/clear` handler (lines 128–142) — `/clear` still flows through from the router. +- Provider selection (`providers/index.ts` or equivalent): read provider from config singleton, not env. + +### `container-config.ts` + +- Add `skills` field to `ContainerConfig` (`string[] | "all"`, default `"all"`) +- Add fields: `provider`, `groupName`, `assistantName`, `maxMessagesPerPrompt` (optional, falls back to code default) + +### `.env` / `.env.example` + +- Remove any `NANOCLAW_*` entries that were documented as tunables. Update `.env.example` to list only TZ and OneCLI-related vars as valid overrides. + +### DB migration + +- Drop `agent_groups.agent_provider` column and `sessions.agent_provider` column. Source of truth becomes `container.json.provider`. +- One-time data migration reads existing values and writes them to each group's `container.json`. Sessions lose any per-session provider override — provider is a per-group property now. + +### Migration + +**This is a breaking change.** Host restart kills all running containers. No gradual rollout. Any code referencing dropped columns or removed env vars must be updated before the migration runs. + +- Provider install skills (`/add-opencode`, `/add-ollama-provider`) write to the shared `container/agent-runner/src/providers/` tree. Per-group provider overlays are removed. Existing provider code in any per-group `agent-runner-src/providers/` must be moved to the shared tree before cutover. +- Delete existing `data/v2-sessions//agent-runner-src/` directories on first run after cutover. +- Existing `.claude-shared/skills/` directories get replaced with symlinks on next spawn. +- DB migration (see above) reads `agent_provider` columns and projects into `container.json`, then drops the columns. + +## What triggers what + +| Change | Action needed | Scope | +|--------|--------------|-------| +| Agent-runner `.ts` source | Kill running containers | All groups | +| Agent-runner npm deps | Rebuild `nanoclaw-agent` + kill all | All groups | +| System deps, Bun, Node | Rebuild `nanoclaw-agent` + kill all | All groups | +| Claude-code version bump | Rebuild `nanoclaw-agent` + kill all | All groups (agent-triggerable) | +| Skill content | Kill running containers | All groups | +| Per-group apt/npm packages | `buildAgentGroupImage()` + kill | One group | +| Per-group config (MCP, mounts, provider, model, skills) | Kill that group's containers | One group | +| CLAUDE.md, working files | Nothing (live via RW mount) | One group | diff --git a/src/command-gate.ts b/src/command-gate.ts new file mode 100644 index 0000000..7bd1b9f --- /dev/null +++ b/src/command-gate.ts @@ -0,0 +1,70 @@ +/** + * Host-side command gate. Classifies inbound slash commands and gates + * them before they reach the container. + * + * - Filtered commands: dropped silently (never reach the container) + * - Admin commands: checked against user_roles; denied senders get a + * "Permission denied" response written directly to messages_out + * - Normal messages: pass through unchanged + */ +import { getDb, hasTable } from './db/connection.js'; + +export type GateResult = + | { action: 'pass' } + | { action: 'filter' } + | { action: 'deny'; command: string }; + +const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']); +const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files']); + +/** + * Classify a message and decide whether it should reach the container. + * Returns 'pass' for normal messages and authorized admin commands, + * 'filter' for silently-dropped commands, 'deny' for unauthorized + * admin commands. + */ +export function gateCommand( + content: string, + userId: string | null, + agentGroupId: string, +): GateResult { + let text: string; + try { + const parsed = JSON.parse(content); + text = (parsed.text || '').trim(); + } catch { + text = content.trim(); + } + + if (!text.startsWith('/')) return { action: 'pass' }; + + const command = text.split(/\s/)[0].toLowerCase(); + + if (FILTERED_COMMANDS.has(command)) return { action: 'filter' }; + + if (ADMIN_COMMANDS.has(command)) { + if (isAdmin(userId, agentGroupId)) { + return { action: 'pass' }; + } + return { action: 'deny', command }; + } + + // Unknown slash commands pass through (the agent/SDK handles them) + return { action: 'pass' }; +} + +function isAdmin(userId: string | null, agentGroupId: string): boolean { + if (!userId) return false; + if (!hasTable(getDb(), 'user_roles')) return true; // no permissions module = allow all + const db = getDb(); + const row = db + .prepare( + `SELECT 1 FROM user_roles + WHERE user_id = ? + AND (role = 'owner' OR role = 'admin') + AND (agent_group_id IS NULL OR agent_group_id = ?) + LIMIT 1`, + ) + .get(userId, agentGroupId); + return row != null; +} diff --git a/src/container-config.ts b/src/container-config.ts index e1366e3..90c24e9 100644 --- a/src/container-config.ts +++ b/src/container-config.ts @@ -1,15 +1,8 @@ /** * Per-group container config, stored as a plain JSON file at - * `groups//container.json`. Replaces the former - * `agent_groups.container_config` DB column. - * - * Shape: - * { - * mcpServers: { [name]: { command, args, env } } - * packages: { apt: string[], npm: string[] } - * imageTag?: string // set by buildAgentGroupImage on rebuild - * additionalMounts?: Array<{hostPath, containerPath, readonly}> - * } + * `groups//container.json`. Mounted read-only inside the container + * at `/workspace/agent/container.json` — the runner reads it at startup but + * cannot modify it. Config changes go through the self-mod approval flow. * * All fields are optional — a missing file or a partial file both resolve * to sensible defaults. Writes are atomic-enough (write-then-rename is not @@ -38,6 +31,18 @@ export interface ContainerConfig { packages: { apt: string[]; npm: string[] }; imageTag?: string; additionalMounts: AdditionalMountConfig[]; + /** Which skills to enable — array of skill names or "all" (default). */ + skills: string[] | 'all'; + /** Agent provider name (e.g. "claude", "opencode"). Default: "claude". */ + provider?: string; + /** Agent group display name (used in transcript archiving). */ + groupName?: string; + /** Assistant display name (used in system prompt / responses). */ + assistantName?: string; + /** Agent group ID — set by the host, read by the runner. */ + agentGroupId?: string; + /** Max messages per prompt. Falls back to code default if unset. */ + maxMessagesPerPrompt?: number; } function emptyConfig(): ContainerConfig { @@ -45,6 +50,7 @@ function emptyConfig(): ContainerConfig { mcpServers: {}, packages: { apt: [], npm: [] }, additionalMounts: [], + skills: 'all', }; } @@ -71,6 +77,12 @@ export function readContainerConfig(folder: string): ContainerConfig { }, imageTag: raw.imageTag, additionalMounts: raw.additionalMounts ?? [], + skills: raw.skills ?? 'all', + provider: raw.provider, + groupName: raw.groupName, + assistantName: raw.assistantName, + agentGroupId: raw.agentGroupId, + maxMessagesPerPrompt: raw.maxMessagesPerPrompt, }; } catch (err) { console.error(`[container-config] failed to parse ${p}: ${String(err)}`); diff --git a/src/container-runner.ts b/src/container-runner.ts index b357a0d..32499bc 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -9,7 +9,7 @@ import path from 'path'; import { OneCLI } from '@onecli-sh/sdk'; -import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, MAX_MESSAGES_PER_PROMPT, ONECLI_URL, TIMEZONE } from './config.js'; +import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, ONECLI_URL, TIMEZONE } from './config.js'; import { readContainerConfig, writeContainerConfig } from './container-config.js'; import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { getAgentGroup } from './db/agent-groups.js'; @@ -91,17 +91,25 @@ async function spawnContainer(session: Session): Promise { } writeSessionRouting(agentGroup.id, session.id); + // Read container config once — threaded through provider resolution, + // buildMounts, and buildContainerArgs so we don't re-read the file. + const containerConfig = readContainerConfig(agentGroup.folder); + + // Ensure container.json has the agent group identity fields the runner needs. + // Written at spawn time so the runner can read them from the RO mount. + ensureRuntimeFields(containerConfig, agentGroup); + // Resolve the effective provider + any host-side contribution it declares // (extra mounts, env passthrough). Computed once and threaded through both // buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire once. - const { provider, contribution } = resolveProviderContribution(session, agentGroup); + const { provider, contribution } = resolveProviderContribution(session, agentGroup, containerConfig); - const mounts = buildMounts(agentGroup, session, contribution); + const mounts = buildMounts(agentGroup, session, containerConfig, contribution); const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`; // OneCLI agent identifier is always the agent group id — stable across // sessions and reversible via getAgentGroup() for approval routing. const agentIdentifier = agentGroup.id; - const args = await buildContainerArgs(mounts, containerName, agentGroup, provider, contribution, agentIdentifier); + const args = await buildContainerArgs(mounts, containerName, agentGroup, containerConfig, provider, contribution, agentIdentifier); log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName }); @@ -156,8 +164,9 @@ export function killContainer(sessionId: string, reason: string): void { function resolveProviderContribution( session: Session, agentGroup: AgentGroup, + containerConfig: import('./container-config.js').ContainerConfig, ): { provider: string; contribution: ProviderContainerContribution } { - const provider = (session.agent_provider || agentGroup.agent_provider || 'claude').toLowerCase(); + const provider = (containerConfig.provider || 'claude').toLowerCase(); const fn = getProviderContainerConfig(provider); const contribution = fn ? fn({ @@ -172,15 +181,20 @@ function resolveProviderContribution( function buildMounts( agentGroup: AgentGroup, session: Session, + containerConfig: import('./container-config.js').ContainerConfig, providerContribution: ProviderContainerContribution, ): VolumeMount[] { + const projectRoot = process.cwd(); + // Per-group filesystem state lives forever after first creation. Init is // idempotent: it only writes paths that don't already exist, so this call - // is a no-op for groups that have spawned before. Pulling in upstream - // built-in skill or agent-runner source updates is an explicit operation - // (host-mediated tools), not something the spawn path does silently. + // is a no-op for groups that have spawned before. initGroupFilesystem(agentGroup); + // Sync skill symlinks based on container.json selection before mounting. + const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared'); + syncSkillSymlinks(claudeDir, containerConfig); + const mounts: VolumeMount[] = []; const sessDir = sessionDir(agentGroup.id, session.id); const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder); @@ -188,28 +202,37 @@ function buildMounts( // Session folder at /workspace (contains inbound.db, outbound.db, outbox/, .claude/) mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false }); - // Agent group folder at /workspace/agent + // Agent group folder at /workspace/agent (RW for working files + CLAUDE.md) mounts.push({ hostPath: groupDir, containerPath: '/workspace/agent', readonly: false }); - // Global memory directory — always read-only. Edits to global config - // happen through the approval flow, not by handing one workspace RW. + // container.json — nested RO mount on top of RW group dir so the agent + // can read its config but cannot modify it. + const containerJsonPath = path.join(groupDir, 'container.json'); + if (fs.existsSync(containerJsonPath)) { + mounts.push({ hostPath: containerJsonPath, containerPath: '/workspace/agent/container.json', readonly: true }); + } + + // Global memory directory — always read-only. const globalDir = path.join(GROUPS_DIR, 'global'); if (fs.existsSync(globalDir)) { mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: true }); } // Per-group .claude-shared at /home/node/.claude (Claude state, settings, - // skills — initialized once at group creation, persistent thereafter) - const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared'); + // skill symlinks) mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false }); - // Per-group agent-runner source at /app/src (initialized once at group - // creation, persistent thereafter — agents can modify their runner) - const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'agent-runner-src'); - mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false }); + // Shared agent-runner source — read-only, same code for all groups. + const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); + mounts.push({ hostPath: agentRunnerSrc, containerPath: '/app/src', readonly: true }); - // Additional mounts from container config (groups//container.json) - const containerConfig = readContainerConfig(agentGroup.folder); + // Shared skills — read-only, symlinks in .claude-shared/skills/ point here. + const skillsSrc = path.join(projectRoot, 'container', 'skills'); + if (fs.existsSync(skillsSrc)) { + mounts.push({ hostPath: skillsSrc, containerPath: '/app/skills', readonly: true }); + } + + // Additional mounts from container config if (containerConfig.additionalMounts && containerConfig.additionalMounts.length > 0) { const validated = validateAdditionalMounts(containerConfig.additionalMounts, agentGroup.name); mounts.push(...validated); @@ -223,32 +246,113 @@ function buildMounts( return mounts; } +/** + * Sync skill symlinks in .claude-shared/skills/ to match the container.json + * selection. Each symlink points to a container path (/app/skills/) + * so it's dangling on the host but valid inside the container. + */ +function syncSkillSymlinks( + claudeDir: string, + containerConfig: import('./container-config.js').ContainerConfig, +): void { + const skillsDir = path.join(claudeDir, 'skills'); + if (!fs.existsSync(skillsDir)) { + fs.mkdirSync(skillsDir, { recursive: true }); + } + + // Determine desired skill set + const projectRoot = process.cwd(); + const sharedSkillsDir = path.join(projectRoot, 'container', 'skills'); + let desired: string[]; + if (containerConfig.skills === 'all') { + // Recompute from shared dir — newly-added upstream skills appear automatically + desired = fs.existsSync(sharedSkillsDir) + ? fs.readdirSync(sharedSkillsDir).filter((e) => { + try { + return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory(); + } catch { + return false; + } + }) + : []; + } else { + desired = containerConfig.skills; + } + + const desiredSet = new Set(desired); + + // Remove symlinks not in the desired set + for (const entry of fs.readdirSync(skillsDir)) { + const entryPath = path.join(skillsDir, entry); + let isSymlink = false; + try { + isSymlink = fs.lstatSync(entryPath).isSymbolicLink(); + } catch { + continue; + } + if (isSymlink && !desiredSet.has(entry)) { + fs.unlinkSync(entryPath); + } + } + + // Create symlinks for desired skills (container path targets) + for (const skill of desired) { + const linkPath = path.join(skillsDir, skill); + let exists = false; + try { + fs.lstatSync(linkPath); + exists = true; + } catch { + /* missing */ + } + if (!exists) { + fs.symlinkSync(`/app/skills/${skill}`, linkPath); + } + } +} + +/** + * Ensure container.json has the runtime identity fields the runner needs. + * Written at spawn time so they're always current even if the DB values + * change (e.g. group rename). Only writes if values differ to avoid + * unnecessary file churn. + */ +function ensureRuntimeFields( + containerConfig: import('./container-config.js').ContainerConfig, + agentGroup: AgentGroup, +): void { + let dirty = false; + if (containerConfig.agentGroupId !== agentGroup.id) { + containerConfig.agentGroupId = agentGroup.id; + dirty = true; + } + if (containerConfig.groupName !== agentGroup.name) { + containerConfig.groupName = agentGroup.name; + dirty = true; + } + if (containerConfig.assistantName !== agentGroup.name) { + containerConfig.assistantName = agentGroup.name; + dirty = true; + } + if (dirty) { + writeContainerConfig(agentGroup.folder, containerConfig); + } +} + async function buildContainerArgs( mounts: VolumeMount[], containerName: string, agentGroup: AgentGroup, + containerConfig: import('./container-config.js').ContainerConfig, provider: string, providerContribution: ProviderContainerContribution, agentIdentifier?: string, ): Promise { const args: string[] = ['run', '--rm', '--name', containerName]; - // Environment + // Environment — only vars read by code we don't own. + // Everything NanoClaw-specific is in container.json (read by runner at startup). args.push('-e', `TZ=${TIMEZONE}`); - args.push('-e', `AGENT_PROVIDER=${provider}`); - // Two-DB split: container reads inbound.db, writes outbound.db - args.push('-e', 'SESSION_INBOUND_DB_PATH=/workspace/inbound.db'); - args.push('-e', 'SESSION_OUTBOUND_DB_PATH=/workspace/outbound.db'); - args.push('-e', 'SESSION_HEARTBEAT_PATH=/workspace/.heartbeat'); - - if (agentGroup.name) { - args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`); - } - args.push('-e', `NANOCLAW_AGENT_GROUP_ID=${agentGroup.id}`); - args.push('-e', `NANOCLAW_AGENT_GROUP_NAME=${agentGroup.name}`); - // Cap on how many pending messages reach one prompt. Accumulated context - // (trigger=0 rows) rides along with wake-eligible rows up to this cap. - args.push('-e', `NANOCLAW_MAX_MESSAGES_PER_PROMPT=${MAX_MESSAGES_PER_PROMPT}`); // Provider-contributed env vars (e.g. XDG_DATA_HOME, OPENCODE_*, NO_PROXY). if (providerContribution.env) { @@ -257,39 +361,8 @@ async function buildContainerArgs( } } - // Users allowed to run admin commands (e.g. /clear) inside this container. - // Computed at wake time: owners + global admins + admins scoped to this - // agent group. Role changes take effect on next container spawn. - // - // SQL inlined to keep core independent of the permissions module — we - // guard on the `user_roles` table directly. If the permissions module - // isn't installed, the table doesn't exist and the set stays empty; the - // formatter treats an empty admin set as permissionless mode (every - // sender is admin). - const adminUserIds = new Set(); - if (hasTable(getDb(), 'user_roles')) { - const db = getDb(); - const owners = db - .prepare("SELECT user_id FROM user_roles WHERE role = 'owner' AND agent_group_id IS NULL") - .all() as Array<{ user_id: string }>; - const globalAdmins = db - .prepare("SELECT user_id FROM user_roles WHERE role = 'admin' AND agent_group_id IS NULL") - .all() as Array<{ user_id: string }>; - const scopedAdmins = db - .prepare("SELECT user_id FROM user_roles WHERE role = 'admin' AND agent_group_id = ?") - .all(agentGroup.id) as Array<{ user_id: string }>; - for (const r of owners) adminUserIds.add(r.user_id); - for (const r of globalAdmins) adminUserIds.add(r.user_id); - for (const r of scopedAdmins) adminUserIds.add(r.user_id); - } - if (adminUserIds.size > 0) { - args.push('-e', `NANOCLAW_ADMIN_USER_IDS=${Array.from(adminUserIds).join(',')}`); - } - // OneCLI gateway — injects HTTPS_PROXY + certs so container API calls // are routed through the agent vault for credential injection. - // Must ensureAgent first for non-admin groups, otherwise applyContainerConfig - // rejects the unknown agent identifier and returns false. try { if (agentIdentifier) { await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); @@ -324,16 +397,7 @@ async function buildContainerArgs( } } - // Pass additional MCP servers from container config (groups//container.json) - const containerConfig = readContainerConfig(agentGroup.folder); - if (containerConfig.mcpServers && Object.keys(containerConfig.mcpServers).length > 0) { - args.push('-e', `NANOCLAW_MCP_SERVERS=${JSON.stringify(containerConfig.mcpServers)}`); - } - // Override entrypoint: run v2 entry point directly via Bun (no tsc, no stdin). - // The image's ENTRYPOINT (tini → entrypoint.sh) handles the stdin-piped - // invocation path; the host-spawned sessions don't need stdin because all - // IO flows through the mounted session DBs. args.push('--entrypoint', 'bash'); // Use per-agent-group image if one has been built, otherwise base image diff --git a/src/group-init.ts b/src/group-init.ts index 527ba6b..211ef1f 100644 --- a/src/group-init.ts +++ b/src/group-init.ts @@ -37,12 +37,12 @@ const DEFAULT_SETTINGS_JSON = * an already-initialized group is a no-op. * * Called once per group lifetime: at creation, or defensively from - * `buildMounts()` for groups that pre-date this code path. After init, the - * host never overwrites any of these paths automatically — agents own them. - * To pull in upstream changes, use the host-mediated reset/refresh tools. + * `buildMounts()` for groups that pre-date this code path. + * + * Source code and skills are shared RO mounts — not copied per-group. + * Skill symlinks are synced at spawn time by container-runner.ts. */ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: string }): void { - const projectRoot = process.cwd(); const initialized: string[] = []; // 1. groups// — group memory + working dir @@ -97,23 +97,12 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s initialized.push('settings.json'); } + // Skills directory — created empty here; symlinks are synced at spawn + // time by container-runner.ts based on container.json skills selection. const skillsDst = path.join(claudeDir, 'skills'); if (!fs.existsSync(skillsDst)) { - const skillsSrc = path.join(projectRoot, 'container', 'skills'); - if (fs.existsSync(skillsSrc)) { - fs.cpSync(skillsSrc, skillsDst, { recursive: true }); - initialized.push('skills/'); - } - } - - // 3. data/v2-sessions//agent-runner-src/ — per-group source copy - const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', group.id, 'agent-runner-src'); - if (!fs.existsSync(groupRunnerDir)) { - const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); - if (fs.existsSync(agentRunnerSrc)) { - fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true }); - initialized.push('agent-runner-src/'); - } + fs.mkdirSync(skillsDst, { recursive: true }); + initialized.push('skills/'); } if (initialized.length > 0) { diff --git a/src/router.ts b/src/router.ts index c1e8881..538c270 100644 --- a/src/router.ts +++ b/src/router.ts @@ -18,6 +18,7 @@ * for policy refusals. */ import { getChannelAdapter } from './channels/channel-registry.js'; +import { gateCommand } from './command-gate.js'; import { getAgentGroup } from './db/agent-groups.js'; import { recordDroppedMessage } from './db/dropped-messages.js'; import { @@ -28,7 +29,7 @@ import { import { findSessionForAgent } from './db/sessions.js'; import { startTypingRefresh } from './modules/typing/index.js'; import { log } from './log.js'; -import { resolveSession, writeSessionMessage } from './session-manager.js'; +import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js'; import { wakeContainer } from './container-runner.js'; import { getSession } from './db/sessions.js'; import type { AgentGroup, MessagingGroup, MessagingGroupAgent } from './types.js'; @@ -398,6 +399,29 @@ async function deliverToAgent( threadId: event.threadId, }; + // Command gate: classify slash commands before they reach the container. + // Filtered commands are dropped silently. Denied admin commands get a + // permission-denied response written directly to messages_out. + if (event.message.kind === 'chat' || event.message.kind === 'chat-sdk') { + const gate = gateCommand(event.message.content, userId, agent.agent_group_id); + if (gate.action === 'filter') { + log.debug('Filtered command dropped by gate', { agentGroupId: agent.agent_group_id }); + return; + } + if (gate.action === 'deny') { + writeOutboundDirect(session.agent_group_id, session.id, { + id: `deny-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + platformId: deliveryAddr.platformId, + channelType: deliveryAddr.channelType, + threadId: deliveryAddr.threadId, + content: JSON.stringify({ text: `Permission denied: ${gate.command} requires admin access.` }), + }); + log.info('Admin command denied by gate', { command: gate.command, userId, agentGroupId: agent.agent_group_id }); + return; + } + } + writeSessionMessage(session.agent_group_id, session.id, { id: messageIdForAgent(event.message.id, agent.agent_group_id), kind: event.message.kind, diff --git a/src/session-manager.ts b/src/session-manager.ts index 2a5ac1d..38eaa0d 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -279,6 +279,34 @@ export function openOutboundDb(agentGroupId: string, sessionId: string): Databas return openOutboundDbRaw(outboundDbPath(agentGroupId, sessionId)); } +/** + * Write a message directly to a session's outbound DB so the host delivery + * loop picks it up. Used by the command gate to send denial responses + * without waking a container. + */ +export function writeOutboundDirect( + agentGroupId: string, + sessionId: string, + message: { + id: string; + kind: string; + platformId: string | null; + channelType: string | null; + threadId: string | null; + content: string; + }, +): void { + const db = openOutboundDb(agentGroupId, sessionId); + try { + db.prepare( + `INSERT OR IGNORE INTO messages_out (id, seq, timestamp, kind, platform_id, channel_type, thread_id, content) + VALUES (?, (SELECT COALESCE(MAX(seq), 0) + 2 FROM messages_out), datetime('now'), ?, ?, ?, ?, ?)`, + ).run(message.id, message.kind, message.platformId, message.channelType, message.threadId, message.content); + } finally { + db.close(); + } +} + /** * @deprecated Use openInboundDb / openOutboundDb instead. */ From c8fc1da7199399b723cb8fd97fd260102172f612 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 03:18:12 +0300 Subject: [PATCH 092/185] refactor(claude-md): compose per-group CLAUDE.md from shared base + fragments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-group "written once at init, owned by the group" CLAUDE.md with a host-regenerated entry point that imports: - a shared base (`container/CLAUDE.md` mounted RO at `/app/CLAUDE.md`) - optional per-skill fragments (skills that ship `instructions.md`) - optional per-MCP-server fragments (inline `instructions` field in `container.json`) - per-group agent memory (`CLAUDE.local.md`, auto-loaded by Claude Code) Principle: RW = per-group memory, RO = shared content. Source/skills/base are shared; personality, config, working files, and Claude state stay per-group. Key changes: - New `src/claude-md-compose.ts` — per-spawn composition + `migrateGroupsToClaudeLocal()` one-time cutover. - New `container/CLAUDE.md` — shared base, seeded verbatim from the former `groups/global/CLAUDE.md`. - `src/container-runner.ts` — swap `/workspace/global` mount for RO `/app/CLAUDE.md`; call `composeGroupClaudeMd()` after `initGroupFilesystem()`. - `src/group-init.ts` — drop `.claude-global.md` symlink + initial `CLAUDE.md` write; seed `CLAUDE.local.md` from `opts.instructions`. - `src/index.ts` — call `migrateGroupsToClaudeLocal()` at startup. - `src/container-config.ts` — add optional `instructions` field to `McpServerConfig` (inline per-MCP guidance fragment). - `container/Dockerfile` — drop dead `/workspace/global` mkdir. - Remove obsolete `scripts/migrate-group-claude-md.ts`. Migration (runs once at host startup, idempotent): - Delete `.claude-global.md` symlinks in each group. - Rename each `groups//CLAUDE.md` → `CLAUDE.local.md` (preserves existing per-group content as memory). - Delete `groups/global/` directory. Design docs: `docs/claude-md-composition.md` and `docs/shared-source.md` (the latter is the sibling design discussion this refactor builds on). Co-Authored-By: Claude Opus 4.7 (1M context) --- container/CLAUDE.md | 166 +++++++++++++++++ container/Dockerfile | 2 +- container/agent-runner/src/index.ts | 5 + docs/claude-md-composition.md | 146 +++++++++++++++ docs/shared-source.md | 270 ++++++++++++++++++++++++++++ scripts/migrate-group-claude-md.ts | 113 ------------ src/claude-md-compose.ts | 182 +++++++++++++++++++ src/container-config.ts | 4 + src/container-runner.ts | 12 ++ src/group-init.ts | 48 ++--- src/index.ts | 4 + 11 files changed, 802 insertions(+), 150 deletions(-) create mode 100644 container/CLAUDE.md create mode 100644 docs/claude-md-composition.md create mode 100644 docs/shared-source.md delete mode 100644 scripts/migrate-group-claude-md.ts create mode 100644 src/claude-md-compose.ts diff --git a/container/CLAUDE.md b/container/CLAUDE.md new file mode 100644 index 0000000..c4428ff --- /dev/null +++ b/container/CLAUDE.md @@ -0,0 +1,166 @@ +# Main + +You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders. + +## What You Can Do + +- Answer questions and have conversations +- Search the web and fetch content from URLs +- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open ` to start, then `agent-browser snapshot -i` to see interactive elements) +- Read and write files in your workspace +- Run bash commands in your sandbox +- Schedule tasks to run later or on a recurring basis +- Send messages back to the chat + +## Communication + +Be concise — every message costs the reader's attention. + +### Destinations + +Each turn, your system prompt lists the destinations available to you. If you only have one destination, just write your response directly — it goes there automatically. If you have multiple, wrap each message in a `...` block: + +``` +On my way home, 15 minutes +kick off the pipeline +``` + +Inbound messages are labeled with `from="name"` so you can tell which destination they came from and reply using that same name. + +### Mid-turn updates + +Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work: + +- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final response. +- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") so the user knows you got the message. +- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages. + +**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call. + +**Outcomes, not play-by-play.** When the work is done, the final message should be about the result, not a transcript of what you did. + +### Internal thoughts + +Wrap reasoning in `...` tags to mark it as scratchpad — logged but not sent. With multiple destinations, any text outside of `` blocks is also treated as scratchpad. With a single destination, only explicit `` tags are scratchpad; the rest of your response is sent. + +``` +Compiled all three reports, ready to summarize. + +Here are the key findings from the research… +``` + +### Sub-agents and teammates + +When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent. + +## Your Workspace + +Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist. + +## Memory + +The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions. + +When you learn something important: +- Create files for structured data (e.g., `customers.md`, `preferences.md`) +- Split files larger than 500 lines into folders +- Keep an index in your memory for the files you create + +## Message Formatting + +Format messages based on the channel you're responding to. Check your group folder name: + +### Slack channels (folder starts with `slack_`) + +Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules: +- `*bold*` (single asterisks) +- `_italic_` (underscores) +- `` for links (NOT `[text](url)`) +- `•` bullets (no numbered lists) +- `:emoji:` shortcodes +- `>` for block quotes +- No `##` headings — use `*Bold text*` instead + +### WhatsApp/Telegram channels (folder starts with `whatsapp_` or `telegram_`) + +- `*bold*` (single asterisks, NEVER **double**) +- `_italic_` (underscores) +- `•` bullet points +- ` ``` ` code blocks + +No `##` headings. No `[links](url)`. No `**double stars**`. + +### Discord channels (folder starts with `discord_`) + +Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`. + +--- + +## Installing Packages & Tools + +Your container is ephemeral — anything installed via `apt-get` or `pnpm install -g` is lost on restart. To install packages that persist, use the self-modification tools: + +1. **`install_packages`** — request system (apt) or global npm packages. Requires admin approval. +2. **`request_rebuild`** — rebuild your container image so approved packages are baked in. Always call this after `install_packages` to apply the changes. + +Example flow: +``` +install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" }) +# → Admin gets an approval card → approves +request_rebuild({ reason: "Apply ffmpeg + transformers" }) +# → Admin approves → image rebuilt with the packages +``` + +**When to use this vs workspace pnpm install:** +- `pnpm install` in `/workspace/agent/` persists on disk (it's mounted) but isn't on the global PATH — use it for project-level dependencies +- `install_packages` is for system tools (ffmpeg, imagemagick) and global npm packages that need to be on PATH + +### MCP Servers + +Use **`add_mcp_server`** to add an MCP server to your configuration, then **`request_rebuild`** to apply. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `pnpm dlx`, e.g.: + +``` +add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] }) +request_rebuild({ reason: "Add memory MCP server" }) +``` + +## Task Scripts + +For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below. Other scheduling tools you might discover (e.g. `CronCreate`, `ScheduleWakeup`) are session-scoped SDK builtins and won't behave the way NanoClaw users expect, so stick with `schedule_task`. + +To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule — it preserves the series id the user already knows. + +Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum. + +### How it works + +1. You provide a bash `script` alongside the `prompt` when scheduling +2. When the task fires, the script runs first (30-second timeout) +3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }` +4. If `wakeAgent: false` — nothing happens, task waits for next run +5. If `wakeAgent: true` — you wake up and receive the script's data + prompt + +### Always test your script first + +Before scheduling, run the script in your sandbox to verify it works: + +```bash +bash -c 'node --input-type=module -e " + const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\"); + const prs = await r.json(); + console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) })); +"' +``` + +### When NOT to use scripts + +If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. + +### Frequent task guidance + +If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups: + +- Explain that each wake-up uses API credits and risks rate limits +- Suggest restructuring with a script that checks the condition first +- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script +- Help the user find the minimum viable frequency diff --git a/container/Dockerfile b/container/Dockerfile index c110bd6..f492f1c 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -109,7 +109,7 @@ COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh # ---- Workspace + permissions ------------------------------------------------- -RUN mkdir -p /workspace/group /workspace/global /workspace/extra && \ +RUN mkdir -p /workspace/group /workspace/extra && \ chown -R node:node /workspace && \ chmod 755 /home/node diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 5535417..9e68968 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -45,6 +45,11 @@ async function main(): Promise { log(`Starting v2 agent-runner (provider: ${providerName})`); + // Destinations addendum is the only runtime-generated context we inject. + // Agent instructions are loaded by Claude Code from /workspace/agent/CLAUDE.md + // (host-composed at spawn, imports /app/CLAUDE.md and fragments) plus + // /workspace/agent/CLAUDE.local.md (agent memory) — no need to read them + // manually. const instructions = buildSystemPromptAddendum(); // Discover additional directories mounted at /workspace/extra/* diff --git a/docs/claude-md-composition.md b/docs/claude-md-composition.md new file mode 100644 index 0000000..b3ce08f --- /dev/null +++ b/docs/claude-md-composition.md @@ -0,0 +1,146 @@ +# CLAUDE.md Composition + +Compose agent instructions from a shared base, skill/tool fragments, and per-group memory — replacing the current per-group CLAUDE.md with a host-regenerated entry point. + +## Problem + +Today each agent group has a single RW `groups//CLAUDE.md`, written once at init and never updated. Consequences: + +- Upstream improvements to shared agent guidance don't propagate to existing groups +- No way to ship tool-specific guidance with the tool itself (e.g., an agent-browser usage fragment) +- Human-authored identity and agent-accumulated memory live in the same file with no separation +- The `.claude-global.md` symlink + `groups/global/CLAUDE.md` pattern handled the shared base but not per-module fragments + +## Design + +**Principle: RW = per-group memory, RO = shared content.** Same rule that governs the shared-source refactor, applied to agent instructions. + +### Three tiers + +| Tier | File | Location | Mount | Editor | Change rate | +|---|---|---|---|---|---| +| **Shared base** | `CLAUDE.md` | `container/CLAUDE.md` | RO at `/app/CLAUDE.md` | Owner (via git) | Rare | +| **Module fragments** | `instructions.md` | Inside each module | RO via shared skills mount, or inline in `container.json` | Module author | Ships with module | +| **Per-group memory** | `CLAUDE.local.md` | `groups//` | RW at `/workspace/agent/` | Agent + owner | Continuous | +| **Composed entry** | `CLAUDE.md` | `groups//` | RW but host-regenerated | **Host, not human** | Every spawn | + +### Composition + +At every spawn, the host regenerates `groups//CLAUDE.md` as an import-only file: + +```markdown + +@./.claude-shared.md +@./.claude-fragments/welcome.md +@./.claude-fragments/agent-browser.md +@./.claude-fragments/.md +@./.claude-fragments/mcp-.md +``` + +Symlinks are created alongside, following the `.claude-global.md` pattern (dangling on host, valid in container via the RO mount): + +- `groups//.claude-shared.md` → `/app/CLAUDE.md` +- `groups//.claude-fragments/.md` → `/app/skills//instructions.md` (for each enabled skill that ships a fragment) + +Claude Code auto-loads `CLAUDE.local.md` from cwd without an import line — native behavior. Agent memory works natively; composition only wraps around it. + +### Module fragment contract + +**Skills.** A skill optionally ships an `instructions.md` at the top of its directory: + +``` +container/skills/welcome/ + SKILL.md — description + when-to-use (existing) + instructions.md — always-in-context guidance (optional, new) +``` + +When the skill is enabled for a group, the host imports `instructions.md` into the composed CLAUDE.md. `SKILL.md` semantics are unchanged — Claude Code still uses it for skill discovery and on-demand invocation. Most skills won't need an `instructions.md` (SKILL.md is sufficient for on-demand skills); it's only for guidance that should be in context at all times. + +**MCP servers.** A `container.json` MCP server entry can contribute a fragment inline: + +```jsonc +{ + "mcpServers": { + "my-db": { + "command": "...", + "instructions": "Read-only access to the production DB. Never run UPDATE/DELETE without admin approval." + } + } +} +``` + +Host writes the inline content to `.claude-fragments/mcp-.md` at spawn and imports it. + +**Global CLIs baked into the image** (agent-browser, vercel, claude-code) have always-present guidance; it belongs in `container/CLAUDE.md`, not as a conditional fragment. Don't try to make universally-present tools dynamic. + +### Identity vs memory + +All per-group content — human-authored identity ("you are the research agent, be terse") and agent-accumulated memory (inventories, user preferences, learned patterns) — lives in a single `CLAUDE.local.md`. Both humans and agents can edit it. + +If the distinction becomes operationally important later (agents confused about what they were told vs. what they learned), split into `identity.md` (human-authored, imported into composed CLAUDE.md) + `CLAUDE.local.md` (agent memory only). Starting with one file. + +## Changes + +### `container/CLAUDE.md` (new) + +Write the shared base: general NanoClaw context, how to engage with users, output conventions, anything that should apply to every agent across every group. Seed from current `groups/global/CLAUDE.md`. + +### `container/skills//instructions.md` (optional, per skill) + +Add for any skill that warrants always-in-context guidance. Optional. + +### `container.json` schema + +Add optional `instructions` field (string) to each MCP server entry. + +### `container-runner.ts` spawn-time sync + +Extend the skill-symlink sync function (added in the shared-source refactor) to also compose CLAUDE.md. On every spawn: + +1. Sync `.claude-shared/skills/` symlinks from `container.json` skill selection. +2. Sync `.claude-shared.md` symlink → `/app/CLAUDE.md`. +3. For each enabled skill with an `instructions.md`, create `.claude-fragments/.md` symlink → `/app/skills//instructions.md`. +4. For each `container.json` MCP server with an `instructions` field, write the inline content to `.claude-fragments/mcp-.md`. +5. Write `groups//CLAUDE.md` atomically (temp + rename) with import lines in a deterministic order: shared base → skill fragments (alphabetical) → MCP fragments (alphabetical). +6. Remove stale symlinks and fragment files for modules no longer enabled. + +### `group-init.ts` + +- Stop writing an initial `groups//CLAUDE.md` at group creation — host regenerates at first spawn. +- Stop creating the `.claude-global.md` symlink — replaced by `.claude-shared.md` in the composition step. +- Optionally create an empty `groups//CLAUDE.local.md` at init as a clear affordance for humans and agents. + +### `groups/global/` + +Eliminate. The shared base moves to `container/CLAUDE.md`. Any deployment-specific overrides live in the owner's customized `container/CLAUDE.md` (same pattern as any other codebase customization). + +## Migration + +Breaking change, one-time cutover: + +- For every group, rename `groups//CLAUDE.md` → `groups//CLAUDE.local.md`. Preserves all existing per-group content as memory. +- Move content from `groups/global/CLAUDE.md` (beyond the default stub) into `container/CLAUDE.md`. Delete `groups/global/`. +- Delete stale `.claude-global.md` symlinks in each group dir — the spawn pass creates `.claude-shared.md` instead. +- First spawn after cutover regenerates `CLAUDE.md` with proper imports. + +## Interaction with shared-source refactor + +This refactor depends on the shared skills mount (`/app/skills/` RO) from the shared-source refactor landing first. It extends the spawn-time sync from "just skill symlinks" to "skill symlinks + CLAUDE.md composition" — both passes share the same helper. + +After this refactor, the "Personality / instructions" row in the shared-source per-group customization table splits: + +| Resource | Location | Mechanism | +|----------|----------|-----------| +| Agent memory | `groups//CLAUDE.local.md` | RW at `/workspace/agent/`, auto-loaded by Claude Code | +| Composed entry | `groups//CLAUDE.md` | Host-regenerated at every spawn | + +## What triggers what + +| Change | Action | Scope | +|--------|--------|-------| +| Edit `container/CLAUDE.md` | Kill running containers (next spawn recomposes) | All groups | +| Add/edit a skill's `instructions.md` | Kill running containers | All groups with the skill enabled | +| Enable/disable a skill in `container.json` | Kill that group's containers | One group | +| Add MCP server with `instructions` field | Kill that group's containers | One group | +| Edit `CLAUDE.local.md` | Nothing — live via RW mount; Claude Code re-reads at next prompt | One group | +| Add a new agent group | Spawn writes `CLAUDE.md` fresh from the composition pass | One group | diff --git a/docs/shared-source.md b/docs/shared-source.md new file mode 100644 index 0000000..ab725ea --- /dev/null +++ b/docs/shared-source.md @@ -0,0 +1,270 @@ +# Shared Source + +Replace per-group agent-runner-src copies with a single shared read-only mount. + +## Problem + +Each agent group gets a full copy of `container/agent-runner/src/` at creation time. This copy is mounted RW at `/app/src` in the container. Consequences: + +- Bug fixes and features don't propagate to existing groups +- Owner edits to `container/agent-runner/src/` silently don't apply to existing groups +- No tooling to diff or detect drift between groups and upstream +- The RW mount lets agents write to their own runtime source without approval +- Cross-cutting changes (host + container) break down when container code is per-group +- Skills have the same copy-and-drift problem + +## Design + +**Principle: RW is per-group, RO is shared.** Every mount is either read-only and shared across all groups, or read-write and scoped to one group. Source and skills become RO + shared. Personality, config, working files, and Claude state stay RW + per-group. This makes drift impossible by construction — no group can diverge from shared code because no group has write access to it. + +### Shared source mount + +Mount `container/agent-runner/src/` into all containers at `/app/src` as **read-only**. + +``` +container/agent-runner/src/ → /app/src (RO, shared) +``` + +Source is never baked into the image. `/app/src/` exists only via this mount — running without it is an intentional startup failure (entrypoint `bun run /app/src/index.ts` → ENOENT). Source-only changes never trigger image rebuilds; edits to `.ts` files take effect on next container spawn. + +Image rebuilds are only needed for: +- Agent-runner npm dependency changes (`package.json` / `bun.lock`) +- System packages, runtime versions, global CLI version bumps +- Dockerfile/entrypoint changes + +### Shared skills mount + +Mount `container/skills/` into all containers at `/app/skills/` as **read-only**. + +Per-group skill selection via `container.json`: + +```jsonc +{ + "skills": ["welcome", "agent-browser", "self-customize"] + // or "skills": "all" (default) +} +``` + +At every spawn, the host syncs symlinks in the group's `.claude-shared/skills/` directory to match the selected set. For `"all"`, the set is recomputed from the shared skills dir on each spawn — newly-added upstream skills appear without intervention. Symlinks for skills no longer in the set are removed. + +Each symlink points to a container path: + +``` +.claude-shared/skills/welcome → /app/skills/welcome +.claude-shared/skills/agent-browser → /app/skills/agent-browser +``` + +Claude Code scans `/home/node/.claude/skills/`, follows the symlinks, loads the selected skills. Same dangling-symlink-on-host pattern as `.claude-global.md` — host tools don't resolve the target, the container mount makes it valid at read time. + +### Per-group customization surface + +What remains per-group (unchanged): + +| Resource | Location | Mechanism | +|----------|----------|-----------| +| Personality / instructions | `groups//CLAUDE.md` | Mount at `/workspace/agent` (RW, live) | +| MCP servers | `groups//container.json` | Env var at spawn | +| apt/npm packages | `groups//container.json` | Per-group image layer | +| Skill selection | `groups//container.json` | Symlinks at spawn | +| Additional mounts | `groups//container.json` | Validated bind mounts | +| Agent provider / model | `groups//container.json` | Read by runner at startup | +| Claude Code settings | `.claude-shared/settings.json` | Mount at `/home/node/.claude` (RW) | +| Working files | `groups//` | Mount at `/workspace/agent` (RW) | + +### Self-modification + +Existing config-level self-mod tools (`install_packages`, `add_mcp_server`, `request_rebuild`) mutate `container.json` and per-group images, not source. Unchanged — stays per-group. + +Source-level self-modification (not yet implemented) uses staging: edits happen against a copy of `container/agent-runner/src/`, reviewed and swapped in on approval. Owner can also edit source directly. + +## Environment variables + +Env is for things read by code we don't own: glibc, Node's http agent, CLIs we shell out to. Everything NanoClaw-specific moves out of env. + +**Stays in env (read by non-nanoclaw code):** + +| Var | Reader | +|---|---| +| `TZ` | glibc, child processes | +| `HTTPS_PROXY`, `NO_PROXY` | Node http agent, curl, git, etc. (OneCLI-injected) | +| `NODE_EXTRA_CA_CERTS` | Node at startup (OneCLI-injected) | + +**Moves to `container.json` (read by runner at startup):** + +| Var | Reason | +|---|---| +| `AGENT_PROVIDER` | Per-group config; runner reads before importing provider module | +| `NANOCLAW_AGENT_GROUP_NAME` | Per-group identity | +| `NANOCLAW_ASSISTANT_NAME` | Per-group identity | +| `NANOCLAW_MAX_MESSAGES_PER_PROMPT` | Config constant; per-group override possible | + +**Deleted (admin gating moves to router):** + +`NANOCLAW_ADMIN_USER_IDS` is removed entirely — not moved to a new location. The container no longer makes authorization decisions. See **Router command gate** below. + +**Hardcoded as conventions:** + +| Var | Convention | +|---|---| +| `SESSION_INBOUND_DB_PATH` | `/workspace/inbound.db` | +| `SESSION_OUTBOUND_DB_PATH` | `/workspace/outbound.db` | +| `SESSION_HEARTBEAT_PATH` | `/workspace/.heartbeat` | +| `NANOCLAW_AGENT_GROUP_ID` | Read from `/workspace/agent/container.json` at startup | + +### Runner startup order + +The runner can no longer assume DB paths or provider identity are handed to it in env. Revised startup: + +1. Set up logging. +2. Read `/workspace/agent/container.json` (mounted RW but read-only here). +3. Open `/workspace/inbound.db` and `/workspace/outbound.db` (fixed paths). +4. Read bootstrap tables from `inbound.db` (destinations). +5. Import the provider module selected by `container.json`. +6. Enter the poll loop. + +### Router command gate + +The host router gates slash commands before writing to `messages_in`. The container still handles whatever reaches it; it just stops making authorization decisions. + +1. **Filtered commands** (`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/start`, `/remote-control`) → drop silently. Never reach the container. +2. **Admin commands** (`/clear`, `/compact`, `/context`, `/cost`, `/files`) → check sender against `user_roles` (owners + global admins + admins scoped to this agent group). + - Denied: write "Permission denied: `` requires admin access." directly to `messages_out` in the same thread. Do not write to `messages_in`. + - Allowed: pass through to container unchanged. +3. **Normal messages** → pass through unchanged. + +Admin commands that flow through continue to be handled the same way they are today: +- `/clear` — container's existing handler in `poll-loop.ts` resets session continuation and writes "Session cleared." +- `/compact`, `/context`, `/cost`, `/files` — container forwards them to Claude Code's native slash-command handler. + +Container receives only authorized messages. The runner has no admin concept, no `adminUserIds` field, no admin-gate branch — but it still recognizes `/clear` to reset session state. + +### Scope rules + +Each channel answers a single scope question: + +| Channel | Scope | What it holds | +|---|---|---| +| Env vars | Process | Things read by code we don't own (`TZ`, `HTTPS_PROXY`) | +| `container.json` | Per-group | Per-group config (MCP, packages, provider, model, skills, mounts) | +| `inbound.db` / `outbound.db` | Per-session | Messages, session state, and host-projected views of cross-group state (destinations) | +| Central DB (`data/v2.db`) | Cross-group | Users, roles, wiring, messaging groups, sessions | + +The runner reads from env (for external-convention vars), `container.json` (for its own group's config), and `inbound.db` (for messages + projected views). It never reads central DB directly — that's always host-projected through inbound.db first. + +After this change, the spawn-time `-e` flags shrink from ~10 to ~3-5 (TZ + OneCLI networking). No `NANOCLAW_*` env var survives. + +## Image layer strategy + +Single Dockerfile with aggressive layer ordering: stable layers first, frequently-bumped layers last. BuildKit's layer cache handles "upstream layers unchanged" rebuilds efficiently — a separate base image isn't justified. + +Two image tags exist at runtime: + +``` +nanoclaw-agent:latest — shared base (rebuild: dep/CLI bumps + Dockerfile changes) + └── nanoclaw-agent: — per-group apt/npm packages (rebuild: per-group via install_packages) +``` + +Layer order within the base: + +```dockerfile +FROM node:22-slim + +# System deps (apt) — rarely change +RUN apt-get install ... + +# Bun — pinned version, rarely changes +RUN ... bun + +# Agent-runner deps — cached independently of CLI versions +COPY agent-runner/package.json agent-runner/bun.lock /app/ +RUN cd /app && bun install --frozen-lockfile + +# Global CLIs — most stable first, most frequently bumped last +RUN pnpm install -g "vercel@${VERCEL_VERSION}" +RUN pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}" +RUN pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" +``` + +Bumping claude-code (the most common change) only rebuilds one layer. Agent-runner deps and other CLIs stay cached. + +Source is never baked into the image — always provided by the shared RO mount at runtime. + +### Agent-triggered version bumps + +Agents can request a claude-code version bump via a new self-mod tool (`bump_claude_code`). Same fire-and-forget pattern as `install_packages`: agent requests → owner approves → host rebuilds base image → kill all running containers. Unlike `install_packages` (per-group image), this rebuilds the shared base image and affects all groups. + +## Changes + +### `group-init.ts` + +- Remove the `agent-runner-src` copy block (lines 109–117) +- Remove the `skills/` copy block (lines 100–107) +- Skill symlinks are no longer created at init — sync is spawn-owned (see `container-runner.ts`) + +### `container-runner.ts` `buildMounts()` + +- Remove per-group `agent-runner-src` mount (lines 206–209) +- Add shared RO mount: `container/agent-runner/src/` → `/app/src` +- Add shared RO mount: `container/skills/` → `/app/skills` +- Sync skill symlinks in `.claude-shared/skills/` at spawn: write desired set from `container.json` (`"all"` = every skill in the shared dir, recomputed per spawn), remove symlinks not in the set + +### `container-runner.ts` `buildContainerArgs()` + +- Remove `-e SESSION_INBOUND_DB_PATH`, `-e SESSION_OUTBOUND_DB_PATH`, `-e SESSION_HEARTBEAT_PATH` (hardcoded conventions now) +- Remove `-e AGENT_PROVIDER` (moves to `container.json`) +- Remove `-e NANOCLAW_ASSISTANT_NAME`, `-e NANOCLAW_AGENT_GROUP_ID`, `-e NANOCLAW_AGENT_GROUP_NAME` +- Remove `-e NANOCLAW_MAX_MESSAGES_PER_PROMPT` +- Remove the `user_roles` join + `-e NANOCLAW_ADMIN_USER_IDS` block (lines 269–287) entirely. Admin gating moves to the router — no admin data passed to the container. +- Keep: `-e TZ`, OneCLI-contributed env (`HTTPS_PROXY`, `NODE_EXTRA_CA_CERTS`, `NO_PROXY`) + +### `router.ts` (new command gate) + +- Classify inbound slash commands before writing to `messages_in`: filtered / admin / normal. +- Filtered (`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/start`, `/remote-control`) → drop silently. +- Admin commands (`/clear`, `/compact`, `/context`, `/cost`, `/files`) from non-admins → write "Permission denied" directly to `messages_out`, skip `messages_in`. +- All authorized messages (admin commands from admins, and normal messages) → pass through unchanged to `messages_in`. Container handles them as today. +- The `ADMIN_COMMANDS` and `FILTERED_COMMANDS` lists move from `container/agent-runner/src/formatter.ts` to a host-side module. + +### `container/agent-runner/src/` (runner) + +- New `config.ts` module: loads `/workspace/agent/container.json` at startup, exposes a typed config singleton. All previous `process.env.NANOCLAW_*` reads go through this. +- `db/connection.ts`: use hardcoded paths `/workspace/inbound.db` and `/workspace/outbound.db`; drop `SESSION_*_DB_PATH` lookups. +- `formatter.ts`: remove `ADMIN_COMMANDS`, `FILTERED_COMMANDS`, and the `filtered` / admin-gate categorization. Keep enough to recognize `/clear` so `poll-loop.ts` can route it (e.g., a narrow `isClearCommand(msg)` helper). +- `poll-loop.ts`: remove `adminUserIds` field from config type and the admin-gate branch (lines 113–126). Keep the `/clear` handler (lines 128–142) — `/clear` still flows through from the router. +- Provider selection (`providers/index.ts` or equivalent): read provider from config singleton, not env. + +### `container-config.ts` + +- Add `skills` field to `ContainerConfig` (`string[] | "all"`, default `"all"`) +- Add fields: `provider`, `groupName`, `assistantName`, `maxMessagesPerPrompt` (optional, falls back to code default) + +### `.env` / `.env.example` + +- Remove any `NANOCLAW_*` entries that were documented as tunables. Update `.env.example` to list only TZ and OneCLI-related vars as valid overrides. + +### DB migration + +- Drop `agent_groups.agent_provider` column and `sessions.agent_provider` column. Source of truth becomes `container.json.provider`. +- One-time data migration reads existing values and writes them to each group's `container.json`. Sessions lose any per-session provider override — provider is a per-group property now. + +### Migration + +**This is a breaking change.** Host restart kills all running containers. No gradual rollout. Any code referencing dropped columns or removed env vars must be updated before the migration runs. + +- Provider install skills (`/add-opencode`, `/add-ollama-tool`) now write to the shared `container/agent-runner/src/providers/` tree. The per-group `providers/` overlay pattern is removed. Any uncommitted provider overlays must be upstreamed before cutover. +- Delete existing `data/v2-sessions//agent-runner-src/` directories on first run after cutover. +- Existing `.claude-shared/skills/` directories get replaced with symlinks on next spawn. +- DB migration (see above) reads `agent_provider` columns and projects into `container.json`, then drops the columns. + +## What triggers what + +| Change | Action needed | Scope | +|--------|--------------|-------| +| Agent-runner `.ts` source | Kill running containers | All groups | +| Agent-runner npm deps | Rebuild `nanoclaw-agent` + kill all | All groups | +| System deps, Bun, Node | Rebuild `nanoclaw-agent` + kill all | All groups | +| Claude-code version bump | Rebuild `nanoclaw-agent` + kill all | All groups (agent-triggerable) | +| Skill content | Kill running containers | All groups | +| Per-group apt/npm packages | `buildAgentGroupImage()` + kill | One group | +| Per-group config (MCP, mounts, provider, model, skills) | Kill that group's containers | One group | +| CLAUDE.md, working files | Nothing (live via RW mount) | One group | diff --git a/scripts/migrate-group-claude-md.ts b/scripts/migrate-group-claude-md.ts deleted file mode 100644 index dd16faf..0000000 --- a/scripts/migrate-group-claude-md.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * One-shot migration: wire each existing group up to global memory via - * an in-tree symlink + @-import. - * - * Claude Code's @-import only follows paths inside cwd, so a direct - * `@/workspace/global/CLAUDE.md` or `@../global/CLAUDE.md` silently does - * nothing (the import line is parsed but the target file is never - * loaded into context). The working approach: - * - * 1. Symlink `groups//.claude-global.md` → - * `/workspace/global/CLAUDE.md` (container path; dangling on host, - * valid inside the container via the /workspace/global mount). - * 2. Have the group's CLAUDE.md import the symlink: - * `@./.claude-global.md`. - * - * This script: - * - Creates the symlink if missing. - * - Replaces any existing broken `@/workspace/global/CLAUDE.md` or - * `@../global/CLAUDE.md` import line with the symlink form. - * - Prepends the symlink import if neither form is present. - * - Skips entirely if `groups/global/CLAUDE.md` doesn't exist. - * - * Idempotent — safe to re-run. - * - * Usage: pnpm exec tsx scripts/migrate-group-claude-md.ts - */ -import fs from 'fs'; -import path from 'path'; - -import { GROUPS_DIR } from '../src/config.js'; - -const GLOBAL_CLAUDE_MD = path.join(GROUPS_DIR, 'global', 'CLAUDE.md'); -const GLOBAL_MEMORY_CONTAINER_PATH = '/workspace/global/CLAUDE.md'; -const GLOBAL_MEMORY_LINK_NAME = '.claude-global.md'; -const IMPORT_LINE = `@./${GLOBAL_MEMORY_LINK_NAME}`; - -// Match any existing @-import that points at global/CLAUDE.md, whether -// via absolute path, relative path, or the new symlink form. -const EXISTING_IMPORT_REGEX = - /^@(?:\/workspace\/global\/CLAUDE\.md|\.\.\/global\/CLAUDE\.md|\.\/\.claude-global\.md)\s*$/m; - -if (!fs.existsSync(GLOBAL_CLAUDE_MD)) { - console.error(`No global CLAUDE.md at ${GLOBAL_CLAUDE_MD} — nothing to migrate.`); - process.exit(1); -} - -if (!fs.existsSync(GROUPS_DIR)) { - console.error(`No groups dir at ${GROUPS_DIR} — nothing to migrate.`); - process.exit(1); -} - -const entries = fs.readdirSync(GROUPS_DIR, { withFileTypes: true }); -let updated = 0; -let alreadyWired = 0; -let missingClaudeMd = 0; -let symlinksCreated = 0; - -for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'global') continue; - - const groupDir = path.join(GROUPS_DIR, entry.name); - - // Symlink (idempotent — skip if already present) - const linkPath = path.join(groupDir, GLOBAL_MEMORY_LINK_NAME); - let linkExists = false; - try { - fs.lstatSync(linkPath); - linkExists = true; - } catch { - /* missing */ - } - if (!linkExists) { - fs.symlinkSync(GLOBAL_MEMORY_CONTAINER_PATH, linkPath); - console.log(`[link] ${entry.name}: created ${GLOBAL_MEMORY_LINK_NAME}`); - symlinksCreated++; - } - - // CLAUDE.md import wiring - const claudeMd = path.join(groupDir, 'CLAUDE.md'); - if (!fs.existsSync(claudeMd)) { - console.log(`[skip] ${entry.name}: no CLAUDE.md`); - missingClaudeMd++; - continue; - } - - const body = fs.readFileSync(claudeMd, 'utf-8'); - const match = body.match(EXISTING_IMPORT_REGEX); - - if (match && match[0] === IMPORT_LINE) { - console.log(`[wired] ${entry.name}: already imports ${IMPORT_LINE}`); - alreadyWired++; - continue; - } - - let newBody: string; - if (match) { - // Replace the broken import with the working form - newBody = body.replace(EXISTING_IMPORT_REGEX, IMPORT_LINE); - console.log(`[fix] ${entry.name}: rewrote ${match[0]} → ${IMPORT_LINE}`); - } else { - // Prepend fresh - newBody = `${IMPORT_LINE}\n\n${body}`; - console.log(`[ok] ${entry.name}: prepended ${IMPORT_LINE}`); - } - - fs.writeFileSync(claudeMd, newBody); - updated++; -} - -console.log( - `\nDone. updated=${updated} alreadyWired=${alreadyWired} missingClaudeMd=${missingClaudeMd} symlinksCreated=${symlinksCreated}`, -); diff --git a/src/claude-md-compose.ts b/src/claude-md-compose.ts new file mode 100644 index 0000000..3cc74c1 --- /dev/null +++ b/src/claude-md-compose.ts @@ -0,0 +1,182 @@ +/** + * CLAUDE.md composition for agent groups. + * + * Replaces the per-group "written once at init, owned by the group" pattern + * with a host-regenerated entry point that imports: + * - a shared base (`container/CLAUDE.md` mounted RO at `/app/CLAUDE.md`) + * - optional per-skill fragments (skills that ship `instructions.md`) + * - optional per-MCP-server fragments (inline `instructions` field in + * `container.json`) + * - per-group agent memory (`CLAUDE.local.md`, auto-loaded by Claude Code) + * + * Runs on every spawn from `container-runner.buildMounts()`. Deterministic — + * same inputs produce the same CLAUDE.md, and stale fragments are pruned. + * + * See `docs/claude-md-composition.md` for the full design. + */ +import fs from 'fs'; +import path from 'path'; + +import { GROUPS_DIR } from './config.js'; +import { readContainerConfig } from './container-config.js'; +import { log } from './log.js'; +import type { AgentGroup } from './types.js'; + +// Symlink targets are container paths — dangling on host (hence the readlink +// dance instead of existsSync), valid inside the container via RO mounts. +const SHARED_CLAUDE_MD_CONTAINER_PATH = '/app/CLAUDE.md'; +const SHARED_SKILLS_CONTAINER_BASE = '/app/skills'; + +const COMPOSED_HEADER = ''; + +/** + * Regenerate `groups//CLAUDE.md` from the shared base, enabled skill + * fragments, and MCP server fragments declared in `container.json`. Creates + * an empty `CLAUDE.local.md` if missing. + */ +export function composeGroupClaudeMd(group: AgentGroup): void { + const groupDir = path.resolve(GROUPS_DIR, group.folder); + if (!fs.existsSync(groupDir)) { + fs.mkdirSync(groupDir, { recursive: true }); + } + + const sharedLink = path.join(groupDir, '.claude-shared.md'); + syncSymlink(sharedLink, SHARED_CLAUDE_MD_CONTAINER_PATH); + + const fragmentsDir = path.join(groupDir, '.claude-fragments'); + if (!fs.existsSync(fragmentsDir)) { + fs.mkdirSync(fragmentsDir, { recursive: true }); + } + + // Desired fragment set. + const config = readContainerConfig(group.folder); + const desired = new Map(); + + // Skill fragments — every skill that ships an `instructions.md`. + // TODO (shared-source refactor): respect `container.json` skill selection. + const skillsHostDir = path.join(process.cwd(), 'container', 'skills'); + if (fs.existsSync(skillsHostDir)) { + for (const skillName of fs.readdirSync(skillsHostDir)) { + const hostFragment = path.join(skillsHostDir, skillName, 'instructions.md'); + if (fs.existsSync(hostFragment)) { + desired.set(`${skillName}.md`, { + type: 'symlink', + content: `${SHARED_SKILLS_CONTAINER_BASE}/${skillName}/instructions.md`, + }); + } + } + } + + // MCP server fragments — inline instructions from container.json. + for (const [name, mcp] of Object.entries(config.mcpServers)) { + if (mcp.instructions) { + desired.set(`mcp-${name}.md`, { + type: 'inline', + content: mcp.instructions, + }); + } + } + + // Reconcile: drop stale, write desired. + for (const existing of fs.readdirSync(fragmentsDir)) { + if (!desired.has(existing)) { + fs.unlinkSync(path.join(fragmentsDir, existing)); + } + } + for (const [name, frag] of desired) { + const fragPath = path.join(fragmentsDir, name); + if (frag.type === 'symlink') { + syncSymlink(fragPath, frag.content); + } else { + writeAtomic(fragPath, frag.content); + } + } + + // Composed entry — imports only. + const imports = ['@./.claude-shared.md']; + for (const name of [...desired.keys()].sort()) { + imports.push(`@./.claude-fragments/${name}`); + } + const body = [COMPOSED_HEADER, ...imports, ''].join('\n'); + writeAtomic(path.join(groupDir, 'CLAUDE.md'), body); + + const localFile = path.join(groupDir, 'CLAUDE.local.md'); + if (!fs.existsSync(localFile)) { + fs.writeFileSync(localFile, ''); + } +} + +/** + * One-time cutover from the `groups/global/CLAUDE.md` + `.claude-global.md` + * pattern. Idempotent — safe to run on every host startup. + * + * For each group dir: + * - remove `.claude-global.md` symlink if present + * - rename `CLAUDE.md` → `CLAUDE.local.md` (only if `CLAUDE.local.md` + * doesn't already exist — preserves pre-cutover content as per-group + * memory; after the first spawn regenerates `CLAUDE.md`, this branch + * is skipped because `CLAUDE.local.md` now exists) + * + * Globally: + * - delete `groups/global/` (content already in `container/CLAUDE.md`) + */ +export function migrateGroupsToClaudeLocal(): void { + if (!fs.existsSync(GROUPS_DIR)) return; + + const actions: string[] = []; + + for (const entry of fs.readdirSync(GROUPS_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name === 'global') continue; + + const groupDir = path.join(GROUPS_DIR, entry.name); + + const oldGlobalLink = path.join(groupDir, '.claude-global.md'); + try { + fs.lstatSync(oldGlobalLink); + fs.unlinkSync(oldGlobalLink); + actions.push(`${entry.name}/.claude-global.md removed`); + } catch { + /* already gone */ + } + + const claudeMd = path.join(groupDir, 'CLAUDE.md'); + const claudeLocal = path.join(groupDir, 'CLAUDE.local.md'); + if (fs.existsSync(claudeMd) && !fs.existsSync(claudeLocal)) { + fs.renameSync(claudeMd, claudeLocal); + actions.push(`${entry.name}/CLAUDE.md → CLAUDE.local.md`); + } + } + + const globalDir = path.join(GROUPS_DIR, 'global'); + if (fs.existsSync(globalDir)) { + fs.rmSync(globalDir, { recursive: true, force: true }); + actions.push('groups/global/ removed'); + } + + if (actions.length > 0) { + log.info('Migrated groups to CLAUDE.local.md model', { actions }); + } +} + +function syncSymlink(linkPath: string, target: string): void { + let currentTarget: string | null = null; + try { + currentTarget = fs.readlinkSync(linkPath); + } catch { + /* missing */ + } + if (currentTarget === target) return; + try { + fs.unlinkSync(linkPath); + } catch { + /* missing */ + } + fs.symlinkSync(target, linkPath); +} + +function writeAtomic(filePath: string, content: string): void { + const tmp = `${filePath}.tmp-${process.pid}`; + fs.writeFileSync(tmp, content); + fs.renameSync(tmp, filePath); +} diff --git a/src/container-config.ts b/src/container-config.ts index 90c24e9..d972842 100644 --- a/src/container-config.ts +++ b/src/container-config.ts @@ -18,6 +18,10 @@ export interface McpServerConfig { command: string; args?: string[]; env?: Record; + // Optional always-in-context guidance. When set, the host writes the + // content to `.claude-fragments/mcp-.md` at spawn and imports it + // into the composed CLAUDE.md. + instructions?: string; } export interface AdditionalMountConfig { diff --git a/src/container-runner.ts b/src/container-runner.ts index 32499bc..6f7f1d1 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -12,6 +12,7 @@ import { OneCLI } from '@onecli-sh/sdk'; import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, ONECLI_URL, TIMEZONE } from './config.js'; import { readContainerConfig, writeContainerConfig } from './container-config.js'; import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; +import { composeGroupClaudeMd } from './claude-md-compose.js'; import { getAgentGroup } from './db/agent-groups.js'; import { getDb, hasTable } from './db/connection.js'; import { initGroupFilesystem } from './group-init.js'; @@ -195,6 +196,10 @@ function buildMounts( const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared'); syncSkillSymlinks(claudeDir, containerConfig); + // Compose CLAUDE.md fresh every spawn from the shared base, enabled skill + // fragments, and MCP server instructions. See `claude-md-compose.ts`. + composeGroupClaudeMd(agentGroup); + const mounts: VolumeMount[] = []; const sessDir = sessionDir(agentGroup.id, session.id); const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder); @@ -218,6 +223,13 @@ function buildMounts( mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: true }); } + // Shared CLAUDE.md — read-only, imported by the composed entry point via + // the `.claude-shared.md` symlink inside the group dir. + const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md'); + if (fs.existsSync(sharedClaudeMd)) { + mounts.push({ hostPath: sharedClaudeMd, containerPath: '/app/CLAUDE.md', readonly: true }); + } + // Per-group .claude-shared at /home/node/.claude (Claude state, settings, // skill symlinks) mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false }); diff --git a/src/group-init.ts b/src/group-init.ts index 211ef1f..437d10f 100644 --- a/src/group-init.ts +++ b/src/group-init.ts @@ -6,18 +6,6 @@ import { initContainerConfig } from './container-config.js'; import { log } from './log.js'; import type { AgentGroup } from './types.js'; -// Container path where groups/global is mounted. The symlink we drop -// into each group's dir resolves to this target inside the container. -// It's a dangling symlink on the host — that's fine, host tools don't -// follow it and the container mount makes it valid at read time. -const GLOBAL_MEMORY_CONTAINER_PATH = '/workspace/global/CLAUDE.md'; - -// Symlink name inside the group's dir. Claude Code's @-import only -// follows paths inside cwd, so we can't reference /workspace/global -// directly — we symlink into the group dir and import the symlink. -export const GLOBAL_MEMORY_LINK_NAME = '.claude-global.md'; -export const GLOBAL_CLAUDE_IMPORT = `@./${GLOBAL_MEMORY_LINK_NAME}`; - const DEFAULT_SETTINGS_JSON = JSON.stringify( { @@ -36,11 +24,15 @@ const DEFAULT_SETTINGS_JSON = * every step is gated on the target not already existing, so re-running on * an already-initialized group is a no-op. * - * Called once per group lifetime: at creation, or defensively from + * Called once per group lifetime at creation, or defensively from * `buildMounts()` for groups that pre-date this code path. * * Source code and skills are shared RO mounts — not copied per-group. * Skill symlinks are synced at spawn time by container-runner.ts. + * + * The composed `CLAUDE.md` is NOT written here — it's regenerated on every + * spawn by `composeGroupClaudeMd()` (see `claude-md-compose.ts`). Initial + * per-group instructions (if provided) seed `CLAUDE.local.md`. */ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: string }): void { const initialized: string[] = []; @@ -52,29 +44,13 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s initialized.push('groupDir'); } - // groups//.claude-global.md — symlink into the group dir so - // Claude Code's @-import can follow it. Uses lstat to avoid tripping - // existsSync on a dangling symlink (target only resolves inside the - // container). - const globalLinkPath = path.join(groupDir, GLOBAL_MEMORY_LINK_NAME); - let linkExists = false; - try { - fs.lstatSync(globalLinkPath); - linkExists = true; - } catch { - /* missing — recreate */ - } - if (!linkExists) { - fs.symlinkSync(GLOBAL_MEMORY_CONTAINER_PATH, globalLinkPath); - initialized.push('.claude-global.md'); - } - - // groups//CLAUDE.md — written once, then owned by the group - const claudeMdFile = path.join(groupDir, 'CLAUDE.md'); - if (!fs.existsSync(claudeMdFile)) { - const body = [GLOBAL_CLAUDE_IMPORT, '', opts?.instructions ?? `# ${group.name}`].join('\n') + '\n'; - fs.writeFileSync(claudeMdFile, body); - initialized.push('CLAUDE.md'); + // groups//CLAUDE.local.md — per-group agent memory, auto-loaded by + // Claude Code. Seeded with caller-provided instructions on first creation. + const claudeLocalFile = path.join(groupDir, 'CLAUDE.local.md'); + if (!fs.existsSync(claudeLocalFile)) { + const body = opts?.instructions ? opts.instructions + '\n' : ''; + fs.writeFileSync(claudeLocalFile, body); + initialized.push('CLAUDE.local.md'); } // groups//container.json — empty container config, replaces the diff --git a/src/index.ts b/src/index.ts index 1ec8619..d3de4d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import path from 'path'; import { DATA_DIR } from './config.js'; +import { migrateGroupsToClaudeLocal } from './claude-md-compose.js'; import { initDb } from './db/connection.js'; import { runMigrations } from './db/migrations/index.js'; import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js'; @@ -63,6 +64,9 @@ async function main(): Promise { runMigrations(db); log.info('Central DB ready', { path: dbPath }); + // 1b. One-time filesystem cutover — idempotent, no-op after first run. + migrateGroupsToClaudeLocal(); + // 2. Container runtime ensureContainerRuntimeRunning(); cleanupOrphans(); From 7da24b166d6550beb6084c5240efa9ef471f4ac5 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 22 Apr 2026 10:42:56 +0000 Subject: [PATCH 093/185] fix(agent-runner): remove thread_id filter and fix processing ack on empty result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concurrent poll in processQuery filtered out messages with mismatched thread_ids, causing a deadlock when the initial batch (e.g. a host-generated welcome trigger with null thread_id) completed but follow-ups arrived with a different thread_id (e.g. a Discord DM). The query stayed open waiting for matching-thread pushes that never came, blocking the poll loop indefinitely. Thread routing is the router's concern — per-thread sessions already isolate threads into separate containers; shared sessions intentionally merge everything. Removed the filter. Also fixed processing_ack: a result event (with or without text) means the turn is done, but markCompleted only ran when event.text was truthy. When the agent responded via MCP send_message (empty result text), the initial batch stayed in 'processing' for the query's lifetime, creating false stuck signals in the host sweep. Now marks completed on any result event. Belt-and-suspenders: init-first-agent welcome trigger now sets threadId to the DM platform_id instead of null. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/poll-loop.ts | 34 ++++++++++++++++++------- scripts/init-first-agent.ts | 2 +- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 5ccb2e4..d93bdd3 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -160,7 +160,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { const skippedSet = new Set(skipped); const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id)); try { - const result = await processQuery(query, routing); + const result = await processQuery(query, routing, processingIds); if (result.continuation && result.continuation !== continuation) { continuation = result.continuation; setStoredSessionId(continuation); @@ -189,6 +189,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise { }); } + // Ensure completed even if processQuery ended without a result event + // (e.g. stream closed unexpectedly). markCompleted(processingIds); log(`Completed ${ids.length} message(s)`); } @@ -232,7 +234,11 @@ interface QueryResult { continuation?: string; } -async function processQuery(query: AgentQuery, routing: RoutingContext): Promise { +async function processQuery( + query: AgentQuery, + routing: RoutingContext, + initialBatchIds: string[], +): Promise { let queryContinuation: string | undefined; let done = false; @@ -246,14 +252,15 @@ async function processQuery(query: AgentQuery, routing: RoutingContext): Promise if (done) return; // Skip system messages (MCP tool responses) and /clear (needs fresh query). - // Also defer messages whose thread_id differs from the active turn's routing - // — mixing threads into one streaming turn would send the reply to the wrong - // thread because `routing` is captured at turn start. The next turn will pick - // them up with fresh routing. + // Thread routing is the router's concern — if a message landed in this + // session, the agent should see it. Per-thread sessions already isolate + // threads into separate containers; shared sessions intentionally merge + // everything. Filtering on thread_id here caused deadlocks when the + // initial batch and follow-ups had mismatched thread_ids (e.g. a + // host-generated welcome trigger with null thread vs a Discord DM reply). const newMessages = getPendingMessages().filter((m) => { if (m.kind === 'system') return false; if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false; - if ((m.thread_id ?? null) !== (routing.threadId ?? null)) return false; return true; }); if (newMessages.length > 0) { @@ -282,8 +289,17 @@ async function processQuery(query: AgentQuery, routing: RoutingContext): Promise // effectively orphaned and the next message started a blank // Claude session with no prior context. setStoredSessionId(event.continuation); - } else if (event.type === 'result' && event.text) { - dispatchResultText(event.text, routing); + } else if (event.type === 'result') { + // A result — with or without text — means the turn is done. Mark + // the initial batch completed now so the host sweep doesn't see + // stale 'processing' claims while the query stays open for + // follow-up pushes. The agent may have responded via MCP + // (send_message) mid-turn, or the message may not need a response + // at all — either way the turn is finished. + markCompleted(initialBatchIds); + if (event.text) { + dispatchResultText(event.text, routing); + } } } } finally { diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index b3d7bd0..dcb99b5 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -363,7 +363,7 @@ async function sendWelcomeViaCliSocket( to: { channelType: dmMg.channel_type, platformId: dmMg.platform_id, - threadId: null, + threadId: dmMg.platform_id, }, }) + '\n'; socket.write(payload, (err) => { From fb82c1babb8d3ad8cc84541a9ad5fa2dece5d8a9 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 13:46:28 +0300 Subject: [PATCH 094/185] Delete docs/shared-src.md --- docs/shared-src.md | 276 --------------------------------------------- 1 file changed, 276 deletions(-) delete mode 100644 docs/shared-src.md diff --git a/docs/shared-src.md b/docs/shared-src.md deleted file mode 100644 index 90476b6..0000000 --- a/docs/shared-src.md +++ /dev/null @@ -1,276 +0,0 @@ -# Shared Source - -Replace per-group agent-runner-src copies with a single shared read-only mount. - -## Problem - -Each agent group gets a full copy of `container/agent-runner/src/` at creation time. This copy is mounted RW at `/app/src` in the container. Consequences: - -- Bug fixes and features don't propagate to existing groups -- Owner edits to `container/agent-runner/src/` silently don't apply to existing groups -- No tooling to diff or detect drift between groups and upstream -- The RW mount lets agents write to their own runtime source without approval -- Cross-cutting changes (host + container) break down when container code is per-group -- Skills have the same copy-and-drift problem - -## Design - -**Principle: RW is per-group, RO is shared.** Every mount is either read-only and shared across all groups, or read-write and scoped to one group. Source and skills become RO + shared. Personality, config, working files, and Claude state stay RW + per-group. This makes drift impossible by construction — no group can diverge from shared code because no group has write access to it. - -### Shared source mount - -Mount `container/agent-runner/src/` into all containers at `/app/src` as **read-only**. - -``` -container/agent-runner/src/ → /app/src (RO, shared) -``` - -Source is never baked into the image. `/app/src/` exists only via this mount — running without it is an intentional startup failure (entrypoint `bun run /app/src/index.ts` → ENOENT). Source-only changes never trigger image rebuilds; edits to `.ts` files take effect on next container spawn. - -Image rebuilds are only needed for: -- Agent-runner npm dependency changes (`package.json` / `bun.lock`) -- System packages, runtime versions, global CLI version bumps -- Dockerfile/entrypoint changes - -### Shared skills mount - -Mount `container/skills/` into all containers at `/app/skills/` as **read-only**. - -Per-group skill selection via `container.json`: - -```jsonc -{ - "skills": ["welcome", "agent-browser", "self-customize"] - // or "skills": "all" (default) -} -``` - -At every spawn, the host syncs symlinks in the group's `.claude-shared/skills/` directory to match the selected set. For `"all"`, the set is recomputed from the shared skills dir on each spawn — newly-added upstream skills appear without intervention. Symlinks for skills no longer in the set are removed. - -Each symlink points to a container path: - -``` -.claude-shared/skills/welcome → /app/skills/welcome -.claude-shared/skills/agent-browser → /app/skills/agent-browser -``` - -Claude Code scans `/home/node/.claude/skills/`, follows the symlinks, loads the selected skills. Same dangling-symlink-on-host pattern as `.claude-global.md` — host tools don't resolve the target, the container mount makes it valid at read time. - -### Per-group customization surface - -What remains per-group (unchanged): - -| Resource | Location | Mechanism | -|----------|----------|-----------| -| Personality / instructions | `groups//CLAUDE.md` | Mount at `/workspace/agent` (RW, live) | -| MCP servers | `groups//container.json` | Read by runner at startup | -| apt/npm packages | `groups//container.json` | Per-group image layer | -| Skill selection | `groups//container.json` | Symlinks at spawn | -| Additional mounts | `groups//container.json` | Validated bind mounts | -| Agent provider / model | `groups//container.json` | Read by runner at startup | -| Claude Code settings | `.claude-shared/settings.json` | Mount at `/home/node/.claude` (RW) | -| Working files | `groups//` | Mount at `/workspace/agent` (RW) | - -`container.json` is mounted **read-only** inside the container (separate RO mount at `/workspace/agent/container.json`). The agent can read its own config but cannot modify it — config changes go through the self-mod approval flow on the host. The parent group dir (`/workspace/agent/`) stays RW for working files and CLAUDE.md. - -### Self-modification - -Existing config-level self-mod tools (`install_packages`, `add_mcp_server`, `request_rebuild`) mutate `container.json` and per-group images, not source. The approval flow should ask whether to apply the change to the current group or all groups — users often expect packages and MCP servers installed for one agent to be available everywhere. "All groups" writes to each group's `container.json` and rebuilds per-group images where needed. - -Source-level self-modification (not yet implemented) uses staging: edits happen against a copy of `container/agent-runner/src/`, reviewed and swapped in on approval. Owner can also edit source directly. - -### Providers - -Provider install skills (`/add-opencode`, `/add-ollama-provider`) add the provider module to the shared `container/agent-runner/src/providers/` tree. This is an instance-level change — owner/admin action, affects all groups. Which provider a group uses is per-group config (`"provider": "opencode"` in `container.json`). The shared source ships all installed provider modules; groups select. - -## Environment variables - -Env is for things read by code we don't own: glibc, Node's http agent, CLIs we shell out to. Everything NanoClaw-specific moves out of env. - -**Stays in env (read by non-nanoclaw code):** - -| Var | Reader | -|---|---| -| `TZ` | glibc, child processes | -| `HTTPS_PROXY`, `NO_PROXY` | Node http agent, curl, git, etc. (OneCLI-injected) | -| `NODE_EXTRA_CA_CERTS` | Node at startup (OneCLI-injected) | - -**Moves to `container.json` (read by runner at startup):** - -| Var | Reason | -|---|---| -| `AGENT_PROVIDER` | Per-group config; runner reads before importing provider module | -| `NANOCLAW_AGENT_GROUP_NAME` | Per-group identity | -| `NANOCLAW_ASSISTANT_NAME` | Per-group identity | -| `NANOCLAW_MAX_MESSAGES_PER_PROMPT` | Config constant; per-group override possible | - -**Deleted (admin gating moves to router):** - -`NANOCLAW_ADMIN_USER_IDS` is removed entirely — not moved to a new location. The container no longer makes authorization decisions. See **Router command gate** below. - -**Hardcoded as conventions:** - -| Var | Convention | -|---|---| -| `SESSION_INBOUND_DB_PATH` | `/workspace/inbound.db` | -| `SESSION_OUTBOUND_DB_PATH` | `/workspace/outbound.db` | -| `SESSION_HEARTBEAT_PATH` | `/workspace/.heartbeat` | -| `NANOCLAW_AGENT_GROUP_ID` | Read from `/workspace/agent/container.json` at startup | - -### Runner startup order - -The runner can no longer assume DB paths or provider identity are handed to it in env. Revised startup: - -1. Set up logging. -2. Read `/workspace/agent/container.json` (mounted RW but read-only here). -3. Open `/workspace/inbound.db` and `/workspace/outbound.db` (fixed paths). -4. Read bootstrap tables from `inbound.db` (destinations). -5. Import the provider module selected by `container.json`. -6. Enter the poll loop. - -### Router command gate - -The host router gates slash commands before writing to `messages_in`. The container still handles whatever reaches it; it just stops making authorization decisions. - -1. **Filtered commands** (`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/start`, `/remote-control`) → drop silently. Never reach the container. -2. **Admin commands** (`/clear`, `/compact`, `/context`, `/cost`, `/files`) → check sender against `user_roles` (owners + global admins + admins scoped to this agent group). - - Denied: write "Permission denied: `` requires admin access." directly to `messages_out` in the same thread. Do not write to `messages_in`. - - Allowed: pass through to container unchanged. -3. **Normal messages** → pass through unchanged. - -Admin commands that flow through continue to be handled the same way they are today: -- `/clear` — container's existing handler in `poll-loop.ts` resets session continuation and writes "Session cleared." -- `/compact`, `/context`, `/cost`, `/files` — container forwards them to Claude Code's native slash-command handler. - -Container receives only authorized messages. The runner has no admin concept, no `adminUserIds` field, no admin-gate branch — but it still recognizes `/clear` to reset session state. - -### Scope rules - -Each channel answers a single scope question: - -| Channel | Scope | What it holds | -|---|---|---| -| Env vars | Process | Things read by code we don't own (`TZ`, `HTTPS_PROXY`) | -| `container.json` | Per-group | Per-group config (MCP, packages, provider, model, skills, mounts) | -| `inbound.db` / `outbound.db` | Per-session | Messages, session state, and host-projected views of cross-group state (destinations) | -| Central DB (`data/v2.db`) | Cross-group | Users, roles, wiring, messaging groups, sessions | - -The runner reads from env (for external-convention vars), `container.json` (for its own group's config), and `inbound.db` (for messages + projected views). It never reads central DB directly — that's always host-projected through inbound.db first. - -After this change, the spawn-time `-e` flags shrink from ~10 to ~3-5 (TZ + OneCLI networking). No `NANOCLAW_*` env var survives. - -## Image layer strategy - -Single Dockerfile with aggressive layer ordering: stable layers first, frequently-bumped layers last. BuildKit's layer cache handles "upstream layers unchanged" rebuilds efficiently — a separate base image isn't justified. - -Two image tags exist at runtime: - -``` -nanoclaw-agent:latest — shared base (rebuild: dep/CLI bumps + Dockerfile changes) - └── nanoclaw-agent: — per-group apt/npm packages (rebuild: per-group via install_packages) -``` - -Layer order within the base: - -```dockerfile -FROM node:22-slim - -# System deps (apt) — rarely change -RUN apt-get install ... - -# Bun — pinned version, rarely changes -RUN ... bun - -# Agent-runner deps — cached independently of CLI versions -COPY agent-runner/package.json agent-runner/bun.lock /app/ -RUN cd /app && bun install --frozen-lockfile - -# Global CLIs — most stable first, most frequently bumped last -RUN pnpm install -g "vercel@${VERCEL_VERSION}" -RUN pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}" -RUN pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" -``` - -Bumping claude-code (the most common change) only rebuilds one layer. Agent-runner deps and other CLIs stay cached. - -Source is never baked into the image — always provided by the shared RO mount at runtime. - -### Agent-triggered version bumps - -Agents can request a claude-code version bump via a new self-mod tool (`bump_claude_code`). Same fire-and-forget pattern as `install_packages`: agent requests → owner approves → host rebuilds base image → kill all running containers. Unlike `install_packages` (per-group image), this rebuilds the shared base image and affects all groups. - -## Changes - -### `group-init.ts` - -- Remove the `agent-runner-src` copy block (lines 109–117) -- Remove the `skills/` copy block (lines 100–107) -- Skill symlinks are no longer created at init — sync is spawn-owned (see `container-runner.ts`) - -### `container-runner.ts` `buildMounts()` - -- Remove per-group `agent-runner-src` mount (lines 206–209) -- Add shared RO mount: `container/agent-runner/src/` → `/app/src` -- Add shared RO mount: `container/skills/` → `/app/skills` -- Sync skill symlinks in `.claude-shared/skills/` at spawn: write desired set from `container.json` (`"all"` = every skill in the shared dir, recomputed per spawn), remove symlinks not in the set - -### `container-runner.ts` `buildContainerArgs()` - -- Remove `-e SESSION_INBOUND_DB_PATH`, `-e SESSION_OUTBOUND_DB_PATH`, `-e SESSION_HEARTBEAT_PATH` (hardcoded conventions now) -- Remove `-e AGENT_PROVIDER` (moves to `container.json`) -- Remove `-e NANOCLAW_ASSISTANT_NAME`, `-e NANOCLAW_AGENT_GROUP_ID`, `-e NANOCLAW_AGENT_GROUP_NAME` -- Remove `-e NANOCLAW_MAX_MESSAGES_PER_PROMPT` -- Remove the `user_roles` join + `-e NANOCLAW_ADMIN_USER_IDS` block (lines 269–287) entirely. Admin gating moves to the router — no admin data passed to the container. -- Keep: `-e TZ`, OneCLI-contributed env (`HTTPS_PROXY`, `NODE_EXTRA_CA_CERTS`, `NO_PROXY`) - -### `router.ts` (new command gate) - -- Classify inbound slash commands before writing to `messages_in`: filtered / admin / normal. -- Filtered (`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/start`, `/remote-control`) → drop silently. -- Admin commands (`/clear`, `/compact`, `/context`, `/cost`, `/files`) from non-admins → write "Permission denied" directly to `messages_out`, skip `messages_in`. -- All authorized messages (admin commands from admins, and normal messages) → pass through unchanged to `messages_in`. Container handles them as today. -- The `ADMIN_COMMANDS` and `FILTERED_COMMANDS` lists move from `container/agent-runner/src/formatter.ts` to a host-side module. - -### `container/agent-runner/src/` (runner) - -- New `config.ts` module: loads `/workspace/agent/container.json` at startup, exposes a typed config singleton. All previous `process.env.NANOCLAW_*` reads go through this. -- `db/connection.ts`: use hardcoded paths `/workspace/inbound.db` and `/workspace/outbound.db`; drop `SESSION_*_DB_PATH` lookups. -- `formatter.ts`: remove `ADMIN_COMMANDS`, `FILTERED_COMMANDS`, and the `filtered` / admin-gate categorization. Keep enough to recognize `/clear` so `poll-loop.ts` can route it (e.g., a narrow `isClearCommand(msg)` helper). -- `poll-loop.ts`: remove `adminUserIds` field from config type and the admin-gate branch (lines 113–126). Keep the `/clear` handler (lines 128–142) — `/clear` still flows through from the router. -- Provider selection (`providers/index.ts` or equivalent): read provider from config singleton, not env. - -### `container-config.ts` - -- Add `skills` field to `ContainerConfig` (`string[] | "all"`, default `"all"`) -- Add fields: `provider`, `groupName`, `assistantName`, `maxMessagesPerPrompt` (optional, falls back to code default) - -### `.env` / `.env.example` - -- Remove any `NANOCLAW_*` entries that were documented as tunables. Update `.env.example` to list only TZ and OneCLI-related vars as valid overrides. - -### DB migration - -- Drop `agent_groups.agent_provider` column and `sessions.agent_provider` column. Source of truth becomes `container.json.provider`. -- One-time data migration reads existing values and writes them to each group's `container.json`. Sessions lose any per-session provider override — provider is a per-group property now. - -### Migration - -**This is a breaking change.** Host restart kills all running containers. No gradual rollout. Any code referencing dropped columns or removed env vars must be updated before the migration runs. - -- Provider install skills (`/add-opencode`, `/add-ollama-provider`) write to the shared `container/agent-runner/src/providers/` tree. Per-group provider overlays are removed. Existing provider code in any per-group `agent-runner-src/providers/` must be moved to the shared tree before cutover. -- Delete existing `data/v2-sessions//agent-runner-src/` directories on first run after cutover. -- Existing `.claude-shared/skills/` directories get replaced with symlinks on next spawn. -- DB migration (see above) reads `agent_provider` columns and projects into `container.json`, then drops the columns. - -## What triggers what - -| Change | Action needed | Scope | -|--------|--------------|-------| -| Agent-runner `.ts` source | Kill running containers | All groups | -| Agent-runner npm deps | Rebuild `nanoclaw-agent` + kill all | All groups | -| System deps, Bun, Node | Rebuild `nanoclaw-agent` + kill all | All groups | -| Claude-code version bump | Rebuild `nanoclaw-agent` + kill all | All groups (agent-triggerable) | -| Skill content | Kill running containers | All groups | -| Per-group apt/npm packages | `buildAgentGroupImage()` + kill | One group | -| Per-group config (MCP, mounts, provider, model, skills) | Kill that group's containers | One group | -| CLAUDE.md, working files | Nothing (live via RW mount) | One group | From a70e41856b40ba107a41e362dd42cfe37a2cee41 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 14:27:29 +0300 Subject: [PATCH 095/185] feat(setup): Microsoft Teams wiring with Claude handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teams is the most complex channel NanoClaw supports — no "paste a token" shortcut exists. Operators walk through ~6 Azure portal steps (app registration, client secret, Azure Bot resource, messaging endpoint, Teams channel, manifest sideload). The driver makes each step as guided as possible and gives the operator an explicit escape to interactive Claude whenever they get stuck. Handoff mechanism (reusable across channels): - setup/lib/claude-handoff.ts: offerClaudeHandoff(ctx) spawns `claude --append-system-prompt --permission-mode acceptEdits` with stdio: 'inherit', returns when Claude exits so the driver can re-offer the same step. Context captures channel, current step, completed steps, collected values (secrets redacted), and file refs. - validateWithHelpEscape / isHelpEscape: wrap clack text/password prompts so typing '?' triggers the handoff mid-paste. - Parallel to the existing claude-assist.ts (which is failure-triggered and runs claude -p for a one-shot command suggestion). This is the user-initiated, interactive counterpart. Teams driver (setup/channels/teams.ts): - 6-step walkthrough, each a clack note + paste prompts + stepGate select ("Done / Stuck — hand me off to Claude / Show me again"). - Collects TEAMS_APP_ID / TEAMS_APP_TENANT_ID / TEAMS_APP_PASSWORD / TEAMS_APP_TYPE plus the operator's public HTTPS URL (advisory — no tunnel automation yet). - Emits the full Azure CLI invocation alongside the portal steps for operators who prefer scripted creation. - UUID/password prompts accept '?' as a help escape; select prompts have an explicit 'Stuck' option that triggers the handoff. Manifest generator (setup/lib/teams-manifest.ts): - Builds data/teams/teams-app-package.zip in-process: manifest.json (schema v1.16) with app ID injected, a 32×32 outline icon, a 192×192 brand-blue color icon, bundled with the system `zip`. - Minimal hand-rolled PNG encoder (CRC32 table + zlib deflate) so we don't need ImageMagick or vendored binary blobs. - ~2.5KB zip, validates with `unzip -l`; icons verify as valid PNGs. Installer (setup/add-teams.sh): - Non-interactive mirror of add-discord.sh. Validates the four env vars, copies adapter from origin/channels, installs @chat-adapter/teams@4.26.0, upserts creds to .env + data/env/env, restarts the service. auto.ts: Teams option in askChannelChoice with 'complex setup' hint, dispatch to runTeamsChannel. Deferred (known limitation, operator instructed to finish manually): - Wait-for-first-DM pairing to capture the auto-generated Teams platform_id. Teams platform IDs are only discoverable after the first inbound activity. The driver installs the adapter and stops there; the operator DMs the bot, NanoClaw auto-creates the messaging group, and they wire an agent via /manage-channels. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-teams.sh | 131 ++++++++ setup/auto.ts | 12 +- setup/channels/teams.ts | 629 ++++++++++++++++++++++++++++++++++++ setup/lib/claude-assist.ts | 2 + setup/lib/claude-handoff.ts | 194 +++++++++++ setup/lib/teams-manifest.ts | 271 ++++++++++++++++ 6 files changed, 1236 insertions(+), 3 deletions(-) create mode 100755 setup/add-teams.sh create mode 100644 setup/channels/teams.ts create mode 100644 setup/lib/claude-handoff.ts create mode 100644 setup/lib/teams-manifest.ts diff --git a/setup/add-teams.sh b/setup/add-teams.sh new file mode 100755 index 0000000..f116f24 --- /dev/null +++ b/setup/add-teams.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# +# Install the Teams adapter, persist TEAMS_APP_ID / _PASSWORD / _TENANT_ID / +# _TYPE to .env + data/env/env, and restart the service. Non-interactive — +# the operator-facing Azure portal walkthroughs live in +# setup/channels/teams.ts. Credentials come in via env vars: +# TEAMS_APP_ID (required) +# TEAMS_APP_PASSWORD (required — client secret value from Azure) +# TEAMS_APP_TYPE (required — SingleTenant | MultiTenant) +# TEAMS_APP_TENANT_ID (required when type=SingleTenant) +# +# Emits exactly one status block on stdout (ADD_TEAMS) at the end. All chatty +# progress messages go to stderr so setup:auto's raw-log capture sees the +# full story without cluttering the final block for the parser. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Keep in sync with .claude/skills/add-teams/SKILL.md. +ADAPTER_VERSION="@chat-adapter/teams@4.26.0" +CHANNELS_BRANCH="origin/channels" + +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + echo "=== NANOCLAW SETUP: ADD_TEAMS ===" + echo "STATUS: ${status}" + echo "ADAPTER_VERSION: ${ADAPTER_VERSION}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-teams] $*" >&2; } + +if [ -z "${TEAMS_APP_ID:-}" ]; then + emit_status failed "TEAMS_APP_ID env var not set" + exit 1 +fi +if [ -z "${TEAMS_APP_PASSWORD:-}" ]; then + emit_status failed "TEAMS_APP_PASSWORD env var not set" + exit 1 +fi +if [ -z "${TEAMS_APP_TYPE:-}" ]; then + emit_status failed "TEAMS_APP_TYPE env var not set (SingleTenant|MultiTenant)" + exit 1 +fi +if [ "${TEAMS_APP_TYPE}" = "SingleTenant" ] && [ -z "${TEAMS_APP_TENANT_ID:-}" ]; then + emit_status failed "TEAMS_APP_TENANT_ID required when TEAMS_APP_TYPE=SingleTenant" + exit 1 +fi + +need_install() { + [ ! -f src/channels/teams.ts ] && return 0 + ! grep -q "^import './teams.js';" src/channels/index.ts 2>/dev/null && return 0 + return 1 +} + +ADAPTER_ALREADY_INSTALLED=true +if need_install; then + ADAPTER_ALREADY_INSTALLED=false + log "Fetching channels branch…" + git fetch origin channels >&2 2>/dev/null || { + emit_status failed "git fetch origin channels failed" + exit 1 + } + + log "Copying adapter from ${CHANNELS_BRANCH}…" + git show "${CHANNELS_BRANCH}:src/channels/teams.ts" > src/channels/teams.ts + + # Append self-registration import if missing. + if ! grep -q "^import './teams.js';" src/channels/index.ts; then + echo "import './teams.js';" >> src/channels/index.ts + fi + + log "Installing ${ADAPTER_VERSION}…" + pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${ADAPTER_VERSION} failed" + exit 1 + } + + log "Building…" + pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 + } +else + log "Adapter files already installed — skipping install phase." +fi + +# Persist credentials. +touch .env +upsert_env() { + local key=$1 value=$2 + if grep -q "^${key}=" .env; then + awk -v k="$key" -v v="$value" \ + 'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \ + .env > .env.tmp && mv .env.tmp .env + else + echo "${key}=${value}" >> .env + fi +} +upsert_env TEAMS_APP_ID "$TEAMS_APP_ID" +upsert_env TEAMS_APP_PASSWORD "$TEAMS_APP_PASSWORD" +upsert_env TEAMS_APP_TYPE "$TEAMS_APP_TYPE" +if [ -n "${TEAMS_APP_TENANT_ID:-}" ]; then + upsert_env TEAMS_APP_TENANT_ID "$TEAMS_APP_TENANT_ID" +fi + +# Container reads from data/env/env (the host mounts it). +mkdir -p data/env +cp .env data/env/env + +log "Restarting service so the new adapter picks up the credentials…" +case "$(uname -s)" in + Darwin) + launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true + ;; + Linux) + systemctl --user restart nanoclaw >&2 2>/dev/null \ + || sudo systemctl restart nanoclaw >&2 2>/dev/null \ + || true + ;; +esac + +# Give the Teams adapter a moment to register its webhook before the driver +# continues. +sleep 5 + +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts index 3be7856..52586c2 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -26,6 +26,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { runDiscordChannel } from './channels/discord.js'; +import { runTeamsChannel } from './channels/teams.js'; import { runTelegramChannel } from './channels/telegram.js'; import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; @@ -224,10 +225,12 @@ async function main(): Promise { await runDiscordChannel(displayName!); } else if (choice === 'whatsapp') { await runWhatsAppChannel(displayName!); + } else if (choice === 'teams') { + await runTeamsChannel(displayName!); } else { p.log.info( wrapForGutter( - 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, or Slack).', + 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, or Slack).', 4, ), ); @@ -522,7 +525,9 @@ async function askDisplayName(fallback: string): Promise { return value; } -async function askChannelChoice(): Promise<'telegram' | 'discord' | 'whatsapp' | 'skip'> { +async function askChannelChoice(): Promise< + 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip' +> { const choice = ensureAnswer( await p.select({ message: 'Want to chat with your assistant from your phone?', @@ -530,12 +535,13 @@ async function askChannelChoice(): Promise<'telegram' | 'discord' | 'whatsapp' | { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, { value: 'discord', label: 'Yes, connect Discord' }, { value: 'whatsapp', label: 'Yes, connect WhatsApp' }, + { value: 'teams', label: 'Yes, connect Microsoft Teams', hint: 'complex setup' }, { value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" }, ], }), ); setupLog.userInput('channel_choice', String(choice)); - return choice as 'telegram' | 'discord' | 'whatsapp' | 'skip'; + return choice as 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'; } // ─── interactive / env helpers ───────────────────────────────────────── diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts new file mode 100644 index 0000000..432bcbf --- /dev/null +++ b/setup/channels/teams.ts @@ -0,0 +1,629 @@ +/** + * Microsoft Teams channel flow for setup:auto. + * + * Teams is the most complex channel NanoClaw supports — the Slack/Discord + * "paste a token" shortcut doesn't exist. The operator has to walk through + * ~7 Azure portal steps (app registration, client secret, Azure Bot + * resource, messaging endpoint, Teams channel enable, manifest, sideload). + * + * This driver's job is to make each of those steps as guided as possible + * inside the terminal: + * 1. Print a clack note with the exact sub-steps and the portal URL. + * 2. Ask for the value(s) that step yields (App ID, secret, tenant, etc.). + * 3. At every step boundary, offer `stepGate` — a Done / Stuck / Show-again + * select. "Stuck" hands off to interactive Claude with full context. + * + * Text/password prompts also accept `?` as an answer to trigger the handoff, + * so the operator can escape at any paste point without scrolling back to a + * step boundary. + * + * What's deferred (known limitation, instruct user how to finish manually): + * - Wait-for-first-DM to capture the auto-generated Teams platformId. + * Unlike Discord/Telegram, the Teams platform_id is only discoverable + * after the first inbound activity. The driver installs the adapter and + * stops there; the operator DMs the bot, NanoClaw auto-creates the + * messaging group, and they wire an agent via `/manage-channels`. + */ +import os from 'os'; +import path from 'path'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import { confirmThenOpen } from '../lib/browser.js'; +import { + isHelpEscape, + offerClaudeHandoff, + validateWithHelpEscape, + type HandoffContext, +} from '../lib/claude-handoff.js'; +import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; +import { buildTeamsAppPackage } from '../lib/teams-manifest.js'; +import * as setupLog from '../logs.js'; + +const CHANNEL = 'teams'; +const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams'); +const AZURE_PORTAL_URL = 'https://portal.azure.com'; + +interface Collected { + publicUrl?: string; + appId?: string; + tenantId?: string; + appType?: 'SingleTenant' | 'MultiTenant'; + appPassword?: string; + agentName?: string; +} + +export async function runTeamsChannel(_displayName: string): Promise { + const collected: Collected = {}; + const completed: string[] = []; + + printIntro(); + + await confirmPrereqs({ collected, completed }); + await stepPublicUrl({ collected, completed }); + await stepAppRegistration({ collected, completed }); + await stepClientSecret({ collected, completed }); + await stepAzureBot({ collected, completed }); + await stepEnableTeamsChannel({ collected, completed }); + const manifestResult = await stepGenerateManifest({ collected, completed }); + await stepSideload({ collected, completed, zipPath: manifestResult.zipPath }); + + await installAdapter(collected); + completed.push('Adapter installed and service restarted.'); + + printPostInstallGuidance(); +} + +// ─── step: intro / prereqs ────────────────────────────────────────────── + +function printIntro(): void { + p.note( + [ + 'Setting up Teams is more involved than the other channels — about', + '7 steps across the Azure portal and Teams admin.', + '', + k.dim("At any prompt you can type '?' and press Enter to hand off"), + k.dim("to Claude interactive mode with your current progress."), + k.dim("You can also pick 'Stuck' at any Done/Stuck/Show-again prompt."), + ].join('\n'), + 'Microsoft Teams setup', + ); +} + +async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise { + p.note( + [ + 'Before we start, confirm you have:', + '', + ' • A Microsoft 365 tenant where you can sideload custom apps', + ' (free personal Teams does NOT support this — you need a', + ' Microsoft 365 Business / EDU / developer tenant)', + ' • Teams admin or developer tenant rights', + ' • A way to expose an HTTPS endpoint from this machine', + ' (ngrok, Cloudflare Tunnel, or a reverse-proxied VPS)', + ].join('\n'), + 'Prereqs', + ); + + await stepGate({ + stepName: 'teams-prereqs', + stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel', + reshow: () => confirmPrereqs(args), + args, + }); + args.completed.push('Prereqs confirmed.'); +} + +// ─── step: public URL ────────────────────────────────────────────────── + +async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise { + p.note( + [ + "Azure Bot Service delivers messages to an HTTPS endpoint you", + "control. The endpoint needs to reach this machine's webhook", + "server at /api/webhooks/teams.", + '', + k.dim('Examples:'), + k.dim(' ngrok http 3000 → https://abcd1234.ngrok.io'), + k.dim(' cloudflared tunnel … → https://.trycloudflare.com'), + k.dim(' or a reverse proxy on your own domain'), + '', + "If you don't have a tunnel running yet, start one in another", + "terminal, then come back here.", + ].join('\n'), + 'Public HTTPS URL', + ); + + while (true) { + const answer = ensureAnswer( + await p.text({ + message: 'Paste your public base URL (e.g. https://abcd1234.ngrok.io)', + placeholder: 'https://…', + validate: validateWithHelpEscape((v) => { + const t = (v ?? '').trim(); + if (!t) return 'Required'; + if (!/^https:\/\/[^\s/]+/.test(t)) { + return 'Must be an https:// URL (Azure rejects http)'; + } + return undefined; + }), + }), + ); + if (isHelpEscape(answer)) { + await offerHandoff({ + step: 'teams-public-url', + stepDescription: + 'setting up a public HTTPS tunnel to reach this machine on port 3000', + args, + }); + continue; + } + const url = (answer as string).trim().replace(/\/$/, ''); + args.collected.publicUrl = url; + setupLog.userInput('teams_public_url', url); + break; + } + + args.completed.push(`Public URL: ${args.collected.publicUrl}`); +} + +// ─── step: Azure App Registration ────────────────────────────────────── + +async function stepAppRegistration(args: { + collected: Collected; + completed: string[]; +}): Promise { + p.note( + [ + `1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`, + '2. Name it (e.g. "NanoClaw")', + '3. Supported account types: Single tenant (your org only) OR', + ' Multi tenant (any Microsoft 365 tenant can add the bot)', + '4. Click Register', + '5. On the Overview page, copy:', + ' • Application (client) ID', + ' • Directory (tenant) ID', + ].join('\n'), + 'Step 1 of 6 — Create Azure App Registration', + ); + await confirmThenOpen( + AZURE_PORTAL_URL, + 'Press Enter to open the Azure portal', + ); + + args.collected.appType = await askAppType(args); + args.collected.appId = await askUuid( + 'Paste the Application (client) ID', + 'teams-app-id', + args, + ); + if (args.collected.appType === 'SingleTenant') { + args.collected.tenantId = await askUuid( + 'Paste the Directory (tenant) ID', + 'teams-tenant-id', + args, + ); + } + + await stepGate({ + stepName: 'teams-app-registration', + stepDescription: 'registering an app in Azure and collecting App ID + tenant type', + reshow: () => stepAppRegistration(args), + args, + }); + args.completed.push( + `App registered: ${args.collected.appId} (${args.collected.appType})`, + ); +} + +async function askAppType(args: { + collected: Collected; + completed: string[]; +}): Promise<'SingleTenant' | 'MultiTenant'> { + while (true) { + const choice = ensureAnswer( + await p.select({ + message: 'Which account type did you pick?', + options: [ + { + value: 'SingleTenant', + label: 'Single tenant', + hint: 'your org only — most common for self-host', + }, + { + value: 'MultiTenant', + label: 'Multi tenant', + hint: 'any Microsoft 365 tenant can install the bot', + }, + { value: 'help', label: 'Stuck — hand me off to Claude' }, + ], + }), + ); + if (choice === 'help') { + await offerHandoff({ + step: 'teams-app-type', + stepDescription: "deciding between Single tenant and Multi tenant for their Azure app", + args, + }); + continue; + } + return choice as 'SingleTenant' | 'MultiTenant'; + } +} + +// ─── step: client secret ─────────────────────────────────────────────── + +async function stepClientSecret(args: { + collected: Collected; + completed: string[]; +}): Promise { + p.note( + [ + `1. In your app registration, open "Certificates & secrets"`, + '2. Click "New client secret"', + ' Description: nanoclaw', + ' Expires: 180 days (recommended) or longer', + '3. Click Add', + '4. ' + k.yellow('COPY THE VALUE NOW — Azure only shows it once'), + ' (the Value column, not the Secret ID)', + ].join('\n'), + 'Step 2 of 6 — Create a client secret', + ); + + while (true) { + const answer = ensureAnswer( + await p.password({ + message: 'Paste the client secret Value', + validate: validateWithHelpEscape((v) => { + const t = (v ?? '').trim(); + if (!t) return 'Required'; + if (t.length < 20) return "That looks too short — make sure you copied the Value, not the Secret ID"; + return undefined; + }), + }), + ); + if (isHelpEscape(answer)) { + await offerHandoff({ + step: 'teams-client-secret', + stepDescription: 'creating and copying the client secret value from Azure', + args, + }); + continue; + } + args.collected.appPassword = (answer as string).trim(); + setupLog.userInput( + 'teams_client_secret', + `${args.collected.appPassword.slice(0, 4)}…${args.collected.appPassword.slice(-4)}`, + ); + break; + } + + await stepGate({ + stepName: 'teams-client-secret', + stepDescription: 'creating and copying the client secret', + reshow: () => stepClientSecret(args), + args, + }); + args.completed.push('Client secret captured.'); +} + +// ─── step: Azure Bot resource ────────────────────────────────────────── + +async function stepAzureBot(args: { + collected: Collected; + completed: string[]; +}): Promise { + const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`; + const tenantFlag = + args.collected.appType === 'SingleTenant' + ? `--tenant-id ${args.collected.tenantId} ` + : ''; + const cliCommand = + `az bot create \\\n` + + ` --resource-group nanoclaw-rg \\\n` + + ` --name nanoclaw-bot \\\n` + + ` --app-type ${args.collected.appType} \\\n` + + ` --appid ${args.collected.appId} \\\n` + + ` ${tenantFlag}--endpoint "${endpoint}"`; + + p.note( + [ + `In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`, + '', + ' • Bot handle: unique name, e.g. nanoclaw-bot', + ` • Type of App: ${args.collected.appType}`, + ' • Creation type: Use existing app registration', + ` • App ID: ${args.collected.appId ?? ''}`, + ...(args.collected.appType === 'SingleTenant' + ? [` • App tenant ID: ${args.collected.tenantId ?? ''}`] + : []), + '', + 'After creating, open the bot → Configuration and set:', + ` Messaging endpoint: ${k.cyan(endpoint)}`, + '', + k.dim('Or via Azure CLI (if you have az installed):'), + k.dim(cliCommand), + ].join('\n'), + 'Step 3 of 6 — Create Azure Bot resource', + ); + + await stepGate({ + stepName: 'teams-azure-bot', + stepDescription: + 'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint', + reshow: () => stepAzureBot(args), + args, + }); + args.completed.push('Azure Bot created; messaging endpoint configured.'); +} + +// ─── step: enable Teams channel ──────────────────────────────────────── + +async function stepEnableTeamsChannel(args: { + collected: Collected; + completed: string[]; +}): Promise { + p.note( + [ + '1. Open your Azure Bot resource → Channels', + '2. Click Microsoft Teams → Accept terms → Apply', + '', + k.dim('CLI alternative:'), + k.dim(' az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot'), + ].join('\n'), + 'Step 4 of 6 — Enable Teams channel on the bot', + ); + await stepGate({ + stepName: 'teams-enable-channel', + stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource', + reshow: () => stepEnableTeamsChannel(args), + args, + }); + args.completed.push('Teams channel enabled on the bot.'); +} + +// ─── step: manifest zip ──────────────────────────────────────────────── + +async function stepGenerateManifest(args: { + collected: Collected; + completed: string[]; +}): Promise<{ zipPath: string }> { + if (!args.collected.appId) { + fail( + 'teams-manifest', + 'Missing Azure App ID.', + "That's an internal bug — open an issue or retry setup.", + ); + } + const shortName = + process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw'; + + const s = p.spinner(); + s.start('Generating your Teams app package…'); + try { + const result = buildTeamsAppPackage({ + appId: args.collected.appId!, + shortName, + longDescription: `${shortName} personal assistant powered by NanoClaw.`, + websiteUrl: args.collected.publicUrl!, + outDir: MANIFEST_DIR, + }); + s.stop(`Package ready: ${k.cyan(shortPath(result.zipPath))}`); + setupLog.step('teams-manifest', 'success', 0, { + ZIP: result.zipPath, + }); + args.completed.push(`Generated manifest zip at ${shortPath(result.zipPath)}.`); + return { zipPath: result.zipPath }; + } catch (err) { + s.stop("Couldn't build the manifest zip.", 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('teams-manifest', 'failed', 0, { ERROR: message }); + fail( + 'teams-manifest', + "Couldn't generate the Teams app package.", + 'Make sure `zip` is available on your PATH, then retry.', + ); + } +} + +// ─── step: sideload ──────────────────────────────────────────────────── + +async function stepSideload(args: { + collected: Collected; + completed: string[]; + zipPath: string; +}): Promise { + p.note( + [ + '1. Open Microsoft Teams', + '2. Go to Apps → Manage your apps → Upload an app', + '3. Click "Upload a custom app" (or "Upload for me or my teams")', + `4. Select: ${k.cyan(args.zipPath)}`, + '5. Click Add', + '', + k.dim('If "Upload a custom app" is missing, your tenant admin has'), + k.dim('disabled sideloading. Enable it in Teams Admin Center →'), + k.dim('Teams apps → Setup policies → Global → Upload custom apps = On'), + ].join('\n'), + 'Step 5 of 6 — Sideload the app into Teams', + ); + await stepGate({ + stepName: 'teams-sideload', + stepDescription: 'uploading the generated zip into Teams as a custom app', + reshow: () => stepSideload(args), + args, + }); + args.completed.push('App sideloaded into Teams.'); +} + +// ─── step: install adapter ───────────────────────────────────────────── + +async function installAdapter(collected: Collected): Promise { + const env: Record = { + TEAMS_APP_ID: collected.appId!, + TEAMS_APP_PASSWORD: collected.appPassword!, + TEAMS_APP_TYPE: collected.appType!, + }; + if (collected.appType === 'SingleTenant') { + env.TEAMS_APP_TENANT_ID = collected.tenantId!; + } + + const install = await runQuietChild( + 'teams-install', + 'bash', + ['setup/add-teams.sh'], + { + running: 'Installing the Teams adapter and restarting the service…', + done: 'Teams adapter installed.', + }, + { + env, + extraFields: { + APP_ID: collected.appId!, + APP_TYPE: collected.appType!, + }, + }, + ); + if (!install.ok) { + fail( + 'teams-install', + "Couldn't install the Teams adapter.", + 'See logs/setup-steps/ for details, then retry setup.', + ); + } +} + +// ─── post-install: how to finish wiring ──────────────────────────────── + +function printPostInstallGuidance(): void { + p.note( + [ + "The Teams adapter is live and the service is running. To finish", + "hooking up an agent:", + '', + ' 1. Find your bot in Teams (search by name, or via the sideloaded', + ' app) and send it a message ("hi" is fine)', + ' 2. NanoClaw auto-creates a messaging group on the first inbound', + ' activity (Teams platform IDs are only discoverable after a', + ' real message arrives)', + ' 3. Run ' + k.cyan('/manage-channels') + ' to wire that messaging', + ' group to an agent group', + '', + k.dim('If the bot never replies, check logs/nanoclaw.log — most'), + k.dim("first-run failures are webhook reachability ('messages never"), + k.dim("reach the bot') or auth ('app password rejected')."), + ].join('\n'), + 'Step 6 of 6 — Finish wiring', + ); +} + +// ─── shared step gate ────────────────────────────────────────────────── + +async function stepGate(args: { + stepName: string; + stepDescription: string; + reshow: () => Promise | Promise; + args: { collected: Collected; completed: string[] }; +}): Promise { + while (true) { + const choice = ensureAnswer( + await p.select({ + message: 'How did that go?', + options: [ + { value: 'done', label: "Done — let's continue" }, + { value: 'help', label: 'Stuck — hand me off to Claude' }, + { value: 'reshow', label: 'Show me the steps again' }, + ], + }), + ); + if (choice === 'done') return; + if (choice === 'help') { + await offerHandoff({ + step: args.stepName, + stepDescription: args.stepDescription, + args: args.args, + }); + continue; + } + if (choice === 'reshow') { + await args.reshow(); + return; + } + } +} + +async function offerHandoff(args: { + step: string; + stepDescription: string; + args: { collected: Collected; completed: string[] }; +}): Promise { + const ctx: HandoffContext = { + channel: CHANNEL, + step: args.step, + stepDescription: args.stepDescription, + completedSteps: args.args.completed.slice(), + collectedValues: redactCollected(args.args.collected), + files: ['setup/channels/teams.ts', 'setup/add-teams.sh'], + }; + await offerClaudeHandoff(ctx); +} + +function redactCollected(c: Collected): Record { + const out: Record = {}; + if (c.publicUrl) out.publicUrl = c.publicUrl; + if (c.appId) out.appId = c.appId; + if (c.tenantId) out.tenantId = c.tenantId; + if (c.appType) out.appType = c.appType; + if (c.appPassword) { + out.appPassword = `${c.appPassword.slice(0, 4)}…${c.appPassword.slice(-4)}`; + } + return out; +} + +// ─── shared: UUID paste with help escape ─────────────────────────────── + +async function askUuid( + message: string, + logKey: string, + args: { collected: Collected; completed: string[] }, +): Promise { + while (true) { + const answer = ensureAnswer( + await p.text({ + message, + placeholder: '00000000-0000-0000-0000-000000000000', + validate: validateWithHelpEscape((v) => { + const t = (v ?? '').trim(); + if (!t) return 'Required'; + if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(t)) { + return 'Expected a UUID like 00000000-0000-0000-0000-000000000000'; + } + return undefined; + }), + }), + ); + if (isHelpEscape(answer)) { + await offerHandoff({ + step: logKey, + stepDescription: `entering a UUID for ${logKey}`, + args, + }); + continue; + } + const value = (answer as string).trim().toLowerCase(); + setupLog.userInput(logKey, value); + return value; + } +} + +// ─── path helpers ────────────────────────────────────────────────────── + +function shortPath(abs: string): string { + const home = os.homedir(); + const cwd = process.cwd(); + if (abs.startsWith(`${cwd}/`)) return abs.slice(cwd.length + 1); + if (abs.startsWith(`${home}/`)) return `~/${abs.slice(home.length + 1)}`; + return abs; +} + diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 4735be6..70b6a3d 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -63,6 +63,8 @@ const STEP_FILES: Record = { 'telegram-validate': ['setup/channels/telegram.ts'], 'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'], 'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'], + 'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'], + 'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'], 'init-first-agent': [ 'scripts/init-first-agent.ts', 'setup/channels/telegram.ts', diff --git a/setup/lib/claude-handoff.ts b/setup/lib/claude-handoff.ts new file mode 100644 index 0000000..9c931f2 --- /dev/null +++ b/setup/lib/claude-handoff.ts @@ -0,0 +1,194 @@ +/** + * User-initiated handoff to interactive Claude, parallel to claude-assist.ts. + * + * claude-assist is for failures: it runs `claude -p` non-interactively, parses + * a suggested command, and offers to run it. This module is for the opposite + * case — the user is mid-flow, not stuck on an error, and wants Claude to + * walk them through something the driver can't fully automate (Azure portal + * clickthrough, writing a manifest, tunneling a port, etc.). + * + * Flow: + * 1. Build a handoff prompt from the caller's context: channel, current + * step, completed steps, collected values (secrets redacted), relevant + * files to read. + * 2. Spawn `claude --append-system-prompt "" + * --permission-mode acceptEdits` with `stdio: 'inherit'` so Claude owns + * the terminal. + * 3. When Claude exits (user types /exit, Ctrl-D, or closes the session), + * control returns to the setup driver. The driver can then re-offer the + * same step (e.g., "How did that go?" select). + * + * Also exports a small helper for text/password prompts: `validateWithHelpEscape` + * wraps a validate callback so typing `?` triggers the handoff instead of + * attempting to parse it as a real answer. + */ +import { execSync, spawn } from 'child_process'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +export interface HandoffContext { + /** Channel this handoff is happening in (e.g., 'teams'). */ + channel: string; + /** Short name of the current step the user is stuck on. */ + step: string; + /** Human-readable summary of what the user was trying to do at this step. */ + stepDescription: string; + /** Checklist of sub-steps already completed (displayed as `✓ `). */ + completedSteps?: string[]; + /** + * Key/value pairs of values collected so far. Callers should redact + * secrets before passing (e.g., show last 4 chars). Used to give Claude + * the state of the operator's progress. + */ + collectedValues?: Record; + /** + * Repo-relative paths Claude should consider reading. Always gets + * logs/setup.log and the relevant SKILL.md appended by the builder. + */ + files?: string[]; +} + +/** + * Spawn interactive Claude with context pre-loaded as a system-prompt + * append. Returns when Claude exits. + * + * Silently no-ops (returns `false`) if `claude` isn't on PATH — setup runs + * where the binary is guaranteed to exist (we install it in the auth step), + * but an ultra-early flow failure could technically reach this before that + * install, and crashing the handoff would be worse than the handoff not + * firing. + */ +export async function offerClaudeHandoff(ctx: HandoffContext): Promise { + if (!isClaudeUsable()) { + p.log.warn( + "Claude isn't installed yet — can't hand you off here. Finish setup first, then retry.", + ); + return false; + } + + const systemPrompt = buildSystemPrompt(ctx); + + p.note( + [ + "I'm handing you off to Claude in interactive mode.", + "It has the context of where you are in setup.", + "", + k.dim("Type /exit (or press Ctrl-D) when you're ready to come back to setup."), + ].join('\n'), + 'Handing off to Claude', + ); + + return new Promise((resolve) => { + const child = spawn( + 'claude', + [ + '--append-system-prompt', + systemPrompt, + '--permission-mode', + 'acceptEdits', + ], + { stdio: 'inherit' }, + ); + child.on('close', () => { + p.log.success("Back from Claude. Let's continue."); + resolve(true); + }); + child.on('error', () => { + p.log.error("Couldn't launch Claude. Continuing without handoff."); + resolve(false); + }); + }); +} + +/** + * Sentinel returned by `validateWithHelpEscape` when the user types `?`. + * The caller compares against this to decide whether to trigger a handoff. + */ +export const HELP_ESCAPE_SENTINEL = '__NANOCLAW_HELP_ESCAPE__'; + +/** + * Wrap a clack `validate` callback so typing `?` short-circuits validation + * and returns the HELP_ESCAPE_SENTINEL. Caller should check for the sentinel + * after awaiting the prompt and trigger offerClaudeHandoff if matched. + * + * Usage: + * const answer = await p.text({ + * message: 'Paste your Azure App ID', + * validate: validateWithHelpEscape((v) => { + * if (!/^[0-9a-f-]{36}$/.test(v)) return 'Expected a UUID'; + * return undefined; + * }), + * }); + * if (answer === HELP_ESCAPE_SENTINEL) { await offerClaudeHandoff(ctx); ... } + */ +export function validateWithHelpEscape( + inner?: (value: string) => string | Error | undefined, +): (value: string) => string | Error | undefined { + return (value: string) => { + if ((value ?? '').trim() === '?') { + // Returning undefined lets clack accept the `?` as the "answer". The + // caller sees a literal "?" and should compare + escape to handoff. + return undefined; + } + return inner ? inner(value) : undefined; + }; +} + +/** + * True if the value returned by a text/password prompt should trigger a + * handoff. Abstracts the sentinel check so callers don't have to import it + * directly at every site. + */ +export function isHelpEscape(value: unknown): boolean { + return typeof value === 'string' && value.trim() === '?'; +} + +function isClaudeUsable(): boolean { + try { + execSync('command -v claude', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function buildSystemPrompt(ctx: HandoffContext): string { + const lines: string[] = [ + `The user is running NanoClaw's interactive \`setup:auto\` flow to wire the ${ctx.channel} channel.`, + `They got stuck at the step: "${ctx.step}" (${ctx.stepDescription}) and asked for help.`, + '', + "Your job: help them complete this specific step and get back to setup.", + "You can read files, run commands (with acceptEdits permissions), search the web,", + "and explain concepts. Be concise. When they're ready to resume, tell them to type", + "/exit and they'll return to the setup flow at the same step.", + '', + ]; + + if (ctx.completedSteps && ctx.completedSteps.length > 0) { + lines.push('Steps they have already completed:'); + for (const s of ctx.completedSteps) lines.push(` ✓ ${s}`); + lines.push(''); + } + + if (ctx.collectedValues && Object.keys(ctx.collectedValues).length > 0) { + lines.push('Values collected so far (secrets redacted):'); + for (const [k, v] of Object.entries(ctx.collectedValues)) { + lines.push(` ${k}: ${v}`); + } + lines.push(''); + } + + const files = [ + ...(ctx.files ?? []), + 'logs/setup.log', + 'logs/setup-steps/', + `.claude/skills/add-${ctx.channel}/SKILL.md`, + `setup/channels/${ctx.channel}.ts`, + ].filter((v, i, a) => a.indexOf(v) === i); + + lines.push('Relevant files (read as needed with the Read tool):'); + for (const f of files) lines.push(` - ${f}`); + + return lines.join('\n'); +} diff --git a/setup/lib/teams-manifest.ts b/setup/lib/teams-manifest.ts new file mode 100644 index 0000000..c40837a --- /dev/null +++ b/setup/lib/teams-manifest.ts @@ -0,0 +1,271 @@ +/** + * Build the Teams app package zip that the operator sideloads from the Teams + * "Manage your apps" screen. + * + * A Teams app package is a zip containing: + * - manifest.json — declares the bot, scopes, required permissions + * - outline.png — 32×32 transparent outline icon + * - color.png — 192×192 full-color icon + * + * Icons are generated in-process using a minimal PNG encoder so we don't + * need ImageMagick or vendor binary icon blobs into the repo. The outline + * icon is a simple rounded square outline; the color icon is a brand-blue + * filled square with a small white "N" blocked in by pixel setting. Good + * enough for a working sideload — teams admins who care can replace the + * icons later. + * + * The manifest is pinned to schema v1.16 to match the skill doc. + */ +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import zlib from 'zlib'; + +const MANIFEST_SCHEMA = + 'https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json'; +const MANIFEST_VERSION = '1.16'; + +export interface ManifestOptions { + /** The Azure AD app ID (same value used for `bots[0].botId`). */ + appId: string; + /** Short bot name shown in Teams (<= 30 chars). */ + shortName: string; + /** Long bot description. */ + longDescription: string; + /** Developer website URL (required by schema — any reachable URL works). */ + websiteUrl: string; + /** Out-dir for the generated zip + loose files. */ + outDir: string; +} + +export interface ManifestResult { + zipPath: string; + manifestPath: string; + outlinePath: string; + colorPath: string; +} + +/** Build the full app package zip and return the paths. */ +export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult { + fs.mkdirSync(opts.outDir, { recursive: true }); + + const manifestPath = path.join(opts.outDir, 'manifest.json'); + const outlinePath = path.join(opts.outDir, 'outline.png'); + const colorPath = path.join(opts.outDir, 'color.png'); + const zipPath = path.join(opts.outDir, 'teams-app-package.zip'); + + fs.writeFileSync(manifestPath, renderManifest(opts)); + fs.writeFileSync(outlinePath, encodeOutlineIcon()); + fs.writeFileSync(colorPath, encodeColorIcon()); + + // Fresh zip every run — idempotent, no stale files. + try { + fs.unlinkSync(zipPath); + } catch { + // noop if missing + } + execSync(`zip -j -q "${zipPath}" "${manifestPath}" "${outlinePath}" "${colorPath}"`, { + stdio: ['ignore', 'ignore', 'inherit'], + }); + + return { zipPath, manifestPath, outlinePath, colorPath }; +} + +function renderManifest(opts: ManifestOptions): string { + const manifest = { + $schema: MANIFEST_SCHEMA, + manifestVersion: MANIFEST_VERSION, + version: '1.0.0', + id: opts.appId, + packageName: 'com.nanoclaw.bot', + developer: { + name: 'NanoClaw', + websiteUrl: opts.websiteUrl, + privacyUrl: opts.websiteUrl, + termsOfUseUrl: opts.websiteUrl, + }, + name: { + short: opts.shortName.slice(0, 30), + full: `${opts.shortName} Assistant`, + }, + description: { + short: 'Your personal assistant in Teams.', + full: opts.longDescription, + }, + icons: { outline: 'outline.png', color: 'color.png' }, + accentColor: '#4A90D9', + bots: [ + { + botId: opts.appId, + scopes: ['personal', 'team', 'groupchat'], + supportsFiles: false, + isNotificationOnly: false, + }, + ], + permissions: ['identity', 'messageTeamMembers'], + validDomains: [new URL(opts.websiteUrl).host], + }; + return JSON.stringify(manifest, null, 2) + '\n'; +} + +// ─── Minimal PNG encoder (solid color, no external deps) ────────────────── + +const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +// Precompute the CRC-32 table per the PNG spec. Node doesn't expose CRC32 +// directly (zlib.crc32 isn't part of the public API), so we roll our own. +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buf: Buffer): number { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type: string, data: Buffer): Buffer { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const typeBuf = Buffer.from(type, 'ascii'); + const crcBuf = Buffer.alloc(4); + crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); + return Buffer.concat([len, typeBuf, data, crcBuf]); +} + +/** + * Encode a solid-color RGBA image as a PNG. `pixels` is a width*height*4 + * byte array (R, G, B, A per pixel, row-major, top-to-bottom). + */ +function encodePng(width: number, height: number, pixels: Uint8Array): Buffer { + // IHDR + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type: RGBA + ihdr[10] = 0; // compression + ihdr[11] = 0; // filter + ihdr[12] = 0; // interlace + + // IDAT: scanlines with filter byte 0 (None) prepended per row. + const rowBytes = width * 4; + const raw = Buffer.alloc(height * (rowBytes + 1)); + for (let y = 0; y < height; y++) { + raw[y * (rowBytes + 1)] = 0; + for (let x = 0; x < rowBytes; x++) { + raw[y * (rowBytes + 1) + 1 + x] = pixels[y * rowBytes + x]; + } + } + const idat = zlib.deflateSync(raw); + + return Buffer.concat([ + PNG_SIG, + chunk('IHDR', ihdr), + chunk('IDAT', idat), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +/** + * Outline icon: 32×32 transparent background with a simple white rounded- + * square outline. Teams renders it against a colored background so the + * outline needs to be visible on both light and dark. + */ +function encodeOutlineIcon(): Buffer { + const size = 32; + const pixels = new Uint8Array(size * size * 4); + const inset = 4; + const stroke = 2; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const onBorder = + ((x >= inset && x < inset + stroke) || (x >= size - inset - stroke && x < size - inset)) && + y >= inset && + y < size - inset; + const onTopBot = + ((y >= inset && y < inset + stroke) || (y >= size - inset - stroke && y < size - inset)) && + x >= inset && + x < size - inset; + const i = (y * size + x) * 4; + if (onBorder || onTopBot) { + pixels[i] = 255; + pixels[i + 1] = 255; + pixels[i + 2] = 255; + pixels[i + 3] = 255; + } else { + pixels[i] = 0; + pixels[i + 1] = 0; + pixels[i + 2] = 0; + pixels[i + 3] = 0; // transparent + } + } + } + return encodePng(size, size, pixels); +} + +/** + * Color icon: 192×192 brand-blue filled square with a white "N" shape drawn + * with simple bars (left vertical, right vertical, diagonal from top-right + * to bottom-left). Crude but recognizable at a glance. + */ +function encodeColorIcon(): Buffer { + const size = 192; + const pixels = new Uint8Array(size * size * 4); + // Brand blue #4A90D9 + const BG_R = 0x4a; + const BG_G = 0x90; + const BG_B = 0xd9; + const thickness = 24; + const margin = 40; + const leftBarX = margin; + const rightBarX = size - margin - thickness; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const i = (y * size + x) * 4; + pixels[i] = BG_R; + pixels[i + 1] = BG_G; + pixels[i + 2] = BG_B; + pixels[i + 3] = 255; + } + } + // Vertical bars + for (let y = margin; y < size - margin; y++) { + for (let dx = 0; dx < thickness; dx++) { + setWhite(pixels, size, leftBarX + dx, y); + setWhite(pixels, size, rightBarX + dx, y); + } + } + // Diagonal from top-right of left bar to bottom-left of right bar + const diagSteps = size - margin * 2; + for (let s = 0; s < diagSteps; s++) { + const t = s / (diagSteps - 1); + const cx = Math.round(leftBarX + thickness + t * (rightBarX - leftBarX - thickness)); + const cy = Math.round(margin + t * (size - margin * 2 - 1)); + for (let dx = -Math.floor(thickness / 2); dx < Math.ceil(thickness / 2); dx++) { + for (let dy = -2; dy <= 2; dy++) { + setWhite(pixels, size, cx + dx, cy + dy); + } + } + } + return encodePng(size, size, pixels); +} + +function setWhite(pixels: Uint8Array, size: number, x: number, y: number): void { + if (x < 0 || x >= size || y < 0 || y >= size) return; + const i = (y * size + x) * 4; + pixels[i] = 255; + pixels[i + 1] = 255; + pixels[i + 2] = 255; + pixels[i + 3] = 255; +} From 390e09597a5a3c6590ce77c98af11d1113e2c4a1 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 14:31:32 +0300 Subject: [PATCH 096/185] feat(setup): hand off to Claude for Teams finish-wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-install was a bare instruction block: "DM the bot, run /manage-channels". Replace it with an explicit Done/Stuck-style select backed by the handoff mechanism — Claude takes over, tails logs/nanoclaw.log for the inbound, inspects data/v2.db for the auto-created messaging_group row, runs scripts/init-first-agent.ts with the discovered platform_id + AAD user id, and verifies end-to-end. Operators who want to drive it themselves pick "I'll do it myself" and get the same terse instructions as before. Default is the handoff (recommended hint). Why Teams and not the other channels: Telegram/Discord/WhatsApp already have synchronous platform IDs we capture during setup, so init-first-agent runs inline. Teams platform IDs only exist after the first real inbound, so the wiring is necessarily deferred — and that deferred work is exactly what the handoff handles best. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/channels/teams.ts | 75 ++++++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index 432bcbf..be29cea 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -72,7 +72,7 @@ export async function runTeamsChannel(_displayName: string): Promise { await installAdapter(collected); completed.push('Adapter installed and service restarted.'); - printPostInstallGuidance(); + await finishWithHandoff(collected, completed); } // ─── step: intro / prereqs ────────────────────────────────────────────── @@ -494,28 +494,71 @@ async function installAdapter(collected: Collected): Promise { } } -// ─── post-install: how to finish wiring ──────────────────────────────── +// ─── post-install: hand off to Claude for the final wiring ──────────── -function printPostInstallGuidance(): void { +async function finishWithHandoff( + collected: Collected, + completed: string[], +): Promise { p.note( [ - "The Teams adapter is live and the service is running. To finish", - "hooking up an agent:", + 'The Teams adapter is live and the service is running.', '', - ' 1. Find your bot in Teams (search by name, or via the sideloaded', - ' app) and send it a message ("hi" is fine)', - ' 2. NanoClaw auto-creates a messaging group on the first inbound', - ' activity (Teams platform IDs are only discoverable after a', - ' real message arrives)', - ' 3. Run ' + k.cyan('/manage-channels') + ' to wire that messaging', - ' group to an agent group', - '', - k.dim('If the bot never replies, check logs/nanoclaw.log — most'), - k.dim("first-run failures are webhook reachability ('messages never"), - k.dim("reach the bot') or auth ('app password rejected')."), + "One thing left: your Teams bot's platform ID (which NanoClaw needs", + 'to wire to an agent group) only becomes known after you DM the bot', + 'for the first time. Claude can walk you through that interactively —', + 'watch the logs for your first inbound, find the auto-created', + 'messaging group in the DB, run scripts/init-first-agent.ts with', + 'the right flags, and verify end-to-end.', ].join('\n'), 'Step 6 of 6 — Finish wiring', ); + + const choice = ensureAnswer( + await p.select({ + message: 'Ready to finish?', + options: [ + { + value: 'handoff', + label: 'Hand me off to Claude to walk me through it', + hint: 'recommended', + }, + { value: 'self', label: "I'll do it myself" }, + ], + }), + ); + + if (choice === 'self') { + p.note( + [ + ' 1. Find your bot in Teams (search by name, or via the sideloaded', + ' app) and send it a message ("hi" is fine)', + ' 2. Tail ' + k.cyan('logs/nanoclaw.log') + ' for the inbound; the router', + ' auto-creates a row in ' + k.cyan('messaging_groups') + ' in data/v2.db', + ' 3. Run ' + k.cyan('scripts/init-first-agent.ts') + ' with --channel teams,', + ' the discovered platform_id, and your AAD user id, OR use', + ' ' + k.cyan('/manage-channels') + ' to wire interactively', + ].join('\n'), + 'Manual finish', + ); + return; + } + + await offerClaudeHandoff({ + channel: CHANNEL, + step: 'teams-finish-wiring', + stepDescription: + 'finishing the Teams wiring: watch for the first inbound, discover the auto-created messaging group in data/v2.db, and run scripts/init-first-agent.ts to wire it to an agent group', + completedSteps: completed, + collectedValues: redactCollected(collected), + files: [ + 'scripts/init-first-agent.ts', + 'src/router.ts', + 'src/db/messaging-groups.ts', + 'logs/nanoclaw.log', + '.claude/skills/manage-channels/SKILL.md', + ], + }); } // ─── shared step gate ────────────────────────────────────────────────── From 8662f21e8f5dbb367d727de42162d291c393f925 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 14:53:22 +0300 Subject: [PATCH 097/185] docs(readme): update for v2 + scripted setup default Rewrite install flow around `bash nanoclaw.sh`, update What It Supports to reflect the three-level isolation model and the real channel roster, fix the Philosophy section (AI-native hybrid, skills over features via channels/providers branches, Codex/OpenCode/Ollama as drop-in providers), and replace the pre-v2 architecture diagram and key-files list with the two-DB session split (`inbound.db` / `outbound.db`) and current src layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 110 ++++++++++++++++++++++-------------------------------- 1 file changed, 45 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 874a8d7..53ac2b0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ 中文  •   日本語  •   Discord  •   - 34.9k tokens, 17% of context window + repo tokens

--- @@ -26,55 +26,36 @@ NanoClaw provides that same core functionality, but in a codebase small enough t ## Quick Start ```bash -gh repo fork qwibitai/nanoclaw --clone -cd nanoclaw -claude +git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw && bash nanoclaw.sh ``` -
-Without GitHub CLI - -1. Fork [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw) on GitHub (click the Fork button) -2. `git clone https://github.com//nanoclaw.git` -3. `cd nanoclaw` -4. `claude` - -
- -Then run `/setup`. Claude Code handles everything: dependencies, authentication, container setup and service configuration. - -> **Note:** Commands prefixed with `/` (like `/setup`, `/add-whatsapp`) are [Claude Code skills](https://code.claude.com/docs/en/skills). Type them inside the `claude` CLI prompt, not in your regular terminal. If you don't have Claude Code installed, get it at [claude.com/product/claude-code](https://claude.com/product/claude-code). +`nanoclaw.sh` walks you from a fresh machine to a named agent you can message. It installs Node, pnpm, and Docker if missing, registers your Anthropic credential with OneCLI, builds the agent container, and pairs your first channel (Telegram, Discord, WhatsApp, or a local CLI). If a step fails, Claude Code is invoked automatically to diagnose and resume from where it broke. ## Philosophy **Small enough to understand.** One process, a few source files and no microservices. If you want to understand the full NanoClaw codebase, just ask Claude Code to walk you through it. -**Secure by isolation.** Agents run in Linux containers (Apple Container on macOS, or Docker) and they can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host. +**Secure by isolation.** Agents run in Linux containers and they can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host. **Built for the individual user.** NanoClaw isn't a monolithic framework; it's software that fits each user's exact needs. Instead of becoming bloatware, NanoClaw is designed to be bespoke. You make your own fork and have Claude Code modify it to match your needs. **Customization = code changes.** No configuration sprawl. Want different behavior? Modify the code. The codebase is small enough that it's safe to make changes. -**AI-native.** -- No installation wizard; Claude Code guides setup. -- No monitoring dashboard; ask Claude what's happening. -- No debugging tools; describe the problem and Claude fixes it. +**AI-native, hybrid by design.** The install and onboarding flow is an optimized scripted path, fast and deterministic. When a step needs judgment, whether a failed install, a guided decision, or a customization, control hands off to Claude Code seamlessly. Beyond setup there's no monitoring dashboard or debugging UI either: describe the problem in chat and Claude Code handles it. -**Skills over features.** Instead of adding features (e.g. support for Telegram) to the codebase, contributors submit [claude code skills](https://code.claude.com/docs/en/skills) like `/add-telegram` that transform your fork. You end up with clean code that does exactly what you need. +**Skills over features.** Trunk ships the registry and infrastructure, not specific channel adapters or alternative agent providers. Channels (Discord, Slack, Telegram, WhatsApp, …) live on a long-lived `channels` branch; alternative providers (OpenCode, Ollama) live on `providers`. You run `/add-telegram`, `/add-opencode`, etc. and the skill copies exactly the module(s) you need into your fork. No feature you didn't ask for. -**Best harness, best model.** NanoClaw runs on the Claude Agent SDK, which means you're running Claude Code directly. Claude Code is highly capable and its coding and problem-solving capabilities allow it to modify and expand NanoClaw and tailor it to each user. +**Best harness, best model.** NanoClaw natively uses Claude Code via Anthropic's official Claude Agent SDK, so you get the latest Claude models and Claude Code's full toolset, including the ability to modify and expand your own NanoClaw fork. Other providers are drop-in options: `/add-codex` for OpenAI's Codex (ChatGPT subscription or API key), `/add-opencode` for OpenRouter, Google, DeepSeek and more via OpenCode, and `/add-ollama-provider` for local open-weight models. Provider is configurable per agent group. ## What It Supports -- **Multi-channel messaging** - Talk to your assistant from WhatsApp, Telegram, Discord, Slack, or Gmail. Add channels with skills like `/add-whatsapp` or `/add-telegram`. Run one or many at the same time. -- **Isolated group context** - Each group has its own `CLAUDE.md` memory, isolated filesystem, and runs in its own container sandbox with only that filesystem mounted to it. -- **Main channel** - Your private channel (self-chat) for admin control; every group is completely isolated -- **Scheduled tasks** - Recurring jobs that run Claude and can message you back -- **Web access** - Search and fetch content from the Web -- **Container isolation** - Agents are sandboxed in Docker (macOS/Linux), [Docker Sandboxes](docs/docker-sandboxes.md) (micro VM isolation), or Apple Container (macOS) -- **Credential security** - Agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits. -- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks -- **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills +- **Multi-channel messaging** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, and email via Resend. Installed on demand with `/add-` skills. Run one or many at the same time. +- **Flexible isolation** — connect each channel to its own agent for full privacy, share one agent across many channels for unified memory with separate conversations, or fold multiple channels into a single shared session so one conversation spans many surfaces. Pick per channel via `/manage-channels`. See [docs/isolation-model.md](docs/isolation-model.md). +- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to. +- **Scheduled tasks** — recurring jobs that run Claude and can message you back +- **Web access** — search and fetch content from the web +- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in +- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits. ## Usage @@ -86,7 +67,7 @@ Talk to your assistant with the trigger word (default: `@Andy`): @Andy every Monday at 8am, compile news on AI developments from Hacker News and TechCrunch and message me a briefing ``` -From the main channel (your self-chat), you can manage groups and tasks: +From a channel you own or administer, you can manage groups and tasks: ``` @Andy list all scheduled tasks across groups @Andy pause the Monday briefing task @@ -110,54 +91,58 @@ The codebase is small enough that Claude can safely modify it. **Don't add features. Add skills.** -If you want to add Telegram support, don't create a PR that adds Telegram to the core codebase. Instead, fork NanoClaw, make the code changes on a branch, and open a PR. We'll create a `skill/telegram` branch from your PR that other users can merge into their fork. +If you want to add a new channel or agent provider, don't add it to trunk. New channel adapters land on the `channels` branch; new agent providers land on `providers`. Users install them in their own fork with `/add-` skills, which copy the relevant module(s) into the standard paths, wire the registration, and pin dependencies. -Users then run `/add-telegram` on their fork and get clean code that does exactly what they need, not a bloated system trying to support every use case. +This keeps trunk as pure registry and infra, and every fork stays lean — users get the channels and providers they asked for and nothing else. ### RFS (Request for Skills) Skills we'd like to see: **Communication Channels** -- `/add-signal` - Add Signal as a channel +- `/add-signal` — Add Signal as a channel ## Requirements -- macOS, Linux, or Windows (via WSL2) -- Node.js 20+ -- [Claude Code](https://claude.ai/download) -- [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop) (macOS/Linux) +- macOS or Linux (Windows via WSL2) +- Node.js 20+ and pnpm 10+ (the installer will install both if missing) +- [Docker Desktop](https://docker.com/products/docker-desktop) (macOS/Windows) or Docker Engine (Linux) +- [Claude Code](https://claude.ai/download) for `/customize`, `/debug`, error recovery during setup, and all `/add-` skills ## Architecture ``` -Channels --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Response +messaging apps → host process (router) → inbound.db → container (Bun, Claude Agent SDK) → outbound.db → host process (delivery) → messaging apps ``` -Single Node.js process. Channels are added via skills and self-register at startup — the orchestrator connects whichever ones have credentials present. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem. +A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs Claude, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter. -For the full architecture details, see the [documentation site](https://docs.nanoclaw.dev/concepts/architecture). +Two SQLite files per session, each with exactly one writer — no cross-mount contention, no IPC, no stdin piping. Channels and alternative providers self-register at startup; trunk ships the registry and the Chat SDK bridge, while the adapters themselves are skill-installed per fork. + +For the full architecture writeup see [docs/architecture.md](docs/architecture.md); for the three-level isolation model see [docs/isolation-model.md](docs/isolation-model.md). Key files: -- `src/index.ts` - Orchestrator: state, message loop, agent invocation -- `src/channels/registry.ts` - Channel registry (self-registration at startup) -- `src/ipc.ts` - IPC watcher and task processing -- `src/router.ts` - Message formatting and outbound routing -- `src/group-queue.ts` - Per-group queue with global concurrency limit -- `src/container-runner.ts` - Spawns streaming agent containers -- `src/task-scheduler.ts` - Runs scheduled tasks -- `src/db.ts` - SQLite operations (messages, groups, sessions, state) -- `groups/*/CLAUDE.md` - Per-group memory +- `src/index.ts` — entry point: DB init, channel adapters, delivery polls, sweep +- `src/router.ts` — inbound routing: messaging group → agent group → session → `inbound.db` +- `src/delivery.ts` — polls `outbound.db`, delivers via adapter, handles system actions +- `src/host-sweep.ts` — 60s sweep: stale detection, due-message wake, recurrence +- `src/session-manager.ts` — resolves sessions, opens `inbound.db` / `outbound.db` +- `src/container-runner.ts` — spawns per-agent-group containers, OneCLI credential injection +- `src/db/` — central DB (users, roles, agent groups, messaging groups, wiring, migrations) +- `src/channels/` — channel adapter infra (adapters installed via `/add-` skills) +- `src/providers/` — host-side provider config (`claude` baked in; others via skills) +- `container/agent-runner/` — Bun agent-runner: poll loop, MCP tools, provider abstraction +- `groups//` — per-agent-group filesystem (`CLAUDE.md`, skills, container config) ## FAQ **Why Docker?** -Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM. +Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM. **Can I run this on Linux or Windows?** -Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via WSL2). Just run `/setup`. +Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via WSL2). Just run `bash nanoclaw.sh`. **Is this secure?** @@ -169,33 +154,28 @@ We don't want configuration sprawl. Every user should customize NanoClaw so that **Can I use third-party or open-source models?** -Yes. NanoClaw supports any Claude API-compatible model endpoint. Set these environment variables in your `.env` file: +Yes. The supported path is `/add-opencode` (OpenRouter, OpenAI, Google, DeepSeek, and more via OpenCode config) or `/add-ollama-provider` (local open-weight models via Ollama). Both are configurable per agent group, so different agents can run on different backends in the same install. + +For one-off experiments, any Claude API-compatible endpoint also works via `.env`: ```bash ANTHROPIC_BASE_URL=https://your-api-endpoint.com ANTHROPIC_AUTH_TOKEN=your-token-here ``` -This allows you to use: -- Local models via [Ollama](https://ollama.ai) with an API proxy -- Open-source models hosted on [Together AI](https://together.ai), [Fireworks](https://fireworks.ai), etc. -- Custom model deployments with Anthropic-compatible APIs - -Note: The model must support the Anthropic API format for best compatibility. - **How do I debug issues?** Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?" "Why did this message not get a response?" That's the AI-native approach that underlies NanoClaw. **Why isn't the setup working for me?** -If you have issues, during setup, Claude will try to dynamically fix them. If that doesn't work, run `claude`, then run `/debug`. If Claude finds an issue that is likely affecting other users, open a PR to modify the setup SKILL.md. +If a step fails, `nanoclaw.sh` hands off to Claude Code to diagnose and resume. If that doesn't resolve it, run `claude`, then `/debug`. If Claude identifies an issue likely to affect other users, open a PR against the relevant setup step or skill. **What changes will be accepted into the codebase?** Only security fixes, bug fixes, and clear improvements will be accepted to the base configuration. That's all. -Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills. +Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills on the `channels` or `providers` branch. This keeps the base system minimal and lets every user customize their installation without inheriting features they don't want. From 8e1c8f8f614748f7c6d20bd3c9f02631aa6c5116 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 14:57:09 +0300 Subject: [PATCH 098/185] style: apply prettier formatting to touched files Co-Authored-By: Claude Opus 4.7 (1M context) --- src/command-gate.ts | 11 +----- src/container-runner.ts | 15 ++++--- src/db/migrations/010-engage-modes.ts | 4 +- src/modules/approvals/response-handler.ts | 4 +- src/modules/index.ts | 1 - src/modules/interactive/index.ts | 6 ++- .../permissions/channel-approval.test.ts | 39 +++++++++---------- .../db/pending-sender-approvals.ts | 15 +++---- src/modules/scheduling/recurrence.test.ts | 4 +- src/modules/self-mod/apply.ts | 5 ++- 10 files changed, 51 insertions(+), 53 deletions(-) diff --git a/src/command-gate.ts b/src/command-gate.ts index 7bd1b9f..a0c1979 100644 --- a/src/command-gate.ts +++ b/src/command-gate.ts @@ -9,10 +9,7 @@ */ import { getDb, hasTable } from './db/connection.js'; -export type GateResult = - | { action: 'pass' } - | { action: 'filter' } - | { action: 'deny'; command: string }; +export type GateResult = { action: 'pass' } | { action: 'filter' } | { action: 'deny'; command: string }; const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']); const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files']); @@ -23,11 +20,7 @@ const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/fil * 'filter' for silently-dropped commands, 'deny' for unauthorized * admin commands. */ -export function gateCommand( - content: string, - userId: string | null, - agentGroupId: string, -): GateResult { +export function gateCommand(content: string, userId: string | null, agentGroupId: string): GateResult { let text: string; try { const parsed = JSON.parse(content); diff --git a/src/container-runner.ts b/src/container-runner.ts index 6f7f1d1..7425299 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -110,7 +110,15 @@ async function spawnContainer(session: Session): Promise { // OneCLI agent identifier is always the agent group id — stable across // sessions and reversible via getAgentGroup() for approval routing. const agentIdentifier = agentGroup.id; - const args = await buildContainerArgs(mounts, containerName, agentGroup, containerConfig, provider, contribution, agentIdentifier); + const args = await buildContainerArgs( + mounts, + containerName, + agentGroup, + containerConfig, + provider, + contribution, + agentIdentifier, + ); log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName }); @@ -263,10 +271,7 @@ function buildMounts( * selection. Each symlink points to a container path (/app/skills/) * so it's dangling on the host but valid inside the container. */ -function syncSkillSymlinks( - claudeDir: string, - containerConfig: import('./container-config.js').ContainerConfig, -): void { +function syncSkillSymlinks(claudeDir: string, containerConfig: import('./container-config.js').ContainerConfig): void { const skillsDir = path.join(claudeDir, 'skills'); if (!fs.existsSync(skillsDir)) { fs.mkdirSync(skillsDir, { recursive: true }); diff --git a/src/db/migrations/010-engage-modes.ts b/src/db/migrations/010-engage-modes.ts index 4bf9798..e7bff99 100644 --- a/src/db/migrations/010-engage-modes.ts +++ b/src/db/migrations/010-engage-modes.ts @@ -75,7 +75,9 @@ export const migration010: Migration = { `); // Backfill existing rows in JS (parsing JSON per-row is painful in pure SQL). - const rows = db.prepare('SELECT id, trigger_rules, response_scope FROM messaging_group_agents').all() as LegacyRow[]; + const rows = db + .prepare('SELECT id, trigger_rules, response_scope FROM messaging_group_agents') + .all() as LegacyRow[]; const update = db.prepare( `UPDATE messaging_group_agents SET engage_mode = ?, diff --git a/src/modules/approvals/response-handler.ts b/src/modules/approvals/response-handler.ts index bd0c2c5..2bbdc9d 100644 --- a/src/modules/approvals/response-handler.ts +++ b/src/modules/approvals/response-handler.ts @@ -96,7 +96,9 @@ async function handleRegisteredApproval( log.info('Approval handled', { approvalId: approval.approval_id, action: approval.action, userId }); } catch (err) { log.error('Approval handler threw', { approvalId: approval.approval_id, action: approval.action, err }); - notify(`Your ${approval.action} was approved, but applying it failed: ${err instanceof Error ? err.message : String(err)}.`); + notify( + `Your ${approval.action} was approved, but applying it failed: ${err instanceof Error ? err.message : String(err)}.`, + ); } deletePendingApproval(approval.approval_id); diff --git a/src/modules/index.ts b/src/modules/index.ts index 2df4477..0228509 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -22,4 +22,3 @@ import './scheduling/index.js'; import './permissions/index.js'; import './agent-to-agent/index.js'; import './self-mod/index.js'; - diff --git a/src/modules/interactive/index.ts b/src/modules/interactive/index.ts index 5a3b8af..324adbe 100644 --- a/src/modules/interactive/index.ts +++ b/src/modules/interactive/index.ts @@ -46,7 +46,11 @@ async function handleInteractiveResponse(payload: ResponsePayload): Promise { await new Promise((r) => setTimeout(r, 10)); const { getDb } = await import('../../db/connection.js'); - const pending = getDb() - .prepare('SELECT messaging_group_id FROM pending_channel_approvals') - .get() as { messaging_group_id: string }; + const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as { + messaging_group_id: string; + }; expect(pending).toBeDefined(); // Owner clicks approve. @@ -240,9 +240,8 @@ describe('unknown-channel registration flow', () => { expect(member).toBeDefined(); // Pending row cleared and container woken via replay. - const stillPending = ( - getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number } - ).c; + const stillPending = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }) + .c; expect(stillPending).toBe(0); expect(wakeContainer).toHaveBeenCalled(); }); @@ -255,9 +254,9 @@ describe('unknown-channel registration flow', () => { await new Promise((r) => setTimeout(r, 10)); const { getDb } = await import('../../db/connection.js'); - const pending = getDb() - .prepare('SELECT messaging_group_id FROM pending_channel_approvals') - .get() as { messaging_group_id: string }; + const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as { + messaging_group_id: string; + }; for (const handler of getResponseHandlers()) { const claimed = await handler({ @@ -285,9 +284,9 @@ describe('unknown-channel registration flow', () => { await routeInbound(groupMention('chat-deny')); await new Promise((r) => setTimeout(r, 10)); const { getDb } = await import('../../db/connection.js'); - const pending = getDb() - .prepare('SELECT messaging_group_id FROM pending_channel_approvals') - .get() as { messaging_group_id: string }; + const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as { + messaging_group_id: string; + }; for (const handler of getResponseHandlers()) { const claimed = await handler({ @@ -317,9 +316,8 @@ describe('unknown-channel registration flow', () => { await routeInbound(groupMention('chat-deny', '@bot please')); await new Promise((r) => setTimeout(r, 10)); expect(deliverMock).not.toHaveBeenCalled(); - const stillPending = ( - getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number } - ).c; + const stillPending = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }) + .c; expect(stillPending).toBe(0); }); @@ -330,9 +328,9 @@ describe('unknown-channel registration flow', () => { await routeInbound(groupMention('chat-unauth')); await new Promise((r) => setTimeout(r, 10)); const { getDb } = await import('../../db/connection.js'); - const pending = getDb() - .prepare('SELECT messaging_group_id FROM pending_channel_approvals') - .get() as { messaging_group_id: string }; + const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as { + messaging_group_id: string; + }; for (const handler of getResponseHandlers()) { const claimed = await handler({ @@ -353,9 +351,8 @@ describe('unknown-channel registration flow', () => { .get(pending.messaging_group_id) as { c: number } ).c; expect(mgaCount).toBe(0); - const stillPending = ( - getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number } - ).c; + const stillPending = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }) + .c; expect(stillPending).toBe(1); }); }); diff --git a/src/modules/permissions/db/pending-sender-approvals.ts b/src/modules/permissions/db/pending-sender-approvals.ts index 9f7e3a4..77a5699 100644 --- a/src/modules/permissions/db/pending-sender-approvals.ts +++ b/src/modules/permissions/db/pending-sender-approvals.ts @@ -37,19 +37,14 @@ export function createPendingSenderApproval(row: PendingSenderApproval): void { } export function getPendingSenderApproval(id: string): PendingSenderApproval | undefined { - return getDb() - .prepare('SELECT * FROM pending_sender_approvals WHERE id = ?') - .get(id) as PendingSenderApproval | undefined; + return getDb().prepare('SELECT * FROM pending_sender_approvals WHERE id = ?').get(id) as + | PendingSenderApproval + | undefined; } -export function hasInFlightSenderApproval( - messagingGroupId: string, - senderIdentity: string, -): boolean { +export function hasInFlightSenderApproval(messagingGroupId: string, senderIdentity: string): boolean { const row = getDb() - .prepare( - 'SELECT 1 AS x FROM pending_sender_approvals WHERE messaging_group_id = ? AND sender_identity = ?', - ) + .prepare('SELECT 1 AS x FROM pending_sender_approvals WHERE messaging_group_id = ? AND sender_identity = ?') .get(messagingGroupId, senderIdentity) as { x: number } | undefined; return row !== undefined; } diff --git a/src/modules/scheduling/recurrence.test.ts b/src/modules/scheduling/recurrence.test.ts index a70d6c8..358e6b4 100644 --- a/src/modules/scheduling/recurrence.test.ts +++ b/src/modules/scheduling/recurrence.test.ts @@ -59,9 +59,7 @@ describe('handleRecurrence', () => { await handleRecurrence(db, fakeSession()); const rows = db - .prepare( - `SELECT id, status, process_after, recurrence, series_id FROM messages_in ORDER BY seq`, - ) + .prepare(`SELECT id, status, process_after, recurrence, series_id FROM messages_in ORDER BY seq`) .all() as Array<{ id: string; status: string; diff --git a/src/modules/self-mod/apply.ts b/src/modules/self-mod/apply.ts index da33fd0..1a3daa8 100644 --- a/src/modules/self-mod/apply.ts +++ b/src/modules/self-mod/apply.ts @@ -24,7 +24,10 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, if (payload.npm) cfg.packages.npm.push(...(payload.npm as string[])); }); - const pkgs = [...((payload.apt as string[] | undefined) || []), ...((payload.npm as string[] | undefined) || [])].join(', '); + const pkgs = [ + ...((payload.apt as string[] | undefined) || []), + ...((payload.npm as string[] | undefined) || []), + ].join(', '); log.info('Package install approved', { agentGroupId: session.agent_group_id, userId }); try { await buildAgentGroupImage(session.agent_group_id); From fe942dd3dd9b872d8ea10ed3c7e40098fa50035f Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 15:10:10 +0300 Subject: [PATCH 099/185] chore: bump to 2.0.0 with v2 CHANGELOG entry Major version for the v2 rewrite. CHANGELOG documents the breaking changes users will hit on upgrade: new entity model, two-DB session split, `bash nanoclaw.sh` as default install, channels/providers relocated to sibling branches, three-level isolation, Apple Container removed from default setup. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 15 +++++++++++++++ nanoclaw.sh | 2 +- package.json | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2503be7..ab2fd5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to NanoClaw will be documented in this file. For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog). +## [2.0.0] - 2026-04-22 + +Major version. NanoClaw v2 is a substantial architectural rewrite. Existing forks should run `/migrate-nanoclaw` (clean-base replay of customizations) or `/update-nanoclaw` (selective cherry-pick) before resuming work. + +- [BREAKING] **New entity model.** Users, roles (owner/admin), messaging groups, and agent groups are now tracked as separate entities, wired via `messaging_group_agents`. Privilege is user-level instead of channel-level, so the old "main channel = admin" concept is retired. See [docs/architecture.md](docs/architecture.md) and [docs/isolation-model.md](docs/isolation-model.md). +- [BREAKING] **Two-DB session split.** Each session now has `inbound.db` (host writes, container reads) and `outbound.db` (container writes, host reads) with exactly one writer each. Replaces the single shared session DB and eliminates cross-mount SQLite contention. See [docs/db-session.md](docs/db-session.md). +- [BREAKING] **Install flow replaced.** `bash nanoclaw.sh` is the new default: a scripted installer that hands off to Claude Code for error recovery and guided decisions. The `/setup` Claude-guided skill still works as an alternative. +- [BREAKING] **Channels moved to the `channels` branch.** Trunk no longer ships Discord, Slack, Telegram, WhatsApp, iMessage, Teams, Linear, GitHub, WeChat, Matrix, Google Chat, Webex, Resend, or WhatsApp Cloud. Install them per fork via `/add-` skills, which copy from the `channels` branch. `/update-nanoclaw` will re-install the channels your fork had. +- [BREAKING] **Alternative providers moved to the `providers` branch.** OpenCode, Codex, and Ollama install via `/add-opencode`, `/add-codex`, `/add-ollama-provider`. Claude remains the default provider baked into trunk. +- [BREAKING] **Three-level channel isolation.** Wire channels to their own agent (separate agent groups), share an agent with independent conversations (`session_mode: 'shared'`), or merge channels into one shared session (`session_mode: 'agent-shared'`). Chosen per channel via `/manage-channels`. +- [BREAKING] **Apple Container removed from default setup.** Still available as an opt-in via `/convert-to-apple-container`. +- **Shared-source agent-runner.** Per-group `agent-runner-src/` overlays are gone; all groups mount the same agent-runner read-only. Per-group customization flows through composed `CLAUDE.md` (shared base + per-group fragments). +- **Agent-runner runtime moved from Node to Bun.** Container image is self-contained; no host-side impact. Host remains on Node + pnpm. +- **OneCLI Agent Vault is the sole credential path.** Containers never receive raw API keys; credentials are injected at request time. + ## [1.2.36] - 2026-03-26 - [BREAKING] Replaced pino logger with built-in logger. WhatsApp users must re-merge the WhatsApp fork to pick up the Baileys logger compatibility fix: `git fetch whatsapp main && git merge whatsapp/main`. If the `whatsapp` remote is not configured: `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git`. diff --git a/nanoclaw.sh b/nanoclaw.sh index a1a22af..95a4824 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -237,7 +237,7 @@ fi # wipe it. export NANOCLAW_BOOTSTRAPPED=1 -# --silent suppresses pnpm's `> nanoclaw@1.2.52 setup:auto / > tsx setup/auto.ts` +# --silent suppresses pnpm's `> nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts` # preamble so the flow continues visually from "Basics installed" straight # into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly. exec pnpm --silent run setup:auto diff --git a/package.json b/package.json index 536714f..5a9f443 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.52", + "version": "2.0.0", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 5371c76c14ae1ca3d641339b33af385e61453c9d Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 15:10:17 +0300 Subject: [PATCH 100/185] docs(readme): mark translations as pre-v2 pending update The Chinese and Japanese READMEs still describe the v1 architecture (setup flow, channel list, "main channel" concept, etc.). Add a notice at the top of each pointing readers to the English README for current content until the translations are refreshed. Co-Authored-By: Claude Opus 4.7 (1M context) --- README_ja.md | 2 ++ README_zh.md | 3 +++ 2 files changed, 5 insertions(+) diff --git a/README_ja.md b/README_ja.md index 5c3f648..5ae2b11 100644 --- a/README_ja.md +++ b/README_ja.md @@ -14,6 +14,8 @@ 34.9k tokens, 17% of context window

+> **注意:** この日本語訳は v1 時点のもので、最新の v2 アーキテクチャは反映されていません。最新の内容は [README.md](README.md) をご覧ください。 + ---

🐳 Dockerサンドボックスで動作

diff --git a/README_zh.md b/README_zh.md index 714bd87..b86d7ad 100644 --- a/README_zh.md +++ b/README_zh.md @@ -13,6 +13,9 @@ Discord  •   34.9k tokens, 17% of context window

+ +> **注意:** 此中文翻译对应 v1 版本,已不反映最新的 v2 架构。请参考 [README.md](README.md) 获取最新内容。 + 通过 Claude Code,NanoClaw 可以动态重写自身代码,根据您的需求定制功能。 **新功能:** 首个支持 [Agent Swarms(智能体集群)](https://code.claude.com/docs/en/agent-teams) 的 AI 助手。可轻松组建智能体团队,在您的聊天中高效协作。 From 3db66c0ced5c088b9a402dac0de84549790237ca Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 15:16:59 +0300 Subject: [PATCH 101/185] fix: forward ONECLI_API_KEY to OneCLI SDK for authenticated container config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the v1 fix from PR #1777 (originally 8b5b581 by @johnnyfish). Cherry-pick did not apply cleanly because v2 reformatted the surrounding code and split OneCLI usage into two sites — manual port was needed. v2-specific adaptations: - Also forward apiKey at the second OneCLI call site in src/modules/approvals/onecli-approvals.ts (v2 split the approvals module out of container-runner). - Skipped the companion test-mock commit (38163bc) — it patches src/container-runner.test.ts, which no longer exists in v2 (tests consolidated into host-core.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: johnnyfish --- src/config.ts | 9 ++++++++- src/container-runner.ts | 4 ++-- src/modules/approvals/onecli-approvals.ts | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/config.ts b/src/config.ts index 043a4a2..4a4eef6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,7 +5,13 @@ import { readEnvFile } from './env.js'; import { isValidTimezone } from './timezone.js'; // Read config values from .env (falls back to process.env). -const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', 'TZ']); +const envConfig = readEnvFile([ + 'ASSISTANT_NAME', + 'ASSISTANT_HAS_OWN_NUMBER', + 'ONECLI_URL', + 'ONECLI_API_KEY', + 'TZ', +]); export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; export const ASSISTANT_HAS_OWN_NUMBER = @@ -26,6 +32,7 @@ export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:la export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL; +export const ONECLI_API_KEY = process.env.ONECLI_API_KEY || envConfig.ONECLI_API_KEY; export const MAX_MESSAGES_PER_PROMPT = Math.max(1, parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10); 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); diff --git a/src/container-runner.ts b/src/container-runner.ts index 7425299..4b7964c 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -9,7 +9,7 @@ import path from 'path'; import { OneCLI } from '@onecli-sh/sdk'; -import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, ONECLI_URL, TIMEZONE } from './config.js'; +import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, ONECLI_API_KEY, ONECLI_URL, TIMEZONE } from './config.js'; import { readContainerConfig, writeContainerConfig } from './container-config.js'; import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { composeGroupClaudeMd } from './claude-md-compose.js'; @@ -30,7 +30,7 @@ import { import { markContainerRunning, markContainerStopped, sessionDir, writeSessionRouting } from './session-manager.js'; import type { AgentGroup, Session } from './types.js'; -const onecli = new OneCLI({ url: ONECLI_URL }); +const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY }); /** Active containers tracked by session ID. */ const activeContainers = new Map(); diff --git a/src/modules/approvals/onecli-approvals.ts b/src/modules/approvals/onecli-approvals.ts index 1594a82..eec05c0 100644 --- a/src/modules/approvals/onecli-approvals.ts +++ b/src/modules/approvals/onecli-approvals.ts @@ -20,7 +20,7 @@ import { OneCLI, type ApprovalRequest, type ManualApprovalHandle } from '@onecli-sh/sdk'; import { pickApprovalDelivery, pickApprover } from './primitive.js'; -import { ONECLI_URL } from '../../config.js'; +import { ONECLI_API_KEY, ONECLI_URL } from '../../config.js'; import { getAgentGroup } from '../../db/agent-groups.js'; import { createPendingApproval, @@ -36,7 +36,7 @@ export const ONECLI_ACTION = 'onecli_credential'; type Decision = 'approve' | 'deny'; -const onecli = new OneCLI({ url: ONECLI_URL }); +const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY }); interface PendingState { resolve: (decision: Decision) => void; From 0ed00b33582f2e97ea96697dbd6a357f92cce62e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 16:20:59 +0300 Subject: [PATCH 102/185] docs(claude.md): add v1-merge STOP banner directing to migrate-v2.sh Prepend a Claude-addressed banner so that when an upgrader (or Claude on their behalf) runs `git pull` / `git merge` from v1 and hits merge conflicts, Claude aborts the merge and routes the user to `bash migrate-v2.sh` instead of trying to resolve the rewrite by hand. Fresh clones are explicitly told to ignore the banner. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ec06a64..464d87b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,18 @@ +# ⚠️ STOP — READ THIS FIRST IF YOU ARE CLAUDE ⚠️ + +**If you are reading this because you just ran `git pull`, `git merge`, `git fetch && git merge`, or any equivalent to bring in upstream changes — and you see merge conflicts or a large diff involving this file — HALT IMMEDIATELY.** + +This is NanoClaw v2, a ground-up rewrite with breaking changes throughout. It cannot be merged into an existing v1 install. Attempting to resolve the conflicts by hand, run builds, or "fix" anything will corrupt the user's install and burn tokens for no result. + +**Do this instead:** +1. Abort the merge: `git merge --abort` (or `git reset --hard ORIG_HEAD` if the merge already completed). +2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Run `bash migrate-v2.sh` to migrate instead."* +3. Wait for the user to confirm before doing anything else. Do not attempt the migration yourself. + +If you are a fresh install (you ran `git clone`, not `git pull`) and there are no conflicts, ignore this banner and continue below. + +--- + # NanoClaw Personal Claude assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`. From 202ee71311ba75c386827b5dc5f9e328b51a365a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 16:44:53 +0300 Subject: [PATCH 103/185] feat(setup): auto-detect timezone after CLI agent step Adds a timezone step between cli-agent and channel wiring in setup:auto. Autodetect via --step timezone; if it resolves to UTC or fails, confirm with the user and accept either an IANA zone or a free-text description (e.g. "New York"). Free-text falls through to a headless `claude -p` call that returns a single IANA string, gated on the claude CLI being on PATH. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 127 ++++++++++++++++++++++++++++++++++-- setup/lib/claude-assist.ts | 1 + setup/lib/tz-from-claude.ts | 126 +++++++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 setup/lib/tz-from-claude.ts diff --git a/setup/auto.ts b/setup/auto.ts index 52586c2..0f94997 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -14,11 +14,12 @@ * "Terminal Agent". * NANOCLAW_SKIP comma-separated step names to skip * (environment|container|onecli|auth|mounts| - * service|cli-agent|channel|verify|first-chat) + * service|cli-agent|timezone|channel|verify| + * first-chat) * - * Timezone defaults to the host system's TZ. Run - * pnpm exec tsx setup/index.ts --step timezone -- --tz - * later if autodetect is wrong. + * Timezone is auto-detected after the CLI agent step. UTC resolves are + * confirmed with the user, and free-text replies fall through to a + * headless `claude -p` call for IANA-zone resolution. */ import { spawn, spawnSync } from 'child_process'; @@ -31,9 +32,14 @@ import { runTelegramChannel } from './channels/telegram.js'; import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { offerClaudeAssist } from './lib/claude-assist.js'; +import { + claudeCliAvailable, + resolveTimezoneViaClaude, +} from './lib/tz-from-claude.js'; import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js'; +import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); @@ -217,6 +223,10 @@ async function main(): Promise { } } + if (!skip.has('timezone')) { + await runTimezoneStep(); + } + if (!skip.has('channel')) { const choice = await askChannelChoice(); if (choice === 'telegram') { @@ -510,6 +520,115 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise { } } +// ─── timezone step ───────────────────────────────────────────────────── + +/** + * Auto-detect TZ, confirm with the user when it comes back as UTC (a + * common sign we're on a VPS that wasn't localised), and persist through + * the usual `--step timezone -- --tz ` path. Free-text answers get + * a headless `claude -p` pass to resolve them to a real IANA zone. + */ +async function runTimezoneStep(): Promise { + const res = await runQuietStep('timezone', { + running: 'Checking your timezone…', + done: 'Timezone set.', + }); + if (!res.ok && res.terminal?.fields.NEEDS_USER_INPUT !== 'true') { + await fail('timezone', "Couldn't determine your timezone."); + } + + const fields = res.terminal?.fields ?? {}; + const resolvedTz = fields.RESOLVED_TZ; + const needsInput = fields.NEEDS_USER_INPUT === 'true'; + const isUtc = + resolvedTz === 'UTC' || + resolvedTz === 'Etc/UTC' || + resolvedTz === 'Universal'; + + if (!needsInput && !isUtc && resolvedTz && resolvedTz !== 'none') { + return; + } + + // Either autodetect failed outright, or it landed on UTC and we should + // check that's really what the user wants before leaving it there. + const message = needsInput + ? "Your system didn't expose a timezone. Which one are you in?" + : "Your system reports UTC as the timezone. Is that right, or are you somewhere else?"; + + const choice = ensureAnswer( + await p.select({ + message, + options: needsInput + ? [ + { value: 'answer', label: "I'll tell you where I am" }, + { value: 'keep', label: 'Leave it as UTC' }, + ] + : [ + { value: 'keep', label: 'Keep UTC', hint: 'remote server / happy with UTC' }, + { value: 'answer', label: "I'm somewhere else" }, + ], + }), + ) as 'keep' | 'answer'; + setupLog.userInput('timezone_choice', choice); + + if (choice === 'keep') return; + + const answer = ensureAnswer( + await p.text({ + message: "Where are you? (city, region, or IANA zone)", + placeholder: 'e.g. New York, London, Asia/Tokyo', + validate: (v) => (v && v.trim() ? undefined : 'Required'), + }), + ); + const raw = (answer as string).trim(); + setupLog.userInput('timezone_input', raw); + + let tz: string | null = isValidTimezone(raw) ? raw : null; + if (!tz) { + if (claudeCliAvailable()) { + tz = await resolveTimezoneViaClaude(raw); + } else { + p.log.warn( + wrapForGutter( + "That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.", + 4, + ), + ); + } + } + + if (!tz) { + // One retry with a direct-IANA ask; if that fails too, leave the + // previously-detected value in .env and move on rather than looping. + const retryAnswer = ensureAnswer( + await p.text({ + message: 'Enter an IANA timezone string', + placeholder: 'e.g. America/New_York', + validate: (v) => { + const s = (v ?? '').trim(); + if (!s) return 'Required'; + if (!isValidTimezone(s)) return 'Not a valid IANA zone'; + return undefined; + }, + }), + ); + tz = (retryAnswer as string).trim(); + setupLog.userInput('timezone_retry', tz); + } + + const persist = await runQuietStep( + 'timezone', + { + running: `Saving timezone ${tz}…`, + done: `Timezone set to ${tz}.`, + }, + ['--tz', tz], + ); + if (!persist.ok) { + await fail('timezone', `Couldn't save timezone ${tz}.`); + } +} + // ─── prompts owned by the sequencer ──────────────────────────────────── async function askDisplayName(fallback: string): Promise { diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 70b6a3d..551d938 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -56,6 +56,7 @@ const STEP_FILES: Record = { mounts: ['setup/mounts.ts'], service: ['setup/service.ts'], 'cli-agent': ['setup/cli-agent.ts', 'scripts/init-cli-agent.ts'], + timezone: ['setup/timezone.ts', 'setup/lib/tz-from-claude.ts'], channel: ['setup/auto.ts'], verify: ['setup/verify.ts'], // Channel-specific sub-steps: diff --git a/setup/lib/tz-from-claude.ts b/setup/lib/tz-from-claude.ts new file mode 100644 index 0000000..5486fbb --- /dev/null +++ b/setup/lib/tz-from-claude.ts @@ -0,0 +1,126 @@ +/** + * Headless Claude fallback for timezone resolution. + * + * When the user answers the UTC-confirmation prompt with something that + * isn't a valid IANA zone ("NYC", "Jerusalem time", "eastern"), spawn + * `claude -p` with a narrow prompt asking for a single IANA string and + * validate the reply with `isValidTimezone` before returning it. + * + * Gated on claude being on PATH — if the user did the paste-OAuth or + * paste-API auth path they may not have the CLI installed. Returns null + * in that case so the caller can ask them to try again with a canonical + * zone string. + */ +import { execSync, spawn } from 'child_process'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import { isValidTimezone } from '../../src/timezone.js'; +import { fitToWidth } from './theme.js'; + +export function claudeCliAvailable(): boolean { + try { + execSync('command -v claude', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * Ask headless Claude to map a free-text location/timezone description to + * a valid IANA zone. Shows a spinner with elapsed time. Returns the + * resolved zone string on success, or null if the CLI is missing, Claude + * errored, or the reply wasn't a valid IANA zone. + */ +export async function resolveTimezoneViaClaude( + input: string, +): Promise { + if (!claudeCliAvailable()) return null; + + const prompt = buildPrompt(input); + + const s = p.spinner(); + const start = Date.now(); + const label = 'Looking up that timezone…'; + s.start(fitToWidth(label, ' (999s)')); + const tick = setInterval(() => { + const elapsed = Math.round((Date.now() - start) / 1000); + const suffix = ` (${elapsed}s)`; + s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`); + }, 1000); + + const reply = await queryClaude(prompt); + + clearInterval(tick); + const elapsed = Math.round((Date.now() - start) / 1000); + const suffix = ` (${elapsed}s)`; + + const resolved = reply ? extractTimezone(reply) : null; + if (resolved) { + s.stop( + `${fitToWidth(`Interpreted as ${resolved}.`, suffix)}${k.dim(suffix)}`, + ); + return resolved; + } + s.stop( + `${fitToWidth("Couldn't interpret that as a timezone.", suffix)}${k.dim( + suffix, + )}`, + 1, + ); + return null; +} + +function buildPrompt(input: string): string { + return [ + 'Convert the user\'s description of where they are into a single IANA', + 'timezone identifier (e.g. "America/New_York", "Europe/London",', + '"Asia/Jerusalem"). Respond with ONLY the IANA string on a single line,', + 'nothing else — no prose, no quotes, no punctuation. If you cannot', + 'determine a zone with reasonable confidence, reply with exactly:', + 'UNKNOWN', + '', + `User's description: ${input}`, + ].join('\n'); +} + +function queryClaude(prompt: string): Promise { + return new Promise((resolve) => { + const child = spawn('claude', ['-p', '--output-format', 'text'], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let settled = false; + const settle = (value: string | null): void => { + if (settled) return; + settled = true; + resolve(value); + }; + + child.stdout.on('data', (c: Buffer) => { + stdout += c.toString('utf-8'); + }); + child.on('close', (code) => { + settle(code === 0 && stdout.trim() ? stdout : null); + }); + child.on('error', () => settle(null)); + + child.stdin.end(prompt); + }); +} + +function extractTimezone(reply: string): string | null { + // Claude occasionally prefixes with a backtick or wraps in quotes despite + // instructions; take the first line that looks like a zone. + const lines = reply + .split('\n') + .map((l) => l.trim().replace(/^["'`]+|["'`]+$/g, '')) + .filter(Boolean); + for (const line of lines) { + if (line === 'UNKNOWN') return null; + if (isValidTimezone(line)) return line; + } + return null; +} From 95e74d83830499ed45a90f36e00081334e6373fa Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 16:45:59 +0300 Subject: [PATCH 104/185] docs(onecli): expand secrets section; correct stale admin-roles refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the selective-mode gotcha for auto-created OneCLI agents (no secrets injected by default) with the CLI commands to inspect and fix it. Note that approval policies are not configurable via the SDK or `onecli@1.3.0` CLI — web UI only. Replace stale `NANOCLAW_ADMIN_USER_IDS` / `src/access.ts` references across CLAUDE.md, docs/architecture.md, docs/checklist.md, and docs/module-contract.md. Admin gating now runs host-side in src/command-gate.ts against `user_roles`; approver picks live in src/modules/approvals/primitive.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 39 ++++++++++++++++++++++++++++++- docs/architecture.md | 2 +- docs/checklist.md | 6 ++--- docs/module-contract.md | 2 +- src/modules/permissions/access.ts | 7 +++--- 5 files changed, 46 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 464d87b..3ad7774 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,9 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f | `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path | | `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` | | `src/container-runtime.ts` | Runtime selection (Docker vs Apple containers), orphan cleanup | -| `src/access.ts` | `pickApprover`, `pickApprovalDelivery`, admin resolution for `NANOCLAW_ADMIN_USER_IDS` | +| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` | +| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry | +| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) | | `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge | | `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache | | `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) | @@ -97,6 +99,41 @@ A second tier (direct source-level self-edits via a draft/activate flow) is plan API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`. +### Gotcha: auto-created agents start in `selective` secret mode + +When the host first spawns a session for a new agent group, `container-runner.ts:385` calls `onecli.ensureAgent({ name, identifier })`. The OneCLI `POST /api/agents` endpoint creates the agent in **`selective`** secret mode — meaning **no secrets are assigned to it by default**, even if the secrets exist in the vault and have host patterns that would otherwise match. + +Symptom: container starts, the proxy + CA cert are wired correctly, but the agent gets `401 Unauthorized` (or similar) from APIs whose credentials *are* in the vault. The credential just isn't in this agent's allow-list. + +The SDK does not expose `setSecretMode` — the only fix is the CLI (or the web UI at `http://127.0.0.1:10254`). + +```bash +# Find the agent (identifier is the agent group id) +onecli agents list + +# Flip to "all" so every vault secret with a matching host pattern gets injected +onecli agents set-secret-mode --id --mode all + +# Or, stay selective and assign specific secrets +onecli secrets list # find secret ids +onecli agents set-secrets --id --secret-ids , + +# Inspect what an agent currently has +onecli agents secrets --id # secrets assigned to this agent +onecli secrets list # all vault secrets (with host patterns) +``` + +If you've just enabled `mode all`, no container restart is needed — the gateway looks up secrets per request, so the next API call from the running container will see the new credentials. + +### Requiring approval for credential use + +Approval-gating credentialed actions is a **two-sided** flow: + +- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@1.3.0`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating. +- **Host-side** (nanoclaw): receives pending approvals and routes them to a human. `src/modules/approvals/onecli-approvals.ts` registers a callback via `onecli.configureManualApproval(cb)` (long-polls `GET /api/approvals/pending`). The callback uses `pickApprover` + `pickApprovalDelivery` from `src/modules/approvals/primitive.ts` to DM an approver. Approvers are resolved from the `user_roles` table — preference order: scoped admins for the agent group → global admins → owners. There is no env var like `NANOCLAW_ADMIN_USER_IDS`; roles are persisted in the central DB only. + +If approvals are configured server-side but the host callback isn't running (or throws), every credentialed call hangs until the gateway times out. Conversely, if the gateway has no rule asking for approval, the host callback never fires regardless of how it's wired. + ## Skills Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxonomy. diff --git a/docs/architecture.md b/docs/architecture.md index 6d8aab7..3f90d8d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -876,7 +876,7 @@ Messages starting with `/` are checked against three lists: - Commands that don't make sense in the NanoClaw context or could cause issues - Silently dropped — no error, no forwarding -The command lists are hardcoded in the agent-runner. Admin verification: the host passes `NANOCLAW_ADMIN_USER_IDS` (a comma-separated list of owner + global-admin + scoped-admin user ids for the current agent group, see `src/container-runner.ts`) to the container. The agent-runner membership-tests the inbound `senderId` against that set before forwarding admin commands. +The command lists are hardcoded in the agent-runner. Admin verification happens host-side before the message ever reaches the container: `src/command-gate.ts` queries `user_roles` (owner / global admin / scoped-admin-of-this-agent-group) and either passes the message through, drops it, or routes it elsewhere. The container has no notion of admin identity — no env var, no DB query, no per-message check. ### Recurring Tasks diff --git a/docs/checklist.md b/docs/checklist.md index 94baf6f..16b3630 100644 --- a/docs/checklist.md +++ b/docs/checklist.md @@ -149,9 +149,9 @@ Status: [x] done, [~] partial, [ ] not started ## Permissions and Approval Flows -- [x] User-level privilege model — `users` + `user_roles` (owner / admin, global or scoped to an agent group). Replaces the old `agent_groups.is_admin` / `messaging_groups.admin_user_id` coupling. See `src/db/users.ts`, `src/db/user-roles.ts`, `src/access.ts`. -- [x] Admin-only command filtering in container — host passes `NANOCLAW_ADMIN_USER_IDS` (owners + global admins + scoped admins for the agent group) to the agent-runner; `poll-loop.ts` gates slash commands against that set. -- [x] Approval routing — `pickApprover` (scoped admin → global admin → owner, dedup) + `pickApprovalDelivery` (first reachable, same-channel-kind tie-break); delivery lands in the approver's DM via `ensureUserDm` / `user_dms` cache. See `src/access.ts`, `src/onecli-approvals.ts`. +- [x] User-level privilege model — `users` + `user_roles` (owner / admin, global or scoped to an agent group). Replaces the old `agent_groups.is_admin` / `messaging_groups.admin_user_id` coupling. See `src/modules/permissions/db/users.ts`, `src/modules/permissions/db/user-roles.ts`, `src/modules/permissions/access.ts`. +- [x] Admin-only command filtering — gate runs host-side in `src/command-gate.ts`, querying `user_roles` directly. The container receives no admin identity (no env var, no fallback). +- [x] Approval routing — `pickApprover` (scoped admin → global admin → owner, dedup) + `pickApprovalDelivery` (first reachable, same-channel-kind tie-break); delivery lands in the approver's DM via `ensureUserDm` / `user_dms` cache. See `src/modules/approvals/primitive.ts`, `src/modules/approvals/onecli-approvals.ts`. - [x] Per-messaging-group unknown-sender gating — `messaging_groups.unknown_sender_policy` (`strict` | `request_approval` | `public`), enforced in `src/router.ts`. - [x] Approval flow (sensitive action -> card to admin -> approve/reject -> execute) — `pending_approvals` table, `requestApproval()` helper, reuses interactive card infra - [x] Agent requests dependency/package install (install_packages, admin approval, rebuild on approval) diff --git a/docs/module-contract.md b/docs/module-contract.md index e226a38..01662dd 100644 --- a/docs/module-contract.md +++ b/docs/module-contract.md @@ -173,7 +173,7 @@ Some code stays in core but references module-owned tables. These use `sqlite_ma | `delivery.ts` channel-permission check (`agent_destinations`) | agent-to-agent | permit (origin-chat always OK) | | `delivery.ts` `createPendingQuestion` (`pending_questions`) | interactive | no-op (log warning) | -`container/agent-runner/src/formatter.ts` has a related non-DB fallback: when `NANOCLAW_ADMIN_USER_IDS` is empty, every sender is treated as admin (permissionless mode). This is the one-line change from the current deny-all behavior. +Container-side admin gating no longer exists. Admin authorization is now performed host-side in `src/command-gate.ts`, which queries `user_roles` directly — no env var is passed to the container, and no agent-runner fallback exists. ## Migrations diff --git a/src/modules/permissions/access.ts b/src/modules/permissions/access.ts index fb7c491..8365e94 100644 --- a/src/modules/permissions/access.ts +++ b/src/modules/permissions/access.ts @@ -1,14 +1,13 @@ /** - * Access control (permissions module half of src/access.ts). + * Access control. * * Privilege is user-level, not group-level. A user holds zero or more roles * (owner | admin) via `user_roles`, and is optionally "known" in specific * agent groups via `agent_group_members`. Admins are implicitly members of * the groups they administer. * - * The approver-picking functions (pickApprover, pickApprovalDelivery) stay - * in src/access.ts for now — they move into the approvals module in the - * planned PR #7 re-tier. + * Approver-picking (`pickApprover`, `pickApprovalDelivery`) lives in the + * approvals module — see `src/modules/approvals/primitive.ts`. */ import { isMember } from './db/agent-group-members.js'; import { isAdminOfAgentGroup, isGlobalAdmin, isOwner } from './db/user-roles.js'; From e64bdb3016ef7198230897eccd9539b1216d282a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 17:14:43 +0300 Subject: [PATCH 105/185] refactor(claude-md): split shared base into module fragments, inject name at runtime Move every agent-specific instruction out of the shared container/CLAUDE.md so the base is genuinely universal. Persona/identity now comes from the system-prompt addendum (buildSystemPromptAddendum now takes assistantName and prepends "# You are {name}"). Per-module instructions live alongside each MCP tool source: container/agent-runner/src/mcp-tools/core.instructions.md container/agent-runner/src/mcp-tools/scheduling.instructions.md container/agent-runner/src/mcp-tools/self-mod.instructions.md composeGroupClaudeMd() scans that directory and emits `module-.md` fragments as symlinks to /app/src/mcp-tools/.instructions.md (valid via the existing RO source mount). Skill fragments renamed to `skill-.md` for naming consistency with `module-*` and `mcp-*`. Mount tightening so composer-managed files can't be clobbered by agent writes: nested RO mounts for /workspace/agent/CLAUDE.md and /workspace/agent/.claude-fragments/. CLAUDE.local.md (per-group memory) stays RW as the only writable CLAUDE.md-family file. .gitignore: ignore CLAUDE.local.md, .claude-shared.md, .claude-fragments/ everywhere, and simplify groups/ rules to ignore the whole tree (per- installation state, not tracked). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 14 +- container/CLAUDE.md | 173 ++---------------- container/agent-runner/src/destinations.ts | 22 ++- container/agent-runner/src/index.ts | 13 +- .../src/mcp-tools/core.instructions.md | 19 ++ .../src/mcp-tools/scheduling.instructions.md | 40 ++++ .../src/mcp-tools/self-mod.instructions.md | 25 +++ src/claude-md-compose.ts | 27 ++- src/config.ts | 8 +- src/container-runner.ts | 18 +- 10 files changed, 178 insertions(+), 181 deletions(-) create mode 100644 container/agent-runner/src/mcp-tools/core.instructions.md create mode 100644 container/agent-runner/src/mcp-tools/scheduling.instructions.md create mode 100644 container/agent-runner/src/mcp-tools/self-mod.instructions.md diff --git a/.gitignore b/.gitignore index 8c02e07..8a57c51 100644 --- a/.gitignore +++ b/.gitignore @@ -11,14 +11,14 @@ store/ data/ logs/ -# Groups - only track base structure and specific CLAUDE.md files +# Groups - per-installation state, not tracked groups/* -!groups/main/ -!groups/global/ -groups/main/* -groups/global/* -!groups/main/CLAUDE.md -!groups/global/CLAUDE.md + +# Composer-managed CLAUDE.md artifacts (regenerated every spawn) and +# per-group memory (CLAUDE.local.md) must never be committed. +**/CLAUDE.local.md +**/.claude-shared.md +**/.claude-fragments/ # Secrets *.keys.json diff --git a/container/CLAUDE.md b/container/CLAUDE.md index c4428ff..0717796 100644 --- a/container/CLAUDE.md +++ b/container/CLAUDE.md @@ -1,166 +1,27 @@ -# Main + -## What You Can Do - -- Answer questions and have conversations -- Search the web and fetch content from URLs -- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open ` to start, then `agent-browser snapshot -i` to see interactive elements) -- Read and write files in your workspace -- Run bash commands in your sandbox -- Schedule tasks to run later or on a recurring basis -- Send messages back to the chat +You are a NanoClaw agent. Your name, destinations, and message-sending rules are provided in the runtime system prompt at the top of each turn. ## Communication -Be concise — every message costs the reader's attention. +Be concise — every message costs the reader's attention. Prefer outcomes over play-by-play; when the work is done, the final message should be about the result, not a transcript of what you did. -### Destinations +## Workspace -Each turn, your system prompt lists the destinations available to you. If you only have one destination, just write your response directly — it goes there automatically. If you have multiple, wrap each message in a `...` block: +Files you create are saved in `/workspace/agent/`. Use this for notes, research, or anything that should persist across turns in this group. -``` -On my way home, 15 minutes -kick off the pipeline -``` +The file `CLAUDE.local.md` in your workspace is your per-group memory. Unlike the composed `CLAUDE.md` next to it (which is regenerated on every spawn and read-only), `CLAUDE.local.md` is writable and persists. Record things there that you'll want to remember in future sessions — user preferences, project context, recurring facts. Keep entries short and structured. -Inbound messages are labeled with `from="name"` so you can tell which destination they came from and reply using that same name. +## Conversation history -### Mid-turn updates - -Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work: - -- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final response. -- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") so the user knows you got the message. -- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages. - -**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call. - -**Outcomes, not play-by-play.** When the work is done, the final message should be about the result, not a transcript of what you did. - -### Internal thoughts - -Wrap reasoning in `...` tags to mark it as scratchpad — logged but not sent. With multiple destinations, any text outside of `` blocks is also treated as scratchpad. With a single destination, only explicit `` tags are scratchpad; the rest of your response is sent. - -``` -Compiled all three reports, ready to summarize. - -Here are the key findings from the research… -``` - -### Sub-agents and teammates - -When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent. - -## Your Workspace - -Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist. - -## Memory - -The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions. - -When you learn something important: -- Create files for structured data (e.g., `customers.md`, `preferences.md`) -- Split files larger than 500 lines into folders -- Keep an index in your memory for the files you create - -## Message Formatting - -Format messages based on the channel you're responding to. Check your group folder name: - -### Slack channels (folder starts with `slack_`) - -Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules: -- `*bold*` (single asterisks) -- `_italic_` (underscores) -- `` for links (NOT `[text](url)`) -- `•` bullets (no numbered lists) -- `:emoji:` shortcodes -- `>` for block quotes -- No `##` headings — use `*Bold text*` instead - -### WhatsApp/Telegram channels (folder starts with `whatsapp_` or `telegram_`) - -- `*bold*` (single asterisks, NEVER **double**) -- `_italic_` (underscores) -- `•` bullet points -- ` ``` ` code blocks - -No `##` headings. No `[links](url)`. No `**double stars**`. - -### Discord channels (folder starts with `discord_`) - -Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`. - ---- - -## Installing Packages & Tools - -Your container is ephemeral — anything installed via `apt-get` or `pnpm install -g` is lost on restart. To install packages that persist, use the self-modification tools: - -1. **`install_packages`** — request system (apt) or global npm packages. Requires admin approval. -2. **`request_rebuild`** — rebuild your container image so approved packages are baked in. Always call this after `install_packages` to apply the changes. - -Example flow: -``` -install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" }) -# → Admin gets an approval card → approves -request_rebuild({ reason: "Apply ffmpeg + transformers" }) -# → Admin approves → image rebuilt with the packages -``` - -**When to use this vs workspace pnpm install:** -- `pnpm install` in `/workspace/agent/` persists on disk (it's mounted) but isn't on the global PATH — use it for project-level dependencies -- `install_packages` is for system tools (ffmpeg, imagemagick) and global npm packages that need to be on PATH - -### MCP Servers - -Use **`add_mcp_server`** to add an MCP server to your configuration, then **`request_rebuild`** to apply. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `pnpm dlx`, e.g.: - -``` -add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] }) -request_rebuild({ reason: "Add memory MCP server" }) -``` - -## Task Scripts - -For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below. Other scheduling tools you might discover (e.g. `CronCreate`, `ScheduleWakeup`) are session-scoped SDK builtins and won't behave the way NanoClaw users expect, so stick with `schedule_task`. - -To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule — it preserves the series id the user already knows. - -Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum. - -### How it works - -1. You provide a bash `script` alongside the `prompt` when scheduling -2. When the task fires, the script runs first (30-second timeout) -3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }` -4. If `wakeAgent: false` — nothing happens, task waits for next run -5. If `wakeAgent: true` — you wake up and receive the script's data + prompt - -### Always test your script first - -Before scheduling, run the script in your sandbox to verify it works: - -```bash -bash -c 'node --input-type=module -e " - const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\"); - const prs = await r.json(); - console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) })); -"' -``` - -### When NOT to use scripts - -If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. - -### Frequent task guidance - -If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups: - -- Explain that each wake-up uses API credits and risks rate limits -- Suggest restructuring with a script that checks the condition first -- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script -- Help the user find the minimum viable frequency +The `conversations/` folder in your workspace holds searchable transcripts of past sessions with this group. Use it to recall prior context when a request references something that happened before. For structured long-lived data, prefer dedicated files (`customers.md`, `preferences.md`, etc.); split any file over ~500 lines into a folder with an index. diff --git a/container/agent-runner/src/destinations.ts b/container/agent-runner/src/destinations.ts index d525cf1..013bd3b 100644 --- a/container/agent-runner/src/destinations.ts +++ b/container/agent-runner/src/destinations.ts @@ -72,8 +72,26 @@ export function findByRouting( return row ? rowToEntry(row) : undefined; } -/** Generate the system-prompt addendum describing destinations and syntax. */ -export function buildSystemPromptAddendum(): string { +/** + * Generate the system-prompt addendum: agent identity + destination map. + * + * Identity is injected here (not in the shared CLAUDE.md) because it's + * per-agent-group and changes when the operator renames an agent, while + * the shared base is identical across all agents. + */ +export function buildSystemPromptAddendum(assistantName?: string): string { + const sections: string[] = []; + + if (assistantName) { + sections.push(['# You are ' + assistantName, '', `Your name is **${assistantName}**. Use it when the channel asks who you are, when introducing yourself, and when signing any message that explicitly calls for a signature.`].join('\n')); + } + + sections.push(buildDestinationsSection()); + + return sections.join('\n\n'); +} + +function buildDestinationsSection(): string { const all = getAllDestinations(); if (all.length === 0) { diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 9e68968..236be4c 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -45,12 +45,13 @@ async function main(): Promise { log(`Starting v2 agent-runner (provider: ${providerName})`); - // Destinations addendum is the only runtime-generated context we inject. - // Agent instructions are loaded by Claude Code from /workspace/agent/CLAUDE.md - // (host-composed at spawn, imports /app/CLAUDE.md and fragments) plus - // /workspace/agent/CLAUDE.local.md (agent memory) — no need to read them - // manually. - const instructions = buildSystemPromptAddendum(); + // Runtime-generated system-prompt addendum: agent identity (name) plus + // the live destinations map. Everything else (capabilities, per-module + // instructions, per-channel formatting) is loaded by Claude Code from + // /workspace/agent/CLAUDE.md — the composed entry imports the shared + // base (/app/CLAUDE.md) and each enabled module's fragment. Per-group + // memory lives in /workspace/agent/CLAUDE.local.md (auto-loaded). + const instructions = buildSystemPromptAddendum(config.assistantName || undefined); // Discover additional directories mounted at /workspace/extra/* const additionalDirectories: string[] = []; diff --git a/container/agent-runner/src/mcp-tools/core.instructions.md b/container/agent-runner/src/mcp-tools/core.instructions.md new file mode 100644 index 0000000..4f9e07a --- /dev/null +++ b/container/agent-runner/src/mcp-tools/core.instructions.md @@ -0,0 +1,19 @@ +## Sending messages + +Your final response is delivered via the `## Sending messages` rules in your runtime system prompt (single-destination: just write; multi-destination: use `...` blocks). See that section for the current destination list. + +### Mid-turn updates (`send_message`) + +Use the `mcp__nanoclaw__send_message` tool to send a message while you're still working (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work: + +- **Short turn (≤2 quick tool calls):** Don't narrate. Output any response. +- **Longer turn (multiple tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it, checking the logs now") so the user knows you got the message. +- **Long-running turns (long-running tasks with many stages):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages. + +**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call. + +**Outcomes, not play-by-play.** When the turn is done, the final message should be about the result, not a transcript of what you did. + +### Internal thoughts + +Wrap reasoning in `...` tags to mark it as scratchpad — logged but not sent. diff --git a/container/agent-runner/src/mcp-tools/scheduling.instructions.md b/container/agent-runner/src/mcp-tools/scheduling.instructions.md new file mode 100644 index 0000000..9b6b829 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/scheduling.instructions.md @@ -0,0 +1,40 @@ +## Task scheduling (`schedule_task`) + +For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below. + +To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule. + +Frequent recurring scheduled tasks — more than a few times a day — consume API credits and can risk account restrictions. You can add a `script` that runs first, and you will only be called when the check passes. + +### How it works + +1. Provide a bash `script` alongside the `prompt` when scheduling +2. When the task fires, the script runs first +3. Script returns: `{ "wakeAgent": true/false, "data": {...} }` +4. If `wakeAgent: false` — nothing happens, task waits for next run +5. If `wakeAgent: true` — claude receives the script's data + prompt and handles + +### Always test your script first + +Before scheduling, run the script directly to verify it works: + +```bash +bash -c 'node --input-type=module -e " + const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\"); + const prs = await r.json(); + console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) })); +"' +``` + +### When NOT to use scripts + +If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. Do not attempt to do things like sentiment analysis or advanced nlp in scripts. + +### Frequent task guidance + +If a user wants a task to run more than a few times a day and a script can't be used: + +- Explain that each time the task fires it uses API credits and risks rate limits +- Suggest adjusting the task requirements in a way that will allow you to use a script +- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script +- Help the user find the minimum viable frequency diff --git a/container/agent-runner/src/mcp-tools/self-mod.instructions.md b/container/agent-runner/src/mcp-tools/self-mod.instructions.md new file mode 100644 index 0000000..15057e0 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/self-mod.instructions.md @@ -0,0 +1,25 @@ +## Installing packages & tools + +To install packages that persist, use the self-modification tools: + +**`install_packages`** — request system (apt) or global npm packages. Requires admin approval. + +Example flow: +``` +install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" }) +# → Admin gets an approval card → approves +``` + +**When to use this vs workspace `pnpm install`:** +- `pnpm install` if you only need it temporarily to do one task. Will not be available in subsequent truns. +- `install_packages` persists for all future turns. Use especially if the user specifically asks you to add a capability + +### MCP servers (`add_mcp_server`) + +Use **`add_mcp_server`** to add an MCP server to your configuration. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `pnpm dlx`, e.g.: + +``` +add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] }) +``` + +Do not ask the user to give you credentials. Credentials are managed by the user in the OneCLI agent vault. Add a "placeholder" string instead of the credential, and ask the user to add the credential to the vault. You can make a test request before the secret is added and the vault proxy will respond with the local url of the vault dashboard on the user's machine and a link to a form for adding that specific credential. diff --git a/src/claude-md-compose.ts b/src/claude-md-compose.ts index 3cc74c1..c0519e4 100644 --- a/src/claude-md-compose.ts +++ b/src/claude-md-compose.ts @@ -26,6 +26,11 @@ import type { AgentGroup } from './types.js'; // dance instead of existsSync), valid inside the container via RO mounts. const SHARED_CLAUDE_MD_CONTAINER_PATH = '/app/CLAUDE.md'; const SHARED_SKILLS_CONTAINER_BASE = '/app/skills'; +const SHARED_MCP_TOOLS_CONTAINER_BASE = '/app/src/mcp-tools'; + +// Host-side source paths used to discover fragment sources at compose time. +// Resolved at call time (process.cwd() = project root) so tests can swap cwd. +const MCP_TOOLS_HOST_SUBPATH = path.join('container', 'agent-runner', 'src', 'mcp-tools'); const COMPOSED_HEADER = ''; @@ -59,7 +64,7 @@ export function composeGroupClaudeMd(group: AgentGroup): void { for (const skillName of fs.readdirSync(skillsHostDir)) { const hostFragment = path.join(skillsHostDir, skillName, 'instructions.md'); if (fs.existsSync(hostFragment)) { - desired.set(`${skillName}.md`, { + desired.set(`skill-${skillName}.md`, { type: 'symlink', content: `${SHARED_SKILLS_CONTAINER_BASE}/${skillName}/instructions.md`, }); @@ -67,7 +72,25 @@ export function composeGroupClaudeMd(group: AgentGroup): void { } } - // MCP server fragments — inline instructions from container.json. + // Built-in module fragments — every MCP tool source file that ships a + // sibling `.instructions.md`. These describe how the agent should + // use that module's MCP tools (schedule_task, install_packages, etc.). + // Always included — these are built-in, not toggleable. + const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH); + if (fs.existsSync(mcpToolsHostDir)) { + for (const entry of fs.readdirSync(mcpToolsHostDir)) { + const match = entry.match(/^(.+)\.instructions\.md$/); + if (!match) continue; + const moduleName = match[1]; + desired.set(`module-${moduleName}.md`, { + type: 'symlink', + content: `${SHARED_MCP_TOOLS_CONTAINER_BASE}/${entry}`, + }); + } + } + + // MCP server fragments — inline instructions from container.json for + // user-added external MCP servers. for (const [name, mcp] of Object.entries(config.mcpServers)) { if (mcp.instructions) { desired.set(`mcp-${name}.md`, { diff --git a/src/config.ts b/src/config.ts index 4a4eef6..96b782a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,13 +5,7 @@ import { readEnvFile } from './env.js'; import { isValidTimezone } from './timezone.js'; // Read config values from .env (falls back to process.env). -const envConfig = readEnvFile([ - 'ASSISTANT_NAME', - 'ASSISTANT_HAS_OWN_NUMBER', - 'ONECLI_URL', - 'ONECLI_API_KEY', - 'TZ', -]); +const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', 'ONECLI_API_KEY', 'TZ']); export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; export const ASSISTANT_HAS_OWN_NUMBER = diff --git a/src/container-runner.ts b/src/container-runner.ts index 4b7964c..8291d42 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -215,7 +215,7 @@ function buildMounts( // Session folder at /workspace (contains inbound.db, outbound.db, outbox/, .claude/) mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false }); - // Agent group folder at /workspace/agent (RW for working files + CLAUDE.md) + // Agent group folder at /workspace/agent (RW for working files + CLAUDE.local.md) mounts.push({ hostPath: groupDir, containerPath: '/workspace/agent', readonly: false }); // container.json — nested RO mount on top of RW group dir so the agent @@ -225,6 +225,22 @@ function buildMounts( mounts.push({ hostPath: containerJsonPath, containerPath: '/workspace/agent/container.json', readonly: true }); } + // Composer-managed CLAUDE.md artifacts — nested RO mounts. These are + // regenerated from the shared base + fragments on every spawn; any + // agent-side writes would be clobbered, so enforce read-only. Only + // CLAUDE.local.md (per-group memory) remains RW via the group-dir mount. + // `.claude-shared.md` is a symlink whose target (`/app/CLAUDE.md`) is + // already RO-mounted, so writes through it fail regardless — no need for + // a nested mount there. + const composedClaudeMd = path.join(groupDir, 'CLAUDE.md'); + if (fs.existsSync(composedClaudeMd)) { + mounts.push({ hostPath: composedClaudeMd, containerPath: '/workspace/agent/CLAUDE.md', readonly: true }); + } + const fragmentsDir = path.join(groupDir, '.claude-fragments'); + if (fs.existsSync(fragmentsDir)) { + mounts.push({ hostPath: fragmentsDir, containerPath: '/workspace/agent/.claude-fragments', readonly: true }); + } + // Global memory directory — always read-only. const globalDir = path.join(GROUPS_DIR, 'global'); if (fs.existsSync(globalDir)) { From 3b8240a91b0db565e5497a30b6fb9f780ab66281 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 17:28:36 +0300 Subject: [PATCH 106/185] =?UTF-8?q?refactor(self-mod):=20drop=20request=5F?= =?UTF-8?q?rebuild=20=E2=80=94=20approvals=20now=20bundle=20rebuild+restar?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install_packages and add_mcp_server already did the right thing on approve (install auto-rebuilt+killed, add_mcp_server just killed), so request_rebuild was redundant plumbing agents sometimes called after an install — wasting an admin approval round-trip. Delete it end-to-end: - container/agent-runner/src/mcp-tools/self-mod.ts: remove requestRebuild tool + registration; update install_packages description. - src/modules/self-mod/{request,apply,index}.ts: drop handleRequestRebuild + applyRequestRebuild + registrations; rewrite the rebuild-failed notify to point admins at retrying install_packages instead. - src/modules/{approvals,self-mod}/{agent,project}.md and skill/self- customize/SKILL.md: scrub agent-facing references; clarify that add_mcp_server needs no rebuild (bun runs TS directly). - docs/{module-contract,architecture-diagram,checklist,db-central,shared- source,v1-vs-v2/*}.md, CLAUDE.md, pending-approvals migration comment, approvals/index.ts docstring, REFACTOR.md: trailing references. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- REFACTOR.md | 2 +- .../agent-runner/src/mcp-tools/self-mod.ts | 44 +++++-------------- container/skills/self-customize/SKILL.md | 19 ++++---- docs/architecture-diagram.md | 2 +- docs/checklist.md | 10 ++--- docs/db-central.md | 2 +- docs/module-contract.md | 2 +- docs/shared-source.md | 2 +- docs/v1-vs-v2/container-mcp-tools.md | 9 ++-- docs/v1-vs-v2/db.md | 2 +- .../module-approvals-pending-approvals.ts | 4 +- src/modules/approvals/agent.md | 12 +---- src/modules/approvals/index.ts | 6 +-- src/modules/approvals/project.md | 4 +- src/modules/self-mod/agent.md | 27 ++++++------ src/modules/self-mod/apply.ts | 19 +++----- src/modules/self-mod/index.ts | 28 ++++++------ src/modules/self-mod/project.md | 25 ++++++----- src/modules/self-mod/request.ts | 27 +++--------- 20 files changed, 97 insertions(+), 151 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3ad7774..ba5f857 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,7 @@ Each `/add-` skill is idempotent: `git fetch origin ` → copy mod One tier of agent self-modification today: -1. **`install_packages` / `add_mcp_server` / `request_rebuild`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Admin approval, rebuild, container restart. `container/agent-runner/src/mcp-tools/self-mod.ts`. +1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only) and restarts the container. `container/agent-runner/src/mcp-tools/self-mod.ts`. A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented. diff --git a/REFACTOR.md b/REFACTOR.md index 2ae7f81..d3f6562 100644 --- a/REFACTOR.md +++ b/REFACTOR.md @@ -128,7 +128,7 @@ The `format:fix` pre-commit hook sometimes reformats peer files *after* the comm 4. **Revisit destinations + A2A capability holistically.** The destination projection invariant, dual-purpose routing+ACL table, channel vs agent destination shapes, `createMessagingGroupAgent` auto-wire coupling — more machinery than the feature warrants. Phase 3 moved it out of core intact; a redesign is warranted but scoped post-refactor. -5. **Self-mod approach rethink.** Three separate MCP tools + three delivery actions + three approval handlers for what's essentially "mutate container.config.json and rebuild." Also: post-rebuild latency (host sweep waits up to 60s), and agents sometimes send redundant `add_mcp_server` + `request_rebuild` pairs. Consider collapsing into a single "apply this container-config diff" approval primitive. +5. **Self-mod approach rethink.** _Partially addressed_ — the redundant `request_rebuild` tool was removed; approval of `install_packages` now bundles rebuild + container restart, and `add_mcp_server` approval restarts without rebuilding (bun runs TS directly). Still to consider: collapsing `install_packages` + `add_mcp_server` into a single "apply this container-config diff" approval primitive to reduce post-rebuild latency further. 6. **Per-agent-group source / per-group base image.** Self-mod today layers packages/MCP on a shared base. As groups diverge (different base images, provider configs, runtime toolchains), the shared-base assumption won't scale. Scope post-refactor. diff --git a/container/agent-runner/src/mcp-tools/self-mod.ts b/container/agent-runner/src/mcp-tools/self-mod.ts index 775ec3b..3e2a2d8 100644 --- a/container/agent-runner/src/mcp-tools/self-mod.ts +++ b/container/agent-runner/src/mcp-tools/self-mod.ts @@ -1,9 +1,13 @@ /** - * Self-modification MCP tools: install_packages, add_mcp_server, request_rebuild. + * Self-modification MCP tools: install_packages, add_mcp_server. * - * All three are fire-and-forget — the tool writes a system action row and - * returns immediately. The host processes the request (including admin - * approval) and notifies the agent via a chat message when complete. + * Both are fire-and-forget — the tool writes a system action row and returns + * immediately. The host processes the request (including admin approval) + * and notifies the agent via a chat message when complete. Admin approval + * is approval to apply the change: `install_packages` auto-rebuilds the + * per-agent image and restarts the container; `add_mcp_server` just + * updates `container.json` and restarts (bun runs TS directly — no build + * step needed for a pure MCP wiring change). * * Package names are sanitized here at the tool boundary AND re-validated on * the host side (defense in depth). @@ -36,7 +40,7 @@ export const installPackages: McpToolDefinition = { tool: { name: 'install_packages', description: - 'Install apt and/or npm packages into YOUR per-agent container image. Requires admin approval; fire-and-forget. After approval, call `request_rebuild` to apply.', + 'Install apt and/or npm packages into YOUR per-agent container image. Requires admin approval; fire-and-forget. On approval, the image is rebuilt and the container is restarted automatically.', inputSchema: { type: 'object' as const, properties: { @@ -113,32 +117,4 @@ export const addMcpServer: McpToolDefinition = { }, }; -export const requestRebuild: McpToolDefinition = { - tool: { - name: 'request_rebuild', - description: - 'Rebuild YOUR container image to pick up approved `install_packages` / `add_mcp_server` changes. Requires admin approval; fire-and-forget.', - inputSchema: { - type: 'object' as const, - properties: { - reason: { type: 'string', description: 'Why the rebuild is needed' }, - }, - }, - }, - async handler(args) { - const requestId = generateId(); - writeMessageOut({ - id: requestId, - kind: 'system', - content: JSON.stringify({ - action: 'request_rebuild', - reason: (args.reason as string) || '', - }), - }); - - log(`request_rebuild: ${requestId}`); - return ok(`Rebuild request submitted. You will be notified when admin approves or rejects.`); - }, -}; - -registerTools([installPackages, addMcpServer, requestRebuild]); +registerTools([installPackages, addMcpServer]); diff --git a/container/skills/self-customize/SKILL.md b/container/skills/self-customize/SKILL.md index e1d5588..c8bad16 100644 --- a/container/skills/self-customize/SKILL.md +++ b/container/skills/self-customize/SKILL.md @@ -11,9 +11,9 @@ You can modify your own environment. Different kinds of changes have different w **What needs to change?** -- **Your CLAUDE.md or files in your workspace** → Edit directly, no approval needed. Your workspace (`/workspace/agent/`) is persisted on the host. -- **System package (apt) or global npm package** → `install_packages` → `request_rebuild`. Requires admin approval. -- **MCP server** → `add_mcp_server` → `request_rebuild`. No approval needed, but rebuild required to apply. +- **`CLAUDE.local.md` or files in your workspace** → Edit directly, no approval needed. Your workspace (`/workspace/agent/`) is persisted on the host. (Note: the composed `CLAUDE.md` itself is read-only and regenerated every spawn — write to `CLAUDE.local.md` instead.) +- **System package (apt) or global npm package** → `install_packages`. Requires admin approval. On approval, image rebuild + container restart happen automatically. +- **MCP server** → `add_mcp_server`. Requires admin approval. On approval, container restarts with the new server wired up (no rebuild — bun runs TS directly). - **Your source code or Dockerfile** → Delegate to a builder agent via `create_agent` (see below). - **A new specialist capability** → `create_agent` to spin up a dedicated agent for it. @@ -25,7 +25,7 @@ For anything that requires editing source files (your own code, Dockerfile, etc. 2. Call `create_agent({ name: "Builder", instructions: "" })` — the returned agent group ID is your builder 3. Call `send_to_agent({ agentGroupId, text: "" })` 4. The builder works in its own container, makes the changes, and reports back -5. You review the builder's summary, confirm with the user, then call `request_rebuild` if the changes require it +5. You review the builder's summary and confirm with the user. Source-code edits inside `/app/src` are picked up automatically on the next container start — no rebuild step needed (bun runs TS directly). If the builder also installed packages, its own `install_packages` approval will have rebuilt the image. ### Builder Agent Instructions (use as CLAUDE.md when creating) @@ -64,12 +64,11 @@ The limits are **per builder task**, not per session. A 500-line feature is fine User: "Can you add a tool for reading RSS feeds?" 1. Check [mcp.so](https://mcp.so) for an existing RSS MCP server -2. If one exists → `add_mcp_server({ name: "rss", command: "npx", args: ["some-rss-mcp"] })` → `request_rebuild` → done +2. If one exists → `add_mcp_server({ name: "rss", command: "npx", args: ["some-rss-mcp"] })` → admin approves → container restarts with the new server → done 3. If nothing suitable exists → delegate to a builder agent: - `create_agent({ name: "RSS Tool Builder", instructions: "" })` - `send_to_agent({ agentGroupId, text: "Add an MCP tool 'read_rss' to container/agent-runner/src/mcp-tools/. It should fetch an RSS URL and return the latest N items. Register it in mcp-tools/index.ts. Target: <200 new lines." })` - - Wait for builder's report - - `request_rebuild` if needed + - Wait for builder's report — new tool code is picked up on the next container start (bun runs TS directly) ## Example: Installing a System Tool @@ -78,10 +77,8 @@ User: "Can you transcribe audio?" 1. Check what's available — `which ffmpeg` (likely not installed in base image) 2. Decide approach: `@xenova/transformers` (npm, workspace-local) or `whisper.cpp` (apt + compile) 3. For persistent system tool: `install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription for voice messages" })` -4. Wait for admin approval -5. `request_rebuild({ reason: "Apply audio transcription packages" })` -6. Wait for admin approval -7. Test the new capability once the container restarts +4. Wait for admin approval — on approve, the image is rebuilt and your container is restarted automatically +5. Test the new capability once the container restarts ## When NOT to Self-Customize diff --git a/docs/architecture-diagram.md b/docs/architecture-diagram.md index d7c5ead..4d8671c 100644 --- a/docs/architecture-diagram.md +++ b/docs/architecture-diagram.md @@ -32,7 +32,7 @@ flowchart TB direction TB PollLoop["Poll Loop
(container/agent-runner)"] Provider["Agent providers
(claude, opencode, mock; todo: codex)"] - MCP["MCP Tools
send_message, send_file, edit_message,
add_reaction, send_card, ask_user_question,
schedule_task, create_agent,
install_packages, add_mcp_server, request_rebuild"] + MCP["MCP Tools
send_message, send_file, edit_message,
add_reaction, send_card, ask_user_question,
schedule_task, create_agent,
install_packages, add_mcp_server"] Skills["Container Skills
(container/skills/)"] InDB[("inbound.db
host writes
even seq
messages_in
destinations
processing_ack")] OutDB[("outbound.db
container writes
odd seq
messages_out
heartbeat file")] diff --git a/docs/checklist.md b/docs/checklist.md index 16b3630..156feb1 100644 --- a/docs/checklist.md +++ b/docs/checklist.md @@ -135,9 +135,8 @@ Status: [x] done, [~] partial, [ ] not started - [x] list_tasks - [x] cancel_task / pause_task / resume_task - [x] create_agent (any agent, creates agent group + folder + bidirectional destinations; host re-normalizes the name, deduplicates folder, path-traversal guarded) -- [x] install_packages (apt/npm, owner/admin approval required via `pickApprover`, strict name validation) -- [x] add_mcp_server (owner/admin approval required via `pickApprover`) -- [x] request_rebuild (rebuilds per-agent-group Docker image) +- [x] install_packages (apt/npm, owner/admin approval required via `pickApprover`, strict name validation; single approval step covers the image rebuild + container restart) +- [x] add_mcp_server (owner/admin approval required via `pickApprover`; approval triggers container restart, no image rebuild needed — bun runs TS directly) ## Scheduling @@ -156,9 +155,8 @@ Status: [x] done, [~] partial, [ ] not started - [x] Approval flow (sensitive action -> card to admin -> approve/reject -> execute) — `pending_approvals` table, `requestApproval()` helper, reuses interactive card infra - [x] Agent requests dependency/package install (install_packages, admin approval, rebuild on approval) - [x] Self-modification — direct tools: - - [x] install_packages (apt/npm, admin approval, name validation both sides, max 20 per request) - - [x] add_mcp_server (admin approval) - - [x] request_rebuild (builds per-agent-group Docker image with approved packages) + - [x] install_packages (apt/npm, admin approval, name validation both sides, max 20 per request; on approve → handler rebuilds the image, kills the container, schedules a verify-and-report follow-up prompt) + - [x] add_mcp_server (admin approval; on approve → handler updates `container.json`, kills the container — no image rebuild) - [x] Fire-and-forget model (write request, return immediately; chat notification on approval; container killed so next wake picks up new config/image) - [~] OneCLI integration for human-loop approvals on credentialed requests (agent touching a credentialed resource → OneCLI gates → approval card to admin → OneCLI releases credential) — SDK 0.3.1 `configureManualApproval` wired into host, routes to admin via existing `pending_approvals` infra - [ ] Tunneled OneCLI dashboard for credential addition (Telegram Mini Apps aside, iMessage without Apple Business Register, Matrix, email). Signed short-lived URL → browser form served by OneCLI at 10254 → tunnel via cloudflare durable object. Value never touches the chat surface. diff --git a/docs/db-central.md b/docs/db-central.md index 8be6ee8..8268acf 100644 --- a/docs/db-central.md +++ b/docs/db-central.md @@ -201,7 +201,7 @@ Access layer: `src/db/agent-destinations.ts`. Two workflows share this table: -- **Session-bound MCP approvals** — `install_packages`, `request_rebuild`, `add_mcp_server`. `session_id` is set. +- **Session-bound MCP approvals** — `install_packages`, `add_mcp_server`. `session_id` is set. - **OneCLI credential approvals** — `session_id` may be NULL; `agent_group_id` + `channel_type` + `platform_id` route the admin card. ```sql diff --git a/docs/module-contract.md b/docs/module-contract.md index 01662dd..04919b9 100644 --- a/docs/module-contract.md +++ b/docs/module-contract.md @@ -66,7 +66,7 @@ export function registerDeliveryAction(action: string, handler: ActionHandler): **Default when action is unknown:** log `"Unknown system action"` at `warn` and return. Message is still marked delivered (it was consumed by the host, not sent to a channel). -**Current consumers:** scheduling (5 actions — `schedule_task`, `cancel_task`, `pause_task`, `resume_task`, `update_task`), approvals (3 actions — `install_packages`, `request_rebuild`, `add_mcp_server`), agent-to-agent (`create_agent`, and the agent-routing branch keyed as a pseudo-action `agent_route`). +**Current consumers:** scheduling (5 actions — `schedule_task`, `cancel_task`, `pause_task`, `resume_task`, `update_task`), approvals (2 actions — `install_packages`, `add_mcp_server`), agent-to-agent (`create_agent`, and the agent-routing branch keyed as a pseudo-action `agent_route`). ### 2. Router sender resolver + access gate diff --git a/docs/shared-source.md b/docs/shared-source.md index ab725ea..95ea94d 100644 --- a/docs/shared-source.md +++ b/docs/shared-source.md @@ -73,7 +73,7 @@ What remains per-group (unchanged): ### Self-modification -Existing config-level self-mod tools (`install_packages`, `add_mcp_server`, `request_rebuild`) mutate `container.json` and per-group images, not source. Unchanged — stays per-group. +Existing config-level self-mod tools (`install_packages`, `add_mcp_server`) mutate `container.json` and per-group images, not source. Unchanged — stays per-group. Source-level self-modification (not yet implemented) uses staging: edits happen against a copy of `container/agent-runner/src/`, reviewed and swapped in on approval. Owner can also edit source directly. diff --git a/docs/v1-vs-v2/container-mcp-tools.md b/docs/v1-vs-v2/container-mcp-tools.md index 95c23b3..41282e1 100644 --- a/docs/v1-vs-v2/container-mcp-tools.md +++ b/docs/v1-vs-v2/container-mcp-tools.md @@ -20,17 +20,16 @@ | — | `scheduling.ts:221-266` `update_task` | **new** | Modify prompt/recurrence/processAfter/script | | — | `interactive.ts:36-129` `ask_user_question` | **new** | Blocking with timeout — writes to outbound.db then polls inbound.db for response | | — | `interactive.ts:131-166` `send_card` | **new** | Structured Chat SDK cards | -| — | `self-mod.ts:34-74` `install_packages` | **new** | apt/npm install, regex name validation, admin approval | -| — | `self-mod.ts:76-113` `add_mcp_server` | **new** | Wire existing MCP server | -| — | `self-mod.ts:115-141` `request_rebuild` | **new** | Async container rebuild | +| — | `self-mod.ts` `install_packages` | **new** | apt/npm install, regex name validation, admin approval; approval handler auto-rebuilds image and restarts container | +| — | `self-mod.ts` `add_mcp_server` | **new** | Wire existing MCP server; approval handler restarts container (no image rebuild) | | — | `agents.ts:30-63` `create_agent` | **new** | Admin-only sub-agent creation; not exposed to non-admin containers | ## New tools in v2 -16 new tools split across 5 capability domains: +15 new tools split across 5 capability domains: - **Message manipulation**: `send_file`, `edit_message`, `add_reaction` - **Scheduling**: 6 task-management tools - **Interactive**: `ask_user_question`, `send_card` -- **Self-modification**: `install_packages`, `add_mcp_server`, `request_rebuild` +- **Self-modification**: `install_packages`, `add_mcp_server` - **Agent management**: `create_agent` ## Missing from v2 diff --git a/docs/v1-vs-v2/db.md b/docs/v1-vs-v2/db.md index 97ee0f8..0614b44 100644 --- a/docs/v1-vs-v2/db.md +++ b/docs/v1-vs-v2/db.md @@ -223,7 +223,7 @@ Per-agent ACL and name-resolution map for `send_message(to="name")`. Projected i ```sql approval_id, session_id, request_id, action, payload, agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status, title, options_json, created_at ``` -Approval queue for `install_packages`, `add_mcp_server`, `request_rebuild`, OneCLI credential flows. v1: no approval model. +Approval queue for `install_packages`, `add_mcp_server`, OneCLI credential flows. v1: no approval model. **`unregistered_senders` (via migration 008):** ```sql diff --git a/src/db/migrations/module-approvals-pending-approvals.ts b/src/db/migrations/module-approvals-pending-approvals.ts index 91aa08e..699e305 100644 --- a/src/db/migrations/module-approvals-pending-approvals.ts +++ b/src/db/migrations/module-approvals-pending-approvals.ts @@ -3,8 +3,8 @@ import type { Migration } from './index.js'; /** * `pending_approvals` table — host-side records for any approval-requiring * request. Used by: - * - install_packages / request_rebuild / add_mcp_server (session-bound, - * `session_id` set, status stays at default 'pending' until handled) + * - install_packages / add_mcp_server (session-bound, `session_id` set, + * status stays at default 'pending' until handled) * - OneCLI credential approvals from the SDK `configureManualApproval` * callback (session_id may be null, action='onecli_credential'). * diff --git a/src/modules/approvals/agent.md b/src/modules/approvals/agent.md index f992040..57b65d0 100644 --- a/src/modules/approvals/agent.md +++ b/src/modules/approvals/agent.md @@ -16,7 +16,7 @@ install_packages({ - Max 20 packages per request. - Names must match strict regex (blocks shell injection via `vim; curl evil.com`). -- After approval: rebuild runs automatically. You do NOT need to call `request_rebuild` separately. +- On approval, the image rebuild and container restart happen automatically — there is no separate rebuild step for you to trigger. ### add_mcp_server @@ -32,15 +32,7 @@ add_mcp_server({ ``` - Does NOT install packages. Use `install_packages` first if the command isn't already available. -- On approval, container is killed so the next message wakes it with the new server wired up. - -### request_rebuild - -Rebuild your container image. Only useful if you've already landed `install_packages` approvals whose rebuild step failed, or if you're recovering from a bad config edit. - -``` -request_rebuild({ reason: "previous install_packages rebuild failed" }) -``` +- On approval, the container is killed and the next message wakes it with the new server wired up. No image rebuild — bun runs TS directly. ### How approval works diff --git a/src/modules/approvals/index.ts b/src/modules/approvals/index.ts index 2bd8446..f70a43f 100644 --- a/src/modules/approvals/index.ts +++ b/src/modules/approvals/index.ts @@ -12,9 +12,9 @@ * once the delivery adapter is set. * - A shutdown callback that stops the OneCLI handler cleanly. * - * Self-mod flows (install_packages, request_rebuild, add_mcp_server) moved - * out to `src/modules/self-mod/` in PR #7 — they now register delivery - * actions + approval handlers via this module's public API. + * Self-mod flows (install_packages, add_mcp_server) moved out to + * `src/modules/self-mod/` in PR #7 — they now register delivery actions + * + approval handlers via this module's public API. */ import { onDeliveryAdapterReady } from '../../delivery.js'; import { registerResponseHandler, onShutdown } from '../../response-registry.js'; diff --git a/src/modules/approvals/project.md b/src/modules/approvals/project.md index 19dae67..6a1f10f 100644 --- a/src/modules/approvals/project.md +++ b/src/modules/approvals/project.md @@ -4,13 +4,13 @@ Admin-gated approval flow for agent self-modification and OneCLI credential acce ### Two flows -**Agent-initiated (DB-backed, fire-and-forget).** The container writes a `system`-kind outbound row with one of three actions — `install_packages`, `request_rebuild`, `add_mcp_server`. The module's delivery-action handlers validate, route to the right approver's DM, and persist a `pending_approvals` row. When the admin clicks a button, the registered response handler applies the change (config update → image rebuild → container kill) and notifies the agent via system chat. +**Agent-initiated (DB-backed, fire-and-forget).** The container writes a `system`-kind outbound row with one of two actions — `install_packages`, `add_mcp_server`. The module's delivery-action handlers validate, route to the right approver's DM, and persist a `pending_approvals` row. When the admin clicks a button, the registered response handler applies the change (config update → image rebuild if needed → container kill) and notifies the agent via system chat. **OneCLI credential (long-poll).** The OneCLI gateway holds an HTTP connection open when it needs credential approval. `onecli-approvals.ts` delivers a card, persists a `pending_approvals` row (action = `onecli_credential`), and waits on an in-memory Promise that resolves on click or expiry timer. Survives host restart: the startup sweep edits stale cards to "Expired (host restarted)" and drops the rows. ### Wiring -- **Delivery actions:** `install_packages`, `request_rebuild`, `add_mcp_server` via `registerDeliveryAction`. +- **Delivery actions:** `install_packages`, `add_mcp_server` via `registerDeliveryAction`. - **Response handler:** single handler claims both agent-initiated and OneCLI approvals. OneCLI is tried first (in-memory Promise); falls through to `pending_approvals` lookup. - **Adapter-ready hook (`onDeliveryAdapterReady`):** starts the OneCLI manual-approval handler once the delivery adapter is set. - **Shutdown hook (`onShutdown`):** stops the OneCLI handler. diff --git a/src/modules/self-mod/agent.md b/src/modules/self-mod/agent.md index 33bca9b..9d67a4a 100644 --- a/src/modules/self-mod/agent.md +++ b/src/modules/self-mod/agent.md @@ -1,29 +1,28 @@ # Self-modification -You can install additional OS or npm packages, rebuild your container image, -or add new MCP servers — but only with admin approval. +You can install additional OS or npm packages or add new MCP servers — but +only with admin approval. ## Tools - `install_packages({ apt?: string[], npm?: string[], reason?: string })` — - adds the listed packages to your container config and rebuilds the image - after admin approval. Package names are validated strictly (`[a-z0-9._+-]` - for apt, standard npm naming with optional scope). Max 20 packages per - request. - -- `request_rebuild({ reason?: string })` — rebuilds your container image - without config changes. Useful if the image has drifted from config. + adds the listed packages to your container config, rebuilds the image, + and restarts your container, all in a single admin approval step. + Package names are validated strictly (`[a-z0-9._+-]` for apt, standard + npm naming with optional scope). Max 20 packages per request. - `add_mcp_server({ name, command, args?, env? })` — adds a new MCP server - to your container config. The container restarts on next message so the - new server is available. + to your container config and restarts the container so the new server + is wired up on the next message. No image rebuild is required (bun runs + TS directly). ## Flow You call one of these tools → the host asks an admin via DM → admin approves -or rejects. On approve, the config is applied and the container is killed; -the host respawns it on the next message. You'll get a system chat message -confirming the outcome (either "Packages installed..." or a failure reason). +or rejects. On approve, the config is applied, the image is rebuilt if +needed, and the container is killed; the host respawns it on the next +message. You'll get a system chat message confirming the outcome (either +"Packages installed..." or a failure reason). On reject you'll see "Your X request was rejected by admin." diff --git a/src/modules/self-mod/apply.ts b/src/modules/self-mod/apply.ts index 1a3daa8..5291937 100644 --- a/src/modules/self-mod/apply.ts +++ b/src/modules/self-mod/apply.ts @@ -5,6 +5,11 @@ * pending_approvals row whose action matches. Each handler mutates the * container config, rebuilds/kills the container as needed, and lets the * host sweep respawn it on the new image on the next message. + * + * install_packages: rebuild image + kill container (apt/npm global installs + * must be baked into the image layer). + * add_mcp_server: kill container only — bun runs TS directly, so a pure + * MCP wiring change needs nothing more than a process restart. */ import { updateContainerConfig } from '../../container-config.js'; import { buildAgentGroupImage, killContainer } from '../../container-runner.js'; @@ -54,24 +59,12 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, log.info('Container rebuild completed (bundled with install)', { agentGroupId: session.agent_group_id }); } catch (e) { notify( - `Packages added to config (${pkgs}) but rebuild failed: ${e instanceof Error ? e.message : String(e)}. Call request_rebuild to retry.`, + `Packages added to config (${pkgs}) but rebuild failed: ${e instanceof Error ? e.message : String(e)}. Tell the user — an admin will need to retry the install_packages request or inspect the build logs.`, ); log.error('Bundled rebuild failed after install approval', { agentGroupId: session.agent_group_id, err: e }); } }; -export const applyRequestRebuild: ApprovalHandler = async ({ session, userId, notify }) => { - try { - await buildAgentGroupImage(session.agent_group_id); - killContainer(session.id, 'rebuild applied'); - notify('Container image rebuilt. Your container will restart with the new image on the next message.'); - log.info('Container rebuild approved and completed', { agentGroupId: session.agent_group_id, userId }); - } catch (e) { - notify(`Rebuild failed: ${e instanceof Error ? e.message : String(e)}`); - log.error('Container rebuild failed', { agentGroupId: session.agent_group_id, err: e }); - } -}; - export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, userId, notify }) => { const agentGroup = getAgentGroup(session.agent_group_id); if (!agentGroup) { diff --git a/src/modules/self-mod/index.ts b/src/modules/self-mod/index.ts index aedf1dc..e1f49e2 100644 --- a/src/modules/self-mod/index.ts +++ b/src/modules/self-mod/index.ts @@ -3,26 +3,28 @@ * * Optional tier. Depends on the approvals default module for the request/ * handler plumbing. On install the module registers: - * - Three delivery actions (install_packages, request_rebuild, add_mcp_server) - * that validate input and queue an approval via requestApproval(). - * - Three matching approval handlers that run on approve: mutate the - * container config, rebuild the image, kill the container so the next - * wake picks up the change. + * - Two delivery actions (install_packages, add_mcp_server) that validate + * input and queue an approval via requestApproval(). + * - Two matching approval handlers that run on approve and perform the + * complete follow-up: + * install_packages → update container.json, rebuild image, kill + * container (next wake respawns on the new image), schedule a + * verify-and-report follow-up prompt. + * add_mcp_server → update container.json, kill container. No image + * rebuild — bun runs TS directly, so the new MCP server is wired + * by the next container start. * - * Without this module: the three MCP tools in the container still write - * outbound system messages with these actions, but delivery logs - * "Unknown system action" and drops them. Admin never sees a card; nothing - * changes. + * Without this module: the MCP tools in the container still write outbound + * system messages with these actions, but delivery logs "Unknown system + * action" and drops them. Admin never sees a card; nothing changes. */ import { registerDeliveryAction } from '../../delivery.js'; import { registerApprovalHandler } from '../approvals/index.js'; -import { applyAddMcpServer, applyInstallPackages, applyRequestRebuild } from './apply.js'; -import { handleAddMcpServer, handleInstallPackages, handleRequestRebuild } from './request.js'; +import { applyAddMcpServer, applyInstallPackages } from './apply.js'; +import { handleAddMcpServer, handleInstallPackages } from './request.js'; registerDeliveryAction('install_packages', handleInstallPackages); -registerDeliveryAction('request_rebuild', handleRequestRebuild); registerDeliveryAction('add_mcp_server', handleAddMcpServer); registerApprovalHandler('install_packages', applyInstallPackages); -registerApprovalHandler('request_rebuild', applyRequestRebuild); registerApprovalHandler('add_mcp_server', applyAddMcpServer); diff --git a/src/modules/self-mod/project.md b/src/modules/self-mod/project.md index bb6a0ec..556bcfe 100644 --- a/src/modules/self-mod/project.md +++ b/src/modules/self-mod/project.md @@ -1,20 +1,25 @@ # Self-mod module Optional-tier module that gives agents admin-gated self-modification: -installing OS/npm packages, rebuilding the container image, and registering -new MCP servers. All three paths go through the approvals module's request -primitive — no unapproved changes ever land. +installing OS/npm packages and registering new MCP servers. Both paths go +through the approvals module's request primitive — no unapproved changes +ever land. The rebuild+restart (or restart-only) follow-up is bundled into +the approval handler itself — there is no separate "request rebuild" step. ## What this module adds -- Three delivery actions (`install_packages`, `request_rebuild`, `add_mcp_server`) - that the container's self-mod MCP tools write into outbound.db. On the host, - each handler validates input and queues an approval via +- Two delivery actions (`install_packages`, `add_mcp_server`) that the + container's self-mod MCP tools write into outbound.db. On the host, each + handler validates input and queues an approval via `approvals.requestApproval()`. -- Three matching approval handlers that run on approve: mutate the container - config via `updateContainerConfig`, rebuild the image via - `buildAgentGroupImage`, and kill the container so the host sweep respawns - it on the new image. +- Two matching approval handlers that run on approve: + - `install_packages` → update `container.json`, rebuild the image via + `buildAgentGroupImage`, and kill the container so the host sweep + respawns it on the new image. Also schedules a verify-and-report + follow-up prompt ~5 s after kill. + - `add_mcp_server` → update `container.json` and kill the container. + No image rebuild — bun runs TS directly, so the new MCP wiring is + picked up on the next container start. ## Dependency diff --git a/src/modules/self-mod/request.ts b/src/modules/self-mod/request.ts index d965616..6cd7f05 100644 --- a/src/modules/self-mod/request.ts +++ b/src/modules/self-mod/request.ts @@ -1,10 +1,12 @@ /** * Delivery-action handlers for agent-initiated self-modification requests. * - * Three actions the container can write into messages_out (via the self-mod - * MCP tools): install_packages, request_rebuild, add_mcp_server. Each one - * validates input and queues an approval request. The admin's approval - * triggers the matching approval handler in ./apply.ts. + * Two actions the container can write into messages_out (via the self-mod + * MCP tools): install_packages, add_mcp_server. Each one validates input + * and queues an approval request. The admin's approval triggers the + * matching approval handler in ./apply.ts, which also performs the + * required follow-up (rebuild+restart for install_packages, restart-only + * for add_mcp_server). * * Host-side sanitization for install_packages is defense-in-depth — the MCP * tool validates first. Both layers matter: the DB row carries the payload @@ -61,23 +63,6 @@ export async function handleInstallPackages(content: Record, se }); } -export async function handleRequestRebuild(content: Record, session: Session): Promise { - const agentGroup = getAgentGroup(session.agent_group_id); - if (!agentGroup) { - notifyAgent(session, 'request_rebuild failed: agent group not found.'); - return; - } - const reason = (content.reason as string) || ''; - await requestApproval({ - session, - agentName: agentGroup.name, - action: 'request_rebuild', - payload: { reason }, - title: 'Rebuild Request', - question: `Agent "${agentGroup.name}" is attempting to rebuild container.${reason ? `\nReason: ${reason}` : ''}`, - }); -} - export async function handleAddMcpServer(content: Record, session: Session): Promise { const agentGroup = getAgentGroup(session.agent_group_id); if (!agentGroup) { From e2b1df876b350e963fdae5a0e699b378dd36fdb9 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 17:46:59 +0300 Subject: [PATCH 107/185] docs(claude-md): strengthen memory discipline in shared base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the passing CLAUDE.local.md mention with an explicit Memory section: anything substantive the user shares must be stored so it's retrievable later, with per-topic files indexed from CLAUDE.local.md. Frames this as a core part of the agent's job — the quality of its memory systems is a main signal of how useful it is. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/CLAUDE.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/container/CLAUDE.md b/container/CLAUDE.md index 0717796..030de9e 100644 --- a/container/CLAUDE.md +++ b/container/CLAUDE.md @@ -20,7 +20,13 @@ Be concise — every message costs the reader's attention. Prefer outcomes over Files you create are saved in `/workspace/agent/`. Use this for notes, research, or anything that should persist across turns in this group. -The file `CLAUDE.local.md` in your workspace is your per-group memory. Unlike the composed `CLAUDE.md` next to it (which is regenerated on every spawn and read-only), `CLAUDE.local.md` is writable and persists. Record things there that you'll want to remember in future sessions — user preferences, project context, recurring facts. Keep entries short and structured. +The file `CLAUDE.local.md` in your workspace is your per-group memory. Record things there that you'll want to remember in future sessions — user preferences, project context, recurring facts. Keep entries short and structured. + +## Memory + +When the user shares any substantive information with you, it must be stored somewhere you can retrieve it when relevant. If it's information that is pertinent to every single conversation turn it should be put into CLAUDE.local.md. Otherwise, create a system for storing the information depending on its type - e.g. create a file of people that the user mentions so you can keep track or a file of projects. For every file you create, add a concise reference in your CLAUDE.local.md so you'll be able to find it in future conversations. + +A core part of your job and the main thing that defines how useful you are to the user is how well you do in creating these systems for organizing information. These are your systems that help you do your job well. Evolve them over time as needed. ## Conversation history From 52ebdce9c97983410c4edbd9e6b139df8727eb13 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 17:47:30 +0300 Subject: [PATCH 108/185] docs(claude-md): drop host-facing header comment from shared base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTML comment at the top was aimed at maintainers opening the file, but it's loaded verbatim into every agent's system prompt via the `.claude-shared.md` import. Agents don't need the meta-explanation of where the file is mounted or how identity gets injected — it's just context-budget drag. Move the maintainer guidance out of the agent's view. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/CLAUDE.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/container/CLAUDE.md b/container/CLAUDE.md index 030de9e..baf911a 100644 --- a/container/CLAUDE.md +++ b/container/CLAUDE.md @@ -1,15 +1,3 @@ - - You are a NanoClaw agent. Your name, destinations, and message-sending rules are provided in the runtime system prompt at the top of each turn. ## Communication From d2f53048f25448fabc26ffed73df7b3aa3cdaed6 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 18:05:50 +0300 Subject: [PATCH 109/185] docs(module-fragments): add instructions for create_agent, interactive, and remaining core tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three MCP tool groups were orphaned from the ambient CLAUDE.md context because they shipped no `*.instructions.md` alongside their source. Backfill them so the composer picks them up as fragments on next spawn: - core.instructions.md: add `send_file` (artifact delivery, path relative to /workspace/agent/) and `add_reaction` (by `#N` id with emoji shortcode name). - interactive.instructions.md: `ask_user_question` (blocking multiple-choice with selectedLabel/value option objects, 300s default timeout) and `send_card` (non-blocking structured render with fallbackText). Opens with a one-line framing of the contrast between the two. - agents.instructions.md: `create_agent` with how-it-works, when-to-use (companions vs collaborators — persistent memory vs independent parallel work), when-NOT-to-use (short tasks should use the SDK `Agent` tool instead), and guidance for writing the seed instructions string. No composer changes — scan in `src/claude-md-compose.ts` already picks up any file matching `*.instructions.md` in the mcp-tools directory. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/mcp-tools/agents.instructions.md | 26 +++++++++++++++++++ .../src/mcp-tools/core.instructions.md | 8 ++++++ .../src/mcp-tools/interactive.instructions.md | 22 ++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 container/agent-runner/src/mcp-tools/agents.instructions.md create mode 100644 container/agent-runner/src/mcp-tools/interactive.instructions.md diff --git a/container/agent-runner/src/mcp-tools/agents.instructions.md b/container/agent-runner/src/mcp-tools/agents.instructions.md new file mode 100644 index 0000000..8ada129 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/agents.instructions.md @@ -0,0 +1,26 @@ +## Companion and collaborator agents (`create_agent`) + +`mcp__nanoclaw__create_agent({ name, instructions })` spins up a new long-lived agent and wires it as a destination — bidirectional, so you can send it tasks and it can message you back. + +### How it works + +- Creates a new agent with its own container, workspace, and session. Your `instructions` string seeds the agent's `CLAUDE.local.md` — its starting role and personality. +- The agent's `name` becomes a destination on both sides: you address it via `send_message({ to: "", ... })`, and its replies arrive as inbound messages with `from=""`. +- Each agent has its own persistent workspace under `groups//` — memory, conversation history, and notes all survive across sessions. This is a full standalone agent, not a stateless sub-query. +- **Fire-and-forget:** the call returns immediately without waiting for the agent to confirm it's ready. Messages you send will queue until it's up. + +### When to use + +- **Companions** — a long-running presence that accumulates context over time: a `Researcher` tracking an ongoing inquiry, a `Calendar` agent managing scheduling, an assistant that knows your preferences and history. +- **Collaborators** — a parallel specialist that works independently and reports back: a `Builder` handling code edits while you stay in conversation, a `Reviewer` running checks in the background. + +The right frame is: does this agent need its own memory and context that builds over time, or does it need to work independently without blocking your turn? Either is a good reason to spawn one. + +### When NOT to use + +- **One-off lookups or short tasks** — use the SDK `Agent` tool instead. It's stateless, spins up and completes in one shot, and leaves no persistent footprint. +- **Work that finishes before the user's next message** — agents persist indefinitely. Don't create one for something you could do inline. + +### Writing good `instructions` + +Cover: the agent's role, who it takes tasks from (you, by name), how it should report back (on completion only? with milestones for long work?), and any domain-specific rules. Don't restate NanoClaw base behavior — the shared base is already loaded on the agent's end. \ No newline at end of file diff --git a/container/agent-runner/src/mcp-tools/core.instructions.md b/container/agent-runner/src/mcp-tools/core.instructions.md index 4f9e07a..d9995bf 100644 --- a/container/agent-runner/src/mcp-tools/core.instructions.md +++ b/container/agent-runner/src/mcp-tools/core.instructions.md @@ -14,6 +14,14 @@ Use the `mcp__nanoclaw__send_message` tool to send a message while you're still **Outcomes, not play-by-play.** When the turn is done, the final message should be about the result, not a transcript of what you did. +### Sending files (`send_file`) + +Use `mcp__nanoclaw__send_file({ path, text?, filename?, to? })` to deliver a file from your workspace. `path` is absolute or relative to `/workspace/agent/`; `filename` overrides the display name shown in chat (defaults to the file's basename); `text` is an optional accompanying message. Use this for artifacts you produce (charts, PDFs, generated images, reports) rather than dumping contents into chat. + +### Reacting to messages (`add_reaction`) + +Use `mcp__nanoclaw__add_reaction({ messageId, emoji })` to react to a specific inbound message by its `#N` id — pass `messageId` as an integer (e.g. `22`, not `"22"`). Good for lightweight acknowledgment (`eyes` = seen, `white_check_mark` = done) when a full reply would be noise. `emoji` is the shortcode name (e.g. `thumbs_up`, `heart`), not the raw character. + ### Internal thoughts Wrap reasoning in `...` tags to mark it as scratchpad — logged but not sent. diff --git a/container/agent-runner/src/mcp-tools/interactive.instructions.md b/container/agent-runner/src/mcp-tools/interactive.instructions.md new file mode 100644 index 0000000..f6601bd --- /dev/null +++ b/container/agent-runner/src/mcp-tools/interactive.instructions.md @@ -0,0 +1,22 @@ +## Interactive prompts + +The two tools here solve different problems: `ask_user_question` forces a decision and waits for it; `send_card` displays structured content and moves on. + +### Asking a multiple-choice question (`ask_user_question`) + +`mcp__nanoclaw__ask_user_question({ title, question, options, timeout? })` presents the user with a set of choices and **blocks your turn** until they tap one or the timeout expires (default: 300 seconds). Returns their chosen value. + +`options` can be plain strings or `{ label, selectedLabel?, value? }` objects: +- `label` — the button text shown before selection +- `selectedLabel` — the text shown on the button *after* selection (useful for confirmations, e.g. `"✓ Confirmed"`) +- `value` — the string returned to you when that option is chosen (defaults to `label`) + +Use this when you genuinely cannot proceed without a decision. For free-text input, send a normal message and wait for their reply — don't reach for this tool. + +### Structured cards (`send_card`) + +`mcp__nanoclaw__send_card({ card, fallbackText? })` renders a structured card and **returns immediately** — it does not pause your turn or collect a response. + +`card` supports: `title`, `description`, `children` (nested text or content blocks), and `actions` (buttons). `fallbackText` is sent as a plain message on platforms without card support. + +Use this for presenting information in a cleaner format than prose: summaries, options the user can read (but you're not waiting on), or results with contextual buttons. If you need the user to actually *choose* something and return a value, use `ask_user_question` instead. \ No newline at end of file From 39ae04df98370e18b449414c993583d2422313ce Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 19:04:00 +0300 Subject: [PATCH 110/185] docs(welcome-skill): expand onboarding tour with capability reveals and trust framing Adds a structured drip-feed of capabilities (memory, agents, scheduling, research, code, UI, files, self-customization) and explicit sections on approvals, access control, and natural interaction. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/skills/welcome/SKILL.md | 77 +++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 10 deletions(-) diff --git a/container/skills/welcome/SKILL.md b/container/skills/welcome/SKILL.md index fe3245b..c540e86 100644 --- a/container/skills/welcome/SKILL.md +++ b/container/skills/welcome/SKILL.md @@ -3,26 +3,83 @@ name: welcome description: Introduce yourself to a newly connected channel. Triggered automatically when a channel is first wired. Send a friendly greeting and brief overview of what you can do. --- -# /welcome — Channel Onboarding +# /welcome — Channel Onboarding (Updated) -You've just been connected to a new messaging channel. Introduce yourself to the user. +You've just been connected to a new user. This your time to shine and make a strong first impression. Introduce yourself and guide the user through what you can do. you got this! ## What to do 1. Send a short, warm greeting using `send_message` -2. Mention your name (from your CLAUDE.md) -3. Make it clear you can do a lot — but do NOT list your tools or skills upfront. Keep it open-ended and intriguing -4. End by asking: would they like to explore what you can do, or jump straight into building/creating something? +2. State your name (from your system prompt / CLAUDE.md) +3. Signal that you're capable of a lot — but don't list everything upfront. Be intriguing, not encyclopedic +4. Ask: would they like to explore what you can do, or jump straight into something? -**If they want to explore:** show one skill or capability at a time. Briefly explain what it does, offer to demo it or let them try it, then ask if they want to see the next one or move on. Drip-feed — never dump a list. +**If they want to explore:** drip-feed one capability at a time. Briefly explain it, offer to demo a compelling example or let them try it. Never dump a full list. -**If they want to jump in:** just go. Help them with whatever they ask. +**If they want to jump in:** just go. + +--- + +## Capabilities to reveal (in order) + +Reveal these one at a time, in this sequence. Each should be 2–4 sentences max. + +### 1. Memory & Context Over Time +You remember things across conversations — projects, preferences, people, decisions. Users don't have to re-explain context every session. The more they work with you, the more situationally aware you become. + +### 2. Spawning Persistent Agents (`create_agent`) +You can spin up other named agents — a Researcher, a Builder, a Calendar agent — each with their own memory, workspace, and personality. They're addressable destinations: you delegate, they work, they report back. These aren't one-shot tasks; they accumulate context across sessions. + +### 3. Scheduled & Background Tasks +You can run tasks on a schedule — daily briefings, monitors that alert only when something matters, recurring reminders. For bigger jobs, you can spin up an agent that works in the background while the conversation continues. + +### 4. Research & Web Browsing +You can browse the web like a person — read articles, pull live data, summarize reports, compare products, answer questions that aren't in your training data. Ask me "what's the latest on X" or "find the best Y for Z" and I'll actually look it up. Very powerful when combined with scheduled tasks. + +### 5. Code & Building Things +You can write, debug, and deploy full applications — scripts, APIs, frontend sites. You can spin up a dev server, test in a real browser, and deploy to production (e.g. Vercel). Concept to live URL. + +### 6. Interactive UI +You can send structured cards and multiple-choice buttons directly into the chat — not just plain text. Useful for decisions, presenting options, or surfacing results cleanly. + +### 7. Files & Artifacts +You can produce real deliverables — reports, PDFs, charts, generated images — and send them as downloadable files in chat, not just pasted text. + +### 8. Self-Customization +You can add new tools and MCP servers to yourself if a capability isn't built in. You can extend your own toolkit when the task requires it. + +--- + +## Trust & Control — always include these + +After the capabilities tour (or woven in naturally), cover these two points. Frame them positively — users stay in control. + +### Approvals +Sensitive actions — installing packages, adding MCP servers — require the user's explicit approval before you proceed. They'll get a prompt; nothing happens automatically. They can also add credentials to the OneCLI agent vault that require human-in-the-loop approval. + +### Access Control +The user owns who can talk to you. Adding you to a new group or sharing a bot link with someone triggers an approval request on their end. Nobody interacts with you without their say-so. + +--- + +## How to interact — always mention this + +There are no special commands. Users just talk naturally. If they want something done, they say so. That's it. + +--- + +## Wrapping up + +After the tour, finish with an open invitation. Ask if they want help with something specific. Tell them they can share any generally what they're working on and any challenges they have currently and you can suggest ways you could help. + +--- ## Tone -Warm, confident, and inviting. Make the user feel like they just unlocked something powerful. Match the channel's vibe (casual for Telegram/Discord, slightly more professional for Slack/Teams/email). +Warm, confident, inviting. Make the user feel like they just unlocked something powerful. Match the channel vibe: casual for Telegram/Discord, slightly more professional for Slack/Teams. ## Important -- Scan your available MCP tools and skills so you know what you have — but keep that knowledge in your back pocket. Reveal capabilities naturally, one at a time, only when relevant or when the user asks to explore. -- Never overwhelm with a full list. Discovery should feel like unwrapping, not reading a manual. +- Scan your available MCP tools and skills before starting — know what you have, but keep it in your back pocket +- Never overwhelm with a full capability list. Discovery should feel like unwrapping, not reading a manual +- Confirmations and corrections from the user during onboarding are feedback — save them to memory for future sessions \ No newline at end of file From 8412b899fa4ce53b893076f4b2037a103ecaebc1 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 19:21:06 +0300 Subject: [PATCH 111/185] feat(diagnostics): funnel events throughout setup with persisted install-id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared bash + node emitter in setup/lib/diagnostics.{sh,ts} reads/writes data/install-id so every event from a single install shares one distinct_id — bash-side setup_launched/setup_start, node-side auto_started, per-step started/completed, auth_method_chosen, channel_chosen, first_chat_ready/failed, setup_incomplete, setup_aborted, setup_completed. Opt-out via NANOCLAW_NO_DIAGNOSTICS=1. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 8 +++++ setup.sh | 19 ++++++++--- setup/auto.ts | 13 ++++++++ setup/lib/diagnostics.sh | 61 ++++++++++++++++++++++++++++++++++ setup/lib/diagnostics.ts | 70 ++++++++++++++++++++++++++++++++++++++++ setup/lib/runner.ts | 17 ++++++++++ 6 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 setup/lib/diagnostics.sh create mode 100644 setup/lib/diagnostics.ts diff --git a/nanoclaw.sh b/nanoclaw.sh index 95a4824..0bf1938 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -29,6 +29,14 @@ LOGS_DIR="$PROJECT_ROOT/logs" STEPS_DIR="$LOGS_DIR/setup-steps" PROGRESS_LOG="$LOGS_DIR/setup.log" +# Diagnostics: persisted install-id + fire-and-forget emit. Sourced early +# so `setup_launched` covers dropoff before bootstrap even starts. +# shellcheck source=setup/lib/diagnostics.sh +source "$PROJECT_ROOT/setup/lib/diagnostics.sh" +ph_event setup_launched \ + platform="$(uname -s | tr 'A-Z' 'a-z')" \ + is_wsl="$([ -f /proc/version ] && grep -qi 'microsoft\|wsl' /proc/version 2>/dev/null && echo true || echo false)" + # ─── log helpers ──────────────────────────────────────────────────────── ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; } diff --git a/setup.sh b/setup.sh index ae5da27..9a81531 100755 --- a/setup.sh +++ b/setup.sh @@ -167,11 +167,20 @@ elif [ "$NATIVE_OK" = "false" ]; then STATUS="native_failed" fi -# Anonymous setup start event (non-blocking, best-effort) -curl -sS --max-time 3 -X POST https://us.i.posthog.com/capture/ \ - -H 'Content-Type: application/json' \ - -d "{\"api_key\":\"phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP\",\"event\":\"setup_start\",\"distinct_id\":\"$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo unknown)\",\"properties\":{\"platform\":\"$PLATFORM\",\"is_wsl\":\"$IS_WSL\",\"is_root\":\"$IS_ROOT\",\"node_version\":\"$NODE_VERSION\",\"deps_ok\":\"$DEPS_OK\",\"native_ok\":\"$NATIVE_OK\",\"has_build_tools\":\"$HAS_BUILD_TOOLS\"}}" \ - >/dev/null 2>&1 & +# Anonymous setup start event (non-blocking, best-effort). Uses the +# persisted distinct_id from data/install-id so bash-side events and the +# node-side funnel share one id. +# shellcheck source=setup/lib/diagnostics.sh +source "$PROJECT_ROOT/setup/lib/diagnostics.sh" +ph_event setup_start \ + platform="$PLATFORM" \ + is_wsl="$IS_WSL" \ + is_root="$IS_ROOT" \ + node_version="$NODE_VERSION" \ + deps_ok="$DEPS_OK" \ + native_ok="$NATIVE_OK" \ + has_build_tools="$HAS_BUILD_TOOLS" \ + status="$STATUS" cat < { printIntro(); initProgressionLog(); + phEmit('auto_started'); const skip = new Set( (process.env.NANOCLAW_SKIP ?? '') @@ -205,8 +207,10 @@ async function main(): Promise { if (!skip.has('first-chat')) { const ping = await confirmAssistantResponds(); if (ping === 'ok') { + phEmit('first_chat_ready'); await runFirstChat(); } else { + phEmit('first_chat_failed', { reason: ping }); renderPingFailureNote(ping); await offerClaudeAssist({ stepName: 'cli-agent', @@ -292,6 +296,12 @@ async function main(): Promise { .map((n) => n.replace(/^•\s*/, '').split('\n')[0].trim()) .filter(Boolean) .join(' · '); + phEmit('setup_incomplete', { + unresolved_count: notes.length, + service_running: res.terminal?.fields.SERVICE === 'running', + has_credentials: res.terminal?.fields.CREDENTIALS === 'configured', + agent_responds: res.terminal?.fields.AGENT_PING === 'ok', + }); await offerClaudeAssist({ stepName: 'verify', msg: summary || 'Verification completed with unresolved issues.', @@ -314,6 +324,7 @@ async function main(): Promise { .join('\n'); p.note(nextSteps, 'Try these'); setupLog.complete(Date.now() - RUN_START); + phEmit('setup_completed', { duration_ms: Date.now() - RUN_START }); p.outro(k.green("You're ready! Enjoy NanoClaw.")); } @@ -440,6 +451,7 @@ async function runAuthStep(): Promise { }), ) as 'subscription' | 'oauth' | 'api'; setupLog.userInput('auth_method', method); + phEmit('auth_method_chosen', { method }); if (method === 'subscription') { await runSubscriptionAuth(); @@ -660,6 +672,7 @@ async function askChannelChoice(): Promise< }), ); setupLog.userInput('channel_choice', String(choice)); + phEmit('channel_chosen', { channel: String(choice) }); return choice as 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'; } diff --git a/setup/lib/diagnostics.sh b/setup/lib/diagnostics.sh new file mode 100644 index 0000000..23629d7 --- /dev/null +++ b/setup/lib/diagnostics.sh @@ -0,0 +1,61 @@ +# diagnostics.sh — shared PostHog emitter for bash-side setup code. +# +# Source this file after $PROJECT_ROOT is set: +# +# source "$PROJECT_ROOT/setup/lib/diagnostics.sh" +# ph_event bootstrap_completed status=success platform=macos +# +# All emits are fire-and-forget (background curl, 3s max timeout); they +# never fail the caller. Honors NANOCLAW_NO_DIAGNOSTICS=1. The distinct_id +# is persisted at data/install-id so the bash + node halves of setup use +# the same id and events from one install join into a single funnel. + +NANOCLAW_PH_KEY='phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP' +NANOCLAW_PH_URL='https://us.i.posthog.com/capture/' + +# Resolve or create the persisted install id. Echoes the id (lowercase uuid). +# Creates data/install-id on first use. Safe to call pre-Node: uses only +# bash + uuidgen/urandom fallback + mkdir. +ph_install_id() { + local root="${NANOCLAW_PROJECT_ROOT:-${PROJECT_ROOT:-$PWD}}" + local f="$root/data/install-id" + if [ ! -s "$f" ]; then + mkdir -p "$(dirname "$f")" 2>/dev/null || return 0 + local id + id=$(uuidgen 2>/dev/null \ + || cat /proc/sys/kernel/random/uuid 2>/dev/null \ + || printf 'fallback-%s-%s' "$(date +%s)" "$$") + printf '%s' "$id" | tr 'A-Z' 'a-z' > "$f" 2>/dev/null || return 0 + fi + cat "$f" 2>/dev/null +} + +# Emit a PostHog event. First arg is the event name; remaining args are +# `key=value` pairs merged into properties. Values are JSON-escaped for +# quotes and backslashes; keep them short and alphanumeric-ish. +ph_event() { + [ "${NANOCLAW_NO_DIAGNOSTICS:-}" = "1" ] && return 0 + local event=$1 + shift + local id + id=$(ph_install_id) + [ -z "$id" ] && return 0 + + local props='' first=1 kv k v + for kv in "$@"; do + k="${kv%%=*}" + v="${kv#*=}" + v=${v//\\/\\\\} + v=${v//\"/\\\"} + if [ "$first" = "1" ]; then first=0; else props+=','; fi + props+="\"$k\":\"$v\"" + done + + local payload + payload=$(printf '{"api_key":"%s","event":"%s","distinct_id":"%s","properties":{%s}}' \ + "$NANOCLAW_PH_KEY" "$event" "$id" "$props") + + curl -sS --max-time 3 -X POST "$NANOCLAW_PH_URL" \ + -H 'Content-Type: application/json' \ + -d "$payload" >/dev/null 2>&1 & +} diff --git a/setup/lib/diagnostics.ts b/setup/lib/diagnostics.ts new file mode 100644 index 0000000..30605a7 --- /dev/null +++ b/setup/lib/diagnostics.ts @@ -0,0 +1,70 @@ +/** + * Thin PostHog emitter shared across setup:auto code. Fire-and-forget — + * never throws, never blocks. Reuses data/install-id (same file bash + * uses in setup/lib/diagnostics.sh) so events from the bash and node + * halves of a single install join into one funnel. + * + * Honors NANOCLAW_NO_DIAGNOSTICS=1. + */ +import { randomUUID } from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +const POSTHOG_KEY = 'phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP'; +const POSTHOG_URL = 'https://us.i.posthog.com/capture/'; +const INSTALL_ID_PATH = path.join('data', 'install-id'); + +let cached: string | null = null; + +export function installId(): string { + if (cached) return cached; + try { + const existing = fs.readFileSync(INSTALL_ID_PATH, 'utf-8').trim(); + if (existing) { + cached = existing; + return existing; + } + } catch { + // fall through to create + } + const id = randomUUID().toLowerCase(); + try { + fs.mkdirSync(path.dirname(INSTALL_ID_PATH), { recursive: true }); + fs.writeFileSync(INSTALL_ID_PATH, id); + } catch { + // best-effort; still return the id so the event fires + } + cached = id; + return id; +} + +export function emit( + event: string, + props: Record = {}, +): void { + if (process.env.NANOCLAW_NO_DIAGNOSTICS === '1') return; + + const cleaned: Record = { platform: process.platform }; + for (const [k, v] of Object.entries(props)) { + if (v === undefined) continue; + cleaned[k] = v; + } + + const body = JSON.stringify({ + api_key: POSTHOG_KEY, + event, + distinct_id: installId(), + properties: cleaned, + }); + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 3000); + void fetch(POSTHOG_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + signal: ctrl.signal, + }) + .catch(() => {}) + .finally(() => clearTimeout(timer)); +} diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index 0e33c74..d8d3765 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -19,6 +19,7 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { offerClaudeAssist } from './claude-assist.js'; +import { emit as phEmit } from './diagnostics.js'; import { fitToWidth } from './theme.js'; export type Fields = Record; @@ -186,11 +187,17 @@ export async function runQuietStep( ): Promise { const rawLog = setupLog.stepRawLog(stepName); const start = Date.now(); + phEmit('step_started', { step: stepName }); const result = await runUnderSpinner(labels, () => spawnStep(stepName, extra, () => {}, rawLog), ); const durationMs = Date.now() - start; writeStepEntry(stepName, result, durationMs, rawLog); + phEmit('step_completed', { + step: stepName, + status: outcomeStatus(result), + duration_ms: durationMs, + }); return { ...result, rawLog, durationMs }; } @@ -209,6 +216,7 @@ export async function runQuietChild( ): Promise { const rawLog = setupLog.stepRawLog(logName); const start = Date.now(); + phEmit('step_started', { step: logName }); const result = await runUnderSpinner(labels, () => spawnQuiet(cmd, args, rawLog, opts?.env), ); @@ -223,9 +231,17 @@ export async function runQuietChild( ? 'skipped' : 'success'; setupLog.step(logName, status, durationMs, fields, rawLog); + phEmit('step_completed', { step: logName, status, duration_ms: durationMs }); return { ...result, rawLog, durationMs }; } +/** Collapse a step run into the three-way status used by diagnostics + progression log. */ +function outcomeStatus(result: StepResult): 'success' | 'skipped' | 'failed' { + const rawStatus = result.terminal?.fields.STATUS; + if (!result.ok) return 'failed'; + return rawStatus === 'skipped' ? 'skipped' : 'success'; +} + /** Turn a step's terminal-block fields into a concise progression-log entry. */ export function writeStepEntry( stepName: string, @@ -318,6 +334,7 @@ export async function fail( rawLogPath?: string, ): Promise { setupLog.abort(stepName, msg); + phEmit('setup_aborted', { step: stepName, reason: msg }); p.log.error(msg); if (hint) p.log.message(k.dim(hint)); p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/')); From 5df8d9d2e565f67dda4ea2bac46e0c681e309006 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 19:43:58 +0300 Subject: [PATCH 112/185] docs: drop v2 refactor planning docs ahead of merge Removes transient analysis/proposal/checklist docs whose purpose is served once v2 ships: REFACTOR.md, docs/v1-vs-v2/, docs/checklist.md, docs/shared-source.md, docs/claude-md-composition.md, docs/module-contract.md, docs/DEBUG_CHECKLIST.md. Updates CLAUDE.md and docs/README.md index rows accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 - REFACTOR.md | 175 ----- docs/DEBUG_CHECKLIST.md | 171 ----- docs/README.md | 1 - docs/checklist.md | 276 -------- docs/claude-md-composition.md | 146 ----- docs/module-contract.md | 221 ------- docs/shared-source.md | 270 -------- docs/v1-vs-v2/ACTION-ITEMS.md | 618 ------------------ docs/v1-vs-v2/SUMMARY.md | 146 ----- docs/v1-vs-v2/channels.md | 305 --------- docs/v1-vs-v2/config.md | 99 --- docs/v1-vs-v2/container-index.md | 72 -- docs/v1-vs-v2/container-mcp-tools.md | 58 -- docs/v1-vs-v2/container-runner.md | 51 -- docs/v1-vs-v2/container-runtime.md | 46 -- docs/v1-vs-v2/db.md | 542 --------------- docs/v1-vs-v2/env.md | 38 -- docs/v1-vs-v2/formatting-test.md | 154 ----- docs/v1-vs-v2/group-folder.md | 38 -- docs/v1-vs-v2/group-queue.md | 48 -- docs/v1-vs-v2/index-host.md | 70 -- docs/v1-vs-v2/ipc.md | 240 ------- docs/v1-vs-v2/logger.md | 38 -- docs/v1-vs-v2/remote-control.md | 90 --- docs/v1-vs-v2/router.md | 67 -- docs/v1-vs-v2/sender-allowlist.md | 46 -- docs/v1-vs-v2/session-cleanup.md | 44 -- docs/v1-vs-v2/task-scheduler.md | 100 --- .../timezone-formatting-v1-recreation.md | 570 ---------------- docs/v1-vs-v2/timezone.md | 27 - docs/v1-vs-v2/types.md | 58 -- 32 files changed, 4826 deletions(-) delete mode 100644 REFACTOR.md delete mode 100644 docs/DEBUG_CHECKLIST.md delete mode 100644 docs/checklist.md delete mode 100644 docs/claude-md-composition.md delete mode 100644 docs/module-contract.md delete mode 100644 docs/shared-source.md delete mode 100644 docs/v1-vs-v2/ACTION-ITEMS.md delete mode 100644 docs/v1-vs-v2/SUMMARY.md delete mode 100644 docs/v1-vs-v2/channels.md delete mode 100644 docs/v1-vs-v2/config.md delete mode 100644 docs/v1-vs-v2/container-index.md delete mode 100644 docs/v1-vs-v2/container-mcp-tools.md delete mode 100644 docs/v1-vs-v2/container-runner.md delete mode 100644 docs/v1-vs-v2/container-runtime.md delete mode 100644 docs/v1-vs-v2/db.md delete mode 100644 docs/v1-vs-v2/env.md delete mode 100644 docs/v1-vs-v2/formatting-test.md delete mode 100644 docs/v1-vs-v2/group-folder.md delete mode 100644 docs/v1-vs-v2/group-queue.md delete mode 100644 docs/v1-vs-v2/index-host.md delete mode 100644 docs/v1-vs-v2/ipc.md delete mode 100644 docs/v1-vs-v2/logger.md delete mode 100644 docs/v1-vs-v2/remote-control.md delete mode 100644 docs/v1-vs-v2/router.md delete mode 100644 docs/v1-vs-v2/sender-allowlist.md delete mode 100644 docs/v1-vs-v2/session-cleanup.md delete mode 100644 docs/v1-vs-v2/task-scheduler.md delete mode 100644 docs/v1-vs-v2/timezone-formatting-v1-recreation.md delete mode 100644 docs/v1-vs-v2/timezone.md delete mode 100644 docs/v1-vs-v2/types.md diff --git a/CLAUDE.md b/CLAUDE.md index ba5f857..7115c4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -209,7 +209,6 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac | [docs/agent-runner-details.md](docs/agent-runner-details.md) | Agent-runner internals + MCP tool interface | | [docs/isolation-model.md](docs/isolation-model.md) | Three-level channel isolation model | | [docs/setup-wiring.md](docs/setup-wiring.md) | What's wired, what's open in the setup flow | -| [docs/checklist.md](docs/checklist.md) | Rolling status checklist across all subsystems | | [docs/architecture-diagram.md](docs/architecture-diagram.md) | Diagram version of the architecture | | [docs/build-and-runtime.md](docs/build-and-runtime.md) | Runtime split (Node host + Bun container), lockfiles, image build surface, CI, key invariants | diff --git a/REFACTOR.md b/REFACTOR.md deleted file mode 100644 index d3f6562..0000000 --- a/REFACTOR.md +++ /dev/null @@ -1,175 +0,0 @@ -# NanoClaw Refactor — Forward-Looking Reference - -Consolidates what's still relevant from `REFACTOR_PLAN.md` and `REFACTOR_EXECUTION.md`: open decisions, remaining work, operational patterns worth keeping. Historical PR timeline and phase framing have been dropped — the work is in the commit history. - ---- - -## Architecture (still authoritative) - -### Module tiers - -Three categories, distinguished by shipping model and dependency direction: - -| Tier | Where it lives | Loaded by default? | Removal cost | -|------|----------------|--------------------|--------------| -| **Core** | `src/**` (outside `src/modules/`, `src/channels/`, `src/providers/`) | always | N/A — can't remove | -| **Default modules** | `src/modules//` on main | yes — imported by `src/modules/index.ts` | edit core imports (intentional friction) | -| **Optional modules** | `src/modules//` on main (for now — see open q #7) | yes, via barrel import | delete files + barrel line + revert `MODULE-HOOK` edits | -| **Channel adapters** | `src/channels/.ts` on `channels` branch | no — cherry-pick via `/add-` | delete files + barrel line | -| **Providers** | on `providers` branch | no — cherry-pick via `/add-` | delete files + barrel line | - -Default modules today: `typing`, `mount-security`, `approvals`, `cli`. -Optional modules: `interactive`, `scheduling`, `permissions`, `agent-to-agent`, `self-mod`. - -Dependency rule: **core ← default modules ← optional modules**. Optional modules must not depend on each other. Known transitional violation (flagged): `src/db/messaging-groups.ts` auto-wires `agent_destinations` when agent-to-agent is installed. - -### The four registries - -Full contract in [`docs/module-contract.md`](docs/module-contract.md). Summary: - -1. **Delivery action handlers** — `delivery.ts`; modules call `registerDeliveryAction(name, fn)`. -2. **Router inbound gate** — `router.ts`; single setter (`setSenderResolver` + `setAccessGate`). Default: allow-all. -3. **Response dispatcher** — `response-registry.ts`; modules call `registerResponseHandler(fn)`. First to return `true` claims. -4. **Container MCP tool self-registration** — `container/agent-runner/src/mcp-tools/server.ts`; modules call `registerTools([...])` at import. - -Anything else single-consumer uses either a `sqlite_master`-guarded inline read or a `MODULE-HOOK::start/end` skill edit. - -### Module distribution (pending) - -- **`main`** — core + default modules + default channel (`cli`). Ships clean. -- **`channels`** — fully loaded runnable branch with all channel adapters; skills cherry-pick from it. -- **`providers`** — same pattern for agent providers (OpenCode). -- **`modules` branch** — proposed but NOT created yet. See "Remaining work" below. - ---- - -## Remaining work - -### Phase 5: merge `v2` → `main` - -Cut-over the refactor. Pre-reqs (already met): green build, green tests, green service boot, clean `channels` / `providers` syncs. - -Open logistics: -- Release versioning: bump to `1.3.0` at merge time or cut a `v2-rc` tag first for internal testing? Non-blocking — decide at merge. -- Coordinate with anyone still running the old `main` (v1.2.53) — breaking change for them. -- Announce the new layout + the one shell command that changed (`pnpm run chat` is new default). - -### `modules` branch — create, skip, or defer? - -The original plan (PR #10) was to fork a `modules` branch and populate it with the 5 optional modules, so future `/add-` skills pull via `git show origin/modules:path`. Three paths: - -- **(a) Create it now.** Matches the `channels`/`providers` pattern for consistency. Extra surface to maintain: every core change must be merged into `modules` at phase boundaries (same cadence as channels/providers). Pays off if we ever want to make a module *truly* optional (not shipped on main). -- **(b) Skip it.** Leave all 5 optional modules shipped on main. No `modules` branch, no install skills, no cherry-picking. Simpler but loses the "opt-in" property for users who want a leaner install. -- **(c) Defer.** Ship main without the modules branch; create it later if someone actually wants to slim their install. No-cost option for now. - -Recommendation leans toward (c) — we've already paid the architectural cost (tier boundary, dependency rule, registries) without needing the branch today. - -### Per-module follow-ups (tracked as open questions below) - -Each has a specific landing zone when we get to it: -- #11–13 (admin mechanism, providers registry, container-runner audit) — scope a focused cleanup pass. -- #14 (CLAUDE.md review) — single dedicated PR touching every module. -- #15 (A2A / destinations rethink) — requires design, not just cleanup. -- #17–18 (self-mod rethink, per-group source) — requires design. -- #19 (system vs user CLAUDE.md) — requires install-skill tooling. - ---- - -## Operational patterns (keep using these) - -### Standing checks for every PR - -Non-negotiable; a unit test suite alone doesn't catch circular-import TDZ bugs: - -1. `pnpm run build` clean. -2. `pnpm test` + `bun test` (in `container/agent-runner/`) all green. -3. **Service actually starts.** `gtimeout 5 node dist/index.js` (or `launchctl kickstart`) must reach `NanoClaw running`. Unit tests import individual files; only `main()` exercises the module-init order. -4. Expected boot log lines present (at least: `Central DB ready`, `Delivery polls started`, `Host sweep started`, `NanoClaw running`, plus any module lifecycle line like `OneCLI approval handler started` or `CLI channel listening`). - -### Module architecture rule (TDZ bug, PR #3) - -Any registry state a module writes to at import time must live in a file with **no back-edge to `src/index.ts`** — transitively. `src/index.ts` imports `src/modules/index.js` for side effects; if a module calls `registerX()` at top level and `X` lives in `src/index.ts`, the ES module loader hits a TDZ reference on the const declaration. Fix: registry state lives in its own dependency-free file (e.g. `src/response-registry.ts`). Any new registry follows the same pattern. - -### Branch sync procedure - -After every `v2` (or future `main`) sync into `channels` / `providers` / future `modules`: - -1. **File-presence diff.** Enumerate files that existed pre-sync but are missing post-sync: - ``` - git ls-tree -r | awk '{print $4}' | sort > /tmp/pre.txt - git ls-tree -r | awk '{print $4}' | sort > /tmp/post.txt - comm -23 /tmp/pre.txt /tmp/post.txt - ``` - Classify each missing file: - - **Intentional** (core deleted it) → leave deleted. - - **Branch-owned** (channels branch still needs it) → restore from pre-sync HEAD. - - This has caught real losses on both `channels` (17 adapter files plus 3 setup scripts after PR #2's channel move) and `providers` (opencode files after PR #2). - -2. **Cross-file consistency.** When restoring a file, check whether something *else* that also changed references it (e.g. `setup/index.ts`'s `STEPS` map). - -3. **Run the standing checks** against the synced branch (not just v2). - -### Prettier drift pattern - -The `format:fix` pre-commit hook sometimes reformats peer files *after* the commit completes, leaving cosmetic-only diffs in the working tree. Discard with `git checkout -- `. Do not re-commit the drift — it's trivial whitespace and noise. - ---- - -## Open questions (curated) - -### Design / architecture - -1. **`NANOCLAW_ADMIN_USER_IDS` as the admin mechanism.** Host queries `user_roles` at container wake, collapses into env var, container compares sender IDs. Conflates identity-at-send with privilege-at-wake and forces the container to care about namespaced user IDs. Revisit during a container-runner audit. - -2. **Host-side `src/providers/` registry.** One real consumer (OpenCode). A registry is probably overkill — the install skill could just edit `container-runner.ts` via `MODULE-HOOK`. Fold into the container-runner audit. - -3. **Container-runner audit.** `src/container-runner.ts` has accreted wake/spawn/kill, mount assembly, OneCLI credential application, admin-ID env var, idle timers, image rebuild. Some pieces should pull apart or move into modules. Not blocking. Related to #1 and #2. - -4. **Revisit destinations + A2A capability holistically.** The destination projection invariant, dual-purpose routing+ACL table, channel vs agent destination shapes, `createMessagingGroupAgent` auto-wire coupling — more machinery than the feature warrants. Phase 3 moved it out of core intact; a redesign is warranted but scoped post-refactor. - -5. **Self-mod approach rethink.** _Partially addressed_ — the redundant `request_rebuild` tool was removed; approval of `install_packages` now bundles rebuild + container restart, and `add_mcp_server` approval restarts without rebuilding (bun runs TS directly). Still to consider: collapsing `install_packages` + `add_mcp_server` into a single "apply this container-config diff" approval primitive to reduce post-rebuild latency further. - -6. **Per-agent-group source / per-group base image.** Self-mod today layers packages/MCP on a shared base. As groups diverge (different base images, provider configs, runtime toolchains), the shared-base assumption won't scale. Scope post-refactor. - -### Distribution / operational - -7. **Providers on a consolidated `modules` branch?** Staying separate for now. Revisit if a second optional provider appears. - -8. **Per-group module enablement.** Modules are currently project-wide. If one agent group wants approvals and another doesn't, we'd need per-group feature flags. Flag if asked. - -9. **Module removal UX.** We do not drop tables on uninstall. Is that the right default? (Alternative: `/remove-` optionally runs a down migration. YAGNI until requested.) - -10. **Cross-module ordering for the response dispatcher.** Registration order determines who claims a given `questionId`. IDs are disjoint in practice (`q-…` vs `appr-…`), so first-match-wins is safe. If a third response-consuming module arrives, we may need keyed dispatch. - -11. **Versioned module migrations.** Reinstalls are idempotent (migrator skips anything already in `schema_version`). If a module ships a *new* migration in a later version, the install skill must append the new file + barrel entry without touching prior ones. Simplest rule: install skills are additive; content changes to an already-applied migration are a hard error. - -12. **Telegram pairing imports from permissions (channels branch).** `src/channels/telegram.ts` reaches into `src/modules/permissions/db/*` for `grantRole`/`hasAnyOwner`/`upsertUser` in the pairing-bootstrap branch. Cross-branch tier violation. Fix: extract those writes into a pairing helper (e.g. `src/channels/telegram-pairing-accept.ts` or `setup/pair-telegram.ts`). Non-blocking. - -### Core slotting (files not explicitly discussed) - -13. **`state-sqlite.ts`, `webhook-server.ts`, `timezone.ts`.** state-sqlite is likely core (host tracker). Webhook-server likely core (channel infra). Timezone likely core utility. Confirm if any of them prove to be module-shaped during future audits. - -14. **Chat SDK bridge location.** `src/channels/chat-sdk-bridge.ts` is channel infra that bridges adapters on the `channels` branch. Stays in `src/channels/` for now. - -15. **OneCLI credential injection.** Lives in `container-runner.ts`. Every agent call uses it, no clean optional boundary. Stays core. Related: `onecli-approvals.ts` is bundled inside the `approvals` default module on the assumption OneCLI stays in core. If OneCLI later moves to its own module, `onecli-approvals` follows. - -### Documentation - -16. **CLAUDE.md content per module.** Every module ships with project.md + agent.md. Need a dedicated review pass: (a) write the missing agent-to-agent snippets, (b) audit other modules for accuracy/tone, (c) confirm `agent.md` files are actually tailored for the agent vs. copy-pastes of `project.md`. - -17. **Split system CLAUDE.md from user CLAUDE.md.** Project `CLAUDE.md` and `groups/global/CLAUDE.md` mix system-authored content (module contracts, install-skill appends) with user customizations. Updates currently risk clobbering user intent. Look at a system-owned region (or separate file) that skills rewrite freely plus a user-owned one that's never touched. Related to #16. - ---- - -## Where the canonical references live - -- **Module contract** — [`docs/module-contract.md`](docs/module-contract.md) -- **Architecture overview** — [`docs/architecture.md`](docs/architecture.md) -- **DB layout** — [`docs/db.md`](docs/db.md), [`docs/db-central.md`](docs/db-central.md), [`docs/db-session.md`](docs/db-session.md) -- **Agent-runner internals** — [`docs/agent-runner-details.md`](docs/agent-runner-details.md) -- **Channel isolation model** — [`docs/isolation-model.md`](docs/isolation-model.md) -- **Build + runtime split** — [`docs/build-and-runtime.md`](docs/build-and-runtime.md) -- **Top-level** — [`CLAUDE.md`](CLAUDE.md) - -This doc (`REFACTOR.md`) is transient — prune when open questions close; retire entirely once the refactor is fully behind us and the operational patterns have been absorbed into `CLAUDE.md` or `docs/`. diff --git a/docs/DEBUG_CHECKLIST.md b/docs/DEBUG_CHECKLIST.md deleted file mode 100644 index af4058a..0000000 --- a/docs/DEBUG_CHECKLIST.md +++ /dev/null @@ -1,171 +0,0 @@ -# NanoClaw Debug Checklist - -## Known Issues (2026-02-08) - -### 1. [FIXED] Resume branches from stale tree position -When agent teams spawns subagent CLI processes, they write to the same session JSONL. On subsequent `query()` resumes, the CLI reads the JSONL but may pick a stale branch tip (from before the subagent activity), causing the agent's response to land on a branch the host never receives a `result` for. **Fix**: pass `resumeSessionAt` with the last assistant message UUID to explicitly anchor each resume. - -### 2. IDLE_TIMEOUT == CONTAINER_TIMEOUT (both 30 min) -Both timers fire at the same time, so containers always exit via hard SIGKILL (code 137) instead of graceful `_close` sentinel shutdown. The idle timeout should be shorter (e.g., 5 min) so containers wind down between messages, while container timeout stays at 30 min as a safety net for stuck agents. - -### 3. Cursor advanced before agent succeeds -`processGroupMessages` advances `lastAgentTimestamp` before the agent runs. If the container times out, retries find no messages (cursor already past them). Messages are permanently lost on timeout. - -### 4. Kubernetes image garbage collection deletes nanoclaw-agent image - -**Symptoms**: `Container exited with code 125: pull access denied for nanoclaw-agent` — the container image disappears overnight or after a few hours, even though you just built it. - -**Cause**: If your container runtime has Kubernetes enabled (Rancher Desktop enables it by default), the kubelet runs image garbage collection when disk usage exceeds 85%. NanoClaw containers are ephemeral (run and exit), so `nanoclaw-agent:latest` is never protected by a running container. The kubelet sees it as unused and deletes it — often overnight when no messages are being processed. Other images (docker-compose services) survive because they have long-running containers referencing them. - -**Fix**: Disable Kubernetes if you don't need it: -```bash -# Rancher Desktop -rdctl set --kubernetes-enabled=false - -# Then rebuild the container image -./container/build.sh -``` - -**Diagnosis**: Check the k3s log for image GC activity: -```bash -grep -i "nanoclaw" ~/Library/Logs/rancher-desktop/k3s.log -# Look for: "Removing image to free bytes" with the nanoclaw-agent image ID -``` - -Check NanoClaw logs for image status: -```bash -grep -E "image found|image NOT found|image missing" logs/nanoclaw.log -``` - -If you need Kubernetes enabled, set `CONTAINER_IMAGE` to an image stored in a registry that the kubelet won't GC, or raise the GC thresholds. - -## Quick Status Check - -```bash -# 1. Is the service running? -launchctl list | grep nanoclaw -# Expected: PID 0 com.nanoclaw (PID = running, "-" = not running, non-zero exit = crashed) - -# 2. Any running containers? -docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw - -# 3. Any stopped/orphaned containers? -docker ps -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw - -# 4. Recent errors in service log? -grep -E 'ERROR|WARN' logs/nanoclaw.log | tail -20 - -# 5. Are channels connected? (look for last connection event) -grep -E 'Connected|Connection closed|connection.*close|channel.*ready' logs/nanoclaw.log | tail -5 - -# 6. Are groups loaded? -grep 'groupCount' logs/nanoclaw.log | tail -3 -``` - -## Session Transcript Branching - -```bash -# Check for concurrent CLI processes in session debug logs -ls -la data/sessions//.claude/debug/ - -# Count unique SDK processes that handled messages -# Each .txt file = one CLI subprocess. Multiple = concurrent queries. - -# Check parentUuid branching in transcript -python3 -c " -import json, sys -lines = open('data/sessions//.claude/projects/-workspace-group/.jsonl').read().strip().split('\n') -for i, line in enumerate(lines): - try: - d = json.loads(line) - if d.get('type') == 'user' and d.get('message'): - parent = d.get('parentUuid', 'ROOT')[:8] - content = str(d['message'].get('content', ''))[:60] - print(f'L{i+1} parent={parent} {content}') - except: pass -" -``` - -## Container Timeout Investigation - -```bash -# Check for recent timeouts -grep -E 'Container timeout|timed out' logs/nanoclaw.log | tail -10 - -# Check container log files for the timed-out container -ls -lt groups/*/logs/container-*.log | head -10 - -# Read the most recent container log (replace path) -cat groups//logs/container-.log - -# Check if retries were scheduled and what happened -grep -E 'Scheduling retry|retry|Max retries' logs/nanoclaw.log | tail -10 -``` - -## Agent Not Responding - -```bash -# Check if messages are being received from channels -grep 'New messages' logs/nanoclaw.log | tail -10 - -# Check if messages are being processed (container spawned) -grep -E 'Processing messages|Spawning container' logs/nanoclaw.log | tail -10 - -# Check if messages are being piped to active container -grep -E 'Piped messages|sendMessage' logs/nanoclaw.log | tail -10 - -# Check the queue state — any active containers? -grep -E 'Starting container|Container active|concurrency limit' logs/nanoclaw.log | tail -10 - -# Check lastAgentTimestamp vs latest message timestamp -sqlite3 store/messages.db "SELECT chat_jid, MAX(timestamp) as latest FROM messages GROUP BY chat_jid ORDER BY latest DESC LIMIT 5;" -``` - -## Container Mount Issues - -```bash -# Check mount validation logs (shows on container spawn) -grep -E 'Mount validated|Mount.*REJECTED|mount' logs/nanoclaw.log | tail -10 - -# Verify the mount allowlist is readable -cat ~/.config/nanoclaw/mount-allowlist.json - -# Check group's container_config in DB -sqlite3 store/messages.db "SELECT name, container_config FROM registered_groups;" - -# Test-run a container to check mounts (dry run) -# Replace with the group's folder name -docker run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/ -``` - -## Channel Auth Issues - -```bash -# Check if QR code was requested (means auth expired) -grep 'QR\|authentication required\|qr' logs/nanoclaw.log | tail -5 - -# Check auth files exist -ls -la store/auth/ - -# Re-authenticate if needed -pnpm run auth -``` - -## Service Management - -```bash -# Restart the service -launchctl kickstart -k gui/$(id -u)/com.nanoclaw - -# View live logs -tail -f logs/nanoclaw.log - -# Stop the service (careful — running containers are detached, not killed) -launchctl bootout gui/$(id -u)/com.nanoclaw - -# Start the service -launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.plist - -# Rebuild after code changes -pnpm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` diff --git a/docs/README.md b/docs/README.md index bb062e5..da5b6af 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,5 @@ The files in this directory are original design documents and developer referenc | [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) | | [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) | | [skills-as-branches.md](skills-as-branches.md) | [Skills system](https://docs.nanoclaw.dev/integrations/skills-system) | -| [DEBUG_CHECKLIST.md](DEBUG_CHECKLIST.md) | [Troubleshooting](https://docs.nanoclaw.dev/advanced/troubleshooting) | | [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) | | [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) | diff --git a/docs/checklist.md b/docs/checklist.md deleted file mode 100644 index 156feb1..0000000 --- a/docs/checklist.md +++ /dev/null @@ -1,276 +0,0 @@ -# NanoClaw Checklist - -Status: [x] done, [~] partial, [ ] not started - ---- - -## Core Architecture - -- [x] Session DB replaces IPC (messages_in / messages_out as sole IO) -- [x] Central DB (agent groups, messaging groups, sessions, routing) -- [x] Host sweep (stale detection via heartbeat file, retry with backoff, recurrence scheduling) -- [x] Active delivery polling (1s for running sessions) -- [x] Sweep delivery polling (60s across all sessions) -- [x] Container runner with session DB mounting -- [x] Per-session container lifecycle and idle timeout -- [ ] Replace hard Idle and Timeout with work aware prompts to user to kill stuck processes -- [x] Session resume (sessionId + resumeAt across queries) -- [x] Graceful shutdown (SIGTERM/SIGINT handlers) -- [x] Orphan container cleanup on startup - -## Agent Runner (Container) - -- [x] Poll loop (pending messages, status transitions, idle detection) -- [x] Concurrent follow-up polling while agent is thinking -- [x] Message formatter (chat, task, webhook, system kinds) -- [x] Command categorization (admin, filtered, passthrough) -- [x] Transcript archiving (pre-compact hook) -- [x] XML message formatting with sender, timestamp -- [~] Media handling inbound (native files support for claude) - -## Agent Providers - -- [x] Claude provider (Agent SDK, tool allowlist, message stream, session resume) -- [x] Mock provider (testing) -- [x] Provider factory -- [ ] Codex provider -- [x] OpenCode provider - -## Channel Adapters - -- [x] Channel adapter interface (setup, deliver, teardown, typing) -- [x] Chat SDK bridge (generic, works with any Chat SDK adapter) -- [x] Chat SDK SQLite state adapter (KV, subscriptions, locks, lists) -- [x] Discord via Chat SDK -- [~] Slack via Chat SDK (adapter + skill written, not tested) -- [x] Telegram via Chat SDK (E2E verified: inbound, routing, typing, delivery) -- [~] Microsoft Teams via Chat SDK (adapter + skill written, not tested) -- [~] Google Chat via Chat SDK (adapter + skill written, not tested) -- [~] Linear via Chat SDK (adapter + skill written, not tested) -- [~] GitHub via Chat SDK (adapter + skill written, not tested) -- [x] WhatsApp Cloud API via Chat SDK (adapter + skill written, not tested) -- [~] Resend (email) via Chat SDK (adapter + skill written, not tested) -- [~] Matrix via Chat SDK (adapter + skill written, not tested) -- [~] Webex via Chat SDK (adapter + skill written, not tested) -- [~] iMessage via Chat SDK (adapter + skill written, not tested) -- [x] Backward compatibility with native channels (old adapters still work) -- [x] Channel barrel wired (src/index.ts imports barrel, skills uncomment) -- [x] Setup flow wired to channels (channel skills + /manage-channels for registration + verify.ts checks all tokens) -- [x] Channel Info metadata in each channel skill (type, terminology, how-to-find-id, isolation defaults) -- [x] /manage-channels skill (wire channels to agent groups with three isolation levels) -- [x] /init-first-agent skill (standalone first-agent bootstrap; walks the operator through channel pick → identity lookup → DM platform_id resolution → wire → welcome DM; fallback to telegram pair-code or "DM the bot first" lookup for channels without cold DM) -- [x] Cold-DM infrastructure — `ChannelAdapter.openDM?(handle)` optional method, resolved via Chat SDK `chat.openDM` for resolution-required channels (Discord, Slack, Teams, Webex, gChat) and fall-through to the handle directly for direct-addressable channels (Telegram, WhatsApp, iMessage, Matrix, Resend). `src/user-dm.ts::ensureUserDm` caches every resolution in `user_dms` so subsequent cold DMs are a DB read. -- [x] Agent-shared session mode (cross-channel shared sessions, e.g. GitHub + Slack) -- [x] Auto-onboarding on channel registration (/welcome skill triggered on first wiring) -- [ ] Wire different chat modes - mentions, whitelist, approve, etc - -## Chat-First Setup Flow - -**Goal:** get the user out of Claude Code and into their messaging app as quickly as possible, then enable every part of customization, configuration, and setup from inside the chat app. Claude Code is the bootstrap, not the home. - -- [~] Minimum-viable bootstrap in Claude Code: install deps, pick one channel, authenticate it, wire it to a default agent group, hand off — nothing else required before the user can leave Claude Code. `/setup` handles deps/auth, `/init-first-agent` handles the first-agent wiring + welcome DM. Still TODO: single top-level entrypoint that composes both, and a true "nothing else required" handoff (today `/setup` still runs through `/manage-channels` for additional channels). -- [~] Post-handoff welcome message in the chat app guides the user through remaining setup (channels, skills, integrations, memory, scheduling, etc.) — `/init-first-agent` stages a `kind:'chat'` / `sender:'system'` welcome prompt that the agent DMs back to the operator via the normal delivery path. Current prompt just introduces the agent; TODO: expand the prompt (or follow-up flow) to walk through remaining setup tasks from within the chat. -- [ ] Add more channels from chat (currently requires returning to Claude Code to run `/add-*` skills) -- [ ] Self-register agent into a new chat room from chat: user gives the agent a channel/group name + approval, and the agent joins via the underlying adapter (e.g. Baileys for WhatsApp), wires the room to an agent group, and posts a first "hi, I'm here" message — no manual invite, no `/add-*` skill, no terminal -- [ ] Authenticate channels from chat (OAuth/token entry via cards, no terminal required) -- [ ] Add credentials / secrets to the OneCLI vault from chat via rich card (agent collects API keys, OAuth tokens, and other secrets through a card flow and writes them into the vault — no `.env` editing, no terminal) -- [ ] Wire channels to agent groups from chat (today lives in `/manage-channels` Claude Code skill — port to in-chat flow with isolation-level question cards) -- [ ] Create new agent groups from chat (`create_agent` exists — expose via user-facing flow, not just agent-called tool) -- [ ] Edit agent group CLAUDE.md / instructions from chat -- [ ] Install / uninstall / configure skills from chat (see Skills & Marketplace section) -- [ ] Install / configure MCP servers from chat (see Skills & Marketplace section) -- [ ] Install packages from chat (today agent can request install_packages — expose a direct user-facing "install X" flow) -- [ ] Manage scheduled tasks from chat (list, pause, cancel, edit recurrence) -- [ ] Manage destinations from chat (list, rename, revoke) -- [ ] Manage permissions from chat (admin list, role assignment, approval policies) -- [ ] Trigger /setup, /debug, /customize, /migrate-nanoclaw from chat (today all require Claude Code) -- [ ] View and edit memory from chat -- [ ] Visualize current setup from chat (ties into Container Skills: installation diagram) -- [ ] Export / share setup from chat (ties into Container Skills: end-of-setup diagram + share) -- [ ] Fallback to Claude Code only when a change requires a code edit the agent can't self-apply (and even then, agent should offer to open Claude Code on the user's behalf) - -## Product Focus - -**North star:** prioritize skills, flows, and custom setups. Platform work (channels, routing, session DBs, approval flows, MCP tools) is plumbing — it should reach a "boring and reliable" state and then stop absorbing attention. The interesting surface area is what users can *build on top* of that plumbing: skills that add capabilities, conversational flows that orchestrate those skills, and custom per-user setups that compose channels/agents/skills/memory into something personal. - -- [ ] Every new feature request should be answered first with "is this a skill?" before being answered with "is this a platform change?" -- [ ] Skills should be the primary extension mechanism users and agents reach for — adding, removing, browsing, editing, debugging -- [ ] Flows (multi-step interactive sequences: setup, onboarding, migration, customize, debug) should be authorable as skills rather than hardcoded into the platform -- [ ] Custom setups (diverging from defaults: multiple agents, cross-channel routing, per-group memory, specialist sub-agents) should be composable from existing primitives without touching core platform code -- [ ] Platform-level work gets budgeted against the question: "does this unblock a class of skills/flows/setups that's otherwise impossible?" - -## Routing - -- [x] Inbound routing (platform ID + thread ID -> agent group -> session) -- [x] Auto-create messaging group on first message -- [x] Session resolution (shared vs per-thread modes) -- [x] Message writing to session DB with seq numbering -- [x] Container waking on new message -- [x] Typing indicator triggered on message route -- [~] Trigger rule matching (router picks highest-priority agent, regex/mention matching TODO) - -## Rich Messaging - -- [x] Interactive cards with buttons (ask_user_question) -- [x] Native platform rendering (Discord embeds, buttons) -- [x] Message editing -- [x] Emoji reactions -- [x] File sending from agent (outbox -> delivery) -- [x] File upload delivery (buffer-based via adapter) -- [x] Markdown formatting -- [~] Formatted /usage, /context, /cost output (commands pass through, no rich card formatting) -- [ ] Context window visibility: show position in context, approaching compaction, when compaction happens, post-compaction state -- [ ] Threading and replies support -- [ ] Auto-compact on idle before cache expires - -## MCP Tools (Container) - -- [x] send_message (routes via named destinations; `to` field resolved against agent's local map) -- [x] send_file (copy to outbox, write messages_out) -- [x] edit_message (routed via destinations) -- [x] add_reaction (routed via destinations) -- [x] send_card -- [x] ask_user_question (blocking poll for response) -- [x] schedule_task (with process_after and recurrence) -- [x] list_tasks -- [x] cancel_task / pause_task / resume_task -- [x] create_agent (any agent, creates agent group + folder + bidirectional destinations; host re-normalizes the name, deduplicates folder, path-traversal guarded) -- [x] install_packages (apt/npm, owner/admin approval required via `pickApprover`, strict name validation; single approval step covers the image rebuild + container restart) -- [x] add_mcp_server (owner/admin approval required via `pickApprover`; approval triggers container restart, no image rebuild needed — bun runs TS directly) - -## Scheduling - -- [x] One-shot scheduled messages (process_after / deliver_after) -- [x] Recurring tasks via cron expressions -- [x] Host sweep picks up due messages and advances recurrence -- [x] Scheduled outbound messages (no container wake needed) -- [ ] Pre-agent scripts (formatter references scriptOutput but no execution logic) - -## Permissions and Approval Flows - -- [x] User-level privilege model — `users` + `user_roles` (owner / admin, global or scoped to an agent group). Replaces the old `agent_groups.is_admin` / `messaging_groups.admin_user_id` coupling. See `src/modules/permissions/db/users.ts`, `src/modules/permissions/db/user-roles.ts`, `src/modules/permissions/access.ts`. -- [x] Admin-only command filtering — gate runs host-side in `src/command-gate.ts`, querying `user_roles` directly. The container receives no admin identity (no env var, no fallback). -- [x] Approval routing — `pickApprover` (scoped admin → global admin → owner, dedup) + `pickApprovalDelivery` (first reachable, same-channel-kind tie-break); delivery lands in the approver's DM via `ensureUserDm` / `user_dms` cache. See `src/modules/approvals/primitive.ts`, `src/modules/approvals/onecli-approvals.ts`. -- [x] Per-messaging-group unknown-sender gating — `messaging_groups.unknown_sender_policy` (`strict` | `request_approval` | `public`), enforced in `src/router.ts`. -- [x] Approval flow (sensitive action -> card to admin -> approve/reject -> execute) — `pending_approvals` table, `requestApproval()` helper, reuses interactive card infra -- [x] Agent requests dependency/package install (install_packages, admin approval, rebuild on approval) -- [x] Self-modification — direct tools: - - [x] install_packages (apt/npm, admin approval, name validation both sides, max 20 per request; on approve → handler rebuilds the image, kills the container, schedules a verify-and-report follow-up prompt) - - [x] add_mcp_server (admin approval; on approve → handler updates `container.json`, kills the container — no image rebuild) - - [x] Fire-and-forget model (write request, return immediately; chat notification on approval; container killed so next wake picks up new config/image) -- [~] OneCLI integration for human-loop approvals on credentialed requests (agent touching a credentialed resource → OneCLI gates → approval card to admin → OneCLI releases credential) — SDK 0.3.1 `configureManualApproval` wired into host, routes to admin via existing `pending_approvals` infra -- [ ] Tunneled OneCLI dashboard for credential addition (Telegram Mini Apps aside, iMessage without Apple Business Register, Matrix, email). Signed short-lived URL → browser form served by OneCLI at 10254 → tunnel via cloudflare durable object. Value never touches the chat surface. -- [ ] Self-modification via direct source edits — planned draft/activate flow: RO baseline mount at `/app/src`, RW draft at `/workspace/src-draft`, atomic snapshot into `pending`, admin approval, `cp -a` into baseline, restart + deadman rollback. Unifies runner src, host src, migrations, package.json, container config through one edit path. Collapses the abandoned `create_dev_agent`/`request_swap` dev-agent-in-worktree approach. - -## Named Destinations + ACL - -- [x] `agent_destinations` table (agent_group_id, local_name, target_type, target_id) — migration 004 -- [x] Per-agent local-name routing map (channels and peer agents referenced by local names) -- [x] Destinations stored in inbound.db `destinations` table (moved from JSON file in `b591d7c`) — single source of truth, no separate file -- [x] Host writes the destination map into inbound.db before every container wake; container queries it live on every lookup so admin changes take effect mid-session -- [x] Container loads map at startup, appends system-prompt addendum listing destinations + `` syntax -- [x] Agent main output parsed for `` blocks; `...` treated as scratchpad -- [x] Host re-validates every outbound route via `hasDestination()` — unauthorized drops logged -- [x] Inbound formatter adds `from="name"` via reverse-lookup (consistent namespace both directions) -- [x] Single-destination shortcut — agents with one destination don't need `` wrapping -- [x] Backfill from existing `messaging_group_agents` on migration -- [x] Removed `NANOCLAW_PLATFORM_ID` / `CHANNEL_TYPE` / `THREAD_ID` env-var routing entirely - -## Agent-to-Agent Communication - -- [x] Host delivery to target agent's session DB (`channel_type='agent'` routing in `src/delivery.ts`) -- [x] Agent spawning a new sub-agent (`create_agent` MCP tool, available to any agent, path-traversal guarded) -- [x] Dynamic agent group creation (folder + optional CLAUDE.md at runtime) -- [x] Internal-only agents (agents created without a channel attached) -- [x] Permission delegation from parent to child (bidirectional destination rows inserted at creation) -- [x] Bidirectional routing via inherited routing context; sender info enriched on the target side -- [ ] Specialist sub-agents (browser agent, dev agent — user's agent delegates with request/approval) -- [ ] Browser agent with per-destination permissions between main agent and browser agent (main requests navigation/interaction; browser agent executes in isolated container) -- [ ] Sanitization of browser agent responses before handing back to main agent (strip scripts, inline images, untrusted HTML; prevent prompt injection from web content) -- [ ] Same permission + sanitization model for any sub-agent that accesses sensitive data sources (files, DBs, third-party APIs) - -## In-Chat Agent Management - -- [x] /clear (resets session) -- [x] /compact (triggers context compaction) -- [~] /context (passes through, no rich formatting) -- [~] /usage (passes through, no rich formatting) -- [~] /cost (passes through, no rich formatting) -- [ ] Smooth session transitions: load context into new sessions, solve cold start problem -- [x] MCP/package installation from chat -- [ ] Browse MCP marketplace / skills repository from chat - -## Skills & Marketplace - -- [ ] Install skills from chat (agent requests, admin approves, skill dropped into container skills dir) -- [ ] Scan skills before install (lint SKILL.md, sandbox-check shell commands, require approval for network/FS-heavy skills) -- [ ] Scan marketplace npm packages before install (supply-chain check, typo-squat detection, known-bad list) -- [ ] MCP server marketplace — discover, preview, install -- [ ] Browse skills / MCP marketplace from chat (cards with search, preview, install) -- [ ] Local voice transcription skill — "just works" install flow: when the user sends a voice message and no transcription backend is installed, the agent asks once ("Install local voice transcription?"), and on approval the skill installs a fully-local speech-to-text model (no cloud calls). Subsequent voice messages transcribe automatically. -- [ ] Fully local NanoClaw — OpenCode + Gemma 4 as the agent provider instead of Claude Code, so an entire install can run with zero cloud inference. Requires wiring OpenCode as an agent provider (see Agent Providers) and a setup path that picks local models, pulls weights, and verifies everything runs offline. - -## Container Skills - -Container skills live inside agent containers at runtime (`container/skills/`) and are loaded into every agent session. These are distinct from feature/operational skills that ship with the host. - -- [ ] Customize container skill — agent-driven customization flow (add channel, integration, behavior change) usable from inside any agent session, not just the main repo -- [ ] Debug container skill — inspect logs, session DB, MCP server state, container env, recent errors from inside the agent -- [ ] Build-system container skills: - - [ ] Karpathy LLM Wiki builder (agent scaffolds a persistent wiki knowledge base for a group) - - [ ] Generic build-system framework for agent-authored sub-systems -- [ ] NanoClaw installation diagram skill — agent generates a visual diagram of the user's current setup (agent groups, channels, wirings, destinations, sub-agents, installed packages/MCP servers) -- [ ] Video replay skill — generate Remotion (or similar) videos that replay chat flows and sessions, referencing good UI patterns to produce shareable clips -- [ ] Excitement trigger skill — detects when the user expresses excitement about the agent's capabilities or their setup, and proactively encourages generating a diagram + sharing it -- [ ] End-of-migration diagram skill — at the end of `/migrate-nanoclaw` (or any migration flow), agent generates a visual diagram of the resulting setup and suggests sharing -- [ ] End-of-setup diagram skill — at the end of first-time `/setup`, agent generates a visual diagram and suggests sharing (merges the old "Generate visual diagram of customized instance at end of setup" line from Channel Adapters) - -## Webhook Ingestion - -- [ ] Generic webhook endpoint for external events -- [ ] GitHub webhook handling -- [ ] CI/CD notification handling -- [ ] Webhook -> messages_in routing - -## System Actions - -- [ ] register_group from inside agent -- [ ] reset_session from inside agent -- [ ] Delivery failures should round-trip back to the agent as system messages so it can decide how to recover (retry as plain text, simplify, give up), with a hard retry cap + poison-pill backstop in delivery.ts to keep the queue healthy - -## Integrations - -- [x] Vercel CLI integration in setup process -- [x] Skills for deploying and managing Vercel websites from chat -- [ ] Office 365 integration (create/edit documents with inline suggestions) - -## Memory - -- [ ] Shared memory with approval flow (write to global memory requires admin approval) -- [ ] Agent memory system skills — skills for building and managing memory systems for an agent: archive/index large collections of files and data, then expose a memory interface the agent can query and update (e.g. QMD-style systems) - -## Migration - -- [ ] Custom skill/code porting -- [ ] OneCLI migration check — determine if existing installs need OneCLI re-init (credentials re-scoped to new `agent_group.id` identifier, new SDK version, approval handler registered). If needed, add a migration step to `/update-nanoclaw` or a dedicated skill. - -## Testing - -- [x] DB layer tests (agent groups, messaging groups, sessions, pending questions) -- [x] Channel registry tests -- [x] Poll loop / formatter tests -- [x] Integration test (container agent-runner) -- [x] Host core tests -- [ ] End-to-end flow tests (message in -> agent -> message out -> delivery) -- [ ] Delivery polling tests -- [ ] Host sweep tests (stale detection, recurrence) -- [ ] Multi-channel integration tests - -## Rollout - -- [ ] Internal testing across all channels -- [ ] Migration skill built and tested -- [ ] PR factory migrated as validation -- [ ] Blog post / announcement -- [ ] Video demos of key flows -- [ ] Vercel coordination diff --git a/docs/claude-md-composition.md b/docs/claude-md-composition.md deleted file mode 100644 index b3ce08f..0000000 --- a/docs/claude-md-composition.md +++ /dev/null @@ -1,146 +0,0 @@ -# CLAUDE.md Composition - -Compose agent instructions from a shared base, skill/tool fragments, and per-group memory — replacing the current per-group CLAUDE.md with a host-regenerated entry point. - -## Problem - -Today each agent group has a single RW `groups//CLAUDE.md`, written once at init and never updated. Consequences: - -- Upstream improvements to shared agent guidance don't propagate to existing groups -- No way to ship tool-specific guidance with the tool itself (e.g., an agent-browser usage fragment) -- Human-authored identity and agent-accumulated memory live in the same file with no separation -- The `.claude-global.md` symlink + `groups/global/CLAUDE.md` pattern handled the shared base but not per-module fragments - -## Design - -**Principle: RW = per-group memory, RO = shared content.** Same rule that governs the shared-source refactor, applied to agent instructions. - -### Three tiers - -| Tier | File | Location | Mount | Editor | Change rate | -|---|---|---|---|---|---| -| **Shared base** | `CLAUDE.md` | `container/CLAUDE.md` | RO at `/app/CLAUDE.md` | Owner (via git) | Rare | -| **Module fragments** | `instructions.md` | Inside each module | RO via shared skills mount, or inline in `container.json` | Module author | Ships with module | -| **Per-group memory** | `CLAUDE.local.md` | `groups//` | RW at `/workspace/agent/` | Agent + owner | Continuous | -| **Composed entry** | `CLAUDE.md` | `groups//` | RW but host-regenerated | **Host, not human** | Every spawn | - -### Composition - -At every spawn, the host regenerates `groups//CLAUDE.md` as an import-only file: - -```markdown - -@./.claude-shared.md -@./.claude-fragments/welcome.md -@./.claude-fragments/agent-browser.md -@./.claude-fragments/.md -@./.claude-fragments/mcp-.md -``` - -Symlinks are created alongside, following the `.claude-global.md` pattern (dangling on host, valid in container via the RO mount): - -- `groups//.claude-shared.md` → `/app/CLAUDE.md` -- `groups//.claude-fragments/.md` → `/app/skills//instructions.md` (for each enabled skill that ships a fragment) - -Claude Code auto-loads `CLAUDE.local.md` from cwd without an import line — native behavior. Agent memory works natively; composition only wraps around it. - -### Module fragment contract - -**Skills.** A skill optionally ships an `instructions.md` at the top of its directory: - -``` -container/skills/welcome/ - SKILL.md — description + when-to-use (existing) - instructions.md — always-in-context guidance (optional, new) -``` - -When the skill is enabled for a group, the host imports `instructions.md` into the composed CLAUDE.md. `SKILL.md` semantics are unchanged — Claude Code still uses it for skill discovery and on-demand invocation. Most skills won't need an `instructions.md` (SKILL.md is sufficient for on-demand skills); it's only for guidance that should be in context at all times. - -**MCP servers.** A `container.json` MCP server entry can contribute a fragment inline: - -```jsonc -{ - "mcpServers": { - "my-db": { - "command": "...", - "instructions": "Read-only access to the production DB. Never run UPDATE/DELETE without admin approval." - } - } -} -``` - -Host writes the inline content to `.claude-fragments/mcp-.md` at spawn and imports it. - -**Global CLIs baked into the image** (agent-browser, vercel, claude-code) have always-present guidance; it belongs in `container/CLAUDE.md`, not as a conditional fragment. Don't try to make universally-present tools dynamic. - -### Identity vs memory - -All per-group content — human-authored identity ("you are the research agent, be terse") and agent-accumulated memory (inventories, user preferences, learned patterns) — lives in a single `CLAUDE.local.md`. Both humans and agents can edit it. - -If the distinction becomes operationally important later (agents confused about what they were told vs. what they learned), split into `identity.md` (human-authored, imported into composed CLAUDE.md) + `CLAUDE.local.md` (agent memory only). Starting with one file. - -## Changes - -### `container/CLAUDE.md` (new) - -Write the shared base: general NanoClaw context, how to engage with users, output conventions, anything that should apply to every agent across every group. Seed from current `groups/global/CLAUDE.md`. - -### `container/skills//instructions.md` (optional, per skill) - -Add for any skill that warrants always-in-context guidance. Optional. - -### `container.json` schema - -Add optional `instructions` field (string) to each MCP server entry. - -### `container-runner.ts` spawn-time sync - -Extend the skill-symlink sync function (added in the shared-source refactor) to also compose CLAUDE.md. On every spawn: - -1. Sync `.claude-shared/skills/` symlinks from `container.json` skill selection. -2. Sync `.claude-shared.md` symlink → `/app/CLAUDE.md`. -3. For each enabled skill with an `instructions.md`, create `.claude-fragments/.md` symlink → `/app/skills//instructions.md`. -4. For each `container.json` MCP server with an `instructions` field, write the inline content to `.claude-fragments/mcp-.md`. -5. Write `groups//CLAUDE.md` atomically (temp + rename) with import lines in a deterministic order: shared base → skill fragments (alphabetical) → MCP fragments (alphabetical). -6. Remove stale symlinks and fragment files for modules no longer enabled. - -### `group-init.ts` - -- Stop writing an initial `groups//CLAUDE.md` at group creation — host regenerates at first spawn. -- Stop creating the `.claude-global.md` symlink — replaced by `.claude-shared.md` in the composition step. -- Optionally create an empty `groups//CLAUDE.local.md` at init as a clear affordance for humans and agents. - -### `groups/global/` - -Eliminate. The shared base moves to `container/CLAUDE.md`. Any deployment-specific overrides live in the owner's customized `container/CLAUDE.md` (same pattern as any other codebase customization). - -## Migration - -Breaking change, one-time cutover: - -- For every group, rename `groups//CLAUDE.md` → `groups//CLAUDE.local.md`. Preserves all existing per-group content as memory. -- Move content from `groups/global/CLAUDE.md` (beyond the default stub) into `container/CLAUDE.md`. Delete `groups/global/`. -- Delete stale `.claude-global.md` symlinks in each group dir — the spawn pass creates `.claude-shared.md` instead. -- First spawn after cutover regenerates `CLAUDE.md` with proper imports. - -## Interaction with shared-source refactor - -This refactor depends on the shared skills mount (`/app/skills/` RO) from the shared-source refactor landing first. It extends the spawn-time sync from "just skill symlinks" to "skill symlinks + CLAUDE.md composition" — both passes share the same helper. - -After this refactor, the "Personality / instructions" row in the shared-source per-group customization table splits: - -| Resource | Location | Mechanism | -|----------|----------|-----------| -| Agent memory | `groups//CLAUDE.local.md` | RW at `/workspace/agent/`, auto-loaded by Claude Code | -| Composed entry | `groups//CLAUDE.md` | Host-regenerated at every spawn | - -## What triggers what - -| Change | Action | Scope | -|--------|--------|-------| -| Edit `container/CLAUDE.md` | Kill running containers (next spawn recomposes) | All groups | -| Add/edit a skill's `instructions.md` | Kill running containers | All groups with the skill enabled | -| Enable/disable a skill in `container.json` | Kill that group's containers | One group | -| Add MCP server with `instructions` field | Kill that group's containers | One group | -| Edit `CLAUDE.local.md` | Nothing — live via RW mount; Claude Code re-reads at next prompt | One group | -| Add a new agent group | Spawn writes `CLAUDE.md` fresh from the composition pass | One group | diff --git a/docs/module-contract.md b/docs/module-contract.md deleted file mode 100644 index 04919b9..0000000 --- a/docs/module-contract.md +++ /dev/null @@ -1,221 +0,0 @@ -# Module Contract - -This doc is the authoritative reference for how core and modules connect. Everything downstream — extraction PRs, install skills, module authors — keys off these signatures and defaults. See [REFACTOR_PLAN.md](../REFACTOR_PLAN.md) for the broader plan; this doc is the narrow interface spec. - -## Principles - -- Core runs standalone (modulo default modules — see tiers below). The optional-module portion of the `src/modules/index.ts` barrel can be empty and NanoClaw still routes messages in and delivers responses out. -- Optional modules are independent. No optional module imports from another optional module. Cross-module coordination goes through a core registry (delivery action, response handler, etc.). -- Registries exist only when multiple modules plug into the same decision point. Single-consumer integrations use skill edits (`MODULE-HOOK` markers) or stay inline with `sqlite_master` guards. -- Removing an optional module = delete files + remove barrel imports + revert any `MODULE-HOOK` content. Migration files stay (data is preserved). Removing a default module is more invasive: it requires editing the core files that import from it. - -## Module taxonomy - -Three categories. All three live under `src/modules/` (or equivalent adapter dirs) with the same folder layout; the distinction is about **shipping** and **who can depend on them**. - -### 1. Default modules - -Ship with `main` in `src/modules/`. Imported by the default `src/modules/index.ts` barrel from day one. They are not really core — they live under `src/modules/` specifically to signal "not really core, rippable if needed" — but they're always present on a `main` install. Core imports from them directly. No hook, no registry indirection for the exports themselves. - -Current: `typing`, `mount-security`. - -### 2. Optional modules - -Live on the `modules` branch. Installed via `/add-` skills that cherry-pick files. Register into core via one of the four registries (or `MODULE-HOOK` skill edits). Core and other optional modules must not statically import an optional module's code. - -Current: `interactive`, `approvals`, `scheduling`, `permissions`. Pending: `agent-to-agent`. - -### 3. Channel adapters - -Live on the `channels` branch, installed via `/add-` skills. Not covered by this contract; they use the pre-existing `ChannelAdapter` interface and `registerChannelAdapter()`. - -## Dependency rule - -``` -core ← default modules ← optional modules -``` - -- **Core** may import from core and from default modules. -- **Default modules** may import from core and from other default modules. They must not import from optional modules. -- **Optional modules** may import from core and from default modules. They must not import from each other. - -Peer-to-peer coupling between optional modules goes through a core registry — see "The four registries" below. This keeps the module dependency graph a DAG and install order irrelevant. - -### Known transitional violations - -- `src/access.ts` (core) imports from `src/modules/permissions/` (optional). Shim left from PR #5; resolved in the planned approvals re-tier (PR #7) which moves approver-picking into a new default `approvals-primitive` module that may then depend on permissions however it likes — at which point `src/access.ts` ceases to exist. - -## The four registries - -Each registry has an explicit default for when no module registers. Core must run when all four are empty. - -### 1. Delivery action handlers - -```typescript -// src/delivery.ts -type ActionHandler = ( - content: Record, - session: Session, - inDb: Database.Database, -) => Promise; - -export function registerDeliveryAction(action: string, handler: ActionHandler): void; -``` - -**Purpose:** system-kind outbound messages (`msg.kind === 'system'`) carry an `action` string. Core dispatches to the registered handler. - -**Default when action is unknown:** log `"Unknown system action"` at `warn` and return. Message is still marked delivered (it was consumed by the host, not sent to a channel). - -**Current consumers:** scheduling (5 actions — `schedule_task`, `cancel_task`, `pause_task`, `resume_task`, `update_task`), approvals (2 actions — `install_packages`, `add_mcp_server`), agent-to-agent (`create_agent`, and the agent-routing branch keyed as a pseudo-action `agent_route`). - -### 2. Router sender resolver + access gate - -Two separate setters, called at different points in `routeInbound`. Preserves the pre-refactor ordering: sender-upsert side effects fire even when the message is ultimately dropped by wiring or trigger rules. - -```typescript -// src/router.ts -type SenderResolverFn = (event: InboundEvent) => string | null; - -export function setSenderResolver(fn: SenderResolverFn): void; - -type AccessGateResult = - | { allowed: true } - | { allowed: false; reason: string }; - -type AccessGateFn = ( - event: InboundEvent, - userId: string | null, - mg: MessagingGroup, - agentGroupId: string, -) => AccessGateResult; - -export function setAccessGate(fn: AccessGateFn): void; -``` - -**Call order in `routeInbound`:** -1. Resolve messaging group. -2. **Sender resolver** (if set). Permissions upserts the users row here so the record exists even if agent resolution drops the message. -3. Resolve wired agents; `no_agent_wired` → record + drop. (Core writes the dropped_messages row.) -4. Pick agent by trigger rules; `no_trigger_match` → record + drop. -5. **Access gate** (if set). On refusal it writes its own `dropped_messages` row keyed by policy reason. - -**Defaults when unset:** resolver returns null; gate defaults to `{ allowed: true }`. Every message routes through, no users table is needed, downstream tolerates `userId=null`. - -**Current consumer:** permissions module (registers both). - -**Not registries, setters.** There is one sender and one access decision per inbound message and one module that owns both. Calling `setSenderResolver` / `setAccessGate` twice overwrites; core does not iterate. - -### 3. Response dispatcher - -```typescript -// src/index.ts (or src/response-dispatch.ts if it grows) -interface ResponsePayload { - questionId: string; - value: string; - userId: string | null; - channelType: string; - platformId: string; - threadId: string | null; -} - -type ResponseHandler = (payload: ResponsePayload) => Promise; - -export function registerResponseHandler(handler: ResponseHandler): void; -``` - -**Purpose:** button-click / question responses arrive via the channel adapter's `onAction` callback. Core iterates registered handlers in registration order. The first one that returns `true` claims the response. - -**Default when empty:** log `"Unclaimed response"` at `warn` and drop. - -**Current consumers:** interactive (matches `pending_questions`), approvals (matches `pending_approvals`). The two tables have disjoint `question_id` / `approval_id` namespaces in practice (`q-*` vs `appr-*`), so first-match-wins is safe. - -### 4. Container MCP tool self-registration - -```typescript -// container/agent-runner/src/mcp-tools/server.ts -export function registerTools(tools: McpToolDefinition[]): void; -``` - -**Purpose:** each tool module calls `registerTools([...])` at import time. The MCP server uses whatever was registered. - -**Default:** only `mcp-tools/core.ts` (`send_message`) registered. - -**Current consumers:** all container-side modules (scheduling, interactive, agents, self-mod). - -## Skill edits to core - -For one-off integrations with a single consumer, install skills edit core directly between `MODULE-HOOK` markers. No registry. - -Marker format: - -```typescript -// MODULE-HOOK:-:start -// MODULE-HOOK:-:end -``` - -The skill inserts between markers on install and clears between them on uninstall. Markers live in core from day one (empty until a skill fills them). - -**Current uses:** - -- `src/host-sweep.ts` → `MODULE-HOOK:scheduling-recurrence` — call to scheduling module's `handleRecurrence`. -- `container/agent-runner/src/poll-loop.ts` → `MODULE-HOOK:scheduling-pre-task` — call to scheduling module's `applyPreTaskScripts`. - -**Promotion rule:** if a third consumer appears for any marker, promote to a registry. - -## Guarded inline (core) - -Some code stays in core but references module-owned tables. These use `sqlite_master` checks to degrade cleanly when the owning module isn't installed. - -| Site | Owning module | Fallback | -|------|---------------|----------| -| `container-runner.ts` admin-ID query (`user_roles`, `agent_group_members`) | permissions | returns `[]` | -| `container-runner.ts` `writeDestinations` (`agent_destinations`) | agent-to-agent | no-op | -| `delivery.ts` channel-permission check (`agent_destinations`) | agent-to-agent | permit (origin-chat always OK) | -| `delivery.ts` `createPendingQuestion` (`pending_questions`) | interactive | no-op (log warning) | - -Container-side admin gating no longer exists. Admin authorization is now performed host-side in `src/command-gate.ts`, which queries `user_roles` directly — no env var is passed to the container, and no agent-runner fallback exists. - -## Migrations - -All migrations live in `src/db/migrations/` as TypeScript files exporting a `Migration` object: - -```typescript -export interface Migration { - version: number; - name: string; - up: (db: Database.Database) => void; -} -``` - -The barrel `src/db/migrations/index.ts` imports each and lists them in an ordered array. - -**Uniqueness key is `name`, not `version`.** The migrator applies any migration whose `name` isn't in `schema_version`. Version stays as an ordering hint; integer collisions across modules are allowed. - -**Module migration naming:** - -- File: `src/db/migrations/module--.ts` -- `Migration.name`: `'-'` (e.g. `'approvals-pending-approvals'`) - -**Uninstall behavior:** migration files and barrel entries stay. Tables persist across reinstalls. No down migrations. - -## What a registry-based module provides - -Each `src/modules//` module must supply: - -- `index.ts` — imported by `src/modules/index.ts` for side-effect registration (calls `registerDeliveryAction` / `setInboundGate` / `registerResponseHandler` at module load time). -- `project.md` — appended to project `CLAUDE.md` by the install skill. Describes module architecture for anyone reading the codebase. -- `agent.md` — appended to `groups/global/CLAUDE.md` by the install skill. Describes the module's tools for the agent. -- Migration file in `src/db/migrations/` if the module owns any tables. -- Barrel entry in `src/db/migrations/index.ts` for that migration. - -Optionally: - -- Container-side additions to `container/agent-runner/src/mcp-tools/.ts` that call `registerTools([...])`, with a barrel entry in `container/agent-runner/src/mcp-tools/index.ts`. -- `MODULE-HOOK` edits to specific core files, applied by the install skill. - -## What a module must not do - -- Import from another module. -- Write to core-owned tables (`sessions`, `agent_groups`, `messaging_groups`, `schema_version`, etc.) outside of migrations. -- Depend on a specific channel adapter being installed. -- Break core behavior when unloaded. If a module's absence leaves a core feature non-functional, that feature belongs in core, not the module. diff --git a/docs/shared-source.md b/docs/shared-source.md deleted file mode 100644 index 95ea94d..0000000 --- a/docs/shared-source.md +++ /dev/null @@ -1,270 +0,0 @@ -# Shared Source - -Replace per-group agent-runner-src copies with a single shared read-only mount. - -## Problem - -Each agent group gets a full copy of `container/agent-runner/src/` at creation time. This copy is mounted RW at `/app/src` in the container. Consequences: - -- Bug fixes and features don't propagate to existing groups -- Owner edits to `container/agent-runner/src/` silently don't apply to existing groups -- No tooling to diff or detect drift between groups and upstream -- The RW mount lets agents write to their own runtime source without approval -- Cross-cutting changes (host + container) break down when container code is per-group -- Skills have the same copy-and-drift problem - -## Design - -**Principle: RW is per-group, RO is shared.** Every mount is either read-only and shared across all groups, or read-write and scoped to one group. Source and skills become RO + shared. Personality, config, working files, and Claude state stay RW + per-group. This makes drift impossible by construction — no group can diverge from shared code because no group has write access to it. - -### Shared source mount - -Mount `container/agent-runner/src/` into all containers at `/app/src` as **read-only**. - -``` -container/agent-runner/src/ → /app/src (RO, shared) -``` - -Source is never baked into the image. `/app/src/` exists only via this mount — running without it is an intentional startup failure (entrypoint `bun run /app/src/index.ts` → ENOENT). Source-only changes never trigger image rebuilds; edits to `.ts` files take effect on next container spawn. - -Image rebuilds are only needed for: -- Agent-runner npm dependency changes (`package.json` / `bun.lock`) -- System packages, runtime versions, global CLI version bumps -- Dockerfile/entrypoint changes - -### Shared skills mount - -Mount `container/skills/` into all containers at `/app/skills/` as **read-only**. - -Per-group skill selection via `container.json`: - -```jsonc -{ - "skills": ["welcome", "agent-browser", "self-customize"] - // or "skills": "all" (default) -} -``` - -At every spawn, the host syncs symlinks in the group's `.claude-shared/skills/` directory to match the selected set. For `"all"`, the set is recomputed from the shared skills dir on each spawn — newly-added upstream skills appear without intervention. Symlinks for skills no longer in the set are removed. - -Each symlink points to a container path: - -``` -.claude-shared/skills/welcome → /app/skills/welcome -.claude-shared/skills/agent-browser → /app/skills/agent-browser -``` - -Claude Code scans `/home/node/.claude/skills/`, follows the symlinks, loads the selected skills. Same dangling-symlink-on-host pattern as `.claude-global.md` — host tools don't resolve the target, the container mount makes it valid at read time. - -### Per-group customization surface - -What remains per-group (unchanged): - -| Resource | Location | Mechanism | -|----------|----------|-----------| -| Personality / instructions | `groups//CLAUDE.md` | Mount at `/workspace/agent` (RW, live) | -| MCP servers | `groups//container.json` | Env var at spawn | -| apt/npm packages | `groups//container.json` | Per-group image layer | -| Skill selection | `groups//container.json` | Symlinks at spawn | -| Additional mounts | `groups//container.json` | Validated bind mounts | -| Agent provider / model | `groups//container.json` | Read by runner at startup | -| Claude Code settings | `.claude-shared/settings.json` | Mount at `/home/node/.claude` (RW) | -| Working files | `groups//` | Mount at `/workspace/agent` (RW) | - -### Self-modification - -Existing config-level self-mod tools (`install_packages`, `add_mcp_server`) mutate `container.json` and per-group images, not source. Unchanged — stays per-group. - -Source-level self-modification (not yet implemented) uses staging: edits happen against a copy of `container/agent-runner/src/`, reviewed and swapped in on approval. Owner can also edit source directly. - -## Environment variables - -Env is for things read by code we don't own: glibc, Node's http agent, CLIs we shell out to. Everything NanoClaw-specific moves out of env. - -**Stays in env (read by non-nanoclaw code):** - -| Var | Reader | -|---|---| -| `TZ` | glibc, child processes | -| `HTTPS_PROXY`, `NO_PROXY` | Node http agent, curl, git, etc. (OneCLI-injected) | -| `NODE_EXTRA_CA_CERTS` | Node at startup (OneCLI-injected) | - -**Moves to `container.json` (read by runner at startup):** - -| Var | Reason | -|---|---| -| `AGENT_PROVIDER` | Per-group config; runner reads before importing provider module | -| `NANOCLAW_AGENT_GROUP_NAME` | Per-group identity | -| `NANOCLAW_ASSISTANT_NAME` | Per-group identity | -| `NANOCLAW_MAX_MESSAGES_PER_PROMPT` | Config constant; per-group override possible | - -**Deleted (admin gating moves to router):** - -`NANOCLAW_ADMIN_USER_IDS` is removed entirely — not moved to a new location. The container no longer makes authorization decisions. See **Router command gate** below. - -**Hardcoded as conventions:** - -| Var | Convention | -|---|---| -| `SESSION_INBOUND_DB_PATH` | `/workspace/inbound.db` | -| `SESSION_OUTBOUND_DB_PATH` | `/workspace/outbound.db` | -| `SESSION_HEARTBEAT_PATH` | `/workspace/.heartbeat` | -| `NANOCLAW_AGENT_GROUP_ID` | Read from `/workspace/agent/container.json` at startup | - -### Runner startup order - -The runner can no longer assume DB paths or provider identity are handed to it in env. Revised startup: - -1. Set up logging. -2. Read `/workspace/agent/container.json` (mounted RW but read-only here). -3. Open `/workspace/inbound.db` and `/workspace/outbound.db` (fixed paths). -4. Read bootstrap tables from `inbound.db` (destinations). -5. Import the provider module selected by `container.json`. -6. Enter the poll loop. - -### Router command gate - -The host router gates slash commands before writing to `messages_in`. The container still handles whatever reaches it; it just stops making authorization decisions. - -1. **Filtered commands** (`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/start`, `/remote-control`) → drop silently. Never reach the container. -2. **Admin commands** (`/clear`, `/compact`, `/context`, `/cost`, `/files`) → check sender against `user_roles` (owners + global admins + admins scoped to this agent group). - - Denied: write "Permission denied: `` requires admin access." directly to `messages_out` in the same thread. Do not write to `messages_in`. - - Allowed: pass through to container unchanged. -3. **Normal messages** → pass through unchanged. - -Admin commands that flow through continue to be handled the same way they are today: -- `/clear` — container's existing handler in `poll-loop.ts` resets session continuation and writes "Session cleared." -- `/compact`, `/context`, `/cost`, `/files` — container forwards them to Claude Code's native slash-command handler. - -Container receives only authorized messages. The runner has no admin concept, no `adminUserIds` field, no admin-gate branch — but it still recognizes `/clear` to reset session state. - -### Scope rules - -Each channel answers a single scope question: - -| Channel | Scope | What it holds | -|---|---|---| -| Env vars | Process | Things read by code we don't own (`TZ`, `HTTPS_PROXY`) | -| `container.json` | Per-group | Per-group config (MCP, packages, provider, model, skills, mounts) | -| `inbound.db` / `outbound.db` | Per-session | Messages, session state, and host-projected views of cross-group state (destinations) | -| Central DB (`data/v2.db`) | Cross-group | Users, roles, wiring, messaging groups, sessions | - -The runner reads from env (for external-convention vars), `container.json` (for its own group's config), and `inbound.db` (for messages + projected views). It never reads central DB directly — that's always host-projected through inbound.db first. - -After this change, the spawn-time `-e` flags shrink from ~10 to ~3-5 (TZ + OneCLI networking). No `NANOCLAW_*` env var survives. - -## Image layer strategy - -Single Dockerfile with aggressive layer ordering: stable layers first, frequently-bumped layers last. BuildKit's layer cache handles "upstream layers unchanged" rebuilds efficiently — a separate base image isn't justified. - -Two image tags exist at runtime: - -``` -nanoclaw-agent:latest — shared base (rebuild: dep/CLI bumps + Dockerfile changes) - └── nanoclaw-agent: — per-group apt/npm packages (rebuild: per-group via install_packages) -``` - -Layer order within the base: - -```dockerfile -FROM node:22-slim - -# System deps (apt) — rarely change -RUN apt-get install ... - -# Bun — pinned version, rarely changes -RUN ... bun - -# Agent-runner deps — cached independently of CLI versions -COPY agent-runner/package.json agent-runner/bun.lock /app/ -RUN cd /app && bun install --frozen-lockfile - -# Global CLIs — most stable first, most frequently bumped last -RUN pnpm install -g "vercel@${VERCEL_VERSION}" -RUN pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}" -RUN pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" -``` - -Bumping claude-code (the most common change) only rebuilds one layer. Agent-runner deps and other CLIs stay cached. - -Source is never baked into the image — always provided by the shared RO mount at runtime. - -### Agent-triggered version bumps - -Agents can request a claude-code version bump via a new self-mod tool (`bump_claude_code`). Same fire-and-forget pattern as `install_packages`: agent requests → owner approves → host rebuilds base image → kill all running containers. Unlike `install_packages` (per-group image), this rebuilds the shared base image and affects all groups. - -## Changes - -### `group-init.ts` - -- Remove the `agent-runner-src` copy block (lines 109–117) -- Remove the `skills/` copy block (lines 100–107) -- Skill symlinks are no longer created at init — sync is spawn-owned (see `container-runner.ts`) - -### `container-runner.ts` `buildMounts()` - -- Remove per-group `agent-runner-src` mount (lines 206–209) -- Add shared RO mount: `container/agent-runner/src/` → `/app/src` -- Add shared RO mount: `container/skills/` → `/app/skills` -- Sync skill symlinks in `.claude-shared/skills/` at spawn: write desired set from `container.json` (`"all"` = every skill in the shared dir, recomputed per spawn), remove symlinks not in the set - -### `container-runner.ts` `buildContainerArgs()` - -- Remove `-e SESSION_INBOUND_DB_PATH`, `-e SESSION_OUTBOUND_DB_PATH`, `-e SESSION_HEARTBEAT_PATH` (hardcoded conventions now) -- Remove `-e AGENT_PROVIDER` (moves to `container.json`) -- Remove `-e NANOCLAW_ASSISTANT_NAME`, `-e NANOCLAW_AGENT_GROUP_ID`, `-e NANOCLAW_AGENT_GROUP_NAME` -- Remove `-e NANOCLAW_MAX_MESSAGES_PER_PROMPT` -- Remove the `user_roles` join + `-e NANOCLAW_ADMIN_USER_IDS` block (lines 269–287) entirely. Admin gating moves to the router — no admin data passed to the container. -- Keep: `-e TZ`, OneCLI-contributed env (`HTTPS_PROXY`, `NODE_EXTRA_CA_CERTS`, `NO_PROXY`) - -### `router.ts` (new command gate) - -- Classify inbound slash commands before writing to `messages_in`: filtered / admin / normal. -- Filtered (`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/start`, `/remote-control`) → drop silently. -- Admin commands (`/clear`, `/compact`, `/context`, `/cost`, `/files`) from non-admins → write "Permission denied" directly to `messages_out`, skip `messages_in`. -- All authorized messages (admin commands from admins, and normal messages) → pass through unchanged to `messages_in`. Container handles them as today. -- The `ADMIN_COMMANDS` and `FILTERED_COMMANDS` lists move from `container/agent-runner/src/formatter.ts` to a host-side module. - -### `container/agent-runner/src/` (runner) - -- New `config.ts` module: loads `/workspace/agent/container.json` at startup, exposes a typed config singleton. All previous `process.env.NANOCLAW_*` reads go through this. -- `db/connection.ts`: use hardcoded paths `/workspace/inbound.db` and `/workspace/outbound.db`; drop `SESSION_*_DB_PATH` lookups. -- `formatter.ts`: remove `ADMIN_COMMANDS`, `FILTERED_COMMANDS`, and the `filtered` / admin-gate categorization. Keep enough to recognize `/clear` so `poll-loop.ts` can route it (e.g., a narrow `isClearCommand(msg)` helper). -- `poll-loop.ts`: remove `adminUserIds` field from config type and the admin-gate branch (lines 113–126). Keep the `/clear` handler (lines 128–142) — `/clear` still flows through from the router. -- Provider selection (`providers/index.ts` or equivalent): read provider from config singleton, not env. - -### `container-config.ts` - -- Add `skills` field to `ContainerConfig` (`string[] | "all"`, default `"all"`) -- Add fields: `provider`, `groupName`, `assistantName`, `maxMessagesPerPrompt` (optional, falls back to code default) - -### `.env` / `.env.example` - -- Remove any `NANOCLAW_*` entries that were documented as tunables. Update `.env.example` to list only TZ and OneCLI-related vars as valid overrides. - -### DB migration - -- Drop `agent_groups.agent_provider` column and `sessions.agent_provider` column. Source of truth becomes `container.json.provider`. -- One-time data migration reads existing values and writes them to each group's `container.json`. Sessions lose any per-session provider override — provider is a per-group property now. - -### Migration - -**This is a breaking change.** Host restart kills all running containers. No gradual rollout. Any code referencing dropped columns or removed env vars must be updated before the migration runs. - -- Provider install skills (`/add-opencode`, `/add-ollama-tool`) now write to the shared `container/agent-runner/src/providers/` tree. The per-group `providers/` overlay pattern is removed. Any uncommitted provider overlays must be upstreamed before cutover. -- Delete existing `data/v2-sessions//agent-runner-src/` directories on first run after cutover. -- Existing `.claude-shared/skills/` directories get replaced with symlinks on next spawn. -- DB migration (see above) reads `agent_provider` columns and projects into `container.json`, then drops the columns. - -## What triggers what - -| Change | Action needed | Scope | -|--------|--------------|-------| -| Agent-runner `.ts` source | Kill running containers | All groups | -| Agent-runner npm deps | Rebuild `nanoclaw-agent` + kill all | All groups | -| System deps, Bun, Node | Rebuild `nanoclaw-agent` + kill all | All groups | -| Claude-code version bump | Rebuild `nanoclaw-agent` + kill all | All groups (agent-triggerable) | -| Skill content | Kill running containers | All groups | -| Per-group apt/npm packages | `buildAgentGroupImage()` + kill | One group | -| Per-group config (MCP, mounts, provider, model, skills) | Kill that group's containers | One group | -| CLAUDE.md, working files | Nothing (live via RW mount) | One group | diff --git a/docs/v1-vs-v2/ACTION-ITEMS.md b/docs/v1-vs-v2/ACTION-ITEMS.md deleted file mode 100644 index 72457b7..0000000 --- a/docs/v1-vs-v2/ACTION-ITEMS.md +++ /dev/null @@ -1,618 +0,0 @@ -# v1 → v2 Action Items - -Working doc for each finding from [SUMMARY.md](SUMMARY.md). Decisions were made one-by-one; this rollup summarizes the outcome. - -**Status legend**: `pending` · `discussing` · `decided` · `deferred` · `dropped` · `done` - ---- - -## Rollup - -### To implement (~800 LOC total, roughly) - -| # | Topic | LOC | Notes | -|---|---|---|---| -| 1 | Engage modes + sender scope + accumulate/drop + fan-out + tool blocklist | ~315 | DB migration drops `trigger_rules`/`response_scope`, adds `engage_mode`/`engage_pattern`/`sender_scope`/`ignored_message_policy` + `trigger` column on `messages_in`; router `pickAgents` fan-out; adapter-level gating via new hooks | -| 5 | `request_approval` flow for unknown senders (default policy flips from `strict` to `request_approval`) | ~175 | New `pending_sender_approvals` table; reuses existing `pickApprover` + card infra | -| 9 | Stuck detection (60s claim-age rule), heartbeat-based lifecycle, `max(30m, bash_timeout)` absolute ceiling, SDK tool blocklist (`AskUserQuestion`, `EnterPlanMode`, `ExitPlanMode`, `EnterWorktree`, `ExitWorktree`), remove `IDLE_TIMEOUT` setTimeout + `IDLE_END_MS` machinery | ~115 | Container state row for Bash timeout tracking | -| 15 | Delete three dead config constants from `src/config.ts` | 3 | `POLL_INTERVAL`, `SCHEDULER_POLL_INTERVAL`, `IPC_POLL_INTERVAL` | -| 18 | Timezone + formatting recreation — port v1 bit-for-bit (`formatLocalTime`, `` header, `reply_to` + `` XML, `stripInternalTags`) + scheduling tool TZ normalization + cron TZ parsing | ~195 (75 prod + 120 tests) | Full spec in [timezone-formatting-v1-recreation.md](timezone-formatting-v1-recreation.md) | - -### Deferred (wait for trigger) - -| # | Topic | Trigger | -|---|---|---| -| 2 | `nonMainReadOnly` mount isolation | If multi-tenant / untrusted-group use ever surfaces. In the meantime, mount-declaration skill must explicitly prompt RO/RW when added | -| 3a | End-to-end recovery test | When next touching `host-sweep.ts` / `index.ts` startup | -| 14 | Remote control subsystem | When someone needs it. Opt-in skill, provider-specific (Claude SDK only) | -| 17 | Dynamic group-add (bridge conversations cache refresh) | When implementing dynamic group registration feature. Code comment added at `chat-sdk-bridge.ts:73` | - -### Dropped (won't implement / not-a-regression) - -| # | Topic | Why | -|---|---|---| -| 3 | Explicit pending-message recovery | Working as designed via sweep's immediate first tick + `cleanupOrphans` | -| 4 | `response_scope` enforcement | Folded into item 1 migration (column deleted, values backfilled) | -| 6 | Per-group container timeout | Not a regression — v1's hard-kill was worse than v2's keep-alive-after-idle | -| 7 | Container streaming output markers | Replaced by `send_message` MCP tool; latency ~1s is fine for chat UX | -| 8 | Per-exit container log files | Underlying info still recoverable (session DBs, heartbeat mtime, exit code) | -| 10 | Host-level retry on agent error | Folded into item 9's kill + sweep-reset loop | -| 11 | Process ID in logger output | Single host process; container stderr already tagged with `agentGroup.folder` | -| 12 | Task dedup via unique series_id index | Recurrence logic is structurally dedup-safe; not a real issue | -| 13 | Silent-drop sender mode | Admin can use `unknown_sender_policy='strict'` or remove from members instead | -| 16 | Configurable retention thresholds | Personal-assistant scale; source constants are fine | - -### Extras recorded during discussion -- **1a**: Implementation-ordering plan for item 1 -- **6a**: Remove `IDLE_END_MS` from `poll-loop.ts` (folded into item 9) -- **3a**: E2E recovery test (deferred) - -### Follow-up PRs (scoped, not in this branch) -| # | Topic | Why later | -|---|---|---| -| 22 | Unknown-channel wiring approval flow (card to owner when bot receives inbound in an unwired messaging group) | Gap surfaced after item 5 landed — item 5's `request_approval` covers unknown senders but presupposes a wired channel. See item 22 for the full design. | - ---- - -## HIGH - -### 1. Trigger-rule matching in `pickAgent` -**Finding**: `src/router.ts:246` TODO. Confirmed trigger filtering is non-functional end-to-end: `trigger_rules` JSON is parsed into `ConversationConfig` and passed to adapters, but the Chat SDK bridge never reads it, and router's `pickAgent` picks by priority only. `response_scope` on `messaging_group_agents` is stored but never enforced. Chat SDK bridge hard-subscribes on every mention (bridge:173) and every DM (bridge:189). - -**Status**: decided — design locked; implementation pending - -**Decision**: replace `trigger_rules` JSON + `response_scope` with four explicit orthogonal columns on `messaging_group_agents`. Fan out inbound messages to all matching agents (N containers for N agents). Adapter-level gating in the bridge. `sender_scope` enforcement moves to the permissions module. - -**Schema** (`messaging_group_agents`): -``` -engage_mode TEXT NOT NULL DEFAULT 'mention' - -- 'pattern' | 'mention' | 'mention-sticky' -engage_pattern TEXT -- required when mode='pattern'; '.' = always -sender_scope TEXT NOT NULL DEFAULT 'all' -- 'all' | 'known' -ignored_message_policy TEXT NOT NULL DEFAULT 'drop' -- 'drop' | 'accumulate' -``` -Drop `trigger_rules` + `response_scope`. **No per-wiring accumulate cap** — storage is unbounded. - -**Global wake cap** (not a column): reuse `MAX_MESSAGES_PER_PROMPT` in `src/config.ts` (already defined, default 10, currently dead code from v1). Pass to container via `NANOCLAW_MAX_MESSAGES_PER_PROMPT`. Container applies `ORDER BY seq DESC LIMIT $N` when pulling pending messages on wake. - -**Session DB** (`messages_in`): -``` -trigger INTEGER NOT NULL DEFAULT 1 -- 0 = context-only, 1 = wake agent -``` -Host's `countDueMessages` / wake logic gates on `trigger=1`. Container reads all messages for context regardless. - -**Decisions locked**: -- `always` collapses into `pattern` with `engage_pattern='.'` (three modes total) -- `mention` and `mention-sticky` are separate modes (stickiness is user-visible) -- `pattern` is a JS regex string — applied as `new RegExp(pattern).test(text)` -- Accumulate cap = last N messages, default 10 -- Fan-out: each matching agent gets its own session + container -- Per-channel defaults live in the setup/register flow, not in the schema: - - DM → `pattern` with `.` - - Threaded group → `mention-sticky` - - Non-threaded group → `mention` - -**Routing flow** (future): -1. Inbound → resolve messaging_group → group-level `unknown_sender_policy` gate -2. `pickAgents()` returns all wired agents (not just priority 0) -3. For each agent: - a. `sender_scope` check (permissions module) - b. `engage_mode` check (regex / mention / mention-sticky) - c. Matched → write with `trigger=1`, wake container - d. Not matched + `accumulate` → write with `trigger=0`, don't wake (no cap — stored forever) - e. Not matched + `drop` → skip - -On wake, container pulls pending messages with `ORDER BY seq DESC LIMIT MAX_MESSAGES_PER_PROMPT` so only the most recent N reach the prompt regardless of accumulation depth. - -**Adapter bridge**: -- Read `conversations.get(channelId)` before `setupConfig.onInbound(...)` -- For `pattern` mode: test regex -- For `mention` / `mention-sticky`: require bot to be mentioned -- Only `thread.subscribe()` when mode is `mention-sticky` (today it subscribes unconditionally) - -**LOC estimate**: ~315 (~255 prod + ~60 test) -- schema migration + backfill: 40 -- session DB `trigger` column: 25 -- types + adapter contract: 20 -- DB helpers (CRUD): 20 -- host→adapter plumbing (including `NANOCLAW_MAX_MESSAGES_PER_PROMPT` env): 10 -- router fan-out + gating: 70 -- sender-scope in permissions module: 15 -- Chat SDK bridge gating + subscribe control: 40 -- container-side `LIMIT N` on pending-message pull: 5 -- smart defaults in setup/register flow: 15 -- tests: 60 - -(Note: earlier plan's "accumulate prune-to-N in router" is dropped — host doesn't prune. Cap is container-side only.) - -**Core vs module split**: -- Core (`src/`): schema, engage_mode enforcement, pickAgents fan-out, bridge gating, `trigger` column, accumulate/drop -- Permissions module: `sender_scope` enforcement (extends existing access gate). Default `sender_scope='all'` → no-op when permissions module absent - -**Next step**: new action item for implementation — see item 1a. - ---- - -### 1a. Implementation plan for engage/sender/ignored columns -**Status**: pending — ready to implement -**Order**: (a) migration + backfill, (b) types + DB helpers, (c) router fan-out + gating, (d) bridge gating, (e) permissions sender_scope, (f) setup-flow defaults, (g) tests -**Next step**: draft the migration + write up the PR plan when ready - -### 2. `nonMainReadOnly` mount isolation -**Finding**: `mount-security.ts` moved to `src/modules/mount-security/index.ts` during the refactor. `validateMount(mount)` no longer takes an `isMain` param; `MountAllowlist` has no `nonMainReadOnly` field. Regression is real. But v1's "main vs non-main" concept doesn't map cleanly to v2 — `agent_groups` has no `is_main` flag. - -**Status**: deferred - -**Decision**: do not restore the v1 flag. Trust admin-declared `readonly` values in `container.json`. The allowlist's per-root `allowReadWrite` + path gating is sufficient for the current threat model (personal-assistant use, single admin). If multi-tenant / untrusted auxiliary groups become a real use case, prefer framing B (add `agent_groups.mount_access: 'rw' | 'ro'` column) over resurrecting `isMain`. - -**Rationale**: v2 deliberately dropped the "main" concept. Reintroducing `isMain` to restore a defense-in-depth check that was designed for a different entity model is the wrong trade. Admin already has to opt-in twice (allowlist `allowReadWrite: true` + container.json `readonly: false`) to get RW — that's two deliberate keys. The v1 flag was a triple-check for a rare class of admin mistakes in a shared-infra setup. - -**Follow-up (required)**: when building the skill / guide / setup flow that lets admins declare additional mounts (e.g. self-customize, manage-mounts, or a new `/add-mount` skill), the flow **must clearly surface the RO vs RW distinction** to the admin — explicit choice, explicit warning when RW is selected, and default to RO. This replaces v1's automatic enforcement with informed consent. - -**Next step**: when the mount-declaration skill/flow is next touched, add explicit RO/RW prompting. Track as a sub-item if a skill exists yet. - -### 3. Explicit pending-message recovery on startup -**Finding**: v1 had a named `recoverPendingMessages()` function at startup. v2 relies on the host sweep. Verified: the recovery path exists and is correct — just renamed/relocated. - -**Status**: decided — working as designed, no code change - -**Current mechanism** (verified against tree): -1. `cleanupOrphans()` at startup kills any leftover container from the previous run (`src/index.ts:69`) -2. `startHostSweep()` runs its first sweep **immediately** — no 60s delay (`src/host-sweep.ts:38`) -3. Sweep per session: `syncProcessingAcks` → `countDueMessages` → `wakeContainer` if work pending and no container → `detectStaleContainers` resets stuck `processing` rows with backoff - -**Scenarios covered**: -- Host crashed while container idle with pending messages → orphan cleanup + first sweep re-wakes -- Host crashed mid-processing → stale detection resets `processing → pending`, next sweep wakes -- Container crashed with host alive → heartbeat mtime catches it inside 10 min `STALE_THRESHOLD_MS` - -**Rationale**: the function got renamed (recovery → sweep) but the behavior is equivalent or better. Sweep is continuous; recovery used to be one-shot. - -**Next step**: see item 3a. - ---- - -### 22. Unknown-channel wiring approval flow -**Finding** (post-item-5 discussion): item 5's `request_approval` only fires when a messaging group already has agents wired. Three scenarios slip through to the earlier `no_agent_wired` structural-drop branch in `src/router.ts` and get silent-dropped with no signal to the owner: - -1. A new user DMs the agent directly (the DM's messaging group auto-creates but has no wiring) -2. The agent is @mentioned in a group the admin hasn't registered -3. The agent is added to a new group and someone there addresses it - -In all three, the user sees no response and the owner has no signal anything happened. - -**Status**: decided — companion PR to item 5, scoped separately - -**Decision**: when the router hits `no_agent_wired` for a non-public event, **instead of silent-dropping, pick the owner and DM them a wiring card**. Two flavors depending on who triggered it: - -- **Sender IS an owner/admin** (the common "I just added the bot" case) → auto-wire IF exactly one agent group exists. Silent seamless flow. If multiple agent groups exist, fall through to the card so the owner picks. -- **Sender is anyone else** (stranger, or owner in a multi-agent install) → deliver a card: - - Title: `🔌 New channel — wire it?` - - Body: ` is trying to reach you in on . Wire to which agent?` - - Options: one button per existing `agent_groups` row, plus `➕ Create new` and `Ignore` - -**On approve (existing agent group)**: -1. `createMessagingGroupAgent(...)` with channel-kind defaults — DM→`pattern` + `'.'`, threaded group→`mention-sticky`, non-threaded group→`mention` (same defaults as `scripts/init-first-agent.ts`) -2. Replay the stored event via `routeInbound` (sender-approval pattern) -3. Delete pending row - -**On approve "Create new"**: [OPEN SCOPE] — needs name/folder input. Options: -- Follow-up ask_question card asking for a name → auto-derive folder from slug → create group + wire -- Or: skill-backed flow — the button dispatches to `/init-agent` or similar and the card just links out -- Punt until implementation; mention in the PR brief that we'll decide when building - -**On ignore**: delete pending row; future attempts re-prompt fresh (consistent with sender-approval deny; no denial persistence). - -**Failure cases** (drop silently with log, don't leave a pending row): -- No owner configured (fresh install) — same behaviour as sender-approval -- No reachable DM for any owner/admin -- Delivery adapter missing - -**New table**: -``` -pending_channel_approvals ( - id TEXT PRIMARY KEY, - messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id), - sender_identity TEXT, -- NULL when triggered by a non-identifiable event - sender_name TEXT, - original_message TEXT NOT NULL, -- JSON InboundEvent for replay - approver_user_id TEXT NOT NULL, - created_at TEXT NOT NULL, - UNIQUE(messaging_group_id) -- one pending wiring per channel -) -``` - -Dedup is narrower than sender-approval's `(mg_id, sender_id)` — one pending wiring per channel, period. A second stranger writing into the same unwired channel piggybacks on the existing card instead of spawning a new one. Latest event replaces the stored `original_message` (we only replay one anyway, and latest is most useful). - -**Card action id prefix**: `nca-:` where value is `agent-group-` / `create` / `ignore`. Response handler lives in `src/modules/permissions/` alongside `handleSenderApprovalResponse`. - -**Owner-sender auto-wire logic**: -``` -if sender is owner/admin AND getAllAgentGroups().length === 1: - auto-wire to that group, replay event, done — no card -else: - deliver card -``` - -Don't auto-create a new agent group silently — always require a prompt for that. - -**LOC estimate**: ~145 -- Migration + CRUD: 45 -- Router hook before `no_agent_wired` drop → try channel approval: 15 -- Owner-sender auto-wire fast path: 20 -- Card delivery (scope `pickApprover(null)`; build buttons from `getAllAgentGroups()`): 25 -- Response handler: 25 -- Tests: 15 - -**Open scopes (flag at PR time)**: -- "Create new" sub-flow — pick between follow-up card vs skill link -- Do we also react to bot-added-to-group platform events? Simpler to stay lazy (first-message-triggered only). Platform lifecycle events are inconsistent across Discord/Slack/Telegram anyway. -- Worth scanning the `channels` branch for any existing channel-lifecycle handlers that might conflict. - -**Next step**: open a follow-up PR off this branch once #1869 lands. - ---- - -### 3a. End-to-end recovery test -**Finding**: no test confirms the host-crash-restart scenario produces timely re-delivery. - -**Status**: pending — nice-to-have - -**Decision**: add an integration test: (1) write a pending message to inbound.db, (2) kill the host simulating crash, (3) start host, (4) assert container is woken and message processed within a bounded time (≤5s? ≤ one sweep interval). - -**Rationale**: the sweep logic is correct as written, but a regression here would be silent (messages just sit). Worth a safety net. - -**Next step**: draft test when touching `host-sweep.ts` or `index.ts` startup flow next. - ---- - -## MEDIUM - -### 4. `response_scope` enforcement -**Finding**: `messaging_group_agents.response_scope` stores `'all' | 'triggered' | 'allowlisted'` but nothing reads it. - -**Status**: decided — folded into item 1 - -**Decision**: delete the `response_scope` column as part of the item-1 migration. Values backfill into the new explicit columns: - -| Old `response_scope` | New columns | -|---|---| -| `all` | `engage_mode='pattern'`, `engage_pattern='.'`, `sender_scope='all'` | -| `triggered` | `engage_mode='mention'` (or `'pattern'` if legacy row has a pattern), `sender_scope='all'` | -| `allowlisted` | `engage_mode` derived from `trigger_rules`, `sender_scope='known'` | - -**Rationale**: `response_scope` conflated two orthogonal axes (engage + sender). Splitting them is the whole point of item 1. - -**Next step**: ensure the item-1 migration includes the `response_scope` backfill in its UP step. - -### 5. `request_approval` flow for unknown senders -**Finding**: `unknown_sender_policy='request_approval'` is scaffolded in `src/modules/permissions/index.ts:100-108` but falls through to log-and-drop (explicit TODO comment). Current default is `'strict'`, which silently drops — user has no signal that their agent isn't responding. - -**Status**: decided — implement, keep simple - -**Decision**: implement full approval flow **and** flip the schema default from `'strict'` to `'request_approval'`. UX rationale: users wire their DM during setup; silent drops create a mystery when the agent doesn't respond. Public is unsafe. Approval default → admin sees a card and explicitly decides. - -**Flow**: -1. Unknown sender writes to wired messaging group with policy `'request_approval'` -2. If pending approval for `(messaging_group, sender)` already exists → drop this message silently (in-flight dedup; not persistence) -3. Otherwise: insert into `pending_sender_approvals` with original message + timestamp -4. `pickApprover(agent_group_id)` + `pickApprovalDelivery(approverUserId)` — existing machinery in `src/access.ts` -5. Deliver a card via adapter's `deliver()` with `Card`/`Actions`/`Button` primitives (already in chat-sdk-bridge) -6. Card action id prefix `nsa::` (parallels existing `ncq:` prefix for `ask_user_question`) -7. On `allow`: upsert `users` row, insert into `agent_group_members`, deliver stored message through normal routing (original timestamp preserved), cleanup pending row -8. On `deny`: cleanup pending row, drop the message. No denial persistence — next attempt from same sender triggers a fresh card. - -**No denial persistence** explicit rationale: personal-assistant scale, admin can switch policy to `'strict'` per messaging group if a hostile sender starts spamming. Avoids a new table column and a TTL config. - -**New table**: -``` -pending_sender_approvals ( - id TEXT PRIMARY KEY, - messaging_group_id TEXT NOT NULL, - agent_group_id TEXT NOT NULL, - sender_identity TEXT NOT NULL, -- channel_type:handle - sender_name TEXT, - original_message TEXT NOT NULL, -- JSON of the InboundEvent - approver_user_id TEXT NOT NULL, - created_at TEXT NOT NULL, - UNIQUE(messaging_group_id, sender_identity) -- enforces in-flight dedup -) -``` -Dedicated (not reusing `pending_approvals` which is OneCLI-specific). - -**Reuse**: -- `pickApprover` / `pickApprovalDelivery` in `src/access.ts` -- Card rendering primitives already in `src/channels/chat-sdk-bridge.ts` -- `onAction` dispatch — add the `nsa:` prefix handler alongside existing `ncq:` - -**LOC estimate**: ~175 -- Migration + CRUD for `pending_sender_approvals`: 55 -- `handleUnknownSender` request_approval branch + in-flight dedup: 25 -- Host-side card dispatcher (pick approver + deliver card): 25 -- `onAction` handler for `nsa:` prefix (allow/deny): 30 -- Schema default flip + router auto-create update: 5 -- Tests: 35 - -**Module location**: all in `src/modules/permissions/`. Module stays optional; default-allow fallback behavior when not loaded is preserved. - -**Open gotchas noted**: -- The router's auto-create at `router.ts:123` currently hardcodes `'strict'` — change to omit the field so schema default applies -- `pickApprover` may return null if no admin/owner exists (e.g. fresh install before first user registered). In that case: log + drop silently, treat as effectively `'strict'` for safety. Don't block message forever. - -**Scope boundary** (important): this item covers **unknown sender in a wired channel**. The parallel case — **unknown channel** (new DM / unwired group / bot-added-to-group) — short-circuits at the `no_agent_wired` structural drop before this flow ever runs. Tracked as item 22. - -**Next step**: implement alongside item 1 or as a follow-up. Same migration window is fine (one migration for engage columns + request_approval default change + new table). - -### 6. Per-group container timeout -**Finding**: v1's `containerConfig.timeout` override is gone. All groups share `IDLE_TIMEOUT`. Original framing (slow-but-healthy agents getting killed) was wrong — v1's `timeout` was a hard wall-clock kill on the whole spawn, totally different from v2's `IDLE_TIMEOUT` (keep-alive after last activity). V2's behavior is strictly better for long-running agents. - -**Status**: dropped — not a regression - -**Decision**: don't restore per-group timeout override. `IDLE_TIMEOUT=30min` global is the right model. If per-group idle tuning ever becomes useful it's ~15 LOC (new column, env injection at spawn) — small feature add, not a regression to repair. - -**Rationale**: v2 lets long-running agents finish; v1 would have hard-killed them at 30min. Current behavior is an improvement. - -**Next step**: see 6a. - ---- - -### 6a. Remove IDLE_END_MS (container-side query idle termination) -**Finding**: `container/agent-runner/src/poll-loop.ts:11` defines `IDLE_END_MS = 20_000`. Inside `processQuery`, a concurrent interval ends the active Agent SDK `query()` stream after 20s of SDK silence. Any SDK event (tool use, tool result, streamed text, new pushed message) resets the timer. - -This is a general "SDK silence detector," not specifically post-result. It fires any time: -- Mid-work: slow tool call with no intermediate SDK events (`npm install`, `pytest`, long `WebFetch`, etc.) -- Post-result: agent finished, stream waiting for potential follow-up -- Any other pause in the SDK stream - -**Status**: decided — remove, pending SDK verification - -**Decision**: delete `IDLE_END_MS` and its setInterval check. Let the `query()` stream stay open as long as the container is active. Container's 30-min `IDLE_TIMEOUT` (host-side in `container-runner.ts`) is the single source of truth for "when to let go." - -**Rationale**: -- When new messages arrive mid-stream, they push in via `query.push()` with no reconnect — stream-open is cheaper per-message than close-and-reopen -- Closing early forces a reconnect + cold prompt cache for the next message -- Container stays alive anyway; ending the stream doesn't save resources at the container level -- `CLAUDE_CODE_AUTO_COMPACT_WINDOW=165000` already handles context window growth within a long-lived query -- Anthropic API's own stream timeout will fire if needed; SDK should handle it transparently -- Avoids the false-positive kill during legitimate slow tool calls (common case: agent running `npm install` gets cut off at 20s) - -**Caveat (must verify before removal)**: confirm Claude Agent SDK doesn't require explicit `query.end()` for prompt-cache commit or session-state persistence. Expected to be fine (SDK checkpoints per turn) but double-check docs / run a quick test where container idles with stream open, then processes a follow-up. - -**LOC estimate**: ~−15 (net deletion — remove constant, setInterval idle check, the `done` flag plumbing may also simplify) - -**Next step**: when implementing item 1's changes (or standalone), verify SDK behavior with stream-open-indefinite, then delete IDLE_END_MS block. Watch for any test assertions on it. - -### 7. Container streaming output (marker-based pre-delivery) -**Finding**: v1's `---NANOCLAW_OUTPUT_START/END---` markers enabled pre-completion delivery. v2's two paths (final-result `dispatchResultText` + mid-turn `send_message` MCP tool) both write to outbound.db; host polls every `ACTIVE_POLL_MS = 1000ms`. - -**Status**: dropped — not a regression - -**Decision**: v2's `send_message` MCP tool is the correct replacement for v1's marker-based streaming. Latency is ≤1s (poll interval), which is fine for chat UX. - -**Rationale**: v1's marker model required the agent and host to share a fragile state machine over stdout. v2 uses explicit tool calls and a DB surface — cleaner architecture, comparable latency, and control stays with the agent. If perceived latency ever becomes a real complaint, tune `ACTIVE_POLL_MS` down (500ms / 250ms) — low-cost knob. - -**Next step**: none. - -### 8. Per-exit container log files -**Finding**: v1 wrote timestamped per-exit logs with full I/O + mounts + stderr. v2: stderr → `log.debug` (invisible at default `LOG_LEVEL=info`), container close → `log.info` with exit code, session DBs preserved on disk. Real gap: stderr on abnormal exit isn't auto-surfaced. - -**Status**: dropped - -**Decision**: skip — no per-exit file restoration, no stderr-on-crash buffer. - -**Rationale**: underlying forensic info is still recoverable (session DBs on disk, heartbeat mtime, exit code in log). `LOG_LEVEL=debug` surfaces stderr when needed. The cost of adding buffered crash-log promotion (~15 LOC) isn't justified by the frequency of post-mortem cases. - -**Next step**: none. - -### 9. Stuck detection + heartbeat-based container lifecycle -**Finding**: v2's sweep detects stale heartbeats (10 min) and resets messages with backoff, but doesn't kill the container. Idle timeout is delivery-count-based (30 min since last messages_out). Together these produce a gap where a stuck container holds resources + blocks new wakes for up to 30 min. - -**Empirical findings from SDK probe** (`container/agent-runner/scripts/sdk-signal-probe.ts`, runs logged in `/tmp/probe-*.jsonl`): -- Silent Bash tools (e.g. `sleep 30`) produce 30+ seconds of zero SDK events — heartbeat goes stale during legitimate work -- Natural intra-stream silences up to ~12s observed mid-tool-use JSON streaming -- `PreToolUse` / `PostToolUse` hook pair is reliable; `PostToolUseFailure` fires on blocked requests -- `SubagentStart`/`SubagentStop` and `system/task_started`/`system/task_notification` pairs also reliable -- **Pushing a new message mid-active-turn does NOT fire `UserPromptSubmit`** (fires only at start of a new turn, after `result`) -- SDK's built-in `AskUserQuestion` doesn't actually block; returns placeholder -- Bash tool's declared `timeout` param is visible in `tool_use` input — we can read it container-side -- Stuck tools (hook that never resolves) produce indefinite silence — no SDK-side timeout - -**Status**: decided - -**Decision**: replace existing IDLE_TIMEOUT setTimeout + STALE_THRESHOLD=10min combo with message-scoped stuck detection + absolute 30-min ceiling. Reset messages inline when we kill. Blocklist SDK tools that don't fit our async model. - -**Sweep logic** (per active session): - -If container isn't running → reset any `'processing'` rows in processing_ack to `'pending'` + tries++ + backoff. Done. - -If container IS running, apply in order: - -1. **Absolute ceiling**: if `heartbeat_mtime` older than `max(30 min, current_bash_timeout)` → kill + reset any processing to pending + retry++. - Rationale: 30 min idle ceiling, extended only if agent is currently inside a Bash tool with longer declared timeout. Agents needing >30 min should use `run_in_background`. - -2. **Message-scoped stuck**: for each `processing_ack` row with status=`'processing'`: - - `claim_age = now - status_changed` - - `tolerance = max(60s, current_bash_timeout)` if Bash in flight, else `60s` - - If `claim_age > tolerance` AND `heartbeat_mtime <= status_changed` → kill + reset this message + retry++ - - Semantics: "container claimed a message and went silent for >tolerance since claim." - -No separate idle rule — rule 1 covers it. An idle container hits 30-min stale with no processing rows; kill has nothing to reset. - -**Container state surface** (for Bash timeout tracking): -New table in outbound.db (or session_state row): -``` -container_state ( - session_id TEXT PRIMARY KEY, - current_tool TEXT, -- null when no tool in flight - tool_declared_timeout_ms INTEGER, - tool_started_at TEXT -) -``` -Container writes on `PreToolUse` (reads Bash `timeout` from tool input), clears on `PostToolUse` / `PostToolUseFailure`. Host reads in sweep decision. - -**Tool blocklist** (initial): -- `AskUserQuestion` — SDK built-in; we have our own DB-backed MCP version -- `EnterPlanMode` / `ExitPlanMode` — Claude Code UI only -- `EnterWorktree` / `ExitWorktree` — Claude Code UI only - -Enforcement: -- Pass `disallowedTools: [...]` to `query()` options — agent never sees them in its tool list -- `PreToolUse` hook guard (defense-in-depth): if a blocklisted tool name somehow fires, immediately reset the current message + kill (treat as stuck) - -**Kill old machinery**: -- Remove `setTimeout` + `resetIdle` plumbing in `container-runner.ts:128-140` -- Remove `resetContainerIdleTimer` export + its caller in `delivery.ts:26` -- Remove `IDLE_END_MS = 20_000` in `poll-loop.ts:11` (item 6a decision) — stream stays open as long as container alive -- Existing `detectStaleContainers` logic merges into the new sweep rules; the heartbeat-stale-10-min path disappears - -**LOC estimate**: ~115 -- New sweep decision logic replacing existing detectStaleContainers + IDLE_TIMEOUT path: 50 -- Container state table + PreToolUse/PostToolUse write, host read: 25 -- Tool blocklist (disallowedTools + hook guard): 15 -- Deletions (IDLE_TIMEOUT setTimeout, IDLE_END_MS): −25 -- Tests (kill paths, Bash-timeout grace, blocklist hit): 50 - -**Why this converged here** (rationale summary): -- Empirical data showed we can't reliably tell stuck from legitimate-silent-work without state. Bash-declared-timeout is the cleanest per-tool signal available. -- 60s-since-claim is tight enough for normal work (WebSearch/WebFetch finish in ~8s) but generous enough for reasonable delays. Exception for Bash covers agents running scripts with user-declared timeouts. -- 30-min absolute ceiling prevents infinitely-stuck containers; agents needing longer have `run_in_background`. -- Pushing messages can't serve as a liveness probe (they're silent mid-turn), so detection is state-driven, not push-driven. -- Blocklist prevents a whole class of "SDK tool designed for interactive UI" footguns that would appear stuck in our async model. - -**Next step**: implement as a focused PR. Order: (a) tool blocklist — safe to ship alone, (b) container state table + PreToolUse writes, (c) sweep rewrite + message reset, (d) delete old IDLE_TIMEOUT + IDLE_END_MS machinery, (e) tests. - -### 10. Host-level retry with backoff on agent error -**Finding**: v1 had MAX_RETRIES=5 + exp. backoff on `processGroupMessages` failure. v2's equivalent is now covered by item 9's sweep logic — any time the container isn't running with `'processing'` rows present, they get reset to pending with backoff + retry++. - -**Status**: folded into item 9 - -**Decision**: no separate action. Agent-error retry happens via container-exit → sweep reset. Container errors also surface via provider-side session invalidation check (`poll-loop.ts:200-211` — `provider.isSessionInvalid(err)` → clears stored session id → fresh retry). Both paths preserved. - -**Next step**: none. - ---- - -### 11. Process ID in logger output -**Finding**: v1 emitted `(${process.pid})` after the level tag. v2 dropped it. - -**Status**: dropped - -**Decision**: don't restore. Host is single-process (PID is constant). Container stderr already gets tagged with `{ container: agentGroup.folder }` at `container-runner.ts:121`, which is more informative than a PID. - -**Next step**: none. - ---- - -## LOW - -### 11. Process ID in logger output -**Finding**: v1 emitted `(${process.pid})` after the level tag. v2 dropped it. -**Status**: pending -**Decision**: -**Rationale**: -**Next step**: - -### 12. Task dedup via unique `(kind, series_id)` index -**Finding**: verified — `messages_in.series_id` column exists with a non-unique index. Concern was theoretical: two pending rows with same series could coexist. - -**Status**: dropped - -**Decision**: not a real issue. Recurrence logic at `src/modules/scheduling/recurrence.ts` is structurally dedup-safe: only `completed` rows with `recurrence` get cloned, and after cloning `recurrence` is cleared on the original so it can't re-clone. Plus container's atomic `markProcessing` prevents double-execution at claim time. - -**Next step**: none. - -### 13. Silent-drop mode for noisy senders -**Finding**: v1's `mode:'drop'` let you ignore specific users without logging. v2 only has binary allow/deny via access gate. - -**Status**: dropped — won't implement - -**Decision**: not worth the table + gate complexity for a personal-assistant scale. If a specific sender becomes a problem, admin can switch the messaging_group's `unknown_sender_policy` to `'strict'` or remove the sender from `agent_group_members`. - -**Next step**: none. - -### 14. Remote control subsystem -**Finding**: v1's `/remote-control` command spawned `claude remote-control` CLI detached, polled stdout for session URL, persisted PID/URL state. Entirely gone in v2. - -**Status**: deferred — opt-in skill when needed - -**Decision**: reintroduce as an opt-in install skill (e.g. `/add-remote-control`), not on trunk. Provider-specific: only works with `claude` provider (Claude Agent SDK); not supported by OpenCode or other providers. Skill should check `agent_group.provider` at install time and bail gracefully with an error message if not `'claude'`. - -**Rationale**: niche feature valuable only for direct agent SDK attachment during dev/debugging. Keeping it off trunk matches v2's "infra-only trunk, features-via-skills" philosophy. Also avoids carrying code for a feature that simply doesn't exist in non-Claude providers. - -**Next step**: none until someone needs it. When implementing, likely lives on the `providers` branch (since it's provider-specific) or its own branch, installed via skill that copies files + checks provider. - -### 15. Dead config constants -**Finding**: verified — `POLL_INTERVAL` (line 13), `SCHEDULER_POLL_INTERVAL` (line 14), and `IPC_POLL_INTERVAL` (line 32) in `src/config.ts` have zero imports elsewhere in v2. Container's `POLL_INTERVAL_MS` in `poll-loop.ts` is a distinct local constant, unrelated. - -**Status**: decided — delete - -**Decision**: remove the three constants from `src/config.ts`. Trivial 3-line deletion. - -**Next step**: do as part of any sweep-touching PR, or standalone. - -### 16. Configurable retention thresholds -**Finding**: `STALE_THRESHOLD_MS` (10 min) and `MAX_TRIES` (5) in `host-sweep.ts` are hardcoded. Item 9's redesign replaces `STALE_THRESHOLD_MS` with new constants (60s claim-age, 30 min ceiling). - -**Status**: dropped — keep as constants - -**Decision**: leave the new item-9 thresholds + `MAX_TRIES` as source constants. Adding config surface for them isn't worth it at personal-assistant scale. If operational tuning ever becomes a real need, revisit — they're small centralized constants, one-line change each. - -**Next step**: none. - -### 17. Dynamic group-add (IPC watcher equivalent) -**Finding**: not actually a restart requirement — investigation showed: -- Router reads `messaging_groups` + `messaging_group_agents` fresh per inbound (dynamic by design) -- Chat SDK bridge has a `conversations: Map` populated at setup + `updateConversations()` method -- **Nothing in the bridge currently reads the map**, and no code calls `updateConversations()` after startup -- Today: stale map has no observable effect (dead state) -- After item 1 ships (adapter-level gating): stale map would matter; new wirings wouldn't apply in the adapter gate until restart - -**Status**: deferred — comment added now, implement alongside dynamic group registration feature - -**Decision**: don't refactor the adapter interface now. Added a NOTE comment at `src/channels/chat-sdk-bridge.ts:73` flagging the staleness issue so the next person touching the bridge or adding dynamic-registration sees it. When dynamic group registration is implemented (admin adds a new messaging_group_agents row while host is running), handle cache refresh then — most likely by calling `adapter.updateConversations(freshConfigs)` after the mutation, keyed off the adapter's `channelType`. - -**Rationale**: item 1's initial landing can keep the adapter gating responsibilities small or skip adapter-side gating entirely. Refactoring ConversationConfig now would add scope; better to ship item 1 first, see if over-subscription bites, address if it does. - -**Next step**: when building the admin-skill path for adding messaging_group ↔ agent_group wirings, include a `refreshAdapterConversations(channelType)` call after the INSERT. ~10 LOC when needed. - ---- - -## Test regressions (v1 `formatting.test.ts` assertions) - -### 18+19+20+21. Timezone + formatting recreation (merged) -**Finding**: v1 had a full timezone-aware formatting pipeline. v2 lost most of it, producing real bugs where the agent misinterprets user intent (scheduling for wrong times, suggesting time-inappropriate things). - -**Scope** — recreate v1 behavior faithfully wherever times touch the agent: -- Timestamp formatting on inbound messages: `formatLocalTime(utcIso, TIMEZONE)` producing "Jan 1, 2024, 1:30 PM" format via `Intl.DateTimeFormat('en-US', {...})` (v1 `timezone.ts`) -- `` header prepended to message block (v1 `router.ts:20-22`) -- Reply-to with message ID: `......` (v1 `router.ts:10-18`) -- `stripInternalTags()`: regex `/[\s\S]*?<\/internal>/g` applied to outbound text, then `.trim()` (v1 `router.ts:25-27`) -- Cron expressions parsed with explicit user TZ: `CronExpressionParser.parse(expr, { tz: TIMEZONE })` (v1 `task-scheduler.ts:20-49`) -- User-specified times normalized via the user's TZ: in v1 this was the host-side task scheduler; in v2 it's the new-in-v2 scheduling MCP tool (`mcp-tools/scheduling.ts`). Same principle — accept user-local times, normalize to UTC for storage, interpret cron in user's TZ. - -**Status**: decided — recreate with tests - -**Decision**: port v1's formatter + timezone behavior faithfully. Full recreation spec at [`timezone-formatting-v1-recreation.md`](timezone-formatting-v1-recreation.md) — includes exact v1 code, line numbers at commit `27c5220`, complete test inventory from `src/v1/formatting.test.ts` and `src/v1/task-scheduler.test.ts`. - -**Core principle** (per Gavriel): the agent operates in the user's timezone. Every timestamp the agent sees is user-local. Every time the agent outputs is interpreted as user-local. This is load-bearing for correctness, not a nice-to-have. - -**Porting plan** (from recreation spec): -1. `container/agent-runner/src/formatter.ts` — replace `formatTime` with `formatLocalTime(ts, TIMEZONE)` call; add reply_to attribute + `` element exactly as v1 -2. Prepend `\n` to the messages block at formatter entry -3. Extract `stripInternalTags` as a named function; apply in outbound dispatch path (`poll-loop.ts:389` currently uses inline regex) -4. `container/agent-runner/src/mcp-tools/scheduling.ts` — clarify `processAfter` description, normalize to UTC ISO in handler -5. `src/modules/scheduling/recurrence.ts` — pass `{ tz: TIMEZONE }` to `CronExpressionParser.parse()` explicitly -6. Port all test cases from v1's `formatting.test.ts` and `task-scheduler.test.ts` to v2's test tree - -**LOC estimate**: ~75 prod + ~120 tests (reproducing v1's 40+ test cases) - -**Next step**: implement as a focused PR. Order: (a) formatter changes + tests, (b) context header + tests, (c) reply_to + tests, (d) stripInternalTags extraction + tests, (e) scheduling tool + cron TZ + tests. - -### 19, 20, 21 — merged into 18 above -See item 18 for the full recreation plan and spec reference. - ---- - -## Notes -- `src/v1/` was deleted upstream (commit 86becf8) after this analysis was written. v2 tree has since had a major module extraction (approvals, interactive, scheduling, permissions, agent-to-agent, self-mod) and a new CLI channel. **Verify each item against the current tree before deciding** — some may already be addressed. diff --git a/docs/v1-vs-v2/SUMMARY.md b/docs/v1-vs-v2/SUMMARY.md deleted file mode 100644 index 30e7d38..0000000 --- a/docs/v1-vs-v2/SUMMARY.md +++ /dev/null @@ -1,146 +0,0 @@ -# v1 → v2 Deep Dive: Aggregate Summary - -Per-file deep-dives were produced for every file in `src/v1/` and `container/agent-runner/src/v1/`. This document aggregates findings across all 21 modules. - -## Per-file docs - -| Topic | File | v1 source(s) | -|---|---|---| -| Configuration | [config.md](config.md) | `src/v1/config.ts` | -| Environment helpers | [env.md](env.md) | `src/v1/env.ts` | -| Types | [types.md](types.md) | `src/v1/types.ts` | -| Logger | [logger.md](logger.md) | `src/v1/logger.ts` | -| Timezone | [timezone.md](timezone.md) | `src/v1/timezone.ts` | -| Database layer | [db.md](db.md) | `src/v1/db.ts` | -| Container runner | [container-runner.md](container-runner.md) | `src/v1/container-runner.ts` | -| Container runtime + mounts | [container-runtime.md](container-runtime.md) | `src/v1/container-runtime.ts`, `mount-security.ts` | -| Group folder | [group-folder.md](group-folder.md) | `src/v1/group-folder.ts` | -| Group queue | [group-queue.md](group-queue.md) | `src/v1/group-queue.ts` | -| Host index | [index-host.md](index-host.md) | `src/v1/index.ts` | -| IPC (host + container) | [ipc.md](ipc.md) | `src/v1/ipc.ts`, `container/.../v1/ipc-mcp-stdio.ts` | -| Remote control | [remote-control.md](remote-control.md) | `src/v1/remote-control.ts` | -| Router | [router.md](router.md) | `src/v1/router.ts` + `index.ts` routing | -| Sender allowlist | [sender-allowlist.md](sender-allowlist.md) | `src/v1/sender-allowlist.ts` | -| Session cleanup | [session-cleanup.md](session-cleanup.md) | `src/v1/session-cleanup.ts` | -| Task scheduler | [task-scheduler.md](task-scheduler.md) | `src/v1/task-scheduler.ts` | -| Channels | [channels.md](channels.md) | `src/v1/channels/*` | -| Agent-runner entry | [container-index.md](container-index.md) | `container/.../v1/index.ts` | -| Agent-runner MCP tools | [container-mcp-tools.md](container-mcp-tools.md) | `container/.../v1/mcp-tools.ts` | -| Formatting test (orphan) | [formatting-test.md](formatting-test.md) | `src/v1/formatting.test.ts` | - -## The big shift - -v2 rewrote the fundamental transport between host and container. The one-line version: - -> **v1 = IPC files + stdin/stdout + in-memory GroupQueue + polling message loop. -> v2 = two SQLite DBs per session + event-driven routing + 60s host sweep.** - -Everything else flows from that. Removing IPC forced a rewrite of the router, the container-runner, the agent-runner entry, and the MCP-tool bridge. The 60s sweep absorbed the task scheduler, session cleanup, and pending-message recovery. The entity model (users/roles/messaging_groups) replaced the flat sender allowlist and chat-level config. Provider abstraction + Chat SDK bridge replaced hardcoded Claude SDK + per-channel adapters. - -Net LOC: v1 (~7.4k host + monolithic container-runner) → v2 (~5.5k host, split modules). Fewer lines, cleaner boundaries, more coverage. - -## What's kept (identical or near-identical) -- `timezone.ts` — byte-identical -- `group-folder.ts` — byte-identical validation; v2 adds `group-init.ts` for filesystem scaffold -- `container-runtime.ts` — nearly identical (only logger import swapped) -- `mount-security.ts` — same structure, one field removed (see regressions) -- `config.ts` / `env.ts` — same structure, same `.env` surface; several constants now dead code -- `logger.ts` — same levels/colors/routing, but API shape changed (message-first instead of data-first) -- MCP `send_message` tool — kept + enhanced with named destinations - -## What's new in v2 -- **Two-DB session model** (`inbound.db` + `outbound.db`) with even/odd seq parity, journal_mode=DELETE for cross-mount visibility -- **Entity model** — `users`, `user_roles` (owner/admin/scoped), `agent_group_members`, `messaging_groups`, `messaging_group_agents`, `user_dms` (cold-DM cache) -- **Host sweep** (60s) — absorbs scheduler, cleanup, pending-message recovery, recurrence firing, stale detection, orphan cleanup -- **Chat SDK bridge** — unifies Discord/Slack/Teams/other adapters through `@anthropic-ai/chat` -- **Provider abstraction** — default Claude + opt-in OpenCode etc. via `providers` branch -- **OneCLI integration** — credential gateway + approval flow (`src/onecli-approvals.ts`) -- **16 new MCP tools** — scheduling (6), interactive (2), self-mod (3), agent mgmt (1), message manipulation (3), plus enhanced `send_message` -- **Heartbeat file mtime** — replaces IPC liveness -- **Session persistence** — session ID survives container restarts -- **Dual-rate polling** — 1000ms idle / 500ms active inside container -- **Idle stream termination** — 20s timeout prevents zombie queries -- **Processing ACK** — reverse channel (outbound → inbound) for idempotence -- **Migration system** — 9 numbered migrations vs v1's ad-hoc ALTERs -- **Webhook server** (new for HTTP-based channels) -- **Container typing indicator refresh** via delivery - -## What's removed (deliberately) -- **IPC transport** (files, stdin/stdout JSON, MCP-over-stdio bridge) — replaced by DB polling -- **`GroupQueue`** in-memory state machine — serialization via `messages_in.status` -- **Output markers** (`---NANOCLAW_OUTPUT_START/END---`) — results land in `messages_out` -- **State persistence** (`router_state`, `lastAgentTimestamp` map) — each message is independent -- **Per-exit container log files** — only logger.debug to host log -- **Flat sender allowlist** (JSON config) — replaced by role-based access + `unknown_sender_policy` -- **Remote control subsystem** (`/remote-control` command → spawned CLI) -- **IPC watcher** (dynamic group-add while running) -- **`task_runs` audit table** — no task execution log -- **Cron/interval task types** as first-class entities — tasks are `messages_in` rows with `kind='task'` + `recurrence` -- **Stdin protocol** for agent input — container reads from inbound.db - -## Regressions worth fixing (ranked) - -### HIGH priority -1. **Trigger-rule matching in `pickAgent`** (`src/router.ts:198` TODO). - Without this, a messaging group wired to multiple agents fires ALL of them on every message. Schema (`messaging_group_agents.trigger_rules`) is ready; the check is ~10 lines. **Likely broken-by-default for multi-agent setups.** - -2. **`nonMainReadOnly` mount isolation removed** (`src/mount-security.ts`). - Non-main/shared agent groups can now mount read-write on any path the allowlist permits. v1 enforced read-only-for-non-main regardless of allowlist. **Security regression** for multi-tenant setups. Restore: add field + restore `isMain` param flow. - -3. **Pending-message recovery on startup** (`src/v1/index.ts:465-473`). - v1 explicitly scanned for unprocessed messages on restart. v2 relies on the sweep to notice. Likely works in practice, but worth a test: kill container mid-message, restart host, verify redelivery within ≤5s. - -### MEDIUM priority -4. **`response_scope` enforcement** (`messaging_group_agents.response_scope` stored but unused). - Values `'all' | 'triggered' | 'allowlisted'` are saved but nothing reads them. - -5. **`request_approval` flow for unknown senders** (`src/router.ts:295` TODO). - `unknown_sender_policy='request_approval'` is scaffolded but doesn't actually produce an approval card. - -6. **Per-group container timeout**. - v1's `containerConfig.timeout` override is gone; all groups share `IDLE_TIMEOUT`. Slow-but-healthy agents get killed with fast agents' timeout. - -7. **Container streaming output**. - v1's marker-based pre-completion delivery is gone. v2 must wait for outbound.db poll. Latency-sensitive UX regresses. - -8. **Per-exit container logs**. - v1 wrote timestamped per-exit log files with full I/O + mounts + stderr. v2 only has logger.debug. Zero-cost on success, high-value on crash. Restore at least for non-zero exit. - -9. **Explicit container kill on stale detection**. - v2's sweep marks messages for retry but doesn't stop the stale container. Only `cleanupOrphans()` at startup removes them. Add `stopContainer()` when heartbeat stale AND processing stuck. - -10. **Host-level retry with backoff on agent error**. - v1 had MAX_RETRIES=5 + exp. backoff on `processGroupMessages` failure. v2 only retries on stale-heartbeat. Explicit agent-error retry could close the gap. - -### LOW priority -11. **Process ID in logger output** — lost multi-process debugging info -12. **Task dedup via unique `(kind, series_id)` index** — v2 can have two pending rows with same series; best-effort via atomic status update -13. **Silent-drop mode for noisy senders** — v1's `mode:'drop'` had a use case; orthogonal to privilege -14. **Remote control** — decide: restore as opt-in skill or document as removed -15. **Dead config constants** (`POLL_INTERVAL`, `SCHEDULER_POLL_INTERVAL`, `IPC_POLL_INTERVAL`) — delete from `src/config.ts` -16. **Configurable retention thresholds** (`STALE_THRESHOLD_MS`, `MAX_TRIES`) — move from constants to `config.ts` -17. **Dynamic group-add** (IPC watcher equivalent) — probably not worth; document that restart is required - -## Things kept as test-only regression risk -The orphan `src/v1/formatting.test.ts` asserted behaviors that aren't fully exercised in v2: -- **Timezone-aware formatted timestamps** — v1 emitted locale strings ("Jan 1, 2024, 1:30 PM"); v2 emits UTC HH:MM -- **`` header** — gone -- **`reply_to=""` attribute** — v2 only stores sender name + truncated preview -- **Trigger-pattern unit tests** — no direct equivalent (logic moved to DB but isn't tested at the router level) -- **Internal tag stripping** tests — no isolated tests in agent-runner - -These are specs worth porting to v2 tests once trigger matching is implemented. - -## Files entirely gone in v2 -- `src/v1/ipc.ts` + `src/v1/ipc-auth.test.ts` — IPC is dead -- `container/.../v1/ipc-mcp-stdio.ts` — MCP-over-stdio bridge dead -- `src/v1/group-queue.ts` — serialization via DB -- `src/v1/session-cleanup.ts` — merged into `host-sweep.ts` -- `src/v1/task-scheduler.ts` — merged into `host-sweep.ts` + system actions in `delivery.ts` -- `src/v1/remote-control.ts` — feature removed -- `src/v1/sender-allowlist.ts` — entity model supersedes - -## Net architectural assessment -v2 is strictly simpler, more consistent, and more robust in its happy path. The remaining TODOs (trigger matching, response_scope, request_approval) reflect scaffolding that was checked in ahead of the feature — none are deep design issues. The one actual regression is `nonMainReadOnly` mount isolation; it was a defense-in-depth feature and deserves to come back. The removal of per-exit container logs and streaming output markers are judgment calls that traded observability for simplicity — both can be restored cheaply if needed. - -No file in v1 contains a behavior that v2 is architecturally unable to express. The outstanding work is feature-completion, not architecture. diff --git a/docs/v1-vs-v2/channels.md b/docs/v1-vs-v2/channels.md deleted file mode 100644 index bd4dda4..0000000 --- a/docs/v1-vs-v2/channels.md +++ /dev/null @@ -1,305 +0,0 @@ -# channels: v1 vs v2 - -## Scope - -### v1 -- **Paths**: `src/v1/channels/index.ts`, `src/v1/channels/registry.ts`, `src/v1/channels/registry.test.ts` -- **LOC**: 62 total (1 + 23 + 38) -- **Purpose**: Registry and interface stubs for external channel adapters (real adapters live on `channels` branch) - -### v2 counterparts -- **Paths**: `src/channels/adapter.ts`, `src/channels/channel-registry.ts`, `src/channels/chat-sdk-bridge.ts`, `src/channels/index.ts`, `src/channels/ask-question.ts`, and tests -- **LOC**: 1,055 total (excluding tests: ~757) -- **Purpose**: Full adapter interface, registry with lifecycle, Chat SDK bridge (new in v2), ask_question normalization, plus integration tests - ---- - -## Adapter Interface Diff - -### v1: `Channel` (from src/v1/types.ts:87–98) - -```typescript -export interface Channel { - name: string; - connect(): Promise; - sendMessage(jid: string, text: string): Promise; - isConnected(): boolean; - ownsJid(jid: string): boolean; - disconnect(): Promise; - setTyping?(jid: string, isTyping: boolean): Promise; // Optional - syncGroups?(force: boolean): Promise; // Optional -} -``` - -**Callbacks** (src/v1/types.ts:101–112): -- `OnInboundMessage(chatJid: string, message: NewMessage): void` -- `OnChatMetadata(chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean): void` - -**Factory & Registration** (src/v1/channels/registry.ts:3–23): -```typescript -export interface ChannelOpts { - onMessage: OnInboundMessage; - onChatMetadata: OnChatMetadata; - registeredGroups: () => Record; -} -export type ChannelFactory = (opts: ChannelOpts) => Channel | null; -registerChannel(name: string, factory: ChannelFactory): void; -getChannelFactory(name: string): ChannelFactory | undefined; -getRegisteredChannelNames(): string[]; -``` - ---- - -### v2: `ChannelAdapter` (from src/channels/adapter.ts:61–106) - -```typescript -export interface ChannelAdapter { - name: string; - channelType: string; - supportsThreads: boolean; // NEW: declares thread model - - // Lifecycle (was: connect/disconnect) - setup(config: ChannelSetup): Promise; - teardown(): Promise; - isConnected(): boolean; - - // Message delivery (was: sendMessage, now structured) - deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise; - - // Optional - setTyping?(platformId: string, threadId: string | null): Promise; - syncConversations?(): Promise; - updateConversations?(conversations: ConversationConfig[]): void; - openDM?(userHandle: string): Promise; // NEW: cold-DM initiation -} -``` - -**Callbacks** (src/channels/adapter.ts:18–30): -```typescript -export interface ChannelSetup { - conversations: ConversationConfig[]; - onInbound(platformId: string, threadId: string | null, message: InboundMessage): void | Promise; - onMetadata(platformId: string, name?: string, isGroup?: boolean): void; - onAction(questionId: string, selectedOption: string, userId: string): void; // NEW -} -``` - -**Factory & Registration** (src/channels/channel-registry.ts:25–47): -```typescript -export type ChannelAdapterFactory = () => ChannelAdapter | Promise | null; -export interface ChannelRegistration { - factory: ChannelAdapterFactory; - containerConfig?: { mounts?: [...]; env?: Record; }; -} -registerChannelAdapter(name: string, registration: ChannelRegistration): void; -getChannelAdapter(channelType: string): ChannelAdapter | undefined; // RENAMED -getActiveAdapters(): ChannelAdapter[]; // NEW -getRegisteredChannelNames(): string[]; -getChannelContainerConfig(name: string): ChannelRegistration['containerConfig']; // NEW -``` - ---- - -## Capability Map - -| v1 Behavior | v2 Location | Status | Notes | -|---|---|---|---| -| **Interface & Lifecycle** | | | | -| `connect()` → `disconnect()` | `setup()` / `teardown()` | Renamed + consolidated | v2 groups init work; adds promise-based retry on NetworkError (src/channels/channel-registry.ts:73) | -| `Channel.name: string` | `ChannelAdapter.name` + `ChannelAdapter.channelType` | Split | `name` is identity; `channelType` is the key for active lookup | -| `ownsJid(jid)` | Implicit in platformId model | Removed | v2 uses structured platformId + threadId; ownership logic pushed to router | -| **Message Flow** | | | | -| `sendMessage(jid, text)` | `deliver(platformId, threadId, message)` | Refactored | v2 passes structured `OutboundMessage` with `kind` field; returns platform messageId; supports edit/reaction ops (src/channels/chat-sdk-bridge.ts:279–289) | -| Callbacks: `onMessage` | `onInbound(platformId, threadId, message)` | Refactored | v2 passes message object with `kind` enum ('chat' \| 'chat-sdk'); can be async | -| Callbacks: `onChatMetadata` | `onMetadata(platformId, name?, isGroup?)` | Simplified | Signature matches v1; removed `channel` param; timestamp now in inbound message itself | -| | `onAction(questionId, option, userId)` | **NEW** | Handles ask_question card button clicks via Chat SDK bridge (src/channels/chat-sdk-bridge.ts:193–218) | -| **Typing Indicator** | | | | -| `setTyping(jid, bool)` | `setTyping(platformId, threadId)` | Refactored | v2 omits boolean flag (always true, no off-toggle); threaded parameter | -| **Group/Conversation Sync** | | | | -| `syncGroups(force?)` | `syncConversations()?: Promise` | Renamed | Now returns structured list; decoupled from periodic init (optional hook) | -| | `updateConversations(configs)`: void | **NEW** | Push notifications of conversation changes from host to adapter (e.g., new wiring) | -| **Thread Model** | | | | -| Implicit (adapter-specific) | `supportsThreads: boolean` | **NEW** | v2 explicitly declares it; router uses this to collapse/expand thread context (src/channels/adapter.ts:73–75) | -| **DM Initiation** | | | | -| Not exposed | `openDM(userHandle)?: Promise` | **NEW** | For cold-DM reaching (approvals, onboarding, alerts) on platforms that distinguish user-id from DM-channel-id. Optional; fallback in user-dm.ts if absent (src/channels/adapter.ts:94–105) | -| **Inbound Message Structure** | | | | -| v1 `NewMessage` object | v2 `InboundMessage` (generic JSON) | Generalized | v1 had flat fields (sender, content, timestamp, thread_id, reply_to_*); v2 wraps serialized Chat SDK Message or native JSON in `content` field; Chat SDK bridge enriches (adds senderId, senderName) before sending (src/channels/chat-sdk-bridge.ts:124–141) | -| **Outbound Message Structure** | | | | -| Plain text + typing flag | v2 `OutboundMessage` (typed `kind` + flexible `content`) | Generalized | Supports 'chat', 'chat-sdk', edit ops, reactions, ask_question cards (src/channels/adapter.ts:46–51, src/channels/chat-sdk-bridge.ts:279–317) | -| **Factory Pattern** | | | | -| `ChannelFactory(opts) → Channel \| null` | `ChannelAdapterFactory() → ChannelAdapter \| Promise<...> \| null` | Async + cred check | v2 supports async factory (for loading credentials); promise-based retry on NetworkError (src/channels/channel-registry.ts:68–87) | -| **Container Config** | | | | -| Not exposed | `ChannelRegistration.containerConfig` | **NEW** | Adapters can declare mounts + env vars for their container (used by container-runner); see src/channels/channel-registry.ts:45–47 | - ---- - -## Message Conversion & Error Handling - -### v1 Flow -- Adapter calls `onMessage(chatJid, NewMessage)` synchronously -- Router extracts fields, upserts user, creates/finds session, writes to `inbound.db` -- No built-in error handling; adapters catch and log themselves - -### v2 Flow (src/channels/chat-sdk-bridge.ts:85–141) -1. **Inbound**: Chat SDK `Message` → `InboundMessage` (kind='chat-sdk', content=serialized JSON) -2. **Attachment handling**: Downloads attachments, converts to base64 (src/channels/chat-sdk-bridge.ts:90–111) -3. **Reply context extraction**: Platform-specific hook (src/channels/chat-sdk-bridge.ts:115–120) -4. **User field normalization**: Maps Chat SDK author → senderId, sender, senderName (src/channels/chat-sdk-bridge.ts:124–131) -5. **Raw data drop**: Removes `raw` to save DB space (src/channels/chat-sdk-bridge.ts:134) -6. **Call onInbound**: Async-capable (can await router writes) - -**Outbound** (src/channels/chat-sdk-bridge.ts:273–344): -- Supports multiple operation types via `content.operation`: - - `'edit'` + `messageId` → `adapter.editMessage()` - - `'reaction'` + `emoji` → `adapter.addReaction()` - - `type: 'ask_question'` → render Card with buttons - - Normal text/markdown → `adapter.postMessage()` with optional files - -**Error Propagation**: -- Network errors on setup get retry (src/channels/channel-registry.ts:73; duck-type check for Error.name==='NetworkError') -- Delivery errors logged but don't block (src/channels/chat-sdk-bridge.ts:213–214, 484–486) - ---- - -## New: Chat SDK Bridge - -The v2 `Chat` abstraction (from `@anthropic-ai/chat`) wraps platform-specific adapters (Discord.js, Slack SDK, etc.) into a unified API. The NanoClaw `createChatSdkBridge()` (src/channels/chat-sdk-bridge.ts:68–384) adapts that `Chat` instance to the `ChannelAdapter` interface. - -**Key methods**: -- `setup(hostConfig)`: Initialize Chat, set up event handlers (subscribed messages, DMs, mentions, actions), start Gateway listener or register webhook (src/channels/chat-sdk-bridge.ts:149–271) -- `deliver()`: Route outbound payloads (text, edit, reaction, ask_question card) to Chat SDK (src/channels/chat-sdk-bridge.ts:273–344) -- `setTyping()`: Delegate to `adapter.startTyping()` (src/channels/chat-sdk-bridge.ts:346–349) -- `teardown()`: Abort Gateway, shutdown Chat (src/channels/chat-sdk-bridge.ts:351–355) -- `updateConversations()`: Rebuild conversation map on changes (src/channels/chat-sdk-bridge.ts:361–363) -- `openDM()`: Conditional; only if underlying adapter supports it (src/channels/chat-sdk-bridge.ts:366–381) - -**Event routing** (src/channels/chat-sdk-bridge.ts:163–191): -- `chat.onSubscribedMessage()` → `onInbound()` for all known threads -- `chat.onNewMention()` → `onInbound()` + auto-subscribe -- `chat.onDirectMessage()` → `onInbound()` for DMs -- `chat.onAction()` → `onAction()` for ask_question button clicks (src/channels/chat-sdk-bridge.ts:193–218) - -**Gateway listener** (src/channels/chat-sdk-bridge.ts:222–268): -- Adapters like Discord that support websocket connection declare `startGatewayListener()`. -- NanoClaw runs it, forwards interactions (button clicks) to a local HTTP webhook server (src/channels/chat-sdk-bridge.ts:392–506). -- Non-Gateway adapters (Slack, Teams) register on the shared webhook-server instead (src/channels/chat-sdk-bridge.ts:266–268). - ---- - -## Test Fixtures - -### v1 (src/v1/channels/registry.test.ts:10–38) -- Simple lambda factories: `() => null` -- No mock adapters (tests only verify registry API mechanics) -- Test count: 4 (unknown-channel, round-trip, listing, overwrite) - -### v2 (src/channels/channel-registry.test.ts + src/channels/chat-sdk-bridge.test.ts) - -**Mock Adapter** (src/channels/channel-registry.test.ts:31–71): -```typescript -createMockAdapter(channelType): ChannelAdapter & { delivered, inbound, setupConfig } - - Properties: name, channelType, supportsThreads, delivered[], inbound[], setupConfig - - Methods: setup(config), teardown(), isConnected(), deliver(), setTyping(), updateConversations() -``` - -**Registry Tests** (src/channels/channel-registry.test.ts:84–119): -- Adapter registration with container config (src/channels/channel-registry.test.ts:88–98) -- Credential-missing adapters skipped (src/channels/channel-registry.test.ts:101–119) - -**Integration Tests** (src/channels/channel-registry.test.ts:122–234): -- Router receives inbound from adapter, writes to inbound.db (src/channels/channel-registry.test.ts:166–197) -- Delivery adapter bridge calls adapter.deliver() (src/channels/channel-registry.test.ts:199–233) - -**Chat SDK Bridge Tests** (src/channels/chat-sdk-bridge.test.ts:11–38): -- Conditional openDM exposure (src/channels/chat-sdk-bridge.test.ts:12–18) -- openDM delegation to underlying adapter (src/channels/chat-sdk-bridge.test.ts:20–37) - ---- - -## Missing from v2 - -### 1. `ownsJid(jid: string): boolean` -- **v1 use**: Adapters declared ownership of a JID (e.g., "does this Telegram numeric ID belong to me?") -- **v2 model**: JIDs → platformId + threadId; ownership is implicit in `platformId` format (e.g., `"telegram:6037840640"` vs `"discord:guildId:channelId"`). Router uses this to route inbound to the right adapter. -- **Impact**: Adapters no longer need explicit ownership checks; the structured ID handles it. - -### 2. `syncGroups(force?: boolean): Promise` -- **v1 use**: Periodic or on-demand sync of all groups/channels from the platform. -- **v2 model**: Optional `syncConversations()` returns metadata instead of mutating internal state; host calls it when needed (not baked into adapter init). Conversations are tracked in central DB `messaging_groups` table. -- **Impact**: Host has more control; adapters don't side-effect their own state. - -### 3. `registeredGroups` callback in `ChannelOpts` -- **v1 use**: Passed at init time; adapters could query which groups were registered. -- **v2 model**: Conversations provided upfront in `ChannelSetup.conversations`; can be updated via `updateConversations()`. -- **Impact**: Cleaner dependency injection; avoids callback nesting. - -### 4. `channel` parameter in `OnChatMetadata` -- **v1 use**: Metadata callback could optionally return which channel type made the discovery. -- **v2 model**: Not needed; `platformId` in `onMetadata(platformId, name, isGroup)` encodes the channel type. - ---- - -## Behavioral Discrepancies - -### 1. Thread-ID Handling -- **v1**: Some adapters (Telegram, WhatsApp) don't use threads; JIDs are the same as channel IDs. Others (Discord, Slack) embed thread IDs in reply_to logic. -- **v2**: Explicit `supportsThreads` flag; adapters that don't support threads pass `threadId: null` to `onInbound()`. Router uses this to decide session granularity (file:src/channels/adapter.ts:73–75). - -### 2. Outbound Message Structure -- **v1**: Plain text + optional typing flag. -- **v2**: Structured `{ kind, content, files? }` with operation support (edit, reaction, ask_question cards). Allows multi-op delivery without repeated deliver() calls. - -### 3. Inbound Serialization -- **v1**: Adapters directly passed `NewMessage` interface objects. -- **v2**: Adapters pass `InboundMessage` with generic `content` field (JSON-serializable JS object). Chat SDK bridge converts Chat SDK Message → JSON, then stringifies for DB (file:src/channels/chat-sdk-bridge.ts:136–140). - -### 4. Ask-Question Handling -- **v1**: No native support; would be custom per-adapter. -- **v2**: Unified via `ask_question` payload type. Chat SDK bridge renders as Card + Buttons; handles button clicks via `onAction()` callback and updates card to show selection (file:src/channels/chat-sdk-bridge.ts:292–317, 459–486). - -### 5. Cold-DM Initiation -- **v1**: Not exposed. -- **v2**: `openDM(userHandle): Promise` allows host to initiate DMs to users without prior message. Adapters that need it (Discord, Slack, Teams) implement; others omit and fall back to direct handle as platformId (file:src/user-dm.ts fallback). - -### 6. Async Factory -- **v1**: `ChannelFactory` returns `Channel | null` synchronously. -- **v2**: `ChannelAdapterFactory` returns `ChannelAdapter | Promise | null`, supporting async credential loading. Registry retries on `NetworkError` (file:src/channels/channel-registry.ts:68–87). - -### 7. Lifecycle Promises -- **v1**: `connect()` / `disconnect()` are separate. -- **v2**: `setup()` / `teardown()` grouped; no intermediate "starting/stopping" state. Gateway listeners and webhook servers are started inside `setup()`, torn down inside `teardown()` (file:src/channels/chat-sdk-bridge.ts:149–271, 351–355). - ---- - -## Worth Preserving? - -**All v1 patterns are preserved in v2, just restructured:** - -1. **Adapter interface model**: v1's optional hooks (`setTyping?`, `syncGroups?`) become v2's optional methods (`setTyping?`, `syncConversations?`, `openDM?`). Structural compatibility for native adapters. - -2. **Registry pattern**: v1's `registerChannel(name, factory)` → v2's `registerChannelAdapter(name, registration)`. Same self-registration barrel; v2 adds container config metadata. - -3. **Callback-driven message flow**: v1's `onMessage` and `onChatMetadata` callbacks live on as `onInbound` and `onMetadata`. v2 adds `onAction` for interactive features (ask_question buttons). - -4. **No built-in state mutation**: v1 adapters own their group state; v2 adapters are stateless (conversations pushed in). Both respect adapter autonomy. - -**What's genuinely new and worth keeping:** - -- **Chat SDK bridge**: Unifies platform SDKs without duplicating channel adapters per SDK. Huge reduction in code duplication (one Discord adapter instead of native + Chat SDK versions). -- **Structured message payloads**: v2's `kind` field and flexible `content` JSON allow single delivery path for text, edits, reactions, and rich interactions. -- **Ask-question cards**: Native support for interactive approvals and user input, reducing agent-side boilerplate. -- **openDM**: Enables host-initiated contact (onboarding, alerts, approvals) without waiting for inbound. -- **supportsThreads**: Explicit declaration lets router make informed session granularity decisions, vs. hardcoded per-adapter assumptions. - -**Minimal migration burden:** - -Native adapters written for v1 need only: -1. Rename `connect` → `setup` (add `ChannelSetup` param). -2. Rename `disconnect` → `teardown`. -3. Rename `sendMessage(jid, text)` → `deliver(platformId, threadId, message)` (wrap text in `{ kind: 'chat', content: { text } }`). -4. Add `supportsThreads: boolean`, `name`, `channelType` fields. -5. Add `isConnected()` stub if not already present. -6. Optional: Implement `setTyping?`, `syncConversations?`, `openDM?` for feature parity. - -Nothing is fundamentally broken; it's a straightforward refactor of the adapter contract. - diff --git a/docs/v1-vs-v2/config.md b/docs/v1-vs-v2/config.md deleted file mode 100644 index c646499..0000000 --- a/docs/v1-vs-v2/config.md +++ /dev/null @@ -1,99 +0,0 @@ -# config: v1 vs v2 - -## Scope - -- **v1**: `/Users/gavriel/nanoclaw4/src/v1/config.ts` (63 lines) + `/Users/gavriel/nanoclaw4/src/v1/env.ts` (42 lines) -- **v2 counterparts**: `/Users/gavriel/nanoclaw4/src/config.ts` (63 lines, **identical**), `/Users/gavriel/nanoclaw4/src/env.ts` (42 lines, **identical**), plus host-level polling in `/Users/gavriel/nanoclaw4/src/host-sweep.ts` and `/Users/gavriel/nanoclaw4/src/delivery.ts`; container agent-runner reads at `/Users/gavriel/nanoclaw4/container/agent-runner/src/index.ts` - -## Capability map - -| v1 behavior | v2 location | Status | Notes | -|---|---|---|---| -| **ASSISTANT_NAME** env var (default: 'Andy') | `src/config.ts:10`; read from `.env` or `process.env` | Kept, partially used | v2 exports it but doesn't use it in host. Container receives via `NANOCLAW_ASSISTANT_NAME` env var (set by `src/container-runner.ts:302`) for transcript archiving only. v1 used it for CLAUDE.md substitution, trigger pattern, and prompt context. | -| **ASSISTANT_HAS_OWN_NUMBER** boolean env var | `src/config.ts:11-12` | **Removed, unused** | Exported but neither v1 nor v2 use it. No evidence of any implementation. | -| **POLL_INTERVAL = 2000ms** | `src/config.ts:13` | **Removed, unused** | v1 used in `index.ts:457` (IPC watcher polling). v2 replaced IPC with session DBs; no polling needed at this interval. | -| **SCHEDULER_POLL_INTERVAL = 60000ms** | `src/config.ts:14` | **Removed, unused** | v1 used in `task-scheduler.ts:231`. v2 uses hard-coded `SWEEP_INTERVAL_MS = 60_000` in `host-sweep.ts:31` instead (same value, different source). | -| **IPC_POLL_INTERVAL = 1000ms** | `src/config.ts:32` | **Removed, unused** | v1 used in `ipc.ts:50, ipc.ts:122`. v2 replaced file-based IPC with SQLite session DBs; this interval has no meaning. | -| **MOUNT_ALLOWLIST_PATH** = `~/.config/nanoclaw/mount-allowlist.json` | `src/config.ts:21` | Kept, same behavior | Used by `src/mount-security.ts` (host) to whitelist directories containers can read. Same in both versions. | -| **SENDER_ALLOWLIST_PATH** = `~/.config/nanoclaw/sender-allowlist.json` | `src/config.ts:22` | Kept, same behavior | Stored outside project root for security. Path derivation identical in v1 and v2. **Unused in v2** (no grep hits outside v1 folder). | -| **STORE_DIR** = `store/` | `src/config.ts:23` | **Removed, unused** | v1 used in `db.ts`. v2 uses central DB (`data/v2.db`) and per-session DBs (`data/v2-sessions//{inbound,outbound}.db`). `store/` directory no longer part of v2 architecture. | -| **GROUPS_DIR** = `groups/` | `src/config.ts:24` | Kept, same behavior | Per-agent-group filesystem (CLAUDE.md, skills, config). Used in `src/container-runner.ts`, `src/delivery.ts`, `src/group-init.ts`. Identical role in both versions. | -| **DATA_DIR** = `data/` | `src/config.ts:25` | Kept, extended usage | v1: IPC files, task DB. v2: central DB, session DBs, heartbeat files. More central in v2. Used in `src/index.ts`, `src/session-manager.ts`, `src/group-init.ts`, etc. | -| **CONTAINER_IMAGE** env var (default: 'nanoclaw-agent:latest') | `src/config.ts:27` | Kept, same behavior | Specifies Docker image name. Used in `src/container-runner.ts`. Identical in both versions. | -| **CONTAINER_TIMEOUT** env var (default: 1800000ms = 30min) | `src/config.ts:28` | Kept, same behavior | Maximum wall-clock time for a single container invocation. Used in `src/container-runner.ts`. Identical in both versions. | -| **CONTAINER_MAX_OUTPUT_SIZE** env var (default: 10485760 bytes = 10MB) | `src/config.ts:29` | **Removed, unused** | Exported but never referenced in v1 or v2. No evidence of implementation. | -| **ONECLI_URL** env var (no default) | `src/config.ts:30` | Kept, same behavior | OneCLI gateway URL for credential management. Read from `.env` or `process.env`. Used in `src/onecli-approvals.ts`. Identical in both versions. | -| **MAX_MESSAGES_PER_PROMPT** env var (default: 10) | `src/config.ts:31` | **Removed, unused** | v1 used in message batching for prompt formatting (`v1/index.ts:192-193, 434-435, 467`). v2 removed MAX_MESSAGES limit; agent processes all pending messages in a turn. | -| **IDLE_TIMEOUT** env var (default: 1800000ms = 30min) | `src/config.ts:33` | Kept, same behavior | How long to keep container alive after last result before killing due to inactivity. Used in `src/container-runner.ts:134-139`. Identical in both versions. | -| **MAX_CONCURRENT_CONTAINERS** env var (default: 5) | `src/config.ts:34` | **Removed, unused** | v1 used in `group-queue.ts` for queue management. v2 removed group queueing (no group-queue.ts equivalent). Sessions start containers independently; no global cap enforced. | -| **escapeRegex()** helper | `src/config.ts:36-38` | Kept, same implementation | Escapes regex special characters. Used by `buildTriggerPattern()`. Identical in both versions. | -| **buildTriggerPattern()** helper | `src/config.ts:40-42` | Kept, same implementation | Builds case-insensitive word-boundary regex from trigger string. Used in v2 by... (no grep hits in non-v1 v2 code). Exported but **unused in v2**. | -| **DEFAULT_TRIGGER** = `@${ASSISTANT_NAME}` | `src/config.ts:44` | Kept, **unused** | Default trigger pattern for agent activation. Computed from ASSISTANT_NAME. Exported but not used in v2 (no grep hits outside v1). | -| **getTriggerPattern()** helper | `src/config.ts:46-49` | Kept, **unused** | Returns regex for trigger matching. Used in v1 for routing decisions. Exported but **not used in v2** (trigger logic moved to DB `messaging_group_agents.trigger_rules`). | -| **TRIGGER_PATTERN** = computed | `src/config.ts:51` | Kept, **unused** | Pre-built DEFAULT_TRIGGER pattern. Exported but **not used in v2**. | -| **resolveConfigTimezone()** helper | `src/config.ts:55-61` | Kept, same implementation | Resolves IANA timezone from TZ env var → `.env` TZ → system timezone → 'UTC'. Identical logic in both versions. | -| **TIMEZONE** const | `src/config.ts:62` | Kept, same behavior | Current timezone for scheduled tasks, message timestamps. Used in `src/host-sweep.ts`, `container/agent-runner/src/index.ts`. Identical in both versions. | -| **readEnvFile()** function | `src/env.ts:11-42` | Kept, identical | Reads `.env` file, returns only requested keys, does not pollute `process.env`. Used by config.ts. Prevents secrets leak to child processes. Identical in both versions. | - ---- - -## Missing from v2 - -- **POLL_INTERVAL** (2000ms hardcoded constant) — v1 polling loop. v2 has no direct equivalent; delivery uses hard-coded `ACTIVE_POLL_MS = 1000` (`src/delivery.ts:56`). Not configurable. - -- **SCHEDULER_POLL_INTERVAL** (60000ms hardcoded constant) — v1 task scheduler. v2 uses hard-coded `SWEEP_INTERVAL_MS = 60_000` (`src/host-sweep.ts:31`). Same interval, not configurable from config.ts. - -- **IPC_POLL_INTERVAL** (1000ms hardcoded constant) — v1 IPC file watcher. No v2 equivalent; IPC replaced with session DBs. - -- **MAX_MESSAGES_PER_PROMPT** (env var, default 10) — v1 message batching. v2 has no message batching limit; all pending messages in a turn are processed together. - -- **MAX_CONCURRENT_CONTAINERS** (env var, default 5) — v1 group queue. v2 has no group-level concurrency cap; sessions start containers independently. - -- **STORE_DIR** (store/ directory) — v1 task/group storage. v2 uses central DB + session DBs; no store/ directory needed. - -- **SENDER_ALLOWLIST_PATH** — Path is defined but never used in either version. - ---- - -## Behavioral discrepancies - -1. **ASSISTANT_NAME usage** - - v1: Used for CLAUDE.md template substitution (`v1/index.ts:135-137`), getLastBotMessageTimestamp comparison, and trigger pattern building. - - v2: Only passed to container as `NANOCLAW_ASSISTANT_NAME` env var (`src/container-runner.ts:302`); container uses it for transcript archiving only. Host does not use it. - - **Impact**: v1 personalized CLAUDE.md by name; v2 relies on statically authored CLAUDE.md in `groups//`. - -2. **Trigger pattern handling** - - v1: Trigger pattern from `getTriggerPattern()` used at host routing layer (`v1/index.ts:200, 419`). - - v2: Trigger rules stored in DB (`messaging_group_agents.trigger_rules` JSON field), evaluated at delivery time by router. `getTriggerPattern()` exported but unused. - - **Impact**: v1 required config-level trigger changes; v2 allows per-messaging-group customization via DB. - -3. **Timezone resolution** - - v1: `resolveConfigTimezone()` used in `task-scheduler.ts:5`. - - v2: Same function; `TIMEZONE` used in `host-sweep.ts`, `container/agent-runner/src/index.ts:45` (but never actually referenced in agent-runner). - - **Impact**: Identical behavior; minor: container reads env var but doesn't use it. - -4. **Poll intervals** - - v1: `POLL_INTERVAL`, `SCHEDULER_POLL_INTERVAL`, `IPC_POLL_INTERVAL` all separately configured. - - v2: Hard-coded `ACTIVE_POLL_MS = 1000`, `SWEEP_POLL_MS = 60_000` in `src/delivery.ts`. Container poll loop uses hard-coded `POLL_INTERVAL_MS = 1000`, `ACTIVE_POLL_INTERVAL_MS = 500` in `container/agent-runner/src/poll-loop.ts:10-11`. - - **Impact**: v2 intervals are not tunable via env vars; requires code change. - -5. **Message batching** - - v1: `MAX_MESSAGES_PER_PROMPT` limits messages per turn (`v1/index.ts:467`). - - v2: No limit; all pending messages (minus filtered/denied commands) are formatted and sent to agent in one turn. - - **Impact**: v2 may send larger prompts; unbounded context risk if message queue grows. - -6. **Container concurrency** - - v1: `MAX_CONCURRENT_CONTAINERS` enforced via group queue (`v1/group-queue.ts`). - - v2: No global or per-group limit. Each session independently starts its container on wake. - - **Impact**: v2 can spawn many containers simultaneously; no backpressure mechanism. - -7. **IPC → Session DB** - - v1: Uses file-based IPC (JSON files, `IPC_POLL_INTERVAL` polling). - - v2: Uses SQLite session DBs (`inbound.db` host-owned, `outbound.db` container-owned). - - **Impact**: v2 is more reliable (ACID semantics) but less debuggable (binary format). - ---- - -## Worth preserving? - -**No.** The config.ts file is largely a legacy artifact. Most of its exports are unused in v2, and the few that remain (TIMEZONE, IDLE_TIMEOUT, ONECLI_URL, paths) are minimally invasive. The hardcoded poll intervals and removed features (MAX_MESSAGES, MAX_CONCURRENT_CONTAINERS, IPC_POLL_INTERVAL) reflect architectural changes that are intentional and correct for v2. The trigger pattern and ASSISTANT_NAME handling in config.ts should be removed from the host layer entirely — they're now managed by the DB and container env vars. Consolidate host-level config into a smaller, focused module that only exports what v2 actually uses: TIMEZONE, IDLE_TIMEOUT, CONTAINER_TIMEOUT, ONECLI_URL, path constants, and the env file reader. diff --git a/docs/v1-vs-v2/container-index.md b/docs/v1-vs-v2/container-index.md deleted file mode 100644 index 4b61d87..0000000 --- a/docs/v1-vs-v2/container-index.md +++ /dev/null @@ -1,72 +0,0 @@ -# container index (agent-runner entry): v1 vs v2 - -## Scope -- v1: `container/agent-runner/src/v1/index.ts` (736 LOC) — monolithic: arg parsing, IPC polling, SDK integration, output marshaling -- v2 (split): `container/agent-runner/src/index.ts` (124 LOC) + `poll-loop.ts` (436 LOC) + `destinations.ts` (118 LOC) + `formatter.ts` (228 LOC) + `db/*.ts` + `providers/*.ts` - -## Startup sequence diff - -| Step | v1 (IPC) | v2 (SQLite poll) | -|------|----------|------------------| -| Arg parsing | stdin JSON via `readStdin()` (v1:105-115) | env vars: `AGENT_PROVIDER`, `NANOCLAW_*` (v2 index.ts:44-51) | -| Env setup | `sdkEnv` + `CLAUDE_CODE_AUTO_COMPACT_WINDOW` (v1:626-629) | same, delegated to provider (index.ts:109) | -| DB open | — (IPC files only) | inbound.db (RO) + outbound.db (RW) + `session_state` table | -| MCP server config | hardcoded nanoclaw server (v1:477-486) | same + `NANOCLAW_MCP_SERVERS` env for additional (index.ts:94-104) | -| Message loop | `waitForIpcMessage()` polling (v1:350-366) | `poll-loop.ts:62+` `getPendingMessages()` every 1000ms idle / 500ms active | -| Provider | Claude SDK direct | provider abstraction factory (`providers/factory.ts`, supports claude/mock/custom) | -| Message stream | `MessageStream` iterable (v1:71-103) | same pattern in `providers/claude.ts:51-80` | -| System prompt | manual CLAUDE.md load + hardcoded destinations (v1:416-420) | `buildSystemPromptAddendum()` from inbound.db destinations (`destinations.ts:76-117`) | -| Query execution | `runQuery()` with IPC polling during query (v1:374-545) | `processQuery()` polls messages_in + `provider.query()` (`poll-loop.ts:259-319`) | -| Session resumption | sessionId on stdin + `resumeAt` tracking | `getStoredSessionId()` from outbound.db; cleared on `/clear` admin command | -| Shutdown | stdout output markers + exit(1) on error | no markers; logs errors; host manages lifecycle | -| Heartbeat | — | file touch at `SESSION_HEARTBEAT_PATH` on each result | - -## Capability map - -| v1 behavior | v2 location | Status | Notes | -|---|---|---|---| -| Parse prompt/session/group/chat/etc. from stdin | env + inbound.db | kept | | -| Env injection (ANTHROPIC_BASE_URL, proxy) | passed to provider.query() (index.ts:109) | kept | | -| Stdin JSON parsing | — | **removed** | | -| IPC file polling | `messages_in` table | modernized | Same semantics, DB-backed | -| IPC `_close` sentinel | implicit (process killed by host) | simplified | | -| Output wrapping markers | writes to `messages_out` | **removed** | | -| Session archiving PreCompact hook | `providers/claude.ts` hook | kept | | -| Session resumption by ID | `getStoredSessionId()` (poll-loop.ts:51) | **persisted** | Survives container restart | -| Scheduled task script execution | `task-script.ts:applyPreTaskScripts()` (poll-loop.ts:159) | kept | | -| Command filtering (`/help`, `/login`) | `categorizeMessage()` + filtered set (formatter.ts:14, poll-loop.ts:95-100) | **enhanced** | Explicit categories | -| Admin commands (`/clear`, etc.) | `categorizeMessage` + `NANOCLAW_ADMIN_USER_IDS` gate (poll-loop.ts:102-131) | kept | Explicit admin role from env | -| Destination routing `to=` | `destinations` table + `dispatchResultText()` (poll-loop.ts:350-432) | modernized | Named destinations instead of raw JIDs | -| Multi-destination message blocks | `MESSAGE_RE` regex (poll-loop.ts:350-414) | kept | | -| Tool allowlist | `providers/claude.ts:19-39` | kept | | -| MCP server setup | index.ts:81-104 | kept + extensible | | -| `@-syntax` additional dirs | `/workspace/extra/*` discovered at startup (index.ts:64-74) | kept | | -| Global CLAUDE.md | SDK preset append (index.ts:56-58) | kept | | -| Idle stream termination | — | **new** (IDLE_END_MS = 20s prevents zombies) | -| Admin user ID prefixing (chat-sdk) | explicit `channel_type:` prefix (formatter.ts:58-66) | **new** | | -| Processing ACK | **new** | prevents re-processing on container restart | -| Message kind formatting | `formatMessages()` (formatter.ts) | enhanced | Routes by kind: chat/task/webhook/system | - -## Missing from v2 -None of v1's core capabilities dropped. Notes on format/protocol shifts: -1. **Stdout markers removed** — host now parses `messages_out` table instead of stdout -2. **Stdin protocol gone** — follow-up messages via `messages_in` table -3. **Script-phase fast exit removed** — v1 could skip container entirely if `wakeAgent=false`; v2 gates message processing but container keeps polling (slightly more idle cost) - -## Behavioral discrepancies -1. **Idle timeout**: v1 had no query-level timeout → zombies possible. v2 ends stream after 20s with no SDK events -2. **Resume**: v1 re-read sessionId from stdin each run; v2 persists in `session_state` across restarts -3. **Admin gating**: v1 passed everything through; v2 categorizes + admin-gates `/clear` etc. -4. **Destination naming**: v1 raw JID; v2 human names from destinations table -5. **Poll cadence**: v2 dual-rate — 1000ms idle, 500ms active (CPU efficiency + responsiveness) -6. **Message kind routing**: v1 uniform; v2 distinguishes chat/chat-sdk/task/webhook/system with per-kind formatting - -## Worth preserving? -v1 should remain historical reference only. v2 strictly supersedes: -- DB-backed state survives restarts -- Provider abstraction allows non-Claude agents -- Dynamic destinations from inbound.db -- Session invalidation detection + processing ACK idempotence -- Dual poll rate + idle termination prevent pathological query hangs - -No merge-back candidates identified. diff --git a/docs/v1-vs-v2/container-mcp-tools.md b/docs/v1-vs-v2/container-mcp-tools.md deleted file mode 100644 index 41282e1..0000000 --- a/docs/v1-vs-v2/container-mcp-tools.md +++ /dev/null @@ -1,58 +0,0 @@ -# container mcp-tools: v1 vs v2 - -## Scope -- v1: `container/agent-runner/src/v1/mcp-tools.ts` (81 LOC) — single tool (`send_message`) -- v2: `container/agent-runner/src/mcp-tools/` — 7 modules (~971 LOC): `index.ts`, `core.ts`, `scheduling.ts`, `interactive.ts`, `agents.ts`, `self-mod.ts`, `types.ts` - -## Tool map - -| v1 tool | v2 file | Status | Schema / behavior diff | -|---|---|---|---| -| `send_message(text, channel, platformId, threadId)` | `core.ts:50-95` | **kept, enhanced** | v2 uses named destinations (`to`), auto-resolves via session default or lookup, preserves `thread_id` intelligently | -| — | `core.ts:133-177` `send_file` | **new** | Copies file to outbox dir, routes via destinations | -| — | `core.ts:179-218` `edit_message` | **new** | Edit previously-sent message by seq id | -| — | `core.ts:220-259` `add_reaction` | **new** | Emoji reaction by seq id | -| — | `scheduling.ts:33-79` `schedule_task` | **new** | One-shot or recurring (cron) | -| — | `scheduling.ts:81-137` `list_tasks` | **new** | Pending/paused tasks grouped by series | -| — | `scheduling.ts:139-165` `cancel_task` | **new** | | -| — | `scheduling.ts:167-192` `pause_task` | **new** | | -| — | `scheduling.ts:194-219` `resume_task` | **new** | | -| — | `scheduling.ts:221-266` `update_task` | **new** | Modify prompt/recurrence/processAfter/script | -| — | `interactive.ts:36-129` `ask_user_question` | **new** | Blocking with timeout — writes to outbound.db then polls inbound.db for response | -| — | `interactive.ts:131-166` `send_card` | **new** | Structured Chat SDK cards | -| — | `self-mod.ts` `install_packages` | **new** | apt/npm install, regex name validation, admin approval; approval handler auto-rebuilds image and restarts container | -| — | `self-mod.ts` `add_mcp_server` | **new** | Wire existing MCP server; approval handler restarts container (no image rebuild) | -| — | `agents.ts:30-63` `create_agent` | **new** | Admin-only sub-agent creation; not exposed to non-admin containers | - -## New tools in v2 -15 new tools split across 5 capability domains: -- **Message manipulation**: `send_file`, `edit_message`, `add_reaction` -- **Scheduling**: 6 task-management tools -- **Interactive**: `ask_user_question`, `send_card` -- **Self-modification**: `install_packages`, `add_mcp_server` -- **Agent management**: `create_agent` - -## Missing from v2 -**None.** v2 strictly adds; v1's only tool (`send_message`) was kept and enhanced. - -## Behavioral discrepancies -1. **Destination resolution**: v1 used explicit channel/platformId/threadId params; v2 resolves named destinations from `destinations` map with fallback to session routing -2. **Two-DB split pattern**: all scheduling/self-mod tools write system actions to **outbound.db**; host processes (applies to inbound.db). Container never writes directly to inbound -3. **`ask_user_question` is blocking**: synchronously polls inbound.db until response arrives or timeout — agent perception is blocking, transport is async -4. **Admin enforcement**: `create_agent` + self-mod tools check admin approval host-side (`NANOCLAW_ADMIN_USER_IDS` env controls tool visibility) -5. **Message editing/reactions**: use internal seq id (not user-visible numeric message ID) — requires outbound.db lookup - -## Transport pattern (v2 common) -1. Agent invokes tool → validation (regex, enum, length) -2. Tool writes `messages_out` or system-action row -3. Tool returns success immediately (fire-and-forget) -4. Host polls outbound.db, applies approval / routing / side effects - -## Worth preserving? -**Yes, fully.** The v2 modular architecture is a large improvement: -- Clear separation by capability domain -- Two-DB constraint cleanly encoded (container → outbound, host → inbound) -- Named destination abstraction (better UX than raw JIDs) -- Admin-only tool filtering at the MCP server level - -v1 is retained as historical reference only. No merge-back. diff --git a/docs/v1-vs-v2/container-runner.md b/docs/v1-vs-v2/container-runner.md deleted file mode 100644 index c598df7..0000000 --- a/docs/v1-vs-v2/container-runner.md +++ /dev/null @@ -1,51 +0,0 @@ -# container-runner: v1 vs v2 - -## Scope -- v1: `src/v1/container-runner.ts` (677 LOC) + `container-runner.test.ts` (204 LOC) — spawn + IPC plumbing + stdin/stdout JSON + process supervision + output-marker parsing -- v2: `src/container-runner.ts` (405 LOC) + `src/container-config.ts` (114 LOC) + `src/session-manager.ts` (DB paths). Net ~272 LOC removed by eliminating IPC and output parsing - -## Capability map - -| v1 behavior | v2 location | Status | Notes | -|---|---|---|---| -| Image selection | `container-runner.ts:348-349` | kept | Reads `imageTag` from container.json or env | -| Env injection | `container-runner.ts:266-284` | **changed** | Replaced IPC vars with `SESSION_INBOUND/OUTBOUND_DB_PATH`, `SESSION_HEARTBEAT_PATH`, `AGENT_PROVIDER`, `NANOCLAW_*` admin IDs | -| Volume mounts | `container-runner.ts:200-252` | **changed** | Removed per-group IPC dir; added session folder `/workspace` + agent group `/workspace/agent` | -| Mount validation | `container-runner.ts:240-244` | kept | Validates `additionalMounts` from container.json | -| Provider integration | `container-runner.ts:184-198` | **new** | `resolveProviderContribution()` wires provider host-side configs | -| stdin/stdout IPC | — | **removed** | v1 lines 318-387; v2 uses DB polling only; stdio=`['ignore','pipe','pipe']` | -| Process spawn | `container-runner.ts:119` | kept | | -| OneCLI `ensureAgent` + `applyContainerConfig` | `container-runner.ts:301-313` | enhanced | v2 calls `ensureAgent` first | -| Admin ID injection | `container-runner.ts:289-295` | **new** | Queries `getOwners/getGlobalAdmins/getAdminsOfAgentGroup` at wake | -| Idle timeout | `container-runner.ts:135-140` | changed | v2 uses `resetIdle()` callback on activeContainers entry, settable by `delivery.ts` | -| Timeout logic | — | **removed** | v1 had configurable per-group timeout reset on output markers | -| Output parsing | — | **removed** | v1 parsed `---NANOCLAW_OUTPUT_START/END---` from stdout; v2 ignores stdout | -| Streaming output callback | — | **removed** | v1 had `onOutput()` for real-time delivery | -| Per-exit log file | — | **removed** | v1 wrote `groups//logs/container-*.log` with full I/O; v2 only logs stderr to logger.debug | -| Graceful SIGTERM→SIGKILL | — | simplified | v2 just calls `stopContainer()` | -| Concurrent wake dedup | `container-runner.ts:44-82` | **new** | `wakePromises` Map prevents race on spawn | -| Per-group image builds | `container-runner.ts:357-405` | **new** | `buildAgentGroupImage()` writes `imageTag` | -| Session folder init | `container-runner.ts:210` | **new** | `initGroupFilesystem()` at spawn | -| Heartbeat file `/workspace/.heartbeat` | session-manager.ts | **new** | File-touch replaces IPC liveness | -| Task/group JSON snapshots (`current_tasks.json`, `available_groups.json`) | — | **removed** | v2 pushes data via inbound.db writeDestinations/writeSessionRouting | -| Container name | `container-runner.ts:103` | changed | `nanoclaw-v2-${folder}-${Date.now()}` | - -## Missing from v2 -1. **Streaming output markers** — `---NANOCLAW_OUTPUT_START/END---` enabled pre-completion delivery; v2 must wait for outbound.db poll to deliver results -2. **Configurable per-group timeout** — `group.containerConfig.timeout` override is gone; all groups share `IDLE_TIMEOUT` -3. **Per-exit detailed logs** — v1 wrote timestamped logs with full I/O + mounts + stderr + stdout; invaluable for post-mortem -4. **Graceful-stop sentinel** — v1 sent SIGTERM and waited for `_close` marker before SIGKILL -5. **JSON snapshots for tasks/groups** — `current_tasks.json` / `available_groups.json` in the group IPC dir - -## Behavioral discrepancies -1. **Async result model**: v1 `runContainerAgent()` returned `Promise` with inline result; v2 `wakeContainer()` is fire-and-forget — results asynchronous via delivery poll -2. **No stdin**: v1 wrote full `ContainerInput` JSON to stdin; v2 container reads everything from inbound.db -3. **Admin injection at wake**: v2 queries admins fresh on every spawn (`NANOCLAW_ADMIN_USER_IDS`) -4. **Destination routing timing**: v2 calls `writeDestinations()` + `writeSessionRouting()` on every wake so changes apply without restart -5. **Session lifecycle**: v1 created a session per spawn; v2 resolves session via router before wake - -## Worth preserving? -- **Streaming output**: Meaningful latency improvement. Hybrid model (DB polling + optional marker pre-delivery) could reduce perceived latency for long outputs -- **Per-group timeout**: Restore — different agent groups have different expected latencies -- **Per-exit logs**: At minimum, restore on non-zero exit. Cheap forensics, huge debug value -- **Graceful-stop sentinel**: Not critical — bun container is disposable diff --git a/docs/v1-vs-v2/container-runtime.md b/docs/v1-vs-v2/container-runtime.md deleted file mode 100644 index e240247..0000000 --- a/docs/v1-vs-v2/container-runtime.md +++ /dev/null @@ -1,46 +0,0 @@ -# container-runtime + mount-security: v1 vs v2 - -## Scope -- v1: `src/v1/container-runtime.ts` (81 LOC), `container-runtime.test.ts` (148 LOC), `mount-security.ts` (406 LOC) -- v2: `src/container-runtime.ts` (81 LOC), `container-runtime.test.ts` (149 LOC), `mount-security.ts` (390 LOC) - -## Capability map - -### container-runtime.ts - -| v1 behavior | v2 location | Status | Notes | -|---|---|---|---| -| `CONTAINER_RUNTIME_BIN = 'docker'` | `container-runtime.ts:1` | kept | Hardcoded; Apple Container runtime is NOT handled here in either version | -| `hostGatewayArgs()` | `container-runtime.ts` | kept | Identical | -| `readonlyMountArgs()` | `container-runtime.ts` | kept | Identical | -| `stopContainer()` | `container-runtime.ts` | kept | Identical | -| `ensureContainerRuntimeRunning()` | `container-runtime.ts` | kept | Identical | -| `cleanupOrphans()` | `container-runtime.ts:60-80` | kept | Identical logic | -| Logging module | | **changed** | v1 imports `logger` (data-first); v2 imports `log` (message-first) | - -### mount-security.ts - -| v1 behavior | v2 location | Status | Notes | -|---|---|---|---| -| `AdditionalMount` / `AllowedRoot` / `MountAllowlist` types | `mount-security.ts:16-29` | kept | Same shape except `nonMainReadOnly` removed | -| Default blocked patterns | `mount-security.ts:39` | kept | Same list | -| Allowlist load + file-watch cache | `mount-security.ts:64-102` | kept | | -| Path expansion (`~`) | `mount-security.ts` | kept | | -| Symlink resolution | `mount-security.ts` | kept | | -| Container-path validation | `mount-security.ts` | kept | | -| Template generation | `mount-security.ts:362-386` | changed | v2 template omits `nonMainReadOnly: true` | -| `validateMount(mount, isMain)` | `mount-security.ts:230-307` | **signature changed** | v2 is `validateMount(mount)` — no `isMain` | -| `validateAdditionalMounts(mounts, groupName, isMain)` | same | **signature changed** | v2 drops `isMain` | -| Non-main groups forced to read-only | — | **removed** | v1 lines 283-291; v2 only checks `allowedRoot.allowReadWrite` | - -## Missing from v2 -1. **`nonMainReadOnly` flag on `MountAllowlist`** — v1 could force non-main agent groups to read-only even when their allowlist permitted RW -2. **`isMain` param flow** through `validateMount` / `validateAdditionalMounts` -3. **Non-main group RW enforcement** at mount-validation time — now delegated entirely to `allowedRoot.allowReadWrite` - -## Behavioral discrepancies -1. **Isolation model weakened**: a non-main ("shared" or auxiliary) agent group can now mount RW on any path its root permits. v1's defense-in-depth (allowlist permits RW + group must be main) is reduced to just the allowlist check -2. **Logger import**: only surface difference in container-runtime.ts - -## Worth preserving? -**`nonMainReadOnly` restoration has security value** for multi-tenant setups where shared/sandbox agent groups should not mutate filesystem even if the allowlist is permissive. Low-cost to reintroduce: restore the field on `MountAllowlist`, restore the `isMain` param, restore the check in `validateMount()`. If v2 has explicitly decided isolation is enforced elsewhere (agent-group config), document that; otherwise this is a regression. diff --git a/docs/v1-vs-v2/db.md b/docs/v1-vs-v2/db.md deleted file mode 100644 index 0614b44..0000000 --- a/docs/v1-vs-v2/db.md +++ /dev/null @@ -1,542 +0,0 @@ -# db: v1 vs v2 - -## Scope - -**v1 (historical, not runtime):** -- `/Users/gavriel/nanoclaw4/src/v1/db.ts` (659 lines) -- `/Users/gavriel/nanoclaw4/src/v1/db.test.ts` (592 lines) -- `/Users/gavriel/nanoclaw4/src/v1/db-migration.test.ts` (60 lines) -- **Single database:** `/messages.db` (better-sqlite3) -- No session/agent-runner separation; chat metadata + message history only - -**v2 counterparts:** -- Central: `/Users/gavriel/nanoclaw4/src/db/*.ts` (index, schema, connection, 9 modules + 7 migrations) -- Session: `/Users/gavriel/nanoclaw4/src/db/session-db.ts` (200+ lines) -- Chat SDK state: `/Users/gavriel/nanoclaw4/src/state-sqlite.ts` (250+ lines) -- Docs: `docs/db.md`, `docs/db-central.md`, `docs/db-session.md` - ---- - -## High-Level Shift - -| Aspect | v1 | v2 | -|--------|----|----| -| **Database count** | 1 | 3 (central + per-session inbound + per-session outbound) | -| **Primary purpose** | Message history for a WhatsApp/multi-channel bot | Admin plane (identity, wiring, approvals) + per-session message queues | -| **Writer model** | Single process | Single writer per file (host writes central + inbound; container writes outbound) | -| **Schema evolution** | Ad-hoc ALTER TABLE in `createSchema()` | Versioned migrations in `src/db/migrations/` | -| **Multi-tenant** | No (one bot per instance) | Yes (multiple agent groups, isolation levels, approval flows) | -| **Key invariants** | Bot prefix filter, last-bot-timestamp cursor | Seq parity (even host, odd container), journal_mode=DELETE cross-mount visibility | - ---- - -## Capability Map - -| v1 Behavior | v2 Location | Status | Notes | -|-------------|-------------|--------|-------| -| **`chats` table** (jid, name, last_message_time, channel, is_group) | `messaging_groups` (central DB) | Kept, renamed | v1: chat metadata only, no messages stored. v2: per-platform chat, with `unknown_sender_policy`, routing to multiple agents. | -| **`messages` table** (id, chat_jid, sender, content, timestamp, is_from_me, is_bot_message, reply_to_*) | `messages_in` (session inbound) | Moved to session DB | v1: indexed by `timestamp`, filtered by bot prefix + flag. v2: indexed by `series_id` (recurring), seq-numbered, multi-kind (chat|task|system), host-written with even seq. Container reads pending/unprocessed. | -| **`scheduled_tasks` table** (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, next_run, context_mode, status) | `messages_in` (session inbound, kind='task') | Moved to session messages | v1: separate table with status='active'\|'paused'\|'completed'. v2: unified into `messages_in` with kind='task', status per message. Scheduling engine lives in v2 `host-sweep.ts`. | -| **`task_run_logs` table** (task_id, run_at, duration_ms, status, result, error) | No direct counterpart | Removed | v2 doesn't persist task execution logs in DB; host-sweep handles recurrence in-memory and via `processing_ack` acks. | -| **`router_state` table** (key, value) | Not needed in v2 | Removed | v1: stored `last_timestamp`, `last_agent_timestamp` for polling cursor. v2: central DB and message tables eliminate need for manual state; routing is deterministic via `messaging_group_agents` and session queues. | -| **`sessions` table** (group_folder, session_id) | `sessions` (central DB) | Kept, extended | v1: maps group folder to session ID. v2: central registry: id, agent_group_id, messaging_group_id, thread_id, status, container_status, last_active. Keyed by `(agent_group_id, messaging_group_id, thread_id)` tuples. | -| **`registered_groups` table** (jid, name, folder, trigger_pattern, requires_trigger, is_main, container_config) | `agent_groups` (central DB) | Converted | v1: per-JID trigger; one agent per bot instance. v2: agent_groups independent of channels; multiple messaging_groups wire to each agent via `messaging_group_agents`. Container config moved to disk (`groups//container.json`). | -| **Bot message filtering (is_bot_message flag + prefix)** | `messages_in` schema + container read filter | Kept, schema-level | v1: dual check (flag + `content LIKE 'Andy:%'` backstop). v2: container-side filtering in agent-runner. | -| **Reply context (reply_to_message_id, reply_to_content, reply_to_sender_name)** | `messages_in` columns | Kept | v1: nullable columns added via migration. v2: same schema, inherited from v1 shape. | -| **Chat metadata sync (last_message_time, channel, is_group)** | `messaging_groups` + lazy platform discovery | Converted | v1: timestamps in `chats.last_message_time`. v2: platform metadata in `messaging_groups`; `last_active` in `sessions` for activity tracking. | -| **Group discovery** (getAllChats) | Channel adapters + `messaging_groups` query | Removed from DB | v1: `getAllChats()` queries local DB. v2: adapters populate `messaging_groups` on first message; host discovers channels via routing, not polling DB. | -| **Message filtering by timestamp window** | `getNewMessages()`, `getMessagesSince()` with LIMIT subquery | Moved to session inbound | v1: queries with ORDER BY DESC, LIMIT N, then re-sort chronologically. v2: host writes to inbound; container polls. Cursor logic inverted (container drives processing, host feeds). | -| **Limit behavior (cap to N most recent)** | Hardcoded LIMIT 200 with timestamp filter | Kept, per-session | v1: `getNewMessages(limit=200)` by default. v2: `messages_in` has process-after and recurrence; container pulls per poll batch. | -| **Journal mode** | Not explicitly configured | DELETE (session), WAL (central) | v1: better-sqlite3 default (volatile). v2: `journal_mode=DELETE` on session DBs for cross-mount visibility; WAL on central DB for consistency. See `db/connection.ts:17` and `db/session-db.ts:15`. | -| **Foreign key constraints** | Soft (checked in code) | Hard (enforced in schema) | v1: no FK constraints. v2: all references are `REFERENCES table(id)` with implicit RESTRICT. Central DB enforces full FK graph. | -| **Pragmas** | Not set | `foreign_keys=ON`, `busy_timeout=5000` | v1: defaults only. v2: explicit, cross-mount-safe timeouts. | -| **Index coverage** | `idx_timestamp` on messages, `idx_next_run` on tasks, `idx_status` on tasks | Expanded | v1: 3 indexes. v2: series_id, user_roles scope, sessions lookup, agent_destinations target, pending_approvals action+status. | - ---- - -## Schema Diff: Table-by-Table - -### **Chats → Messaging Groups** - -**v1 `chats` (PK: jid):** -```sql -jid, name, last_message_time, channel, is_group -``` - -**v2 `messaging_groups` (PK: id, UNIQUE: channel_type, platform_id):** -```sql -id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at -``` - -**Diff:** -- v1: jid is the platform ID directly (`"tg:123"`, `"group@g.us"`) -- v2: splits into `channel_type` ("telegram", "whatsapp") + `platform_id` (normalized ID) -- v1: no `unknown_sender_policy`; dropped messages silently -- v2: adds policy for first-time senders: `strict` (drop), `request_approval` (ask admin), `public` (allow) -- v1: `last_message_time` per chat; v2: moved to `sessions.last_active` -- **Table lifecycle:** `chats` is ephemeral in v2 (discovered lazily); `messaging_groups` is central registry - -### **Messages → Messages In (Session)** - -**v1 `messages` (PK: id + chat_jid):** -```sql -id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, -reply_to_message_id, reply_to_message_content, reply_to_sender_name -``` - -**v2 `messages_in` (PK: id, UNIQUE: seq):** -```sql -id, seq, kind, timestamp, status, process_after, recurrence, series_id, tries, -platform_id, channel_type, thread_id, content -``` - -**Diff:** -- v1: single-session messages; chat_jid is the routing key -- v2: per-session inbound queue; platform_id + channel_type + thread_id from routing, not payload -- v1: sender/sender_name as columns -- v2: content is JSON (all fields, including sender, are inside) -- v1: `is_bot_message` flag -- v2: `kind` field (`'chat'`, `'task'`, `'system'`) replaces ad-hoc bot detection -- v1: no seq, no status, no recurrence -- v2: **seq invariant** — even numbers only (host-assigned); see `nextEvenSeq()` at `src/db/session-db.ts:75` -- v1: `reply_to_*` columns preserved in v2 -- v1: indexed on timestamp; v2: indexed on series_id (for recurring task grouping) - -### **Scheduled Tasks → Messages In + Processing** - -**v1 `scheduled_tasks` (PK: id):** -```sql -id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, -next_run, last_run, last_result, context_mode, status, created_at -``` - -**v2 spread across:** -- `messages_in` (host writes kind='task') -- `processing_ack` (container reads/writes status) -- No persistent `task_run_logs` - -**Diff:** -- v1: tasks are a separate schema; v2: tasks are messages (kind='task') -- v1: `prompt`, `script`, `context_mode` in task row; v2: in JSON `content` -- v1: `schedule_type` (once, recurring), `schedule_value` (cron); v2: same, in `recurrence` field (cron string) -- v1: `next_run`, `last_run` tracked in table; v2: `process_after`, `status` in messages_in; recurrence logic in host-sweep -- v1: `last_result` stored; v2: no persistence; result is in container logs or delivery flow -- v1: status='active'|'paused'|'completed'; v2: status='pending'|'processing'|'completed'|'failed'|'paused' (per message, unified with chat) - -### **Task Run Logs → Removed** - -**v1 `task_run_logs` (PK: id auto-increment, FK: task_id):** -```sql -task_id, run_at, duration_ms, status, result, error -``` - -**v2:** Not in DB. - -**Rationale:** v2 doesn't persist execution history in-DB; logs are streamed to host and written to operational logs. Task state is tracked via `processing_ack` status on the message itself, not a separate log table. - -### **Router State → Removed** - -**v1 `router_state` (PK: key):** -```sql -key (last_timestamp, last_agent_timestamp), value -``` - -**v2:** Not needed. - -**Rationale:** v1 used this to track polling cursors across restarts. v2 uses message IDs and seq numbers; polling logic is implicit in the session queue architecture. - -### **Sessions Table** - -**v1 `sessions` (PK: group_folder):** -```sql -group_folder, session_id -``` - -**v2 `sessions` (PK: id):** -```sql -id, agent_group_id, messaging_group_id, thread_id, agent_provider, status, container_status, last_active, created_at -``` - -**Diff:** -- v1: simple folder → session mapping -- v2: full session tuple: agent group + messaging group + thread, with lookup index on (messaging_group_id, thread_id) -- v1: no status tracking; v2: `status` (active|paused|archived), `container_status` (stopped|starting|running) -- v2: `agent_provider` override per session -- v2: `last_active` timestamp for stale detection - -### **Registered Groups → Agent Groups + Messaging Group Agents** - -**v1 `registered_groups` (PK: jid):** -```sql -jid, name, folder, trigger_pattern, requires_trigger, is_main, added_at, container_config -``` - -**v2 split into:** -- `agent_groups` (PK: id): `id, name, folder, agent_provider, created_at` — container config on disk -- `messaging_group_agents` (PK: id): bridges messaging groups to agents with wiring rules - -**Diff:** -- v1: one-to-one chat ↔ group; v2: many-to-many messaging group ↔ agent group -- v1: `trigger_pattern` on chat; v2: `trigger_rules` (JSON) on the `messaging_group_agents` wiring -- v1: `container_config` JSON in DB; v2: lives on disk at `groups//container.json` -- v1: `requires_trigger`, `is_main` flags; v2: `session_mode` (shared|per-thread|agent-shared) on wiring - -### **New v2 Tables (Central)** - -**`users`:** -```sql -id, kind, display_name, created_at -``` -Platform identities: `"tg:123"`, `"discord:abc"`, `"phone:+1555..."`, `"email:a@x.com"`. No v1 counterpart (permissions were implicit). - -**`user_roles`:** -```sql -user_id, role (owner|admin), agent_group_id (NULL=global), granted_by, granted_at -``` -v1 had no explicit permissions; v2 enforces owner/admin privilege with audit trail. - -**`agent_group_members`:** -```sql -user_id, agent_group_id, added_by, added_at -``` -Non-privileged user membership. v1: implied (everyone could message the bot). - -**`user_dms`:** -```sql -user_id, channel_type, messaging_group_id, resolved_at -``` -Cached DM channel discovery (avoids repeated API calls). No v1 equivalent. - -**`pending_questions`:** -```sql -question_id, session_id, message_out_id, platform_id, channel_type, thread_id, title, options_json, created_at -``` -Interactive multiple-choice questions. v1: no interactive prompts. - -**`agent_destinations`:** -```sql -agent_group_id, local_name, target_type, target_id, created_at -``` -Per-agent ACL and name-resolution map for `send_message(to="name")`. Projected into session inbound as `destinations` table (see db-session.md §2.3). v1: no permission model for outbound sends. - -**`pending_approvals`:** -```sql -approval_id, session_id, request_id, action, payload, agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status, title, options_json, created_at -``` -Approval queue for `install_packages`, `add_mcp_server`, OneCLI credential flows. v1: no approval model. - -**`unregistered_senders` (via migration 008):** -```sql -user_id, messaging_group_id, first_seen, last_seen -``` -Audit trail of unknown senders (strict unknown_sender_policy). v1: silently dropped. - -**Chat SDK tables (via migration 002):** -- `chat_sdk_kv` (key, value, expires_at) -- `chat_sdk_subscriptions` (thread_id, subscribed_at) -- `chat_sdk_locks` (thread_id, token, expires_at) -- `chat_sdk_lists` (key, idx, value, expires_at) - -Backing store for Chat SDK state adapter. No v1 equivalent (Chat SDK didn't exist). - -### **New v2 Session Tables (Inbound, Host-written)** - -**`delivered`:** -```sql -message_out_id, platform_message_id, status, delivered_at -``` -Host tracks delivery outcomes without writing to container-owned outbound.db. - -**`destinations` (projection from central):** -```sql -name, display_name, type, channel_type, platform_id, agent_group_id -``` -Local ACL cache; rewritten on every container wake. Container queries this live to authorize sends. - -**`session_routing` (single-row table):** -```sql -id=1, channel_type, platform_id, thread_id -``` -Default reply routing for the session. Allows container to default outbound messages without querying central DB. - -### **New v2 Session Tables (Outbound, Container-written)** - -**`messages_out`:** -```sql -id, seq (ODD), in_reply_to, timestamp, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content -``` -Container-produced: chat replies, edits, reactions, cards, system actions. Seq always odd (container-assigned); see `src/db/session-db.ts:76` for parity logic. - -**`processing_ack`:** -```sql -message_id, status (processing|completed|failed), status_changed -``` -Container writes status for each message_in it touched. Host polls and syncs back into messages_in (avoids container writing inbound.db). - -**`session_state` (KV):** -```sql -key, value, updated_at -``` -Container persistent store (Chat SDK session ID, conversation state). Cleared by `/clear`. - ---- - -## Missing from v2 - -1. **Per-message sender/sender_name columns** — moved into JSON `content`. Container unpacks on read. -2. **`task_run_logs` persistent history** — v2 streams logs to host; no in-DB history. -3. **`last_agent_timestamp` cursor state** — implicit in session message seq. -4. **Message filtering by bot prefix** — handled by container when writing to outbound; inbound doesn't filter. -5. **Direct chat timestamp tracking** — replaced by `sessions.last_active` and message timestamps. -6. **Single-writer assumption for one bot** — v2: one writer per file, across multiple agent groups (containers). - ---- - -## Behavioral Discrepancies - -### Sequence Numbering (Load-Bearing Invariant) - -**v1:** No seq; messages identified by (id, chat_jid). - -**v2:** -- Host assigns **even** seq (2, 4, 6, …) to `messages_in`; see `nextEvenSeq()` at `src/db/session-db.ts:75–78`. -- Container assigns **odd** seq (1, 3, 5, …) to `messages_out`; see container logic at `container/agent-runner/src/db/messages-out.ts:54`. -- **Invariant:** seq is globally unique within a session across both tables. Parity disambiguates table membership for `edit_message(seq=5)` (odd → messages_out, even → messages_in). -- **If violated:** edits target wrong table; messaging breaks. - -### Message Status Lifecycle - -**v1:** `messages` are immutable once written; `scheduled_tasks` have status (active|paused|completed). - -**v2:** `messages_in` have status (pending|processing|completed|failed|paused). Container writes status into `processing_ack`; host syncs back. Processing is non-blocking (container reads when status='pending'). - -### Journal Mode (Cross-Mount Visibility) - -**v1:** Not configured (better-sqlite3 defaults to `PRAGMA journal_mode = memory` or implicit rollback). - -**v2:** **`journal_mode = DELETE` on session DBs** (see `db/session-db.ts:15`), **WAL on central** (see `db/connection.ts:17`). - -**Rationale:** v1 is single-process. v2 has host and container accessing the same session DBs across a Docker mount or Apple Container mount. WAL has issues with cross-mount visibility (rolled WAL files don't sync reliably); DELETE forces each write to flush the main file, so readers see the latest state. - -### Unknown Sender Handling - -**v1:** Silently dropped or stored with no policy tracking. - -**v2:** `unknown_sender_policy` on `messaging_groups`: `strict` (drop), `request_approval` (admin card), `public` (allow). Dropped senders tracked in `unregistered_senders` audit table (migration 008). - -### Recurring Tasks - -**v1:** `scheduled_tasks.recurrence` (cron); `schedule_type` (once|recurring); status tracking in row. - -**v2:** `messages_in.recurrence` (cron string), `series_id` (groups occurrences). Host-sweep recalculates next run via cron parser; no persistence. Status per message (pending|paused|completed). - -### Chat Metadata Sync - -**v1:** `getAllChats()` queries local DB; `last_message_time` updated by each message insert. - -**v2:** Metadata lives in `messaging_groups` (central, discovered lazily by adapters). Activity tracked in `sessions.last_active`. No global "last message" timestamp per chat. - -### Destinations and Permissions - -**v1:** No model; all agents can send to all chats. - -**v2:** -- Central: `agent_destinations` (source of truth) -- Session: `destinations` (projection in inbound.db, rewritten on wake) -- Container: queries `destinations` live; sends rejected if name not found -- Invariant: if wiring changes mid-session and `writeDestinations()` isn't called, container sees stale data - -### Foreign Key Enforcement - -**v1:** No constraints; referential integrity checked in code. - -**v2:** All FKs enforced; central DB will reject orphaned references. Session DBs don't need as many FKs (immutable projections). - ---- - -## Pragmas & Configuration - -### v1 - -```javascript -// Implicit defaults — not set in code -``` - -### v2 - -**Central DB (src/db/connection.ts:17–18):** -```javascript -_db.pragma('journal_mode = WAL'); -_db.pragma('foreign_keys = ON'); -``` - -**Session Inbound (src/db/session-db.ts:23–24):** -```javascript -db.pragma('journal_mode = DELETE'); -db.pragma('busy_timeout = 5000'); -``` - -**Session Outbound (src/db/session-db.ts:31–32):** -```javascript -// Opens readonly -db.pragma('busy_timeout = 5000'); -``` - ---- - -## Migrations - -### v1 -Adhoc `ALTER TABLE` in `createSchema()` (src/v1/db.ts:82–134): -- context_mode → scheduled_tasks -- script → scheduled_tasks -- is_bot_message → messages -- is_main → registered_groups -- channel, is_group → chats -- reply_to_* → messages - -No versioning; all tables are `IF NOT EXISTS` and ALTERs are try-catch silent. - -### v2 -Numbered migrations in `src/db/migrations/` (1–9, note: 5–6 missing): - -1. **001-initial.ts** — all core tables (agent_groups, messaging_groups, users, user_roles, agent_group_members, user_dms, sessions, pending_questions) -2. **002-chat-sdk-state.ts** — chat_sdk_kv, chat_sdk_subscriptions, chat_sdk_locks, chat_sdk_lists -3. **003-pending-approvals.ts** — pending_approvals table with action, payload, status -4. **004-agent-destinations.ts** — agent_destinations table + backfill from existing messaging_group_agents wirings -5. **(missing)** -6. **(missing)** -7. **007-pending-approvals-title-options.ts** — adds title, options_json columns to pending_approvals -8. **008-dropped-messages.ts** — unregistered_senders audit table -9. **009-drop-pending-credentials.ts** — cleanup (if any) - -**Runner:** `runMigrations()` (src/db/migrations/index.ts:28–60) tracks version in `schema_version` table; applies pending migrations in transaction. - ---- - -## Index Coverage - -### v1 - -- `idx_timestamp` on `messages(timestamp)` — range queries for new messages -- `idx_next_run` on `scheduled_tasks(next_run)` — sweep query for due tasks -- `idx_status` on `scheduled_tasks(status)` — filter for active tasks -- `idx_task_run_logs` on `task_run_logs(task_id, run_at)` — log lookup - -### v2 - -- `idx_user_roles_scope` on `user_roles(agent_group_id, role)` — permission queries -- `idx_sessions_agent_group` on `sessions(agent_group_id)` — session lookup per agent -- `idx_sessions_lookup` on `sessions(messaging_group_id, thread_id)` — resolve session from channel+thread -- `idx_messages_in_series` on `messages_in(series_id)` — recurring task grouping -- `idx_agent_dest_target` on `agent_destinations(target_type, target_id)` — reverse lookup (find agents that can send to this target) -- `idx_pending_approvals_action_status` on `pending_approvals(action, status)` — sweep query for pending/expired approvals - ---- - -## Prepared Queries & Helpers - -### v1 Helpers (src/v1/db.ts) - -``` -storeChatMetadata(jid, timestamp, name?, channel?, isGroup?) - — INSERT OR REPLACE into chats (ON CONFLICT upsert) - -storeMessage(NewMessage) -storeMessageDirect({id, chat_jid, sender, ...}) - — INSERT OR REPLACE into messages - -getNewMessages(jids[], lastTimestamp, botPrefix, limit=200) - — SELECT from messages, filter by jid list, timestamp > last, bot filter - — Returns {messages, newTimestamp} - -getMessagesSince(chatJid, sinceTimestamp, botPrefix, limit=200) - — SELECT from messages, filter by chat, timestamp > since, bot filter, ORDER DESC + outer sort - -getLastBotMessageTimestamp(chatJid, botPrefix) - — SELECT MAX(timestamp) from messages WHERE (is_bot_message=1 OR content LIKE prefix) - -createTask(ScheduledTask) / updateTask(id, fields) / getTaskById(id) / deleteTask(id) - — Standard CRUD - -getDueTasks() - — SELECT * WHERE status='active' AND next_run <= now - -updateTaskAfterRun(id, nextRun, lastResult) - — UPDATE task set next_run, last_run, last_result, status - -logTaskRun(TaskRunLog) - — INSERT into task_run_logs - -getRouterState(key) / setRouterState(key, value) - — KV table - -getSession(groupFolder) / setSession(groupFolder, sessionId) / deleteSession(groupFolder) - — Simple mapping - -getRegisteredGroup(jid) / setRegisteredGroup(jid, group) / getAllRegisteredGroups() - — CRUD on registered_groups -``` - -### v2 Helpers - -**Central DB (src/db/*.ts):** -- `createAgentGroup`, `getAgentGroup`, `getAgentGroupByFolder`, `updateAgentGroup`, `deleteAgentGroup` -- `createMessagingGroup`, `getMessagingGroup`, `getMessagingGroupByPlatform`, `updateMessagingGroup`, `deleteMessagingGroup` -- `createMessagingGroupAgent`, `getMessagingGroupAgents`, `getMessagingGroupAgentByPair`, `updateMessagingGroupAgent`, `deleteMessagingGroupAgent` -- `grantRole`, `revokeRole`, `getUserRoles`, `isOwner`, `isGlobalAdmin`, `isAdminOfAgentGroup`, `hasAdminPrivilege` -- `createUser`, `upsertUser`, `getUser`, `getAllUsers`, `updateDisplayName`, `deleteUser` -- `addMember`, `removeMember`, `getMembers`, `isMember` -- `upsertUserDm`, `getUserDm`, `getUserDmsForUser`, `deleteUserDm` -- `createSession`, `getSession`, `findSession`, `findSessionByAgentGroup`, `getSessionsByAgentGroup`, `getActiveSessions`, `getRunningSessions`, `updateSession`, `deleteSession` -- `createPendingQuestion`, `getPendingQuestion`, `deletePendingQuestion` -- `createPendingApproval`, `getPendingApproval`, `updatePendingApprovalStatus`, `deletePendingApproval`, `getPendingApprovalsByAction` - -**Session DB (src/db/session-db.ts):** -- `ensureSchema(dbPath, 'inbound'|'outbound')` — idempotent schema setup -- `openInboundDb(dbPath)`, `openOutboundDb(dbPath)` — safe open with pragmas -- `nextEvenSeq(db)` — helper for host seq assignment -- `insertMessage(db, {id, kind, timestamp, platformId, channelType, threadId, content, processAfter, recurrence})` -- `insertTask(db, {id, processAfter, recurrence, ...})` -- `cancelTask(db, taskId)`, `pauseTask(db, taskId)`, `resumeTask(db, taskId)` -- `upsertSessionRouting(db, {channel_type, platform_id, thread_id})` -- `replaceDestinations(db, entries: DestinationRow[])` - ---- - -## Key Invariants - -### v1 -- **Bot message filtering:** is_bot_message flag + content prefix as backstop (for pre-migration rows) -- **Cursor recovery:** getLastBotMessageTimestamp() to resume after stale downtime -- **Single writer:** Process that imports db.ts owns all writes; no IPC -- **Chat metadata immutability:** chats table updated only on metadata sync or first message, never deleted - -### v2 (Load-Bearing) - -1. **Single writer per file** — host writes central + inbound; container writes outbound only -2. **Seq parity invariant** — even in messages_in, odd in messages_out; parity disambiguates edit target -3. **Journal mode = DELETE on session DBs** — `DELETE` mode ensures cross-mount visibility (no WAL rollback issues) -4. **Foreign keys enforced** — central DB rejects orphans; schema_version tracks migrations -5. **Projection consistency** — `agent_destinations` (central) must be projected to `destinations` (session inbound) on every container wake; if wiring changes mid-session, must call `writeDestinations()` or container sees stale ACL -6. **Seq monotonicity** — no gaps, no reuse. `nextEvenSeq()` and container logic both scan MAX(seq) across both tables before assigning next -7. **Processing_ack as reverse channel** — container never writes to inbound.db; all status goes through outbound.db processing_ack, which host polls -8. **Heartbeat out of band** — `.heartbeat` file mtime is liveness signal, not a DB write; avoids serialization with message processing -9. **Admin at A implies membership in A** — invariant enforced in code (src/db/user-roles.ts, src/access.ts); no FK prevents deletion - ---- - -## Worth Preserving? - -**Yes — all v1 features are preserved or evolved:** -- Message history: v1 stores per-chat; v2 per-session. Content and metadata shapes mostly compatible. -- Scheduled tasks: v1 separate table; v2 unified into messages_in with kind='task'. Recurrence logic identical (cron). -- Bot filtering: v1 dual-check (flag + prefix); v2 single flag. Backstop logic removed (assumed migrated by now). -- Reply context: All v1 columns kept; v2 schema inherited. - -**What's gone and why:** -- `task_run_logs` — v2 doesn't persist execution history; logging is operational only. -- `router_state` — v1 polling cursors; v2 implicit in message queuing. -- Single-bot assumption — v2 is multi-tenant; this is a feature, not a loss. - -**Migration path:** v1 `src/v1/db-migration.test.ts` shows the pattern: create legacy table, init v2 schema, backfill. Migration 004 does this for agent_destinations (backfill from messaging_group_agents wirings). \ No newline at end of file diff --git a/docs/v1-vs-v2/env.md b/docs/v1-vs-v2/env.md deleted file mode 100644 index 560362c..0000000 --- a/docs/v1-vs-v2/env.md +++ /dev/null @@ -1,38 +0,0 @@ -# env: v1 vs v2 - -## Scope -- v1: `src/v1/env.ts` (42 LOC), `src/v1/config.ts` (63 LOC) -- v2 counterparts: `src/env.ts` (identical), `src/config.ts` (identical structure); plus new consumers `src/webhook-server.ts`, `src/log.ts`, `src/container-runner.ts`, `container/build.sh`, `container/agent-runner/src/index.ts` - -## Capability map - -| v1 behavior | v2 location | Status | Notes | -|---|---|---|---| -| `readEnvFile(keys)` | `src/env.ts:11-42` | kept | Identical — reads `.env` without polluting `process.env` | -| `ASSISTANT_NAME` / `ASSISTANT_HAS_OWN_NUMBER` | `src/config.ts:8-12` | kept | Same read order: process.env → .env → default | -| `ONECLI_URL` | `src/config.ts:30` | kept | Used host-side + container-side | -| `TZ` + `isValidTimezone` guard | `src/config.ts:56-62` | kept | Passes to containers | -| `CONTAINER_IMAGE` / `CONTAINER_TIMEOUT` / `CONTAINER_MAX_OUTPUT_SIZE` | `src/config.ts:27-29` | kept | Same defaults | -| `MAX_MESSAGES_PER_PROMPT` | `src/config.ts:31` | kept | **Unused in v2** | -| `IDLE_TIMEOUT` | `src/config.ts:33` | kept | Used by container heartbeat model | -| `MAX_CONCURRENT_CONTAINERS` | `src/config.ts:34` | kept | Enforced in `container-runner.ts` | -| `POLL_INTERVAL` / `SCHEDULER_POLL_INTERVAL` / `IPC_POLL_INTERVAL` | `src/config.ts:13-32` | **dead code** | Defined but not imported anywhere in v2 runtime | -| `MOUNT_ALLOWLIST_PATH` / `SENDER_ALLOWLIST_PATH` | `src/config.ts:21-22` | kept | SENDER_ALLOWLIST_PATH unused (model replaced by `user_roles`) | -| `STORE_DIR` / `GROUPS_DIR` / `DATA_DIR` | `src/config.ts:23-25` | kept | `DATA_DIR` now hosts `v2.db` + `v2-sessions//*` | -| `buildTriggerPattern` / `getTriggerPattern` / `TRIGGER_PATTERN` / `DEFAULT_TRIGGER` | `src/config.ts:40-51` | kept | Used sparingly; trigger model largely DB-driven now | -| Container env injection via stdin JSON | `src/container-runner.ts:266-338` | **changed** | Replaced with `docker run -e`. New vars: `SESSION_INBOUND_DB_PATH`, `SESSION_OUTBOUND_DB_PATH`, `SESSION_HEARTBEAT_PATH`, `AGENT_PROVIDER`, `NANOCLAW_AGENT_GROUP_ID`, `NANOCLAW_AGENT_GROUP_NAME`, `NANOCLAW_MCP_SERVERS`, `NANOCLAW_ADMIN_USER_IDS` | -| `INSTALL_CJK_FONTS` | `container/build.sh:18-26`, `container/Dockerfile:13` | **new in v2** | Build-time arg, not runtime env | -| `WEBHOOK_PORT` (default 3000) | `src/webhook-server.ts:82` | **new in v2** | | -| `LOG_LEVEL` | `src/log.ts:16` | **new in v2** | | - -## Missing from v2 -Nothing user-facing. Container-only vars (`SESSION_*_DB_PATH`, `AGENT_PROVIDER`, `NANOCLAW_*`) are dynamic per-session and never belong in `.env`. - -## Behavioral discrepancies -1. **Dead constants**: `POLL_INTERVAL`, `SCHEDULER_POLL_INTERVAL`, `IPC_POLL_INTERVAL` remain in `src/config.ts` but are not imported by any v2 runtime code — safe to delete -2. **Container transport**: v1 piped config via stdin JSON; v2 injects via `-e` at spawn -3. **Build-time vs runtime**: `INSTALL_CJK_FONTS` is a Dockerfile build-arg, not a process env var -4. **Output markers**: v1's `---NANOCLAW_OUTPUT_START/END---` stdout markers are gone — v2 reads from `messages_out` table - -## Worth preserving? -Dead constants (`POLL_INTERVAL`, `SCHEDULER_POLL_INTERVAL`, `IPC_POLL_INTERVAL`) should be **removed** from `src/config.ts` — they're confusing carry-overs. Everything else is either actively used or deliberately dynamic. The `.env`-based config surface is byte-identical and correct to keep. diff --git a/docs/v1-vs-v2/formatting-test.md b/docs/v1-vs-v2/formatting-test.md deleted file mode 100644 index c9be286..0000000 --- a/docs/v1-vs-v2/formatting-test.md +++ /dev/null @@ -1,154 +0,0 @@ -# formatting (test-only) : v1 vs v2 - -## Scope - -- **v1**: `/Users/gavriel/nanoclaw4/src/v1/formatting.test.ts` (316 lines) -- **v1 production sibling**: `/Users/gavriel/nanoclaw4/src/v1/router.ts` (43 lines) — `escapeXml()`, `formatMessages()`, `stripInternalTags()`, `formatOutbound()`, plus `/Users/gavriel/nanoclaw4/src/v1/config.ts` (63 lines) — `getTriggerPattern()`, `TRIGGER_PATTERN`, `buildTriggerPattern()`, `DEFAULT_TRIGGER` -- **v2 counterparts**: - - Inbound message formatting: `/Users/gavriel/nanoclaw4/container/agent-runner/src/formatter.ts` (228 lines) — `formatMessages()`, `categorizeMessage()`, `extractRouting()` - - Outbound tag stripping: embedded in container delivery logic - - Trigger patterns: moved to DB model (`messaging_group_agents.trigger_rules` JSON) — no code-level function - - v2 tests: `/Users/gavriel/nanoclaw4/container/agent-runner/src/poll-loop.test.ts:26–84` (formatter section only) - ---- - -## Test-case map - -| v1 Test Case | v2 Formatter Handling | Status | Notes | -|---|---|---|---| -| **escapeXml: ampersands** (src/v1/formatting.test.ts:22–23) | `/container/agent-runner/src/formatter.ts:225` `escapeXml()` with `&` → `&` | ✅ Preserved | Both use identical regex replacement. V2 escaping is used in `formatSingleChat()` for sender, time, text. | -| **escapeXml: less-than** (test:26–27) | `formatter.ts:225` `escapeXml()` with `<` → `<` | ✅ Preserved | Used in XML attributes and content. | -| **escapeXml: greater-than** (test:30–31) | `formatter.ts:225` with `>` → `>` | ✅ Preserved | Same. | -| **escapeXml: double quotes** (test:34–35) | `formatter.ts:225` with `"` → `"` | ✅ Preserved | Same. | -| **escapeXml: multiple special characters** (test:38–39) | `formatter.ts:225` (regex composition) | ✅ Preserved | Single pass through all four replacements. | -| **escapeXml: passthrough clean text** (test:42–43) | `formatter.ts:225` (no-op if no specials) | ✅ Preserved | Same. | -| **escapeXml: empty string** (test:46–47) | `formatter.ts:225` (no-op on empty) | ✅ Preserved | Same. | -| **formatMessages: single message with context header & time** (test:56–62) | `/container/agent-runner/src/formatter.ts:124–158` `formatChatMessages()` & `formatSingleChat()` | ⚠️ Changed | v1 formats as `\n...\n` with full timestamp in US locale. v2 uses `...` with 24-hour time only. No context header. | -| **formatMessages: multiple messages** (test:64–84) | `formatter.ts:124–134` (batch wrapping in `` tag) | ⚠️ Changed | v2 wraps multiple chat messages in `` tags but structure differs: no timezone attribute, different time format, `from` attribute added. | -| **formatMessages: escape sender names** (test:86–88) | `formatter.ts:157` `sender="${escapeXml(sender)}"` | ✅ Preserved | Same escaping strategy. | -| **formatMessages: escape content** (test:91–93) | `formatter.ts:157` `${escapeXml(text)}` | ✅ Preserved | Same. | -| **formatMessages: empty array** (test:96–99) | `formatter.ts:98` — returns empty string if no messages | ❌ Incompatible | v1 returns `\n\n\n` even for empty. v2 returns empty string. Different expected output. | -| **formatMessages: reply context (quoted_message)** (test:102–116) | `formatter.ts:143, 183–188` `formatReplyContext()` | ⚠️ Changed | v1 renders `reply_to="42"` attribute + `text` child. v2 renders as `preview` without message ID attribute. | -| **formatMessages: omit reply when absent** (test:119–122) | `formatter.ts:183` (conditional) | ✅ Preserved | Both check for presence before rendering. | -| **formatMessages: omit quoted_message when content missing** (test:125–136) | `formatter.ts:184` (check `replyTo.text`) | ✅ Preserved | Both guard against missing content. | -| **formatMessages: escape reply context** (test:139–151) | `formatter.ts:188` `escapeXml()` on sender and text | ✅ Preserved | Same escaping applied. | -| **formatMessages: timezone conversion** (test:154–160) | `formatter.ts:216–223` `formatTime()` — HH:MM UTC only | ❌ Incompatible | v1 uses `formatLocalTime()` (full locale string with date, month, am/pm) from `timezone.ts:26–37`. v2 uses 24-hour `HH:MM` UTC only; no timezone localization. | -| **TRIGGER_PATTERN: matches @name at start** (test:170–171) | No v2 code equivalent | ❌ Not in v2 | v2 moved trigger rules to DB; no regex pattern in code. Router evaluates `messaging_group_agents.trigger_rules` JSON. | -| **TRIGGER_PATTERN: case-insensitive** (test:174–176) | DB model (applied at runtime by router) | ❌ Not in v2 | Same behavior (case-insensitive in router) but no test coverage for trigger logic in v2. | -| **TRIGGER_PATTERN: word boundary checks** (test:179–192) | DB model (router enforces) | ❌ Not in v2 | Router evaluates trigger rules; no unit tests for pattern matching. | -| **getTriggerPattern: custom per-group trigger** (test:201–206) | `/src/router.ts` evaluates `messaging_group_agents.trigger_rules` at delivery time | ❌ Not tested in v2 | v2 has no unit test for custom trigger selection. Behavior preserved in router but untested. | -| **getTriggerPattern: regex characters literal** (test:215–219) | DB-stored rule (router uses literal match or regex) | ❌ Not tested | v2 stores trigger as string in DB; runtime evaluation depends on router implementation (not inspected here). | -| **stripInternalTags: single-line** (test:226–227) | No direct v2 function — embedded in polling | ❌ Not isolated | v1 regex `/[\s\S]*?<\/internal>/g` with `.trim()`. v2 container poll-loop does not test this; no dedicated outbound function in v2 agent-runner. | -| **stripInternalTags: multi-line** (test:230–231) | Not tested in v2 | ❌ Not isolated | v1 regex handles `[\s\S]*?` (newlines included). | -| **stripInternalTags: multiple blocks** (test:234–235) | Not tested in v2 | ❌ Not isolated | Regex global flag `/g` handles multiple. Not verified in v2 tests. | -| **stripInternalTags: only internal tags** (test:238–239) | Not tested in v2 | ❌ Not isolated | v1 returns empty after trim; behavior not verified in v2. | -| **formatOutbound: passthrough clean text** (test:244–245) | Not tested in v2 | ❌ Not isolated | v1 calls `stripInternalTags()` then returns. v2 does not have isolated test. | -| **formatOutbound: empty after strip** (test:248–249) | Not tested in v2 | ❌ Not isolated | v1 returns empty if all was internal. | -| **formatOutbound: strip tags from text** (test:252–253) | Not tested in v2 | ❌ Not isolated | v1 example: `thinkingThe answer is 42` → `The answer is 42`. | -| **trigger gating: main group always processes** (test:277–279) | No unit test in v2; logic in `/src/router.ts` routing decision | ❌ Not tested | v1 shows that main groups bypass trigger check. Behavior likely preserved (main group always forwards to agent) but not verified by test. | -| **trigger gating: main group ignores requiresTrigger flag** (test:282–284) | Not tested in v2 | ❌ Not tested | v1 shows `isMainGroup=true` overrides `requiresTrigger` flag. No v2 test. | -| **trigger gating: non-main needs trigger (default)** (test:287–289) | Not tested in v2 | ❌ Not tested | v1 default behavior: non-main group requires trigger unless explicitly disabled. | -| **trigger gating: custom per-group trigger enforcement** (test:302–309) | Not tested in v2 | ❌ Not tested | v1 shows per-group trigger override. Behavior in v2 DB but no test. | -| **trigger gating: requiresTrigger=false disables check** (test:312–314) | Not tested in v2 | ❌ Not tested | v1 allows opting out of trigger requirement per group. | - ---- - -## Missing from v2 - -1. **Timezone-aware time formatting** - - v1: `formatLocalTime(utcIso, timezone)` in `src/v1/timezone.ts:26–37` converts UTC ISO timestamp to user's local timezone with full locale formatting (date, month, am/pm). - - v2: `formatTime()` in `container/agent-runner/src/formatter.ts:216–223` only extracts `HH:MM` in UTC, no localization. - - **Impact**: v2 loses per-agent timezone context. Timestamps appear in UTC only, potentially confusing users in different timezones. - -2. **Context header with timezone attribute** - - v1: Every message batch includes `` header. - - v2: No context header; timestamp is a message attribute only. - - **Impact**: Agent sees no explicit timezone declaration; must infer from message times or system prompt. - -3. **Reply context with message ID attribute** - - v1: `reply_to=""` attribute on message; separate `content` child. - - v2: Consolidated into `preview` without message ID; preview truncated to 100 chars. - - **Impact**: v2 loses structured reply tracking; agent can't reference specific message IDs in follow-ups. - -4. **Message ID sequence in XML** - - v1: No `id` attribute on messages (WhatsApp-era design). - - v2: Each message has `id="seq"` (database sequence number). - - **Impact**: Allows agent to reference messages by ID, but v1 tests do not verify this. - -5. **Trigger pattern unit tests** - - v1: Comprehensive tests for `getTriggerPattern()`, `TRIGGER_PATTERN`, case-insensitivity, word boundaries, regex escaping. - - v2: No unit tests; trigger logic moved to DB and router. Untested. - - **Impact**: Trigger matching behavior not verified by tests; regression risk if router changes. - -6. **Internal tag stripping tests** - - v1: `stripInternalTags()` and `formatOutbound()` tested for single-line, multi-line, multiple blocks, edge cases. - - v2: No isolated tests for outbound tag stripping. - - **Impact**: No verification that internal tags are reliably removed before delivery. - -7. **Trigger gating (requiresTrigger flag) tests** - - v1: Detailed tests of main-group bypass, per-group override, default behavior, flag combinations. - - v2: No tests; logic moved to DB schema and router evaluation. - - **Impact**: Trigger enforcement behavior not verified. - -8. **Empty message batch handling** - - v1: Explicitly returns `\n\n\n` for empty array. - - v2: Returns empty string. - - **Impact**: No clear protocol for "no messages to process" signals. - ---- - -## Behavioral discrepancies - -### 1. Message XML structure (formatMessages) -- **v1**: `\n\ncontent\n` -- **v2**: `content` (no wrapper for single message) -- **v1 line**: `src/v1/router.ts:9–23` -- **v2 line**: `container/agent-runner/src/formatter.ts:124–158` - -### 2. Time formatting -- **v1**: Full locale string (e.g., "Jan 1, 2024, 1:30 PM") using `Intl.DateTimeFormat` with timezone localization (`src/v1/timezone.ts:26–37`) -- **v2**: 24-hour UTC only (e.g., "13:30") without timezone info (`container/agent-runner/src/formatter.ts:216–223`) -- **Impact**: v2 loses timezone awareness; agent cannot distinguish between user's local time and server time. - -### 3. Reply context structure -- **v1**: Two-part — `reply_to=""` attribute + `text` child element -- **v2**: Single element — `100-char preview` (no ID, preview truncated) -- **v1 line**: `src/v1/router.ts:12–16` -- **v2 line**: `container/agent-runner/src/formatter.ts:143, 183–188` -- **Impact**: v2 cannot support message-ID-based threading; loses structured reply metadata. - -### 4. Trigger pattern matching -- **v1**: Implemented as regex returned by `getTriggerPattern()` with word-boundary enforcement (`config.ts:40–49`) -- **v2**: Stored in DB as JSON in `messaging_group_agents.trigger_rules`; evaluated by router at delivery time -- **v1 line**: `src/v1/config.ts:40–49` -- **v2 line**: `/src/router.ts` (router logic, not inspected in detail here) -- **Impact**: v1 enforces word boundaries via regex (`\b`); v2 implementation unknown (DB-driven). - -### 5. Empty message handling -- **v1**: Returns `\n\n\n` — preserves structure -- **v2**: Returns empty string -- **v1 line**: `src/v1/router.ts:22` -- **v2 line**: `container/agent-runner/src/formatter.ts:98` - -### 6. Internal tag stripping -- **v1**: Regex-based, `.trim()` called after removal -- **v2**: Not isolated; no dedicated function or test in v2 formatter -- **v1 line**: `src/v1/router.ts:25–26` -- **v2 line**: No equivalent - ---- - -## Worth preserving? - -**Partially.** The v1 formatting test suite is **essential for documenting lost functionality**, not for v2 regression. Key behaviors that should be preserved in v2 but are currently missing: - -1. **Timezone-aware message timestamps** — v2 should restore `formatLocalTime()` from `src/v1/timezone.ts` and include timezone context in the XML header. Without this, agents cannot reason about when messages arrived relative to the user's clock. - -2. **Reply context with message IDs** — v2's truncated reply preview is lossy. Consider restoring the `reply_to=""` attribute so agents can reference prior messages by sequence number for structured threading. - -3. **Trigger pattern unit tests** — v2 moved trigger logic to the DB but lost test coverage. The DB schema and router must enforce the same invariants (word boundaries, case-insensitivity, custom per-group overrides) that v1 tested. Recommend adding integration tests to `src/router.ts` or `src/channels/adapter.ts` to verify trigger matching. - -4. **Internal tag stripping tests** — v2 agent-runner should include unit tests for `stripInternalTags()` (if the skill applies) to prevent regression when Claude adds `` thinking tags. - -The v1 test file serves as a **specification document** for channel formatting and trigger gating that v2 partially refactored away. Keeping it in the repo (even unpowered) documents the intended semantics. - diff --git a/docs/v1-vs-v2/group-folder.md b/docs/v1-vs-v2/group-folder.md deleted file mode 100644 index bf0d890..0000000 --- a/docs/v1-vs-v2/group-folder.md +++ /dev/null @@ -1,38 +0,0 @@ -# group-folder: v1 vs v2 - -## Scope -- v1: `src/v1/group-folder.ts` (45 LOC), `group-folder.test.ts` (35 LOC) — validation + path resolution only -- v2 counterparts: - - `src/group-folder.ts` (45 LOC) — byte-identical to v1 - - `src/group-init.ts` (128 LOC) — **new** filesystem bootstrap - - `src/container-config.ts` (115 LOC) — **new** per-group container.json management - - `src/group-folder.test.ts` (35 LOC) — identical to v1 - -## Capability map - -| v1 behavior | v2 location | Status | Notes | -|---|---|---|---| -| `GROUP_FOLDER_PATTERN` (alphanumeric + `-` + `_`, 1-64) | `group-folder.ts:5-6` | identical | | -| Reserved folder `global` | `group-folder.ts:6` | identical | `RESERVED_FOLDERS` set | -| `isValidGroupFolder()` (reject empty, whitespace, traversal, absolute) | `group-folder.ts:8-16` | identical | | -| `assertValidGroupFolder()` | `group-folder.ts:18-22` | identical | | -| `resolveGroupFolderPath()` + `ensureWithinBase()` | `group-folder.ts:31-36` | identical | | -| `resolveGroupIpcPath()` (resolves `data/ipc/`) | `group-folder.ts:38-44` | kept | IPC dir is legacy — no longer used since v2 moved to session DBs | -| Filesystem scaffold (CLAUDE.md, skills, overlays) | — | **new in v2** | `group-init.ts:48-127` | -| Global memory symlink (`.claude-global.md` → `/workspace/global/CLAUDE.md`) | `group-init.ts:55-70` | **new** | Uses `lstat` to detect dangling symlinks | -| Per-group `container.json` init | `group-init.ts:83-85` + `container-config.ts:109-114` | **new** | Graceful fallback on corruption | -| `.claude-shared` session dir | `group-init.ts:87-92` | **new** | Under `data/v2-sessions//` | -| `settings.json` with `CLAUDE_CODE_*` flags | `group-init.ts:94-98` | **new** | | -| Recursive skill copy from `container/skills/` | `group-init.ts:100-107` | **new** | | -| Per-group agent-runner src overlay copy | `group-init.ts:109-117` | **new** | | -| Idempotent init (every step gates on `fs.existsSync()`) | `group-init.ts:44-127` | **new** | Safe to re-run | -| Step logging via `log.info()` | `group-init.ts:119-126` | **new** | | - -## Missing from v2 -None. All v1 validation/resolution behavior is preserved byte-for-byte. - -## Behavioral discrepancies -None on the validation layer. v2 adds the filesystem-scaffold layer as a separate module (`group-init.ts`) so validation stays pure. - -## Worth preserving? -Clean split — keep as-is. `group-folder.ts` = names + paths; `group-init.ts` = file creation. Both modules are small and single-purpose. diff --git a/docs/v1-vs-v2/group-queue.md b/docs/v1-vs-v2/group-queue.md deleted file mode 100644 index da21d03..0000000 --- a/docs/v1-vs-v2/group-queue.md +++ /dev/null @@ -1,48 +0,0 @@ -# group-queue: v1 vs v2 - -## Scope -- v1: `src/v1/group-queue.ts` (325 LOC), `group-queue.test.ts` (457 LOC) — in-memory per-group state machine, IPC-file dispatch -- v2: **no equivalent class**. Serialization is now DB-based and distributed across `src/session-manager.ts`, `src/host-sweep.ts`, `src/container-runner.ts`, `src/delivery.ts` - -## Capability map - -| v1 behavior | v2 location | Status | Notes | -|---|---|---|---| -| Per-group message queue | `inbound.db.messages_in` + `status='pending'` | replaced | Atomic status transitions serialize work per-session | -| Per-group task queue | `inbound.db.messages_in` with `kind='task'` | replaced | Same table; `kind` discriminates | -| `MAX_CONCURRENT_CONTAINERS` global cap | `container-runner.ts:42-52` `activeContainers` Map + `wakeContainer` dedup | kept | Enforced at spawn | -| One container per group invariant | One container per **session** | redefined | Session is identity unit now | -| Task-before-message priority (`drainGroup`) | `host-sweep.ts` recurrence + `delivery.ts` active poll | **partially lost** | No priority; polled by `process_after` timestamp ordering | -| Exponential retry backoff | `host-sweep.ts:145-147` `BACKOFF_BASE_MS * 2^tries` | kept | Max 5 tries, same shape | -| Idle preemption (`notifyIdle`/`closeStdin`) | heartbeat file mtime | **removed** | No interrupt signal — container polls continuously | -| Message dispatch to active container (`sendMessage`) | Write to `messages_in` table | replaced | Host writes; container polls | -| Cascading drain on task arrival | `delivery.ts` (~1s) + `host-sweep.ts` (~60s) polls | **async-ized** | Work discovery on next tick, not synchronous | -| Shutdown without kill | containers continue under `--rm` | similar | Host shutdown does not stop containers | -| Task dedup (`pendingTasks.some(t => t.id === id)`) | PK on `messages_in.id` | partial | Unique ID prevents DB duplicates; does not prevent two distinct rows with same series_id | -| `drainWaiting` (waiting-group fairness) | Implicit: any session can wake if slot free | async | No explicit fairness | - -## Serialization model diff -**v1 (push-based):** `GroupState` in memory per group: `active`, `pendingMessages`, `pendingTasks`, `idleWaiting`, `runningTaskId`. `drainGroup()` synchronously dispatches. IPC file write signals container readiness. State lost on restart. - -**v2 (pull-based via DB):** `messages_in.status` is the queue (`pending` → `processing` → `completed`/`failed`). Host writes rows + calls `wakeContainer()`; container polls + atomic UPDATE to take work. One writer per DB file (host→inbound, container→outbound) eliminates cross-mount contention. Heartbeat file mtime replaces IPC for liveness. State persisted; survives crashes. - -## Missing from v2 -1. **Idle-state preemption** — v1 could interrupt an idle container on task arrival via `closeStdin`. v2 has no interrupt; container finishes current work and polls again -2. **Synchronous drain cascade** — v1's `drainGroup` immediately ran the next item; v2 discovers it on the next poll tick (~1s active, ~60s sweep) -3. **In-memory task dedup** — v1 checked pending-task list before enqueue. v2 can have two task rows with the same series_id coexisting (both pending) — relies on atomic `status` update for single-execution, best-effort -4. **Priority ordering** — v1 tasks preempted messages; v2 is timestamp-ordered only - -## Behavioral discrepancies -| Aspect | v1 | v2 | -|---|----|----| -| Wake trigger | on enqueue (sync) | on `wakeContainer()` call, or poll finding due message | -| Idle timeout | implicit via IPC | explicit heartbeat mtime (10 min) | -| Task ordering | FIFO within group, tasks preempt messages | `process_after` timestamp; ties by insert seq | -| Retry | host `scheduleRetry()` | host sweep detects stale, increments `tries`, sets backoff | -| Concurrency cap | same | same (enforced in `spawnContainer` dedup) | - -## Worth preserving? -1. **Explicit task dedup** — add `(kind, series_id, session_id)` unique index on `messages_in`, or dedup in `host-sweep.ts` before inserting retry rows. Currently best-effort via atomic status update -2. **Priority ordering** — add a `priority` column or document the ~1s task-wake latency as the SLA -3. **Idle preemption** — not critical; 1s polling is acceptable for most workflows -4. **Fairness** — v1's `drainWaiting` ensured no group starved. v2 is fair by timestamp but untested under concurrent load. Monitor in production diff --git a/docs/v1-vs-v2/index-host.md b/docs/v1-vs-v2/index-host.md deleted file mode 100644 index 277daf4..0000000 --- a/docs/v1-vs-v2/index-host.md +++ /dev/null @@ -1,70 +0,0 @@ -# host index: v1 vs v2 - -## Scope -- v1: `src/v1/index.ts` (647 LOC) — monolithic entry: config, DB, state, channels, queues, scheduler, IPC watcher, message loop -- v2: `src/index.ts` (345 LOC) — lean entry: DB+migrations, channels, delivery/sweep polls, OneCLI handler - -## Startup sequence diff - -| # | v1 step | v2 step | Status | -|---|---------|---------|--------| -| 1 | `ensureContainerRuntimeRunning()` + `cleanupOrphans()` | same | kept | -| 2 | `initDatabase()` | `initDb()` + `runMigrations()` | enhanced (explicit migrations) | -| 3 | `loadState()` — cursor, groups, agent timestamps | — | removed (no global state) | -| 4 | OneCLI `ensureAgent` per group | — | removed (now per-wake in `container-runner.ts`) | -| 5 | `restoreRemoteControl()` | — | removed | -| 6 | SIGTERM/SIGINT handlers | same | kept | -| 7 | `handleRemoteControl` bind | — | removed | -| 8 | Channel options + callbacks | `initChannelAdapters()` | rewritten (adapter API) | -| 9 | Channel discovery + connection | absorbed into adapters | — | -| 10 | `startSchedulerLoop()` | — | removed (folded into `startHostSweep`) | -| 11 | `startIpcWatcher()` | — | removed (no IPC in v2) | -| 12 | `startSessionCleanup()` | — | removed (folded into `startHostSweep`) | -| 13 | `queue.setProcessMessagesFn()` | — | removed (GroupQueue gone) | -| 14 | `recoverPendingMessages()` | — | **removed** (implicit in sweep) | -| 15 | `startMessageLoop()` (polling) | `startActiveDeliveryPoll()` + `startSweepDeliveryPoll()` | **fundamentally changed** (event-driven) | -| 16 | — | `startHostSweep()` | **new** | -| 17 | — | `startOneCLIApprovalHandler()` | **new** | - -## Capability map - -| v1 behavior | v2 location | Status | Notes | -|---|---|---|---| -| Arg/env parsing | `src/config.ts` (shared) | kept | | -| Central DB init | `src/index.ts:47-50` | kept | + `runMigrations()` | -| Container runtime bring-up | `src/index.ts:52-54` | kept | identical | -| Global cursor + timestamps state | — | **removed** | v2 session-scoped state in outbound.db | -| Periodic message polling loop | — | **removed** | Replaced by event-driven delivery + 60s sweep | -| OneCLI group-wide sync at startup | — | **removed** | Per-wake in `container-runner.ts:303` | -| Remote control subsystem | — | **removed** | No equivalent — feature deferred | -| Group message queue (`GroupQueue`) | — | **removed** | DB-based serialization | -| Channel adapter array + callbacks | `src/channels/channel-registry.ts` | refactored | `ChannelAdapter` interface | -| Pending message recovery on startup | — | **removed** | Sweep detects stale containers + resets messages | -| IPC watcher (dynamic group add) | — | **removed** | Static topology at startup; restart to add groups | -| Signal handlers | `src/index.ts:339-340` | kept | Simplified teardown | -| Top-level error handling | `src/index.ts:342-345` | kept | Same fatal exit | - -## Missing from v2 -1. **Polling message loop** (v1:370-459) — replaced by event-driven + sweep (net improvement) -2. **GroupQueue state machine** — now DB-based -3. **Cross-restart cursor state** — no `lastAgentTimestamp` persisted; recovery implicit via DB scan -4. **Remote control** — gone -5. **Explicit `recoverPendingMessages()`** — implicit in sweep; worth verifying via post-crash test -6. **IPC watcher** (`startIpcWatcher`) — cannot add groups dynamically; restart required -7. **Scheduler loop** — merged into sweep's due-message wake - -## Behavioral discrepancies -| Aspect | v1 | v2 | -|---|----|----| -| Startup time | ~500ms (long loop init) | ~200ms | -| Message fetch | polling every POLL_INTERVAL | event-driven callbacks + 1s delivery poll | -| Container spawn | on-demand via GroupQueue | per-message wake via router/sweep | -| Group topology | dynamic (IPC watcher) | static at startup | -| Error recovery | per-message cursor rollback | implicit via stale detection | -| Shutdown | GroupQueue 10s grace then disconnect | stop handlers/polls/sweep/adapters in order | - -## Worth preserving? -1. **Polling loop**: No — event-driven is superior. Verify delivery poll latency regression vs old POLL_INTERVAL under load -2. **Pending-message recovery**: Worth explicit restoration — kill a container mid-message, restart host, verify re-delivery within ≤5s. If sweep doesn't cover this, add startup-phase scan -3. **Remote control**: Unknown — either restore as opt-in skill or document removal -4. **Dynamic group add (IPC watcher)**: Probably not worth — modern flow is "admin skill adds group to DB, restart". But document that restart is required diff --git a/docs/v1-vs-v2/ipc.md b/docs/v1-vs-v2/ipc.md deleted file mode 100644 index 10c643f..0000000 --- a/docs/v1-vs-v2/ipc.md +++ /dev/null @@ -1,240 +0,0 @@ -# IPC: v1 vs v2 - -## Scope - -### v1 -- **Host side:** `/Users/gavriel/nanoclaw4/src/v1/ipc.ts` (127 lines) — file-system watcher, task authorization, message routing -- **Auth/handshake tests:** `/Users/gavriel/nanoclaw4/src/v1/ipc-auth.test.ts` (614 lines) — authorization gates, schedule types, cron validation -- **Container side:** `/Users/gavriel/nanoclaw4/container/agent-runner/src/v1/ipc-mcp-stdio.ts` (509 lines) — MCP server over stdio, file-based message writes -- **Total v1 codebase:** ~1,250 lines (v1/ subtree) - -### v2 counterparts -This is not a file-for-file mapping. The entire IPC abstraction layer has been replaced with SQLite DBs: - -- **Host delivery/routing:** `/Users/gavriel/nanoclaw4/src/delivery.ts` (912 lines) — polls outbound.db, delivers, handles system actions -- **Host sweep/recurrence:** `/Users/gavriel/nanoclaw4/src/host-sweep.ts` (174 lines) — 60s maintenance, stale detection via heartbeat, processing_ack sync -- **Session setup/DB:** `/Users/gavriel/nanoclaw4/src/session-manager.ts` (361 lines) — DB paths, folder init, destinations + routing writes -- **Container poll loop:** `/Users/gavriel/nanoclaw4/container/agent-runner/src/poll-loop.ts` (200+ lines) — fetches messages_in, marks status in processing_ack -- **Container destinations:** `/Users/gavriel/nanoclaw4/container/agent-runner/src/destinations.ts` (118 lines) — reads inbound.db's destinations table live -- **DB layer (host):** `src/db/session-db.ts` — insertMessage, getDueOutboundMessages, markDelivered, syncProcessingAcks, etc. -- **DB layer (container):** `container/agent-runner/src/db/{messages-in,messages-out,session-state,connection}.ts` -- **Schema:** `/Users/gavriel/nanoclaw4/docs/db-session.md` (184 lines) — definitive per-session DB layout - ---- - -## Paradigm shift - -**v1: IPC as explicit message files + stdio tunnel + MCP server** - -In v1, the host spawned an MCP server inside each container's stdio. The container's `ipc-mcp-stdio.ts` exposed tools (`send_message`, `schedule_task`, `register_group`, etc.) by writing JSON files to the host's `data/ipc/{groupFolder}/{messages|tasks}/` directory. The host's `ipc.ts` file-watcher scanned these directories every `IPC_POLL_INTERVAL` (~1s), parsed the JSON, applied authorization gates (isMain? folder-match?), executed side effects (DB writes, group registration), and deleted the files. Ordering, atomicity, and backpressure were implicit in the filesystem. - -**v2: Everything is a message in two persistent DBs** - -The IPC abstraction has been *entirely removed*. All host↔container communication now flows through two SQLite files per session: -- **inbound.db** (host writes, container reads): `messages_in` for inbound chat/tasks, `destinations` for the routing map, `session_routing` for default reply channel -- **outbound.db** (container writes, host reads): `messages_out` for agent responses, `processing_ack` for status acks, `session_state` for continuation storage - -There is no MCP server inside the container that exposes system tools. Instead: -- **Container side** calls `writeMessageOut()` directly, writing a JSON `content` blob with `action="schedule_task"` (or similar) into the `messages_out` table. -- **Host side** polls `getDueOutboundMessages()` from outbound.db, deserializes the `content`, and in `handleSystemAction()` interprets the action, validates it, and applies it directly to inbound.db (no IPC file write). - -The single-writer-per-file invariant (host writes inbound.db, container writes outbound.db) replaces the file-system locking and atomic rename semantics. - -**Key ownership shift:** -- v1: Container owned the "request to do something" (file write). Host decided whether to act (authorization on read). -- v2: Host owns the "task is pending" state (messages_in row). Container marks its progress (processing_ack). Host syncs status, detects stale containers, and triggers recurrence. - ---- - -## Capability map - -| v1 IPC Behavior | v2 Equivalent | Status | Notes | -|---|---|---|---| -| **Handshake / auth** | Database schema + envelope ID | ✓ Functional but different | v1: read `isMain` env var at startup, gate each IPC op. v2: host resolves session once, container reads `destinations` table on every query. No per-message auth envelope. | -| **Message framing** | JSON in files (atomic rename) | ✓ Replaced with DB schema | v1: `writeIpcFile()` temp-then-rename. v2: `better-sqlite3` with `journal_mode=DELETE` + open-close-per-op for cross-mount visibility. | -| **Transport (pipes/sockets)** | SQLite on FUSE mount | ✓ Completely different | v1: filesystem watching (no network). v2: cross-mount DB access (requires `journal_mode=DELETE` pragma, see session-manager.ts:9–11). | -| **Message types** | `kind` field in messages_in/out | ✓ Expanded | v1: message/task files. v2: `kind=chat|task|system|...` in DB rows, content shape in [api-details.md](../api-details.md). | -| **Auth / authorization gates** | Host-side permission checks in delivery.ts | ◐ Simplified but different | v1: checked per file (isMain flag, folder-match). v2: admin perms checked at container startup (adminUserIds set in poll-loop.ts:22–33), destination ACL in agent_destinations table, delivery.ts enforces on send. No per-message envelope. | -| **Handshake semantics** | None (session exists at startup) | ✗ Removed | v1: env vars set identity at container boot. v2: session_id/agent_group_id is stable DB fixture; container learns routing from `session_routing` table. No negotiation. | -| **Backpressure / flow control** | Implicit (filesystem poll interval) | ◐ Different model | v1: host polls files at 1s intervals; if processing is slow, files pile up. v2: messages_in rows sit with `status='pending'` until container marks `processing_ack='processing'`, then host polls and syncs status. Host can enforce delivery retry budget (MAX_DELIVERY_ATTEMPTS=3 in delivery.ts:58). | -| **Keepalives / timeouts** | No explicit mechanism | ✓ Heartbeat file replaces | v1: IPC files served as implicit liveness. v2: container touches `.heartbeat` file (mtime tracked by host). Host uses heartbeat staleness (10min threshold in host-sweep.ts:32) to detect crash and reset stuck messages. | -| **Ordering / seq parity** | Implicit filename order (timestamp+random) | ✓ Enforced | v1: files had timestamps but no formal ordering. v2: `seq` is monotonic per session, even←host / odd←container (see db-session.md §3). Parity disambiguates edit/reaction targeting. | -| **Reconnect semantics** | Container restart picks up where it left off (env vars) | ✓ Improved | v1: continuation not persisted across restarts. v2: provider continuation (Claude JSON transcript, etc.) stored in `session_state.session_id` on every SDK result. Survives crash. | -| **Error handling / retries** | File left in `errors/` dir on parse failure | ✓ Better visibility | v1: failed IPC files moved to `data/ipc/errors/` for manual inspection. v2: `status='failed'` in messages_in; delivery.ts retries with exponential backoff (3 attempts), marks failed on max. Persisted in DB for audit. | -| **Task scheduling (schedule_task)** | IPC file write → host parses → DB insert | ✓ Same end result, different path | v1: container writes task JSON, host reads/validates cron. v2: container writes `system` message with `action="schedule_task"` to messages_out, host reads + inserts into messages_in as new `kind='task'` row. Validation still in host (cron parsing at delivery time). | -| **Admin commands (/clear, /setup)** | Not in v1 IPC | ✓ Implemented | v2 has `/clear` command in poll-loop.ts, checked against adminUserIds set. Clears `session_state.session_id`. No MCP server expose. | -| **Tool-call plumbing** | MCP server in container exposes send_message, schedule_task, etc. | ✗ Removed entirely | v1 tools are now plain SDK result processors. send_message writes messages_out. schedule_task writes messages_out with action="schedule_task". | -| **Message delivery tracking** | None (fire-and-forget) | ✓ Added | v1: host sends message, doesn't track if it reached the user. v2: `delivered` table in inbound.db (platform_message_id + status). delivery.ts marks as delivered/failed. Enables message edits, reactions, and retry logic. | -| **Stale container detection** | None | ✓ Added | v1: no heartbeat. v2: host-sweep.ts checks `.heartbeat` mtime. If >10min old and processing_ack has 'processing' entries, resets with backoff. | -| **Recurrence / cron re-firing** | Not in v1 | ✓ Added | v1: tasks were one-shot. v2: `recurrence` field in messages_in + `series_id`. host-sweep.ts fires next occurrence when completed message has recurrence. CronExpressionParser used at sync time. | - ---- - -## Missing from v2 - -### 1. **Auth handshake envelope** -v1 had explicit authorization gates for *every* IPC operation: -- Read `isMain` and `groupFolder` from env vars at startup (ipc-mcp-stdio.ts:19–21) -- For `schedule_task`: gate the `targetJid` — non-main groups can only schedule for `chatJid` (line 187–188) -- For `register_group`: only isMain=true can call (line 471–481) -- For `send_message`: isMain || (target group's folder == sender's folder) (ipc.ts:78) - -**v2 equivalent:** Authorization is now **split**: -- Container time: adminUserIds set passed at boot (poll-loop.ts:22–33), used to gate `/clear` command only -- Delivery time: host checks destination ACL via agent_destinations table, permission to send to a messaging group (delivery.ts:535–561) -- No per-message auth envelope; the session fixture itself represents authorization - -**What's lost:** Per-request explicit authorization metadata. The agent can't *prove* it's "main" anymore; instead the host verifies at delivery time using the central DB. This is arguably *better* security (no token in container to leak), but if the agent needs to know *why* a request failed, it no longer gets an explicit auth reject response. - -### 2. **Backpressure / request queuing** -v1 file-based IPC was **implicitly backpressured**: -- Container calls `send_message()` MCP tool, which calls `writeIpcFile()` and returns immediately (fire-and-forget) -- If the host is slow or overloaded, files pile up in `data/ipc/messages/` -- Container is blocked only if the filesystem fills - -**v2 equivalent:** No queueing or explicit backpressure: -- Container calls `writeMessageOut()`, which executes a synchronous SQLite INSERT into outbound.db -- Host polls outbound.db at 1s (active) or 60s (sweep) -- If delivery fails, messages sit in outbound.db with `status='pending'` until 3 retries exhausted - -**What's lost:** Queue depth visibility. In v1, you could see `ls data/ipc/messages/ | wc -l` to get backlog. In v2, you have to query the outbound DB. The container has no way to ask "how many pending messages are waiting for me?" — it just writes and hopes the host picks them up. - -### 3. **Explicit keepalive / ping** -v1 had implicit keepalives via file timestamps: -- Each IPC file wrote a `timestamp` field (ipc-mcp-stdio.ts:61, 202) -- Host could reason about "last IPC activity" - -**v2 equivalent:** Heartbeat file mtime: -- Container touches `.heartbeat` file (connection.ts `touchHeartbeat()`) -- Host checks mtime every 60s in host-sweep.ts -- Detects stale if >10min old and processing_ack has 'processing' entries - -**What's lost:** Sub-heartbeat timeouts. If the container is hung but the heartbeat is fresh (just stuck in a long computation), the host won't detect it. v1 had no explicit timeout either, so this is not a regression, but there's no keepalive *mechanism* (no ping/pong protocol). - -### 4. **Payload size limits / chunking** -v1 wrote task files with a single JSON blob: -- ipc-mcp-stdio.ts:31: `fs.writeFileSync(tempPath, JSON.stringify(data, null, 2))` -- Filesystem might have limits on inode size, but generally no explicit cap - -**v2:** No explicit chunking or size limits in the DB layer: -- messages_in.content and messages_out.content are TEXT -- SQLite TEXT default is ~1GB per cell -- No mention in the codebase of max payload size - -**What's lost:** Explicit awareness. In v1, if a task prompt was 10MB, it would be a 10MB JSON file. In v2, it's a 10MB DB cell. The system doesn't actively prevent this, and there's no mention of a sanitizer. - ---- - -## Behavioral discrepancies - -### 1. **Task scheduling authorization** -**v1** (ipc-auth.test.ts:71–127): -```typescript -// Main group can schedule for another group -await processTaskIpc({ type: 'schedule_task', targetJid: 'other@g.us' }, 'whatsapp_main', true, deps); -// Non-main group can ONLY schedule for itself -await processTaskIpc({ type: 'schedule_task', targetJid: 'main@g.us' }, 'other-group', false, deps); -// ↑ blocked by authorization gate (ipc.ts:170) -``` - -**v2** (delivery.ts:645–712): -The container writes a `system` message with `action="schedule_task"` directly into messages_out. The host reads it and calls `insertTask(inDb, {...})` **with no authorization gate**. The `targetJid` is derived from the system message `platformId` and `channelType`, not from an explicitly routed `targetJid` parameter. - -**Discrepancy:** v1 prevented non-main groups from scheduling cross-group tasks at the *request* stage. v2 has no equivalent gate — the container can write any task to any group (in theory) because it's the host that does the actual DB insert. In practice, the container only has one session and only sees messages for that session, so it can't *reach* another group's messages_in. But the authorization model is implicitly structural, not explicit. - -### 2. **Message send authorization** -**v1** (ipc-auth.test.ts:339–373): -```typescript -// Main can send to any chat -expect(isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups)).toBe(true); -// Non-main can send to its own chat -expect(isMessageAuthorized('other-group', false, 'other@g.us', groups)).toBe(true); -// Non-main cannot send to another group's chat -expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe(false); -``` - -**v2** (delivery.ts:550–561): -```typescript -const isOriginChat = session.messaging_group_id === mg.id; -if (!isOriginChat && !hasDestination(session.agent_group_id, 'channel', mg.id)) { - throw new Error(`unauthorized channel destination: ...`); -} -``` - -The container's session has a fixed `messaging_group_id` + `thread_id`. The agent can only reply to that origin or to a destination in the `agent_destinations` table. There is no isMain flag. - -**Discrepancy:** v1 was group-centric (folder-based identity). v2 is session-centric (agent is wired to one or more messaging groups via central DB, projected into inbound.db). If an agent is wired to multiple chats with `session_mode='agent-shared'`, it has one session and can see all of them as destinations. This is more flexible than v1's binary main/non-main gate. - -### 3. **Task update semantics** -**v1** (ipc-auth.test.ts:264–309): Container passes `type='update_task'`, host reads the task, re-computes `next_run` if schedule changed, updates DB. - -**v2** (delivery.ts:695–712): Container writes `system` message with `action="update_task"`, host applies the update directly. The host **does not** recompute `next_run` if the schedule changes — it only updates the fields the container specified. Recurrence is re-fired by the *host* in host-sweep.ts (line 160–165), not at update time. - -**Discrepancy:** v1 eagerly recomputed next_run on update. v2 lazily computes it during the 60s sweep. If an agent updates a task's cron expression, it won't take effect until the next sweep cycle. This is a ~60s latency increase. - -### 4. **Error handling** -**v1** (ipc.ts:85–91): Files that fail to parse are moved to `data/ipc/errors/` for manual inspection. - -**v2** (delivery.ts:422–459): Messages that fail delivery get up to 3 retries with exponential backoff. If they still fail, they're marked `status='failed'` in the DB. There's no "errors" directory; the audit trail is in the DB + logs. - -**Discrepancy:** v1's error handling was "fire-and-forget" (parse, move on). v2's is "retry + persistent state." This is better observability, but v1's "move to errors/" was a gentler way to pause processing without losing the file. - -### 5. **Reconnect / session resumption** -**v1:** No persistence. If the container crashed, the next restart had no knowledge of prior messages or state. - -**v2** (poll-loop.ts:51–55): Reads `session_state.session_id` at startup and passes it to the provider as `continuation`. The provider (Claude) deserializes a `.jsonl` transcript and resumes. Survives container crash. - -**Discrepancy:** v2 has explicit continuation support. v1 did not. This is a strict improvement. - ---- - -## Worth preserving? - -### 1. **Per-request authorization envelope** -**Recommendation:** No, v2's structural approach is better. In v1, a malicious container could spoof an isMain flag to bypass gates (though env vars are hard to spoof). v2's model — the host resolves identity once and checks permissions against the central DB — is more robust and easier to audit. - -### 2. **Message send ACL at request time** -**Recommendation:** Partially — v2 should validate `agent_destinations` rows exist *before* the agent attempts a send, so it fails fast instead of silently dropping at delivery time. Currently, if an agent tries `...`, it writes to messages_out and the host later rejects it. A pre-send validation in the container (via destinations.ts) would be better UX. - -### 3. **Backpressure / delivery acknowledgment** -**Recommendation:** Maybe. If an agent rapidly fires 100 `send_message()` calls, they all block on SQLite INSERT (fast) and return immediately. The host drains them at 1s per poll. If the channel adapter is slow, messages pile up in messages_out. There's no way for the agent to ask "is there backlog?" or "wait until sent." This is probably fine for most use cases (agents don't spam), but if latency-sensitive, a `send_message()` that returns `{delivered_at}` would help. - -### 4. **Heartbeat / stale detection** -**Recommendation:** Yes, and it's been preserved (`.heartbeat` file replaces file-based timestamps). But the 10min threshold is conservative. Consider shorter thresholds for interactive agents (containers should be responsive, stale is a sign of crash, not slow work). - ---- - -## File references - -### v1 (historical, in `src/v1/` and `container/agent-runner/src/v1/`) -- **ipc.ts:30–127** — startIpcWatcher loop, per-group folder scan, message/task file dispatch -- **ipc.ts:129–356** — processTaskIpc with authorization gates (lines 169, 228, 241, 254, 271, 313, 326) -- **ipc-auth.test.ts:71–127** — schedule_task authorization tests -- **ipc-auth.test.ts:339–373** — message send authorization tests -- **ipc-mcp-stdio.ts:37–68** — send_message MCP tool, writeIpcFile -- **ipc-mcp-stdio.ts:70–216** — schedule_task tool with validation, target_group_jid param -- **ipc-mcp-stdio.ts:445–504** — register_group tool, isMain gate - -### v2 (active, in `src/` and `container/agent-runner/src/`) -- **db-session.md:1–50** — inbound.db schema (messages_in, delivered, destinations, session_routing) -- **db-session.md:120–174** — outbound.db schema (messages_out, processing_ack, session_state) -- **db-session.md:104–117** — seq parity invariant -- **delivery.ts:383–394** — drainSession loop (active poll 1s, sweep 60s) -- **delivery.ts:467–638** — deliverMessage, handles all message kinds, permission checks, delivery retry -- **delivery.ts:645–906** — handleSystemAction, interprets action="schedule_task" etc. -- **host-sweep.ts:48–109** — sweepSession, syncProcessingAcks, stale detection via heartbeat, recurrence handling -- **session-manager.ts:1–12** — cross-mount invariant doc (journal_mode=DELETE, close-per-op) -- **session-manager.ts:122–130** — initSessionFolder, schema creation -- **session-manager.ts:152–222** — writeSessionRouting, writeDestinations (replaces static env vars with live table) -- **session-manager.ts:231–267** — writeSessionMessage (host writes to messages_in) -- **poll-loop.ts:22–33** — PollLoopConfig with adminUserIds set -- **poll-loop.ts:46–77** — runPollLoop entry, getPendingMessages, markProcessing -- **destinations.ts:44–52** — getAllDestinations, findByName (reads from inbound.db live) -- **db/messages-in.ts** — getPendingMessages, markProcessing, markCompleted -- **db/messages-out.ts** — writeMessageOut (container writes system actions here) -- **db/session-state.ts** — getStoredSessionId, setStoredSessionId (continuation persistence) -- **db/connection.ts** — touchHeartbeat, journal_mode=DELETE pragma, cross-mount setup - ---- - -Generated from deep-dive analysis of v1 IPC → v2 DB paradigm shift. diff --git a/docs/v1-vs-v2/logger.md b/docs/v1-vs-v2/logger.md deleted file mode 100644 index 26ac548..0000000 --- a/docs/v1-vs-v2/logger.md +++ /dev/null @@ -1,38 +0,0 @@ -# logger: v1 vs v2 - -## Scope -- v1: `src/v1/logger.ts` (70 LOC) — export `logger` -- v2 counterpart: `src/log.ts` (65 LOC) — export `log` - -## Capability map - -| v1 behavior | v2 location | Status | Notes | -|---|---|---|---| -| Levels (debug=20, info=30, warn=40, error=50, fatal=60) | `src/log.ts:1` | kept | Identical numeric map | -| `debug/info/warn/error/fatal` methods | `src/log.ts:50-54` | renamed | `logger.X(...)` → `log.X(...)` | -| Data-first signature `(data, msg)` | `src/log.ts:42-58` | **changed** | v2 requires message-first `(msg, data?)` — breaking for every callsite | -| Color codes (per-level + KEY_COLOR=magenta, MSG_COLOR=cyan) | `src/log.ts:4-14` | kept | Identical | -| LOG_LEVEL env threshold | `src/log.ts:16` | kept | `'info'` default | -| Timestamp `HH:MM:SS.mmm` | `src/log.ts:33-40` | kept | Refactored, same output | -| Error formatting | `src/log.ts:18-23` | **changed** | v1 pretty multi-line JSON; v2 single-line | -| Data formatting | `src/log.ts:25-31` | **changed** | v1 per-line indented; v2 inline `key=value` | -| Process ID in output | — | **removed** | v1 emitted `(${process.pid})`; v2 drops it | -| info/debug → stdout, warn/error/fatal → stderr | `src/log.ts:45` | kept | Identical routing | -| `uncaughtException` → fatal + exit(1) | `src/log.ts:57-60` | kept | Arg order swapped | -| `unhandledRejection` → error | `src/log.ts:62-64` | kept | Arg order swapped | - -## Missing from v2 -1. **Process ID in log output** — lost visibility into emitting process in multi-container scenarios -2. **Data-first overload** — v1 `logger.warn({err, path}, 'msg')` is a breaking API change in v2 -3. **Multi-line error formatting** — condensed single-line form is harder to read for stack traces - -## Behavioral discrepancies -1. **Argument order**: `logger.error({err}, 'failed')` must become `log.error('failed', {err})` at every callsite -2. **Error output**: v1 pretty-prints JSON over 3 lines; v2 collapses to one line -3. **Data output**: v1 newline+indent per key; v2 space-separated inline - -## Not in either -File rotation, redaction rules, on-disk logging — both stream to stdout/stderr only. - -## Worth preserving? -Restoring PID to v2 output is cheap and helps multi-process debugging. Multi-line error format is worth a verbose-mode flag for `error`/`fatal`. Signature swap is stylistic; not worth reverting but every v1 `logger` → `log` migration must swap `(data, msg)` → `(msg, data)`. diff --git a/docs/v1-vs-v2/remote-control.md b/docs/v1-vs-v2/remote-control.md deleted file mode 100644 index 7cba133..0000000 --- a/docs/v1-vs-v2/remote-control.md +++ /dev/null @@ -1,90 +0,0 @@ -# remote-control: v1 vs v2 - -## Scope - -**v1:** -- `/Users/gavriel/nanoclaw4/src/v1/remote-control.ts` (218 lines) -- `/Users/gavriel/nanoclaw4/src/v1/remote-control.test.ts` (379 lines) -- Integrated into v1 host via `restoreRemoteControl()` call at startup (v1/index.ts:42) - -**v2 Counterparts:** -- `/Users/gavriel/nanoclaw4/src/access.ts` (115 lines) — privilege/approval routing -- `/Users/gavriel/nanoclaw4/src/onecli-approvals.ts` (269 lines) — OneCLI credential-gated action approval -- `/Users/gavriel/nanoclaw4/src/webhook-server.ts` (134 lines) — HTTP webhook ingress for Chat SDK adapters -- `/Users/gavriel/nanoclaw4/src/router.ts` (start of file) — inbound message routing with access gates - -## Capability Map - -| v1 Behavior | v2 Location | Status | Notes | -|---|---|---|---| -| Start `claude remote-control` child process, extract URL | **Removed** | ❌ Removed | v2 has no equivalent. The `claude remote-control` CLI was a v1-only mechanism tied to individual Telegram chats. | -| Session state persistence (PID, URL, metadata) | **Removed** | ❌ Removed | v2 is stateless at the host level — all per-session state lives in `inbound.db` / `outbound.db`. | -| Auto-accept "Enable Remote Control?" prompt via stdin | **Removed** | ❌ Removed | v1 quirk tied to Claude CLI's interactive mode; no equivalent in v2. | -| Restore session from disk on startup | **Removed** | ❌ Removed | v2 has no startup recovery loop for stale processes. Sessions are created on-demand. | -| Detect dead process by signal check | **Removed** | ❌ Removed | v2 uses per-session heartbeat file (`/workspace/.heartbeat`) and inactivity detection via 60s sweep. | -| HTTP URL polling + timeout handling | **Webhook server** | ✅ Moved | v2's `webhook-server.ts` (line 16–124) runs a persistent HTTP server (default port 3000) for Chat SDK adapter webhooks. Routes via `/webhook/{adapterName}` (not URL-in-stdout polling). | -| Single active session per host | **Per-agent-group sessions** | ✅ Evolved | v2 supports unlimited concurrent sessions. Each `(agent_group, messaging_group, thread)` tuple is a separate session with its own container. | -| `getActiveSession()` getter | **Removed** | ❌ Removed | No global session concept. v2 queries sessions via `getSession(sessionId)` in `db/sessions.ts`. | -| Credential access approval | **OneCLI approval handler** | ✅ Moved | v2's `onecli-approvals.ts` (line 92–215) handles credential-gated action approval. OneCLI gateway intercepts HTTP, delivers ask_question card to approver, persists `pending_approvals` row (line 173–196). | -| Approver selection (admin → owner chain) | **access.ts** | ✅ Moved | `pickApprover()` (access.ts:55–72) returns ordered list: agent-group admins → global admins → owners. Same preference order as v1 logic. | -| Approval delivery to DM (same channel kind preferred) | **access.ts + user-dm.ts** | ✅ Moved | `pickApprovalDelivery()` (access.ts:83–101) walks approver list, prefers same channel kind via `channelTypeOf()` (line 112–115), falls back to any reachable DM. Uses `ensureUserDm()` for cold-DM resolution (user-dm.ts). | -| Ask_question card delivery | **onecli-approvals.ts** | ✅ Moved | v2 builds ask_question card (onecli-approvals.ts:148–167) with Approve/Reject buttons, routes via `deliveryAdapter.deliver()` with action_id for button callbacks. | -| Button click → approval resolution | **onecli-approvals.ts** | ✅ Moved | `resolveOneCLIApproval()` (line 68–83) matches approval_id, resolves Promise, updates status to approved/rejected, deletes `pending_approvals` row. | -| Approval expiry + cleanup | **onecli-approvals.ts** | ✅ Moved | Expiry timer fires just before gateway's TTL (line 200–211); `expireApproval()` (line 217–226) edits card to "Expired (reason)" and deletes row. Startup sweep cleans stale rows (line 247–255). | -| Rate limiting | **Not implemented** | ❌ Missing | Neither v1 nor v2 has rate limiting on remote-control or approval requests. | -| Audit logging | **Partial** | ⚠️ Partial | v1: `logger.info()` on session start/stop. v2: `log.info()` on approval resolved (onecli-approvals.ts:81), stale sweeps (line 250), expiry (line 225). Payload stored in `pending_approvals.payload` for audit (line 178–186). | -| Error recovery (process death) | **Minimal** | ⚠️ Minimal | v1: restores from disk, kills stale PID. v2: no equivalent — dead container is detected by stale heartbeat, then respawned via `wakeContainer()`. | -| Transport | HTTP via stdout polling | HTTP via standard webhook server | v1 is ephemeral per session; v2 is persistent, multi-tenant. | -| Auth | None (CLI subprocess) | OneCLI gateway (credential-gated via HTTP) | v1 has no auth; v2 gates on agent identity + OneCLI decision. | - -## Missing from v2 - -1. **CLI subprocess spawning** — v2 has no `claude remote-control` equivalent. Agents run in Docker containers, not standalone CLI processes. The OneCLI agent sandbox is managed by the agent-runner container, not the host. - -2. **Process-level lifecycle management** — v1 tracks individual process PIDs and signal-kills them. v2 uses container IDs + heartbeat file, handled by host-sweep (host-sweep.ts) and container-runner.ts. - -3. **Per-message URL polling with regex extraction** — v2's webhook server is push-based (HTTP handler), not pull-based polling of stdout files. - -4. **Direct user-to-bot communication model** — v1's remote-control was tied to a single Telegram JID + chat. v2 decouples messaging groups from agent groups, allowing one agent to serve multiple channels with different isolation levels. - -5. **State file on disk** (`remote-control.json`) — v2 stores all session state in SQLite central DB and per-session `inbound.db` / `outbound.db`. - -## Behavioral Discrepancies - -1. **Approval delivery model**: - - v1: Remote control was tied to a single message sender; approvals implicitly went to the initiator's contact or a hardcoded owner. - - v2: Approvals route to admins of the originating agent group, with tie-break by channel kind (pickApprovalDelivery line 87–94). Multiple approvers can be reached, decoupling approval from message sender. - -2. **Session multiplicity**: - - v1: One active `RemoteControlSession` per host at a time. - - v2: Unlimited concurrent sessions, each with independent state (`inbound.db`, `outbound.db`, heartbeat). - -3. **Timeout & cleanup**: - - v1: Explicit timeout on URL polling (30s), then kill process. No ongoing monitoring. - - v2: Heartbeat-based inactivity detection (60s sweep), graceful cleanup on stale. Approval expiry tied to OneCLI gateway TTL, not a fixed timeout. - -4. **Error transparency**: - - v1: Polling errors logged to stdout/stderr files; user doesn't see unless they debug. - - v2: All approval errors logged centrally; card is edited to "Expired" on failure, so approver sees state change. - -## Worth Preserving? - -**No — v2 supersedes v1's remote-control model.** - -v1's remote-control was a bridge between Telegram chats and a single Claude CLI session. v2 achieves equivalent (and superior) remote operation via: -- **OneCLI credential approvals** (onecli-approvals.ts): Admins approve API/credential requests from agents, just as v1 surfaced sensitive actions. -- **Approval routing** (access.ts): Automatically picks the right admin on the right channel, with fallback to any reachable DM. -- **Multi-tenant agent groups**: Agents can serve multiple channels with different approval chains, not just one chat JID. - -Users still get on-demand approval for sensitive actions; they just don't manage a CLI subprocess anymore. The host handles container lifecycle, and the container agent is managed by OneCLI. - ---- - -### Citation Summary - -- v1 remote-control: `/Users/gavriel/nanoclaw4/src/v1/remote-control.ts:1–218` -- v1 tests: `/Users/gavriel/nanoclaw4/src/v1/remote-control.test.ts:1–379` -- v2 access control: `/Users/gavriel/nanoclaw4/src/access.ts:29–115` (pickApprover, pickApprovalDelivery, canAccessAgentGroup) -- v2 approval handler: `/Users/gavriel/nanoclaw4/src/onecli-approvals.ts:50–270` (handleRequest, resolveOneCLIApproval, sweepStaleApprovals) -- v2 webhook server: `/Users/gavriel/nanoclaw4/src/webhook-server.ts:73–124` (registerWebhookAdapter, ensureServer) -- v2 router: `/Users/gavriel/nanoclaw4/src/router.ts:19–50` (inbound access gate, unknown_sender_policy) diff --git a/docs/v1-vs-v2/router.md b/docs/v1-vs-v2/router.md deleted file mode 100644 index 361edb1..0000000 --- a/docs/v1-vs-v2/router.md +++ /dev/null @@ -1,67 +0,0 @@ -# router: v1 vs v2 - -## Scope -- v1 (distributed across): `src/v1/index.ts` (startMessageLoop, trigger check), `group-queue.ts` (concurrency, retry), `router.ts` (outbound formatting, 44 LOC), `sender-allowlist.ts` (drop/allow) -- v2: `src/router.ts` (317 LOC), `src/session-manager.ts` (346 LOC), `src/container-runner.ts`, `src/access.ts`, `src/db/messaging-groups.ts` (trigger_rules schema) - -## Routing-flow diff - -### v1 (polling, per-group) -1. Channel receives message → `onMessage` → store in DB -2. Sender allowlist drop-mode filter → discard denied -3. `startMessageLoop` polls every POLL_INTERVAL -4. For each group: lookup channel (`findChannel` O(n)), check trigger requirement, load allowlist, scan for pattern, skip if no trigger -5. Pull messages since `lastAgentTimestamp`, XML-format with tz context -6. If active container: write JSON to IPC file; else `enqueueMessageCheck(groupJid)` → GroupQueue -7. Retry on failure (up to 5, exp. backoff); rollback cursor on agent error - -### v2 (event-driven, entity model) -1. Channel adapter → `routeInbound(platformId, threadId, message)` -2. Apply thread policy (`supportsThreads` → collapse to null) -3. Resolve `messaging_group` (lookup or auto-create) -4. Extract sender → upsert `users` row → `userId` (namespaced `channel_type:handle`) -5. Lookup wired agent groups via `messaging_group_agents`; drop if none -6. `pickAgent` (highest priority; **trigger_rules matching is TODO**) -7. `enforceAccess`: owner/admin/member gate; `unknown_sender_policy: strict | request_approval | public` -8. `resolveSession` by `session_mode` (`agent-shared`/`shared`/`per-thread`) -9. `insertMessage` to session `inbound.db`, write session_routing + destinations -10. `startTypingRefresh`; `wakeContainer(session)` (dedup by `activeContainers` + `wakePromises`) -11. Container polls inbound.db, writes outbound.db; host `delivery.ts` polls and sends via adapter; `stopTypingRefresh` on container exit - -## Capability map - -| v1 behavior | v2 location | Status | Notes | -|---|---|---|---| -| Sender allowlist drop/allow modes | — | **removed** | Replaced by access gate + `unknown_sender_policy` | -| Group registration auto-creating folder on first message | `router.ts` auto-creates messaging_group; group folder via `group-init.ts` on wake | moved | Admin skill path for agent groups | -| Trigger pattern matching (`requiresTrigger`, `DEFAULT_TRIGGER`) | `messaging_group_agents.trigger_rules` JSON | **deferred** | Schema ready; `pickAgent` has TODO comment | -| `lastAgentTimestamp` cursor tracking | — | **removed** | All messages written immediately to inbound.db | -| IPC file polling (`inputDir`, `_close` sentinel) | — | **removed** | DB polling replaces | -| GroupQueue concurrency + waiting-groups | `container-runner.ts:42-82` `activeContainers` + `wakePromises` | reimplemented | Per-session not per-group | -| Task scheduler → enqueue to GroupQueue | host-sweep due-wake + delivery system-actions | preserved | | -| Session reuse rules (session mode) | `session-manager.ts` (agent-shared/shared/per-thread) | **enhanced** | Explicit per-wiring | -| Remote control command interception | — | **removed** | | -| Idle timeout + stdin close | `container-runner.ts:135-140` `resetIdle` | kept | Heartbeat instead of stdin | -| Host-level retry on agent error | — | **removed** | Container is authority; host sweep retries stale only | -| Typing indicator | `delivery.ts:startTypingRefresh` | kept | Gated on heartbeat | - -## Missing from v2 -1. **Trigger-rule matching** — `router.ts:198` TODO. Currently every wired agent fires on every message (only priority breaks ties). **Without this, multi-agent wirings don't work as intended.** -2. **Sender drop mode** — v1's silent-drop for noisy users is gone. v2 only has binary allow/deny. -3. **Cursor / state recovery** — v2 writes immediately to DB. If container crashes mid-output, no host-level dedup guarantees (beyond `messages_in.id` PK) -4. **Remote control** — v1 intercepted `/remote-control` commands pre-storage; no v2 equivalent -5. **Host-level retry with backoff on agent error** — v1 had MAX_RETRIES=5 + exp. backoff on `processGroupMessages`; v2 only retries on stale heartbeat detection - -## Behavioral discrepancies -1. **Trigger evaluation**: v1 eager (skip group until trigger arrives, accumulate context); v2 TODO — once implemented, likely drops non-trigger messages at ingest (semantic change) -2. **Session reuse**: v1 single session per group; v2 multiple (one per thread on threaded platforms) -3. **Access control timing**: v1 pre-storage (cheap drop); v2 post-sender-resolution (requires `users` upsert) -4. **Unknown channels**: v1 silently ignored; v2 auto-creates `messaging_groups` row — no data loss but orphaned rows possible -5. **Formatting**: v1 host formats with tz + cursor-based message subset; v2 pushes raw JSON to inbound.db, container formats from full session history - -## Worth preserving? -1. **Trigger rule matching (HIGH priority)** — schema is ready; 10-line implementation in `pickAgent`. Currently broken-by-default for multi-agent wirings -2. **Sender drop mode (MEDIUM)** — add `(agent_group_id, sender_pattern)` drop table; orthogonal to privilege -3. **State recovery (LOW)** — add unique constraint on `messages_in.id` if not already; v2's model is simpler + more robust -4. **Host-level retry on agent error (MEDIUM)** — currently only stale containers retry. Explicit container-exit-error retry could be valuable -5. **Remote control** — decide: restore as opt-in skill or document deletion diff --git a/docs/v1-vs-v2/sender-allowlist.md b/docs/v1-vs-v2/sender-allowlist.md deleted file mode 100644 index 7f7c518..0000000 --- a/docs/v1-vs-v2/sender-allowlist.md +++ /dev/null @@ -1,46 +0,0 @@ -# sender-allowlist: v1 vs v2 - -## Scope -- v1: `src/v1/sender-allowlist.ts` (97 LOC), `sender-allowlist.test.ts` (217 LOC) — flat JSON config at `~/.config/nanoclaw/sender-allowlist.json` -- v2 counterparts: `src/access.ts` (116 LOC), `src/router.ts` (317 LOC), `src/db/schema.ts` (user_roles, agent_group_members, messaging_groups.unknown_sender_policy), `src/container-runner.ts:291-295` (admin injection), `src/types.ts` (MessagingGroupAgent.response_scope) - -## Capability map - -| v1 behavior | v2 location | Status | Notes | -|---|---|---|---| -| Per-chat entry (`chats[chatJid]`) | `messaging_groups.unknown_sender_policy` | replaced | Policy per channel, not allowlist entries | -| Default entry | Default `unknown_sender_policy = 'strict'` | **reversed** | v1 default-allow → v2 default-deny | -| `allow: '*'` wildcard | Not present | removed | | -| `allow: string[]` (exact-match list) | `agent_group_members` rows + `user_roles` | replaced | Role-based / membership-based | -| `mode: 'trigger'` (allow for processing) | Implicit (access granted → routed) | kept | | -| `mode: 'drop'` (silent drop) | `recordDroppedMessage()` (logs only) | **partially lost** | No silent-drop mode; denied = logged | -| Admin override | owner / global_admin / scoped_admin | **new in v2** | Richer privilege hierarchy | -| Static JSON file | Central DB (`users`, `user_roles`, `agent_group_members`) | changed | Runtime-mutable, queryable | -| Exact-string sender | Namespaced `channel_type:handle` user IDs | enhanced | Explicit channel scoping | -| `logDenied` flag | implicit (log at decision point) | kept | | - -## Access-model diff -**v1**: flat allowlist per chat → default-allow → binary allowed/denied. -**v2**: entity model (`users` + roles + memberships) + per-messaging-group policy (`strict | request_approval | public`) → default-deny for unknowns. - -**Strictly more expressive:** role hierarchy, per-agent-group scope, three-way unknown handling, user metadata (display_name/kind), runtime reconfig. -**Lost:** per-message `drop` mode, default-allow posture, simple JSON editing. - -## Missing from v2 -1. **`request_approval` flow** — marked TODO in `router.ts:295`. Approval-on-first-contact for unknown senders is scaffolded but not wired -2. **`response_scope` enforcement** — field exists (`'all' | 'triggered' | 'allowlisted'`) but is not checked in `router.ts` or `delivery.ts` -3. **Trigger-rule matching on `messaging_group_agents`** — `router.ts:198` TODO ("Future: trigger rule matching"); currently only priority-based agent selection -4. **Silent-drop option for known-noisy senders** — v1's `mode: 'drop'` allowed "I see you but I ignore you"; v2 can only log and drop - -## Behavioral discrepancies -1. **Default posture flipped**: v1 open-by-default vs v2 closed-by-default — **breaking for migrations that relied on default-allow** -2. **Drop semantics**: v1 silent drop; v2 `recordDroppedMessage()` always logs -3. **Admin bypass**: v1 had no implicit bypass; v2 grants owners/admins access regardless of membership — more permissive for privileged users -4. **Scope resolution**: v1 per-chat; v2 per-agent-group via `user_roles.agent_group_id` — misalignment if one chat routes to multiple agent groups with different admins - -## Worth preserving? -The v2 role-based model is architecturally superior. The gaps worth closing: -- **Finish `request_approval`** flow — half-implemented scaffolding -- **Finish `response_scope` enforcement** — exists in schema but unused -- **Finish trigger-rule matching** in `pickAgent` — without it, every wired agent fires on every message -- **Consider silent-drop via a dedicated table** (`(agent_group_id, sender_pattern)` with action=drop) — orthogonal to privilege diff --git a/docs/v1-vs-v2/session-cleanup.md b/docs/v1-vs-v2/session-cleanup.md deleted file mode 100644 index 87aa3d4..0000000 --- a/docs/v1-vs-v2/session-cleanup.md +++ /dev/null @@ -1,44 +0,0 @@ -# session-cleanup: v1 vs v2 - -## Scope -- v1: `src/v1/session-cleanup.ts` (26 LOC) + `scripts/cleanup-sessions.sh` (151 LOC) — cadence 24h -- v2: `src/host-sweep.ts` (174 LOC) primary, plus `src/container-runtime.ts:60-80` (orphan cleanup), `src/session-manager.ts` (heartbeat path) - -## Capability map - -| v1 behavior | v2 location | Status | Notes | -|---|---|---|---| -| Cleanup cadence 24h | `host-sweep.ts:31` 60s sweep | **changed** | Continuous monitoring | -| Stale session detection via JSONL mtime | `host-sweep.ts:116-151` heartbeat file mtime | simplified | Heartbeat replaces JSONL | -| Heartbeat threshold | `STALE_THRESHOLD_MS = 10 * 60 * 1000` (`host-sweep.ts:32`) | **new** | 10 min | -| Stuck-processing detection | `getStuckProcessingIds()` via outbound.db (`host-sweep.ts:134`) | **new** | | -| Retry with exponential backoff | `BACKOFF_BASE_MS * 2^tries` (`host-sweep.ts:145`) | **new** | | -| Max retries | `MAX_TRIES = 5` (`host-sweep.ts:33`) | **new** | Messages → failed after 5 | -| Explicit container kill on stale | — | **not done** | Stale detection resets messages, doesn't stop container | -| JSONL + tool-results cleanup | — | **removed** | No artifact cleanup (SQLite persists in DB) | -| Artifact cleanup (debug logs, todos, telemetry) | — | **removed** | Per-type retention windows gone | -| Orphan container cleanup | `container-runtime.ts:60-80` `cleanupOrphans()` | **new** | At startup only | -| Active session detection via `store/messages.db` | `getActiveSessions()` from `v2.db` (`host-sweep.ts:52`) | changed | DB schema different | -| Sync `processing_ack` (outbound.db → inbound.db) | `syncProcessingAcks()` (`host-sweep.ts:87`) | **new** | | -| Wake container for due messages | `countDueMessages()` + `wakeContainer()` (`host-sweep.ts:91-96`) | **new** | Replaces scheduler's role | -| Recurrence firing | `handleRecurrence()` (`host-sweep.ts:154-173`) | **new** | Cron-parsed next-run insertion | - -## Missing from v2 -1. **Artifact cleanup** — v1 pruned JSONLs (7d), debug logs (3d), todos (3d), telemetry (7d), group logs (7d). v2 has none; if v1 leftovers exist on disk, they'll accumulate -2. **Explicit container termination** on stale detection — v2 marks messages as retry-eligible but leaves the stale container running; orphan cleanup only runs at next startup -3. **Configurable retention windows** — v1 had per-artifact-type retention; v2 constants are hardcoded - -## Behavioral discrepancies -| Aspect | v1 | v2 | -|---|---|---| -| Cadence | daily batch | 60s continuous | -| Stale trigger | 24h-old JSONL | 10-min heartbeat | -| Retry | none (session removed) | 5 tries, exp. backoff | -| Container wake | via message loop | via `countDueMessages()` in sweep | -| Transactions | implicit (offline script) | explicit per-session try/finally | - -## Worth preserving? -1. **Stop running containers on stale detection** — currently only startup `cleanupOrphans()` removes them. If a container truly dies while the host runs, the host will retry messages but won't kill the shell. Low-cost fix: `stopContainer(name)` when heartbeat is stale AND processing_ack is stuck -2. **Artifact cleanup migration** — if v1 data exists on disk post-migration, one-time prune is worth scripting. Not a v2 runtime concern -3. **Configurable thresholds** — `STALE_THRESHOLD_MS` / `MAX_TRIES` could live in `config.ts` for operational tuning; minor improvement -4. **Continuous sweep + recurrence + orphan cleanup** are all **significant improvements**; keep as-is diff --git a/docs/v1-vs-v2/task-scheduler.md b/docs/v1-vs-v2/task-scheduler.md deleted file mode 100644 index 29a0606..0000000 --- a/docs/v1-vs-v2/task-scheduler.md +++ /dev/null @@ -1,100 +0,0 @@ -# task-scheduler: v1 vs v2 - -## Scope - -**v1 task scheduler:** -- Files: `src/v1/task-scheduler.ts` (241 lines), `src/v1/task-scheduler.test.ts` (122 lines) -- Self-contained scheduler loop with DB persistence and container execution -- Stores tasks in central DB table `scheduled_tasks` -- Runs a polling loop at `SCHEDULER_POLL_INTERVAL` (configurable, typically 5–60s) - -**v2 task distribution:** -- No central task-scheduler file; tasks spread across host sweep and session DBs -- Core files: `src/host-sweep.ts` (174 lines), `src/delivery.ts` (task handlers ~line 654–713), `src/db/session-db.ts` (task mutation logic) -- Optional: `container/agent-runner/src/task-script.ts` (pre-task script execution) -- Task rows live in per-session `inbound.db` table `messages_in` (polymorphic message kind) -- Recurrence computed in `host-sweep.ts` (host-sweep.ts:159–173) - ---- - -## Capability map - -| v1 Behavior | v2 Location | Status | Notes | -|---|---|---|---| -| **One-shot tasks** (schedule_type='once') | `insertTask()` in `src/db/session-db.ts:103–122`; processAfter field set, recurrence=NULL | ✅ Supported | Task inserted into messages_in with process_after timestamp, processed once, no recurrence | -| **Recurring via cron** (schedule_type='cron') | `insertTask()` with recurrence field; `host-sweep.ts:159–173` parses cron | ✅ Supported | Cron expression stored in messages_in.recurrence, next occurrence computed on completion via CronExpressionParser | -| **Recurring via fixed interval** (schedule_type='interval') | Not directly supported; v2 uses cron for all recurring | ⚠️ Removed | v2 requires cron syntax for recurrence. No interval-based scheduling (e.g., "every 5 minutes") without converting to cron | -| **Timezone handling** | `host-sweep.ts:159–161` uses CronExpressionParser with no explicit TZ param; cron-parser respects system TZ | ⚠️ Degraded | v1's explicit TIMEZONE config (via timezone.ts helpers) is absent in v2. Cron evaluation uses system/Node.js default TZ, not agent/session-level configuration | -| **Persistence** | Per-session `inbound.db` `messages_in` table + `series_id` grouping | ✅ Supported | Tasks persisted as DB rows with status (pending/completed/paused). Series_id backfilled for recurring task groups | -| **Restart recovery** | `host-sweep.ts:85–96` syncs processing_ack on startup to detect stale containers; tasks marked paused if container crashes | ✅ Supported | Stale container detection via heartbeat file mtime (host-sweep.ts:122–131); stuck messages retried with exponential backoff | -| **Due-message wake** | `host-sweep.ts:91–96` queries countDueMessages, wakes container if due tasks exist | ✅ Supported | 60s sweep checks for pending tasks with process_after in the past and wakes container if found | -| **Missed-run catch-up** (interval-based) | `computeNextRun()` skips past missed intervals to prevent cumulative drift; tests verify no infinite loop | ⚠️ Degraded | v2 doesn't handle missed intervals — if a recurring cron task gets skipped, next occurrence is computed from completion time only. No "make up" for missed runs | -| **Cancellation** | `updateTask(id, {status: 'paused'})` prevents retry churn | ✅ Supported | `cancelTask()` in `src/db/session-db.ts:128–132` sets status='completed' and clears recurrence; matches by id OR series_id | -| **Pause/resume** | `updateTask(id, {status: 'paused'})` / resume | ✅ Supported | `pauseTask()` (line 134–138) and `resumeTask()` (line 140–144); both match id or series_id | -| **Retry-on-failure** | `updateTaskAfterRun()` on error; no explicit retry logic in scheduler loop | ⚠️ Degraded | v2 uses `retryWithBackoff()` only when container goes stale (host-sweep.ts:147). No automatic retry for task execution errors | -| **Concurrent-run prevention** | Task status 'active' gate (task-scheduler.ts:221); no concurrent-run logic | ⚠️ Degraded | v2 allows multiple pending tasks to wake the container in the same sweep; container processes serially but no host-level concurrency control | -| **Idempotency** | Task ID is primary key; `insertTask()` will fail if re-run with same ID | ✅ Supported | messages_in.id is PRIMARY KEY; insertTask() fails on duplicate (caller must handle or use ON CONFLICT) | -| **Max-age drop** | No explicit max-age field; tasks can remain pending indefinitely | ⚠️ Missing | No max-age or TTL in v2 messages_in schema. A stuck task can remain pending forever unless manually cancelled | -| **Task context mode** (group vs isolated session) | v1: context_mode field drives session reuse (task-scheduler.ts:122) | ⚠️ Removed | v2 doesn't track context_mode; all tasks are processed in the container's default session context; no isolation toggle | -| **Task result logging** | `logTaskRun()` writes to task_runs table; stores error + result summary | ⚠️ Degraded | v2 has no equivalent task_runs table. Task output is written as system messages back to the agent; no persistent audit trail | -| **Task script execution** | v1: prompt + optional script field, passed to container | ✅ Supported | v2: `applyPreTaskScripts()` in `container/agent-runner/src/task-script.ts:79–121` runs scripts pre-prompt, enriches prompt with scriptOutput | - ---- - -## Missing from v2 - -1. **Interval-based recurrence** — v1 `schedule_type='interval'` (e.g., "every 5000ms") is gone. v2 only supports cron expressions. Workaround: convert to equivalent cron (e.g., `*/5 * * * * *` for every 5 min). - -2. **Timezone awareness** — v1 passed `TIMEZONE` config to cron parser and had explicit `formatLocalTime()` helpers. v2 has no way to specify a session/agent timezone for cron evaluation; it uses the system/Node.js TZ. - -3. **Task context modes** — v1's `context_mode: 'group' | 'isolated'` is removed. No way to force a task into a dedicated session vs. the agent group's shared session. - -4. **Task result audit trail** — v1 logged every run to `task_runs(task_id, run_at, duration_ms, status, result, error)`. v2 has no persistent task execution history; output is a system message only. - -5. **Max-age / task TTL** — v1 tasks could be implicitly aged out (not directly visible in the code, but conceivable via cleanup logic). v2 has no TTL; a paused/completed task lingers in messages_in forever. - -6. **Task-level concurrency control** — v1 prevented concurrent runs of the same task (single status check per loop iteration). v2 can queue multiple pending tasks in one sweep, though the container processes them serially. - ---- - -## Behavioral discrepancies - -1. **Missed-interval catch-up** (v1 `computeNextRun()` lines 32–46 vs. v2 absence): - - **v1:** If a task is due at 10:00, 10:05, 10:10 but the scheduler is down during 10:00–10:15, it computes `next_run = 10:20` (skips missed intervals, stays on the grid). - - **v2:** If the same recurring cron task is skipped, the next occurrence is computed from the *completion* time (host-sweep.ts:160–161), not from the original grid. A task that should run at :00 and :05 every 10 minutes might drift if completions are delayed. - -2. **Stale-container recovery** (v1 none vs. v2 heartbeat-based): - - **v1:** Tasks remain due if the container crashes; the scheduler will retry on the next poll. - - **v2:** If the heartbeat goes stale (container unresponsive for 10 min), stuck processing messages are retried with exponential backoff. Tasks stuck in 'processing' state are reset. - -3. **Task script pre-processing** (v1 prompt + script → container vs. v2 script → output enrichment): - - **v1:** Passes script alongside prompt to container; container execution model unclear from scheduler.ts (likely runs in group-queue). - - **v2:** Host runs script *before* waking container; script output (`scriptOutput`) is merged into prompt JSON via `applyPreTaskScripts()` (task-script.ts:115–117). If script fails or returns `wakeAgent=false`, the task is skipped entirely. - -4. **Retry semantics**: - - **v1:** On execution error (runTask throws), `updateTaskAfterRun()` is called with `error`. Next retry relies on scheduler polling the same task again (no backoff). - - **v2:** Execution errors are not retried; container processes the task once. If the container crashes mid-task, the message is retried with exponential backoff only up to `MAX_TRIES=5` (host-sweep.ts:145–150). - ---- - -## Worth preserving? - -**Interval-based recurrence** (v1 `schedule_type='interval'`) is a practical feature that v2 trades away. Cron syntax is powerful but less intuitive for simple "every X milliseconds" patterns. If users want "run every 30 seconds," they must learn cron (`*/30 * * * * *` for seconds doesn't exist in standard cron; workaround is job-level looping in the prompt). Consider a thin adapter layer in agent-facing APIs to accept `{interval: 5000}` and convert to cron, or extend the v2 schema to support an optional `interval_ms` alongside `recurrence`. - -**Task context modes** (`group` vs. `isolated`) were a way to isolate task execution context. v2's removal simplifies the model but loses the ability to run a task in a fresh container state. If a task needs a clean slate (no session history), that's now impossible; workaround is a manual system-action to clear session state before running the task. - -**Task result audit trail** is a gap for operational visibility. v2's system messages are ephemeral; there's no way to query "how many times did task X run and what were the outcomes?" Adding a lightweight `task_execution_log` table (optional, populated on task completion) would help without burdening the common case. - ---- - -## References by line - -- v1 task-scheduler: `src/v1/task-scheduler.ts:20–49` (computeNextRun), `:203–235` (startSchedulerLoop) -- v1 test coverage: `src/v1/task-scheduler.test.ts:49–121` (drift, missed-interval, once-task tests) -- v1 timezone: `src/v1/timezone.ts:26–37` (formatLocalTime with explicit TZ) -- v1 types: `src/v1/types.ts:60–74` (ScheduledTask interface with context_mode) -- v2 sweep: `src/host-sweep.ts:154–173` (handleRecurrence, insertRecurrence) -- v2 delivery system actions: `src/delivery.ts:645–713` (handleSystemAction switch on schedule_task/cancel_task/pause_task/resume_task/update_task) -- v2 session-db: `src/db/session-db.ts:103–198` (insertTask, cancelTask, pauseTask, resumeTask, updateTask, all with series_id matching) -- v2 task-script: `container/agent-runner/src/task-script.ts:79–121` (applyPreTaskScripts, wakeAgent logic) -- v2 DB schema: `docs/db-session.md:31–56` (messages_in table with process_after, recurrence, series_id) diff --git a/docs/v1-vs-v2/timezone-formatting-v1-recreation.md b/docs/v1-vs-v2/timezone-formatting-v1-recreation.md deleted file mode 100644 index eabf012..0000000 --- a/docs/v1-vs-v2/timezone-formatting-v1-recreation.md +++ /dev/null @@ -1,570 +0,0 @@ -# v1 Timezone + Formatting — Recreation Spec - -## Source commits - -**Parent of deletion**: `86becf8^ = 27c52205f9fdeac0483600b2663f1c4d80aba45d` - -**Deletion commit**: `86becf8` (chore: delete v1 reference code) - -### Relevant v1 files at commit 27c5220 (v1^): -- `src/v1/router.ts` — message formatting logic (escapeXml, formatMessages, stripInternalTags, formatOutbound) -- `src/v1/timezone.ts` — timezone utility functions (isValidTimezone, resolveTimezone, formatLocalTime) -- `src/v1/config.ts` — configuration and trigger patterns (buildTriggerPattern, getTriggerPattern, TIMEZONE resolution) -- `src/v1/task-scheduler.ts` — scheduled task timezone handling (computeNextRun with cron-parser) -- `src/v1/types.ts` — data structures (NewMessage interface) -- `src/v1/formatting.test.ts` — comprehensive test suite for all formatting behavior -- `src/v1/timezone.test.ts` — timezone utility tests -- `src/v1/task-scheduler.test.ts` — scheduler tests - ---- - -## 1. Timestamp formatting on inbound messages - -### v1 behavior (exact) - -**Function**: `formatLocalTime()` in `src/v1/timezone.ts:26-36` - -```typescript -export function formatLocalTime(utcIso: string, timezone: string): string { - const date = new Date(utcIso); - return date.toLocaleString('en-US', { - timeZone: resolveTimezone(timezone), - year: 'numeric', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true, - }); -} -``` - -**Input**: UTC ISO 8601 timestamp (e.g., `'2024-01-01T00:00:00.000Z'`) + timezone name (e.g., `'America/New_York'`) - -**Output format example**: -- Input: `'2024-01-01T18:30:00.000Z'` with timezone `'America/New_York'` (EST, UTC-5) -- Output: `'1:30 PM'` (with additional date components: month short name, day, year, hour, 2-digit minute, 12-hour format) -- Full example output: `"Jan 1, 2024, 1:30 PM"` (exact format depends on browser/Node locale) - -**Critical Details**: -- Uses JavaScript's `Intl.DateTimeFormat` API with `en-US` locale -- Format options: `{ year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }` -- Handles invalid timezone gracefully by calling `resolveTimezone(timezone)` which falls back to UTC -- No external dependencies (no moment.js, date-fns, or day.js) - -**Where it's called**: -- `src/v1/router.ts:11` in `formatMessages()` function to convert each message's `m.timestamp` to display time -- The display time is then placed in the `time="..."` attribute of the XML message element - -### Test coverage - -From `src/v1/formatting.test.ts:51-84`: - -1. **Basic formatting with context header** - - Input: Single message with timestamp `'2024-01-01T00:00:00.000Z'`, timezone `'UTC'` - - Asserts: `result.toContain('Jan 1, 2024')` and `''` - - File:line: `src/v1/formatting.test.ts:51-56` - -2. **Timezone conversion to local time** - - Input: Timestamp `'2024-01-01T18:30:00.000Z'` with timezone `'America/New_York'` (EST) - - Asserts: Result contains `'1:30'` and `'PM'` (correct EST conversion, UTC-5) - - File:line: `src/v1/formatting.test.ts:74-78` - -From `src/v1/timezone.test.ts:10-30`: - -3. **formatLocalTime with timezone conversion** - - Input: `'2026-02-04T18:30:00.000Z'` with `'America/New_York'` - - Asserts: Contains `'1:30'`, `'PM'`, `'Feb'`, `'2026'` - - File:line: `src/v1/timezone.test.ts:10-16` - -4. **Multiple timezones comparison** - - Input: Same UTC time with different timezones (`'America/New_York'`, `'Asia/Tokyo'`) - - Asserts: NY shows `'8:00'` (EDT, UTC-4 in summer), Tokyo shows `'9:00'` (UTC+9) - - File:line: `src/v1/timezone.test.ts:18-26` - -5. **Invalid timezone fallback** - - Input: Invalid timezone `'IST-2'` - - Asserts: Does not throw, formats as UTC (falls back) - - File:line: `src/v1/timezone.test.ts:28-33` - ---- - -## 2. Context timezone header - -### v1 behavior (exact) - -**Location**: Prepended at the START of the formatted message block in `src/v1/router.ts:20-22` - -**Format**: -```xml - -``` - -**Code**: -```typescript -const header = `\n`; -return `${header}\n${lines.join('\n')}\n`; -``` - -**What it includes**: -- Only the timezone name (IANA identifier, e.g., `'UTC'`, `'America/New_York'`) -- **NOT** the current time (that's in each individual message's `time="..."` attribute) -- XML-escaped to prevent injection (via `escapeXml()`) - -**Per-message vs per-turn**: -- The header appears **once per call to `formatMessages()`**, which formats a batch of messages -- The entire batch (header + all messages) is passed to the agent as a single unit -- The `timezone` parameter is passed in from the caller (`src/v1/router.ts:9` line signature) - -**Where it's wired**: -- `src/v1/router.ts:9` — `formatMessages(messages: NewMessage[], timezone: string)` accepts timezone as a parameter -- This function is called from the channel message processing loop (inbound message handler) -- The caller supplies the `TIMEZONE` constant from `src/v1/config.ts:62` - -### Test coverage - -From `src/v1/formatting.test.ts:51-56`: - -1. **Context header is included in output** - - Input: Any message list with timezone `'UTC'` - - Asserts: `result.toContain('')` - - File:line: `src/v1/formatting.test.ts:51-56` - -2. **Context header with non-UTC timezone** - - Input: Timezone `'America/New_York'` - - Asserts: `result.toContain('')` - - File:line: `src/v1/formatting.test.ts:74-78` - -3. **Context header with empty message list** - - Input: Empty array with timezone `'UTC'` - - Asserts: `result.toContain('')` even when no messages - - File:line: `src/v1/formatting.test.ts:80-83` - ---- - -## 3. Reply-to handling with message IDs - -### v1 behavior (exact) - -**Location**: In the message formatting loop in `src/v1/router.ts:10-18` - -**Code**: -```typescript -const replyAttr = m.reply_to_message_id ? ` reply_to="${escapeXml(m.reply_to_message_id)}"` : ''; -const replySnippet = - m.reply_to_message_content && m.reply_to_sender_name - ? `\n ${escapeXml(m.reply_to_message_content)}` - : ''; -return `${replySnippet}${escapeXml(m.content)}`; -``` - -**Format of reply-to**: -- Attribute: `reply_to=""` on the `` tag (if `m.reply_to_message_id` is present) -- The ID is XML-escaped via `escapeXml()` -- Nested element: `` (if both sender and content are present) -- Both sender name and content are XML-escaped - -**What it contains**: -- `reply_to=""` attribute with the exact message ID from `m.reply_to_message_id` -- Sender name from `m.reply_to_sender_name` -- Original message content from `m.reply_to_message_content` -- **No timestamp** of the referenced message - -**Conditional rendering**: -1. If `m.reply_to_message_id` is present: include `reply_to=""` attribute -2. If `m.reply_to_message_id` is present but content/sender missing: include attribute only, no `` element -3. If only content and sender (no ID): only `` element, no attribute - -**Example output**: -```xml - - Are you coming tonight? -Yes, on my way! -``` - -### Test coverage - -From `src/v1/formatting.test.ts:96-139`: - -1. **Reply with both ID and quoted content** - - Input: Message with `reply_to_message_id: '42'`, `reply_to_sender_name: 'Bob'`, `reply_to_message_content: 'Are you coming tonight?'`, content: `'Yes, on my way!'` - - Asserts: - - `result.toContain('reply_to="42"')` - - `result.toContain('Are you coming tonight?')` - - `result.toContain('Yes, on my way!')` - - File:line: `src/v1/formatting.test.ts:96-112` - -2. **No reply context when missing** - - Input: Message without reply fields - - Asserts: - - `result.not.toContain('reply_to')` - - `result.not.toContain('quoted_message')` - - File:line: `src/v1/formatting.test.ts:114-119` - -3. **ID present but content missing** - - Input: `reply_to_message_id: '42'`, `reply_to_sender_name: 'Bob'`, but NO `reply_to_message_content` - - Asserts: - - `result.toContain('reply_to="42"')` - - `result.not.toContain('quoted_message')` - - File:line: `src/v1/formatting.test.ts:121-130` - -4. **XML escape in reply context** - - Input: `reply_to_message_id: '1'`, `reply_to_sender_name: 'A & B'`, `reply_to_message_content: ''` - - Asserts: - - `result.toContain('from="A & B"')` - - `result.toContain('<script>alert("xss")</script>')` - - File:line: `src/v1/formatting.test.ts:131-139` - ---- - -## 4. Internal tag stripping - -### v1 behavior (exact) - -**Function name**: `stripInternalTags()` in `src/v1/router.ts:25-27` - -**Implementation**: -```typescript -export function stripInternalTags(text: string): string { - return text.replace(/[\s\S]*?<\/internal>/g, '').trim(); -} -``` - -**Regex pattern**: `/[\s\S]*?<\/internal>/g` -- `` — literal opening tag -- `[\s\S]*?` — match any character (whitespace or non-whitespace) non-greedily -- `<\/internal>` — literal closing tag -- `g` flag — global (all matches) - -**Post-processing**: `.trim()` removes leading/trailing whitespace after all tags are stripped - -**Where it's called**: -- `src/v1/router.ts:30` in `formatOutbound()` function -- Called AFTER the tag removal to clean the output before returning - -**Used for**: Stripping internal thinking/reasoning from outbound messages before sending to channel - -**Input/Output examples**: - -1. Single-line internal tag: - - Input: `'hello secret world'` - - Output: `'hello world'` (then `.trim()` would be `'hello world'`) - -2. Multi-line internal tags: - - Input: `'hello \nsecret\nstuff\n world'` - - Output: `'hello world'` - -3. Multiple blocks: - - Input: `'ahellob'` - - Output: `'hello'` - -4. Only internal content: - - Input: `'only this'` - - Output: `''` (empty after trim) - -### Test coverage - -From `src/v1/formatting.test.ts:163-181`: - -1. **Single-line tag stripping** - - Input: `'hello secret world'` - - Asserts: Result is `'hello world'` (two spaces, then `.trim()` removes outer whitespace) - - Expected (with trim): `'hello world'` - - File:line: `src/v1/formatting.test.ts:163-165` - -2. **Multi-line tag stripping** - - Input: `'hello \nsecret\nstuff\n world'` - - Asserts: Result is `'hello world'` (after trim) - - File:line: `src/v1/formatting.test.ts:167-169` - -3. **Multiple internal blocks** - - Input: `'ahellob'` - - Asserts: Result is `'hello'` - - File:line: `src/v1/formatting.test.ts:171-173` - -4. **Only internal content** - - Input: `'only this'` - - Asserts: Result is `''` (empty string) - - File:line: `src/v1/formatting.test.ts:175-177` - -From `src/v1/formatting.test.ts:183-194`: - -5. **formatOutbound with no internal tags** - - Input: `'hello world'` - - Asserts: Result is `'hello world'` - - File:line: `src/v1/formatting.test.ts:183-185` - -6. **formatOutbound with all internal content** - - Input: `'hidden'` - - Asserts: Result is `''` (returns early after strip) - - File:line: `src/v1/formatting.test.ts:187-189` - -7. **formatOutbound strips and returns remaining** - - Input: `'thinkingThe answer is 42'` - - Asserts: Result is `'The answer is 42'` - - File:line: `src/v1/formatting.test.ts:191-194` - ---- - -## 5. Timezone handling for scheduled tasks - -### v1 behavior (exact) - -**Location**: `src/v1/task-scheduler.ts:20-49` - -**Key function**: `computeNextRun(task: ScheduledTask): string | null` - -**Cron timezone handling**: -```typescript -if (task.schedule_type === 'cron') { - const interval = CronExpressionParser.parse(task.schedule_value, { - tz: TIMEZONE, - }); - return interval.next().toISOString(); -} -``` - -**Critical details**: -- Uses `cron-parser` library's `CronExpressionParser.parse()` method -- Passes timezone option as `{ tz: TIMEZONE }` (e.g., `{ tz: 'America/New_York' }`) -- `TIMEZONE` is imported from `src/v1/config.ts:62` and resolved via `resolveConfigTimezone()` -- The cron expression is interpreted in the **user's timezone**, not UTC -- Example: cron `'0 9 * * *'` with `tz: 'America/New_York'` means 9 AM ET every day - -**Interval task handling**: -```typescript -if (task.schedule_type === 'interval') { - const ms = parseInt(task.schedule_value, 10); - if (!ms || ms <= 0) { - logger.warn({ taskId: task.id, value: task.schedule_value }, 'Invalid interval value'); - return new Date(now + 60_000).toISOString(); - } - let next = new Date(task.next_run!).getTime() + ms; - while (next <= now) { - next += ms; - } - return new Date(next).toISOString(); -} -``` - -**Interval specifics**: -- Intervals are timezone-agnostic (pure millisecond-based) -- Anchored to the task's `next_run` time to prevent cumulative drift -- If intervals have been missed, the loop skips forward to land in the future while maintaining the original schedule grid - -**Once-only tasks**: -```typescript -if (task.schedule_type === 'once') return null; -``` - -**MCP tool description**: -- v1 did not expose cron task scheduling directly to the agent (it was a server-side feature) -- The scheduling was configured in group config files, not via agent tool calls - -### Test coverage - -From `src/v1/task-scheduler.test.ts:33-60`: - -1. **computeNextRun returns null for once-tasks** - - Input: Task with `schedule_type: 'once'` - - Asserts: `computeNextRun(task)` returns `null` - - File:line: `src/v1/task-scheduler.test.ts:40-49` - -2. **Interval task anchoring to prevent drift** - - Input: Task scheduled 2s ago with interval `60000` (1 minute) - - Asserts: Next run = `scheduledTime + 60s`, not `now + 60s` - - Expected: Exact alignment to the scheduled time grid - - File:line: `src/v1/task-scheduler.test.ts:33-39` - -3. **Interval task catches up without infinite loop** - - Input: Task with 10 missed intervals (missed by 10 * 60000ms) - - Asserts: Next run is in the future and aligned to original schedule grid - - File:line: `src/v1/task-scheduler.test.ts:51-60` - ---- - -## 6. Complete test inventory (formatting.test.ts) - -### All test cases from src/v1/formatting.test.ts (lines 1-254): - -#### Block 1: escapeXml tests (lines 22-46) - -| Test name | Input | Expected output | -|-----------|-------|-----------------| -| escapes ampersands | `'a & b'` | `'a & b'` | -| escapes less-than | `'a < b'` | `'a < b'` | -| escapes greater-than | `'a > b'` | `'a > b'` | -| escapes double quotes | `'"hello"'` | `'"hello"'` | -| handles multiple special characters together | `'a & b < c > d "e"'` | `'a & b < c > d "e"'` | -| passes through strings with no special chars | `'hello world'` | `'hello world'` | -| handles empty string | `''` | `''` | - -#### Block 2: formatMessages tests (lines 48-159) - -| Test name | Input | Key asserts | -|-----------|-------|------------| -| formats a single message as XML with context header (line 51) | Single message with timestamp `'2024-01-01T00:00:00.000Z'`, TZ `'UTC'` | Contains `''`, `'hello'`, `'Jan 1, 2024'` | -| formats multiple messages (line 59) | 2 messages: Alice at 00:00, Bob at 01:00 | Contains both sender names and contents | -| escapes special characters in sender names (line 72) | Sender `'A & B '` | Contains `'sender="A & B <Co>"'` | -| escapes special characters in content (line 79) | Content `''` | Contains escaped script tags `'<script>...'` | -| handles empty array (line 85) | Empty message list, TZ `'UTC'` | Contains header and `'\n\n'` | -| renders reply context as quoted_message element (line 96) | Message with `reply_to_message_id: '42'`, `reply_to_sender_name: 'Bob'`, `reply_to_message_content: 'Are you coming tonight?'` | Contains `'reply_to="42"'`, `'Are you coming tonight?'` | -| omits reply attributes when no reply context (line 114) | Message without reply fields | Does NOT contain `'reply_to'` or `'quoted_message'` | -| omits quoted_message when content is missing but id is present (line 121) | Message with `reply_to_message_id: '42'` but no `reply_to_message_content` | Contains `'reply_to="42"'` but NOT `'alert("xss")'` | Contains `'from="A & B"'` and escaped script | -| converts timestamps to local time for given timezone (line 140) | Timestamp `'2024-01-01T18:30:00.000Z'` with TZ `'America/New_York'` (EST, UTC-5) | Contains `'1:30'`, `'PM'`, header has `'America/New_York'` | - -#### Block 3: TRIGGER_PATTERN tests (lines 146-169) - -| Test name | Input | Expected result | -|-----------|-------|-----------------| -| matches @name at start of message (line 152) | `'@Andy hello'` (assuming ASSISTANT_NAME='Andy') | `true` | -| matches case-insensitively (line 156) | `'@andy hello'` or `'@ANDY hello'` | `true` | -| does not match when not at start of message (line 160) | `'hello @Andy'` | `false` | -| does not match partial name like @NameExtra (word boundary) (line 164) | `'@Andyextra hello'` | `false` | -| matches with word boundary before apostrophe (line 168) | `'@Andy\'s thing'` | `true` | -| matches @name alone (end of string is a word boundary) (line 172) | `'@Andy'` | `true` | -| matches with leading whitespace after trim (line 175) | `' @Andy hey'` (after `.trim()`) | `true` | - -#### Block 4: getTriggerPattern tests (lines 177-196) - -| Test name | Input | Expected behavior | -|-----------|-------|-------------------| -| uses the configured per-group trigger when provided (line 180) | `getTriggerPattern('@Claw')` | Matches `'@Claw hello'`, does NOT match `'@Andy hello'` | -| falls back to the default trigger when group trigger is missing (line 186) | `getTriggerPattern(undefined)` | Matches default trigger `'@Andy hello'` | -| treats regex characters in custom triggers literally (line 192) | `getTriggerPattern('@C.L.A.U.D.E')` | Matches literal dots, NOT wildcard (does NOT match `'@CXLXAUXDXE'`) | - -#### Block 5: stripInternalTags tests (lines 198-210) - -| Test name | Input | Expected output | -|-----------|-------|-----------------| -| strips single-line internal tags (line 199) | `'hello secret world'` | `'hello world'` (then `.trim()` makes it `'hello world'`) | -| strips multi-line internal tags (line 203) | `'hello \nsecret\nstuff\n world'` | `'hello world'` | -| strips multiple internal tag blocks (line 207) | `'ahellob'` | `'hello'` | -| returns empty string when text is only internal tags (line 211) | `'only this'` | `''` | - -#### Block 6: formatOutbound tests (lines 213-226) - -| Test name | Input | Expected output | -|-----------|-------|-----------------| -| returns text with internal tags stripped (line 214) | `'hello world'` | `'hello world'` | -| returns empty string when all text is internal (line 218) | `'hidden'` | `''` | -| strips internal tags from remaining text (line 222) | `'thinkingThe answer is 42'` | `'The answer is 42'` | - -#### Block 7: trigger gating (requiresTrigger interaction) tests (lines 228-254) - -| Test name | Input | Expected result | -|-----------|-------|-----------------| -| main group always processes (no trigger needed) (line 239) | `isMainGroup: true`, message without trigger | `true` | -| main group processes even with requiresTrigger=true (line 244) | `isMainGroup: true`, `requiresTrigger: true`, no trigger | `true` | -| non-main group with requiresTrigger=undefined requires trigger (line 249) | `isMainGroup: false`, `requiresTrigger: undefined`, no trigger | `false` | -| non-main group with requiresTrigger=true requires trigger (line 254) | `isMainGroup: false`, `requiresTrigger: true`, no trigger | `false` | -| non-main group with requiresTrigger=true processes when trigger present (line 259) | `isMainGroup: false`, trigger in message | `true` | -| non-main group uses per-group trigger instead of default (line 264) | `isMainGroup: false`, `trigger: '@Claw'`, message `'@Claw do something'` | `true` | -| non-main group does not process when only default trigger is present for custom-trigger group (line 269) | `isMainGroup: false`, `trigger: '@Claw'`, message `'@Andy do something'` | `false` | -| non-main group with requiresTrigger=false always processes (line 274) | `isMainGroup: false`, `requiresTrigger: false`, no trigger | `true` | - ---- - -## v2 porting plan - -### For each of sections 1–5: the specific change to make in v2 - -#### 1. Timestamp formatting - -**v2 file to modify**: (Unknown — search for where v2 formats inbound messages to the agent) - -**Change needed**: -1. Find where v2 currently formats message timestamps for the agent -2. Replace any custom date formatting with the v1 pattern: - - Call `new Date(timestamp).toLocaleString('en-US', { timeZone, year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })` -3. Ensure the timezone parameter is sourced from `config.TIMEZONE` (or equivalent in v2) - -**Test to port**: `src/v1/formatting.test.ts:51-56` (basic formatting) and `src/v1/formatting.test.ts:74-78` (timezone conversion) - -#### 2. Context timezone header - -**v2 file to modify**: (Unknown — search for where v2 constructs the XML/prompt for inbound messages) - -**Change needed**: -1. Prepend `\n` to the formatted message block -2. The timezone should be the resolved IANA identifier (e.g., `'UTC'`, `'America/New_York'`) -3. Ensure it's placed BEFORE the `` element - -**Test to port**: `src/v1/formatting.test.ts:51-56` and `src/v1/formatting.test.ts:80-83` (empty array still has header) - -#### 3. Reply-to with message ID - -**v2 file to modify**: (Unknown — search for where v2 formats message metadata) - -**Change needed**: -1. If `message.reply_to_message_id` is present, add ` reply_to=""` attribute to the `` element -2. If BOTH `message.reply_to_message_content` AND `message.reply_to_sender_name` are present, include a nested `` element -3. XML-escape all three values (ID, sender name, content) - -**Test to port**: -- `src/v1/formatting.test.ts:96-112` (full reply context) -- `src/v1/formatting.test.ts:121-130` (ID only, no content) -- `src/v1/formatting.test.ts:131-139` (XML escaping in reply) - -#### 4. Internal tag stripping - -**v2 file to modify**: (Unknown — search for where v2 processes outbound messages before sending) - -**Change needed**: -1. Apply the regex `/[\s\S]*?<\/internal>/g` to strip all internal thinking/reasoning blocks -2. Call `.trim()` on the result after stripping -3. Return empty string if result is empty after stripping - -**Test to port**: -- `src/v1/formatting.test.ts:163-177` (stripInternalTags) -- `src/v1/formatting.test.ts:183-194` (formatOutbound) - -#### 5. Scheduled task timezone handling - -**v2 file to modify**: (Unknown — search for where v2 handles cron task scheduling) - -**Change needed**: -1. When parsing cron expressions, pass the timezone option to cron-parser: - ```typescript - const interval = CronExpressionParser.parse(cronExpression, { tz: TIMEZONE }); - ``` -2. For interval-based tasks, anchor to the original `next_run` time, not `Date.now()`, to prevent drift -3. Ensure the TIMEZONE constant is resolved at startup via a function like: - ```typescript - function resolveConfigTimezone(): string { - const candidates = [process.env.TZ, envConfig.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone]; - for (const tz of candidates) { - if (tz && isValidTimezone(tz)) return tz; - } - return 'UTC'; - } - ``` - -**Test to port**: -- `src/v1/task-scheduler.test.ts:33-39` (interval anchoring) -- `src/v1/task-scheduler.test.ts:40-49` (once-task returns null) -- `src/v1/task-scheduler.test.ts:51-60` (interval catch-up) - ---- - -## Git references for verification - -All code snippets above can be verified with: - -```bash -git show 27c5220:src/v1/router.ts -git show 27c5220:src/v1/timezone.ts -git show 27c5220:src/v1/config.ts -git show 27c5220:src/v1/task-scheduler.ts -git show 27c5220:src/v1/types.ts -git show 27c5220:src/v1/formatting.test.ts -git show 27c5220:src/v1/timezone.test.ts -git show 27c5220:src/v1/task-scheduler.test.ts -``` - -Or from the deletion parent commit: - -```bash -git show 86becf8^:src/v1/ -``` diff --git a/docs/v1-vs-v2/timezone.md b/docs/v1-vs-v2/timezone.md deleted file mode 100644 index f036fa3..0000000 --- a/docs/v1-vs-v2/timezone.md +++ /dev/null @@ -1,27 +0,0 @@ -# timezone: v1 vs v2 - -## Scope -- v1: `src/v1/timezone.ts` (37 LOC), `src/v1/timezone.test.ts` (64 LOC) -- v2 counterparts: `src/timezone.ts` (37 LOC), `src/timezone.test.ts` (64 LOC) - -## Capability map - -| v1 behavior | v2 location | Status | Notes | -|---|---|---|---| -| `isValidTimezone(tz)` | `src/timezone.ts:5-12` | kept | Byte-identical | -| `resolveTimezone(tz)` | `src/timezone.ts:17-19` | kept | Byte-identical | -| `formatLocalTime(utcIso, timezone)` | `src/timezone.ts:26-37` | kept | Byte-identical | - -## Tests (byte-identical) -- `formatLocalTime`: UTC→local display with offset; DST awareness (EDT vs EST); fall back to UTC on invalid tz without throwing -- `isValidTimezone`: accepts `America/New_York`, `UTC`, `Asia/Tokyo`, `Asia/Jerusalem`; rejects `IST-2`, `XYZ+3`, empty/garbage -- `resolveTimezone`: returns tz if valid; falls back to UTC on invalid or empty - -## Missing from v2 -None — v1 and v2 files are byte-for-byte identical. - -## Behavioral discrepancies -None. - -## Worth preserving? -No action needed — v2 already mirrors v1 exactly. Minimal, correct, no external deps. No cron-time conversions in either version (that logic lived in `task-scheduler.ts`). diff --git a/docs/v1-vs-v2/types.md b/docs/v1-vs-v2/types.md deleted file mode 100644 index fbf4b3f..0000000 --- a/docs/v1-vs-v2/types.md +++ /dev/null @@ -1,58 +0,0 @@ -# types: v1 vs v2 - -## Scope -- v1: `src/v1/types.ts` (112 LOC) — 10 exported types/interfaces covering AdditionalMount, MountAllowlist, AllowedRoot, ContainerConfig, RegisteredGroup, NewMessage, ScheduledTask, TaskRunLog, Channel, OnInboundMessage/OnChatMetadata -- v2 counterparts (distributed): - - `src/types.ts` — central DB entities (`AgentGroup`, `MessagingGroup`, `MessageIn`, `User`, `MessagingGroupAgent` etc.) - - `src/container-config.ts` — file-based per-group container config - - `src/mount-security.ts` — mount types - - `src/channels/adapter.ts` — v2 channel interface - - `container/agent-runner/src/db/messages-in.ts`, `destinations.ts` — session-level types - - `src/db/schema.ts` — schema reference - -## Capability map - -| v1 type / field | v2 location | Status | Notes | -|---|---|---|---| -| `AdditionalMount` | `src/mount-security.ts:16-18` | kept | Same fields | -| `MountAllowlist` / `AllowedRoot` | `src/mount-security.ts:21-29` | kept | `nonMainReadOnly` field removed (see container-runtime doc) | -| `ContainerConfig` | split: `src/container-config.ts:36` (file-based) + `src/mount-security.ts` | refactored | `timeout` dropped; added `mcpServers`, `packages`, `imageTag` | -| `RegisteredGroup` | `agent_groups` + `messaging_group_agents` + `container.json` | refactored | One entity split across two DB tables + filesystem | -| `RegisteredGroup.trigger` | `messaging_group_agents.trigger_rules` JSON | moved | Per-wiring, not per-group | -| `RegisteredGroup.containerConfig` | `groups//container.json` | moved | DB → disk | -| `RegisteredGroup.isMain` | convention (`agent_group_id = 'main'`) | removed | No explicit flag | -| `NewMessage` | split: `MessageIn` (`src/types.ts:98-111`) + `InboundMessage` (`src/channels/adapter.ts:33-38`) + `MessageInRow` (`container/.../db/messages-in.ts`) | refactored | Platform fields separated | -| `NewMessage.chat_jid` | `channel_type` + `platform_id` | refactored | Explicit split, no more JID parsing | -| `NewMessage.sender` / `sender_name` | inside JSON `content` blob | moved | Less type safety, more flexibility | -| `NewMessage.is_from_me` / `is_bot_message` | — | removed | Inferred from identity or `messages_out` | -| `NewMessage.reply_to_*` | inside `content` blob | moved | | -| `ScheduledTask` (entire type) | `MessageIn` with `kind='task'` + `recurrence` | removed | No separate task entity; no task UI/API | -| `TaskRunLog` | — | removed | No audit trail in v2 | -| `Channel` (connect/disconnect/sendMessage/ownsJid/syncGroups/setTyping) | `ChannelAdapter` (`src/channels/adapter.ts:60-105`) | refactored | Stateless request/response, async, no callback loop | -| `Channel.ownsJid` | — | removed | Routing keyed on `channel_type + platform_id` | -| `OnInboundMessage(chatJid, message)` | `onInbound(platformId, threadId, message)` | refactored | Routing fields explicit | -| `OnChatMetadata` | `onMetadata(platformId, name?, isGroup?)` | refactored | Drops timestamp/channel params | - -## Schema diff (v1 `RegisteredGroup` → v2 split) -- **Identity** (`name`, `folder`, `created_at`) → `agent_groups` table -- **Wiring** (`trigger`, `requiresTrigger`) → `messaging_group_agents` table (`trigger_rules`, `response_scope`, `session_mode`) -- **Container config** (`containerConfig`) → `groups//container.json` -- Normalization gain: an agent group can have N wirings with different triggers - -## Missing from v2 -1. `ScheduledTask` + `TaskRunLog` — no first-class task entity or execution log -2. `ContainerConfig.timeout` — per-group timeout override gone; single hardcoded `IDLE_TIMEOUT` -3. `NewMessage.is_from_me` / `is_bot_message` — flat flags gone -4. `Channel.ownsJid` — JID ownership concept gone -5. `Channel.connect()`/`disconnect()`/`isConnected()` lifecycle — replaced by stateless `setup`/`teardown` - -## Behavioral discrepancies -- **JID → channel_type + platform_id**: routing fields are now structured, not bundled strings -- **Pull vs push channels**: v1 channels pushed events via callbacks; v2 adapters are stateless with DB-mediated flow -- **Container config storage**: v1 in DB, v2 on disk (survives container restarts without DB query) - -## Worth preserving? -- **ScheduledTask / TaskRunLog**: v2's removal leaves a visibility gap; if scheduled-task introspection matters, reintroduce a log table keyed on `messages_in.id` to capture run metadata -- **Per-group timeout**: meaningful loss — some agent groups are slow, others fast; hardcoded timeout = false positives -- **is_from_me / is_bot_message**: trivial to reconstruct; not worth restoring -- **Channel lifecycle callbacks**: obsolete; v2 model is cleaner From 25687dca8f4cd48f699d0079f414bd64bb83afe3 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 19:44:54 +0300 Subject: [PATCH 113/185] chore(deps): drop @chat-adapter/telegram from trunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trunk ships no channel adapters — /add-telegram installs the package on demand from the channels branch. This dependency was stale and pulled ~2 transitive packages into every fresh install. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 1 - pnpm-lock.yaml | 22 ---------------------- 2 files changed, 23 deletions(-) diff --git a/package.json b/package.json index 5a9f443..124e1bc 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "test:watch": "vitest" }, "dependencies": { - "@chat-adapter/telegram": "4.26.0", "@clack/prompts": "^1.2.0", "@onecli-sh/sdk": "^0.3.1", "better-sqlite3": "11.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f284a4..7e3de02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@chat-adapter/telegram': - specifier: 4.26.0 - version: 4.26.0 '@clack/prompts': specifier: ^1.2.0 version: 1.2.0 @@ -69,12 +66,6 @@ importers: packages: - '@chat-adapter/shared@4.26.0': - resolution: {integrity: sha512-YD0MGktFXrArUqTBsyPfL5vkdD1WBS58PAWO0oVrMQAMmPxpAXfWGjBtZCkf3y8R8Svb0uVuVXiMZSForaEnMQ==} - - '@chat-adapter/telegram@4.26.0': - resolution: {integrity: sha512-PE2HoCQ4648VNKZTuHFanQNoYzM/niNoSbDyYlPq6VOoB5qsoo1ctR8TERyl1EfPBNexWZpSWYrrnQPr15LUfA==} - '@clack/core@1.2.0': resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==} @@ -1499,19 +1490,6 @@ packages: snapshots: - '@chat-adapter/shared@4.26.0': - dependencies: - chat: 4.26.0 - transitivePeerDependencies: - - supports-color - - '@chat-adapter/telegram@4.26.0': - dependencies: - '@chat-adapter/shared': 4.26.0 - chat: 4.26.0 - transitivePeerDependencies: - - supports-color - '@clack/core@1.2.0': dependencies: fast-wrap-ansi: 0.1.6 From dbb82440bdec51ff7cb81d9bd7e03e362ffb1209 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Apr 2026 16:50:10 +0000 Subject: [PATCH 114/185] chore: bump version to 2.0.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 124e1bc..36cc2b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.0", + "version": "2.0.1", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From dbb859bfeca74bf45e102a0d28c29172bf042131 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Apr 2026 16:50:16 +0000 Subject: [PATCH 115/185] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?27k=20tokens=20=C2=B7=2064%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 81b608a..20db68f 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,22 +1,22 @@ - - 43.8k tokens, 22% of context window + + 127k tokens, 64% of context window - + - - + + tokens - - 43.8k + + 127k From 469dd9af7e074bec60be3584ed8c6789a8696e6f Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 23:13:55 +0300 Subject: [PATCH 116/185] =?UTF-8?q?refactor(skills):=20collapse=20setup=20?= =?UTF-8?q?skill=20to=20one=20instruction=20=E2=80=94=20run=20bash=20nanoc?= =?UTF-8?q?law.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes the Claude-orchestrated /setup and /new-setup flows. The scripted installer (bash nanoclaw.sh → setup:auto) now handles bootstrap, container, OneCLI, auth, service, first agent, and optional channel wiring end-to-end with inline Claude-assisted recovery on failure. Keeps /setup as a one-line redirect so the trigger still resolves. Drops the opt-out diagnostics files that belonged to the old flow and updates cross-refs in add-wechat, migrate-nanoclaw, and update-nanoclaw. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-wechat/SKILL.md | 2 +- .../skills/migrate-nanoclaw/diagnostics.md | 9 +- .claude/skills/new-setup/SKILL.md | 270 -------------- .claude/skills/setup/SKILL.md | 345 +----------------- .claude/skills/setup/diagnostics.md | 49 --- .claude/skills/setup/setup-permissions.json | 34 -- .claude/skills/update-nanoclaw/diagnostics.md | 7 +- 7 files changed, 11 insertions(+), 705 deletions(-) delete mode 100644 .claude/skills/new-setup/SKILL.md delete mode 100644 .claude/skills/setup/diagnostics.md delete mode 100644 .claude/skills/setup/setup-permissions.json diff --git a/.claude/skills/add-wechat/SKILL.md b/.claude/skills/add-wechat/SKILL.md index ba0294a..49746ce 100644 --- a/.claude/skills/add-wechat/SKILL.md +++ b/.claude/skills/add-wechat/SKILL.md @@ -167,4 +167,4 @@ Otherwise, restart the service to pick up the new channel and wiring. - **supports-threads**: no (WeChat has no reply threads) - **typical-use**: Long-poll — the adapter holds a persistent connection to Tencent's iLink API and receives messages in real time. No webhook URL needed. - **default-isolation**: `shared` session mode per messaging group (DM or room). Use `strict` sender policy if you want only specific users to reach the agent; `public` opens it to anyone who messages the bot. -- **post-install-wiring**: Use the `wire-dm.ts` helper (see the "Wire your first DM" section above) if running this skill standalone. If running inside `/new-setup`, `init-first-agent.ts` handles wiring — just pass the `platform-id` and `admin-user-id` captured above. +- **post-install-wiring**: Use the `wire-dm.ts` helper (see the "Wire your first DM" section above) if running this skill standalone. If running as part of `bash nanoclaw.sh`, `init-first-agent.ts` handles wiring — just pass the `platform-id` and `admin-user-id` captured above. diff --git a/.claude/skills/migrate-nanoclaw/diagnostics.md b/.claude/skills/migrate-nanoclaw/diagnostics.md index a308b7a..170fd7a 100644 --- a/.claude/skills/migrate-nanoclaw/diagnostics.md +++ b/.claude/skills/migrate-nanoclaw/diagnostics.md @@ -45,8 +45,7 @@ rm /tmp/nanoclaw-diagnostics.json **No**: `rm /tmp/nanoclaw-diagnostics.json` **Never ask again**: -1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out` -2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out` -3. Replace contents of `.claude/skills/migrate-nanoclaw/diagnostics.md` with `# Diagnostics — opted out` -4. Remove the diagnostics sections from each corresponding SKILL.md -5. `rm /tmp/nanoclaw-diagnostics.json` +1. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out` +2. Replace contents of `.claude/skills/migrate-nanoclaw/diagnostics.md` with `# Diagnostics — opted out` +3. Remove the diagnostics sections from each corresponding SKILL.md +4. `rm /tmp/nanoclaw-diagnostics.json` diff --git a/.claude/skills/new-setup/SKILL.md b/.claude/skills/new-setup/SKILL.md deleted file mode 100644 index 4a3f1b8..0000000 --- a/.claude/skills/new-setup/SKILL.md +++ /dev/null @@ -1,270 +0,0 @@ ---- -name: new-setup -description: End-to-end NanoClaw setup for any user regardless of technical background — from zero to a named agent reachable on a real messaging channel, with sensible defaults and every post-verification step skippable. -allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(bash setup/install-node.sh) Bash(bash setup/install-docker.sh) Bash(bash setup/install-telegram.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm exec tsx scripts/init-first-agent.ts:*) Bash(pnpm run chat) Bash(pnpm run chat:*) Bash(open -a Docker) Bash(sudo systemctl start docker) Bash(node --version) Bash(tail:*) Bash(head:*) Bash(grep:*) ---- - -# NanoClaw setup - -Purpose of this skill is to take any user — technical or not — from zero to a named agent wired to a real messaging channel in the fewest steps possible. - -The flow has two halves: - -- **Steps 1–6 — required.** Prerequisites, credential, service start, end-to-end ping. These run straight through. -- **Steps 7–12 — skippable.** Naming, channel wiring, QoL. Every step here is skippable: if the user says "skip", "not now", "later", or similar, move on without complaint. If they say they're done at any point, stop cleanly — don't push the remaining steps. - -Before each step, narrate to the user in your own words what's about to happen — one short, friendly sentence, no jargon. Don't read a scripted line; use the step context below to speak naturally. - -Each step is invoked as `pnpm exec tsx setup/index.ts --step ` and emits a structured status block Claude parses to decide what to do next. - -Start with a probe: a single upfront scan that snapshots every prerequisite and dependency. The rest of the flow reads this snapshot to decide what to run, skip, or ask about — no per-step re-checking. The probe is pure bash (`setup/probe.sh`) with no external deps so it runs correctly before Node has been installed. - -## Current state - -!`bash setup/probe.sh` - -## Flow - -Parse the probe block above. For each step below, consult the named probe fields and skip, ask, or run accordingly. The probe always returns a real snapshot — there is no "node not installed" fallback; `HOST_DEPS=missing` is how you know Node/pnpm haven't been bootstrapped yet. - -## Ordering and parallelism - -Run steps sequentially by default: invoke the step, wait for its status block, act on the result, move to the next. - -One permitted parallelism: - -- **Step 2 (container image build) and step 3 (OneCLI install)** are independent — they may start together in the background. -- **Step 4 (auth) must NOT start until step 3 has completed.** Auth writes the secret into the OneCLI vault; if OneCLI isn't installed and healthy yet, the user gets asked for a credential the system can't store. Do not open an `AskUserQuestion` for step 4 while OneCLI is still installing. -- Step 2's image build may continue running past step 4 — the image isn't consumed until step 6 (first CLI agent). Join before step 6. - -### 1. Node bootstrap - -Check probe results and skip if `HOST_DEPS=ok` — Node, pnpm, `node_modules`, and `better-sqlite3`'s native binding are already in place. - -If `HOST_DEPS=missing` and `node --version` fails (Node isn't installed at all), run `bash setup/install-node.sh` **before** `bash setup.sh` — the script handles both macOS (via `brew`) and Linux/WSL (NodeSource + apt). It's idempotent and short-circuits when node is already on PATH. - -Then run `bash setup.sh`. If Node is already present and only `HOST_DEPS=missing`, run `bash setup.sh` directly — deps just haven't been installed yet. - -Parse the status block: - -- `NODE_OK=false` → Node install didn't take effect (PATH issue, keg-only formula, etc.). Investigate `logs/setup.log`, resolve, re-run. -- `DEPS_OK=false` or `NATIVE_OK=false` → Read `logs/setup.log`, fix, re-run. - -> **Loose command:** `bash setup.sh`. Justification: pre-Node bootstrap. Can't call the Node-based dispatcher before Node and `pnpm install` are in place. - -### 2. Docker - -Check probe results and skip if `DOCKER=running` AND `IMAGE_PRESENT=true`. - -**Runtime:** -- `DOCKER=not_found` → Docker is missing — install it so agent containers have an isolated place to run. Run `bash setup/install-docker.sh` (handles macOS via `brew --cask` and Linux via the official get.docker.com script, and adds the user to the `docker` group on Linux). On Linux the user may need to log out/in for group membership to take effect. On macOS, launch Docker afterwards with `open -a Docker`. -- `DOCKER=installed_not_running` → Docker is installed but the daemon is down — start it. - - macOS: `open -a Docker` - - Linux: `sudo systemctl start docker` - -Wait ~15s after either, then proceed. - -> **Loose commands:** `open -a Docker`, `sudo systemctl start docker`. Justification: daemon-start is a one-liner per platform, not worth wrapping. The actual install (which had the unmatchable `curl | sh` pattern) is now inside `setup/install-docker.sh`. - -**Image (run if `IMAGE_PRESENT=false`):** build the agent container image — takes a few minutes the first time, one-off cost. - -`pnpm exec tsx setup/index.ts --step container -- --runtime docker` - -### 3. OneCLI - -Check probe results and skip if `ONECLI_STATUS=healthy`. - -OneCLI is the local vault that holds API keys and only releases them to agents when they need them. - -`pnpm exec tsx setup/index.ts --step onecli` - -### 4. Anthropic credential - -Check probe results and skip if `ANTHROPIC_SECRET=true`. - -The credential never travels through chat — the user generates it, registers it with OneCLI themselves, and the skill verifies. - -**4a. Pick the source.** `AskUserQuestion`: - -1. **Claude subscription (Pro/Max)** — "Generate a token via `claude setup-token` in another terminal." -2. **Anthropic API key** — "Use a pay-per-use key from console.anthropic.com/settings/keys." - -**4b. Wait for the user to obtain the credential.** For subscription, have them run `claude setup-token` in another terminal. For API key, point them to the console URL above. Either way, they keep the token — just confirm when they have it. - -**4c. Pick the registration path.** `AskUserQuestion` — substitute `${ONECLI_URL}` from the probe (or `.env`): - -1. **Dashboard** — "Open ${ONECLI_URL} in a browser; add a secret of type `anthropic`, value = the token, host-pattern `api.anthropic.com`." -2. **CLI** — "Run in another terminal: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`" - -Wait for the user's confirmation. If their reply happens to include a token (starts with `sk-ant-`), register it for them: `pnpm exec tsx setup/index.ts --step auth -- --create --value `. - -**4d. Verify.** - -`pnpm exec tsx setup/index.ts --step auth -- --check` - -If `ANTHROPIC_OK=false`, the secret isn't there yet — ask them to retry, then re-check. - -### 5. Service - -Check probe results and skip if `SERVICE_STATUS=running`. - -Start the NanoClaw background service — it relays messages between the user and the agent. - -`pnpm exec tsx setup/index.ts --step service` - -### 6. Wire a scratch CLI agent and verify end-to-end - -**Do not narrate this step.** No "creating your first agent…", no "sending a ping…" chatter. The user's experience here is: they finished the last visible step (service), then a single success line appears. Wiring is an implementation detail at this point, not a user-facing milestone. - -If step 2's container build is still running in the background, join it here before proceeding — the agent needs the image. - -Use `INFERRED_DISPLAY_NAME` from the probe silently. **Do not ask the user.** The CLI agent at this stage is a scratch agent whose only purpose is to verify the end-to-end pipeline (host → container → agent → back). The user's real name capture happens in step 7. - -Run wiring and ping back-to-back, silently: - -``` -pnpm exec tsx setup/index.ts --step cli-agent -- --display-name "" -pnpm run chat ping -``` - -First container spin-up takes ~60s. When the agent's reply arrives, emit exactly one line to the user: - -> Test Agent success, proceeding with setup - -If `pnpm run chat ping` times out or errors, tail `logs/nanoclaw.log`, diagnose, and fix — don't surface a half-success. - -> **Loose command:** `pnpm run chat ping`. Justification: this is the same command the user will keep using after setup, so verification and the real channel are one and the same. - -### 7. What should the agent call you? - -Plain-prose ask (do **not** use `AskUserQuestion`): - -> What should your agent call you? (Default: ``) - -Capture the answer into a local variable `OPERATOR_NAME`. **Don't persist yet** — this value is consumed by step 10's channel wiring. If the user skips or confirms the default, set `OPERATOR_NAME = INFERRED_DISPLAY_NAME`. - -### 8. What's your agent's name? - -Plain-prose ask: - -> What would you like to call your agent? (Default: ``) - -Capture as `AGENT_NAME`. If skipped, set `AGENT_NAME = OPERATOR_NAME`. Nothing persisted yet. - -### 9. Timezone - -Run `pnpm exec tsx setup/index.ts --step timezone` and parse the status block. - -- **RESOLVED_TZ is `UTC` or `Etc/UTC`** — before leaving UTC in `.env`, confirm with `AskUserQuestion`: - - - **Question**: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?" - - **Header**: "Timezone" - - **Options**: - 1. `Keep UTC` — "Leave timezone as UTC." - 2. `I'm somewhere else` — "I'll name the IANA zone (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`) via Other." - - If they pick "I'm somewhere else" (or type an IANA zone via Other), re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` to overwrite `.env`. If they keep UTC or skip, leave UTC in place. - -- **NEEDS_USER_INPUT=true** — autodetection failed. Use `AskUserQuestion` with the same two options above (reword the question to "Autodetection failed — what timezone are you in?"), then re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` if they supply one. If they skip, move on. - -- Otherwise — timezone is already set; move on. - -### 10. Pick a messaging channel - -Print the list as a numbered plain-prose list (too many options for `AskUserQuestion`, which caps at 4). The user replies with a number or channel name. Preserve the numbering exactly: - -> Which messaging channel should I wire your agent to? -> -> 1. **WhatsApp (native)** — `/add-whatsapp` -> 2. **WhatsApp Cloud (Meta official)** — `/add-whatsapp-cloud` -> 3. **Telegram** — `/add-telegram` -> 4. **Slack** — `/add-slack` -> 5. **Discord** — `/add-discord` -> 6. **iMessage** — `/add-imessage` -> 7. **Teams** — `/add-teams` -> 8. **Matrix** — `/add-matrix` -> 9. **Google Chat** — `/add-gchat` -> 10. **Linear** — `/add-linear` -> 11. **GitHub** — `/add-github` -> 12. **Webex** — `/add-webex` -> 13. **Resend (email)** — `/add-resend` -> 14. **Emacs** — `/add-emacs` -> 15. **WeChat** — `/add-wechat` -> -> Or say "skip" to leave this for later. - -When the user picks one: - -1. **Install the adapter.** For **Telegram**, run `bash setup/install-telegram.sh` directly — it bundles the preflight + fetch + copy + register + `pnpm install` + build from `/add-telegram` into one idempotent call. Then handle Telegram credentials inline (below) — **do not** invoke `/add-telegram` afterward; its Credentials section would generate an unapprovable `grep && sed && rm` to write `.env`. For every other channel, invoke the matching `/add-` skill via the Skill tool; it copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels also run a pairing step as part of their flow. - - **Telegram credentials (inline):** - - Walk the user through BotFather: `/newbot` → pick name + username ending in `bot` → copy the token. - - Remind them: in `@BotFather` → `/mybots` → their bot → Bot Settings → Group Privacy → **Turn off** (only needed if the bot will live in groups; DM-only can skip). - - Persist the token and sync it to the container mount with the generic setter: - - ``` - pnpm exec tsx setup/index.ts --step set-env -- \ - --key TELEGRAM_BOT_TOKEN --value "" --sync-container - ``` - -2. **Capture platform IDs.** After the `/add-` skill finishes (or after inline credentials for Telegram), you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent ` directly and follow its `PAIR_TELEGRAM_ISSUED`/`PAIR_TELEGRAM STATUS=success` blocks — `PLATFORM_ID` and `ADMIN_USER_ID` land in the success block. -3. **Wire the agent.** Run `init-first-agent.ts` in DM mode: - - ``` - pnpm exec tsx scripts/init-first-agent.ts \ - --channel \ - --user-id "" \ - --platform-id "" \ - --display-name "" \ - --agent-name "" - ``` - -4. **Announce.** On success, emit the encouragement line verbatim: - - > Your agent is now available on {channel-name}, you can already start chatting — But I encourage you to continue and finish this setup, we're almost done! - - Substitute `{channel-name}` with the friendly name (e.g. "Telegram", "WhatsApp", "Slack"). - -If the user skipped, move on to step 11. - -### 11. Host directory access - -By default, agent containers can only touch their own workspace. If the user wants the agent to read or write files in specific host directories, those paths need to go on the mount allowlist. - -Use `AskUserQuestion`: - -- **Question**: "Want your agent to read or write files in any host directories (e.g. a code project, `~/Documents`)?" -- **Header**: "Host mounts" -- **Options**: - 1. `Keep isolated` — "Agent only touches its own workspace (Recommended)." - 2. `Add host paths` — "I'll name the directories to allowlist via Other." - -If they pick "Add host paths" (or name paths via Other), invoke `/manage-mounts` via the Skill tool to add them. If they keep it isolated or skip, move on. - -### 12. Quality of life - -Optional polish. Print the list; the user may pick zero, one, or several — invoke each chosen skill in sequence: - -> Want to add any of these? Pick any that sound useful — or skip: -> -> - `/add-dashboard` — browser dashboard showing agent activity -> - `/add-compact` — `/compact` slash command for managing long sessions -> - `/add-karpathy-llm-wiki` — persistent knowledge-base memory for the agent - -If the probe reports `PLATFORM=darwin`, also offer: - -> - `/add-macos-statusbar` — macOS menu bar indicator with Start/Stop/Restart controls - -Do **not** list `/add-macos-statusbar` on Linux. If the user skips everything, just move on. - -### 13. Done - -Short wrap-up: - -> Setup complete. You can chat with your agent on {channel-name} — or via CLI with `pnpm run chat `. - -Substitute `{channel-name}` with whatever was wired in step 10. If step 10 was skipped, drop the "on {channel-name} — or" clause entirely so the line just mentions the CLI form. - -## If anything fails - -Any step that reports `STATUS: failed` in its status block: read `logs/setup.log` (or `logs/nanoclaw.log` for runtime failures), diagnose, fix the underlying cause, re-run the same `--step`. Don't bypass errors to keep moving. diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 3b21e14..5ce96e3 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -1,349 +1,10 @@ --- name: setup -description: Run initial NanoClaw setup. Use when user wants to install dependencies, authenticate messaging channels, register their main channel, or start the background services. Triggers on "setup", "install", "configure nanoclaw", or first-time setup requests. +description: Run initial NanoClaw setup. Use when user wants to install NanoClaw, configure it, or go through first-time setup. Triggers on "setup", "install", "configure nanoclaw", or first-time setup requests. --- # NanoClaw Setup -Welcome the user to NanoClaw. Introduce yourself — you'll be walking them through the entire setup process step by step, from installing dependencies to getting their first message through. Keep it warm and brief (2-3 sentences). +Tell the user to run `bash nanoclaw.sh` in their terminal. That script handles the full end-to-end setup — dependencies, container image, OneCLI vault, Anthropic credential, service, first agent, and optional channel wiring. -Then explain that setup involves running many shell commands (installing packages, building containers, starting services), and recommend pre-approving the standard setup commands so they don't have to confirm each one individually. - -Use `AskUserQuestion` with these options: - -1. **Pre-approve (recommended)** — description: "Pre-approve standard setup commands so you don't have to confirm each one. You can review the list first if you'd like." -2. **No thanks** — description: "I'll approve each command individually as it comes up." -3. **Show me the list first** — description: "Show me exactly which commands will be pre-approved before I decide." - -If they pick option 1: read `.claude/skills/setup/setup-permissions.json`, then read the project settings file at `.claude/settings.json` (create it if it doesn't exist with `{}`), and directly edit it to add/merge the permissions into the `permissions.allow` array. Do NOT use the `update-config` skill. - -If they pick option 3: read and display `.claude/skills/setup/setup-permissions.json`, then re-ask with just options 1 and 2. - -If they decline, continue — they'll approve commands individually. - ---- - -**Internal guidance (do not show to user):** - -- Run setup steps automatically. Only pause when user action is required (channel authentication, configuration choices). -- Setup uses `bash setup.sh` for bootstrap, then `npx tsx setup/index.ts --step ` for all other steps. Steps emit structured status blocks to stdout. Verbose logs go to `logs/setup.log`. -- **Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. authenticating a channel, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair. -- **UX Note:** Use `AskUserQuestion` for multiple-choice questions only (e.g. "which credential method?"). Do NOT use it when free-text input is needed (e.g. phone numbers, tokens, paths) — just ask the question in plain text and wait for the user's reply. -- **Timeouts:** Use 5m timeouts for install and build steps. -- **Waiting on user:** When the user needs to do something (change a setting, get a token, open a browser, etc.), stop and wait. Give clear instructions, then say "Let me know when done or if you need help." Do NOT continue to the next step. If they ask for help, give more detail, ask where they got stuck, and try to assist. - -## 0. Git Upstream - -Ensure `upstream` remote points to `qwibitai/nanoclaw`. If missing, add it silently: - -```bash -git remote -v -git remote add upstream https://github.com/qwibitai/nanoclaw.git 2>/dev/null || true -``` - -## 1. Bootstrap (Node.js + Dependencies) - -Run `bash setup.sh` and parse the status block. - -- If NODE_OK=false → Node.js is missing or too old. Use `AskUserQuestion: Would you like me to install Node.js 22?` If confirmed: - - macOS: `brew install node@22` (if brew available) or install nvm then `nvm install 22` - - Linux: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`, or nvm - - After installing Node, re-run `bash setup.sh` -- If DEPS_OK=false → Read `logs/setup.log`. Try: delete `node_modules`, re-run `bash setup.sh`. If native module build fails, install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry. -- If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run. -- Record PLATFORM and IS_WSL for later steps. - -## 2. Check Environment - -Run `pnpm exec tsx setup/index.ts --step environment` and parse the status block. - -- If HAS_AUTH=true → WhatsApp is already configured, note for step 5 -- If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure -- Record DOCKER value for step 3 - -### OpenClaw Migration Detection - -If OPENCLAW_PATH is not `none` from the environment check above, AskUserQuestion: - -1. **Migrate now** — "Import identity, credentials, and settings from OpenClaw before continuing setup." -2. **Fresh start** — "Skip migration and set up NanoClaw from scratch." -3. **Migrate later** — "Continue setup now, run `/migrate-from-openclaw` anytime later." - -If "Migrate now": invoke `/migrate-from-openclaw`, then return here and continue at step 2a (Timezone). - -## 2a. Timezone - -Run `pnpm exec tsx setup/index.ts --step timezone` and parse the status block. - -- If NEEDS_USER_INPUT=true → The system timezone could not be autodetected (e.g. POSIX-style TZ like `IST-2`). AskUserQuestion: "What is your timezone?" with common options (America/New_York, Europe/London, Asia/Jerusalem, Asia/Tokyo) and an "Other" escape. Then re-run: `pnpm exec tsx setup/index.ts --step timezone -- --tz `. -- If STATUS=success and RESOLVED_TZ is `UTC` or `Etc/UTC` → confirm with the user: "Your system timezone is UTC — is that correct, or are you on a remote server?" If wrong, ask for their actual timezone and re-run with `--tz`. -- If STATUS=success → Timezone is configured. Note RESOLVED_TZ for reference. - -## 3. Container Runtime (Docker) - -### 3a. Install Docker - -- DOCKER=running → continue to step 4 -- DOCKER=installed_not_running → start Docker: `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Wait 15s, re-check with `docker info`. -- DOCKER=not_found → Use `AskUserQuestion: Docker is required for running agents. Would you like me to install it?` If confirmed: - - macOS: install via `brew install --cask docker`, then `open -a Docker` and wait for it to start. If brew not available, direct to Docker Desktop download at https://docker.com/products/docker-desktop - - Linux: install with `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER`. Note: user may need to log out/in for group membership. - -### 3b. CJK fonts - -Agent containers skip CJK fonts by default (~200MB saved). Without them, Chromium-rendered screenshots and PDFs show tofu for Chinese/Japanese/Korean. - -- **User writing to you in Chinese, Japanese, or Korean** → enable without asking. Mention it briefly. -- **Resolved timezone from step 2a is a CJK region** (`Asia/Tokyo`, `Asia/Shanghai`, `Asia/Hong_Kong`, `Asia/Taipei`, `Asia/Seoul`) or other signal short of active CJK use → ask: "Enable CJK fonts? Adds ~200MB, lets the agent render CJK in screenshots and PDFs." -- **Otherwise** → skip. - -To enable, write `INSTALL_CJK_FONTS=true` to `.env`: - -```bash -grep -q '^INSTALL_CJK_FONTS=' .env && sed -i.bak 's/^INSTALL_CJK_FONTS=.*/INSTALL_CJK_FONTS=true/' .env && rm -f .env.bak || echo 'INSTALL_CJK_FONTS=true' >> .env -``` - -The next step's build picks it up automatically. - -### 3c. Build and test - -Run `pnpm exec tsx setup/index.ts --step container -- --runtime docker` and parse the status block. - -**If BUILD_OK=false:** Read `logs/setup.log` tail for the build error. -- Cache issue (stale layers): `docker builder prune -f`. Retry. -- Dockerfile syntax or missing files: diagnose from the log and fix, then retry. - -**If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test. - -## 4. Credential System - -### 4a. OneCLI - -Install OneCLI and its CLI tool: - -```bash -curl -fsSL onecli.sh/install | sh -curl -fsSL onecli.sh/cli/install | sh -``` - -Verify both installed: `onecli version`. If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH for the current session and persist it: - -```bash -export PATH="$HOME/.local/bin:$PATH" -# Persist for future sessions (append to shell profile if not already present) -grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc -grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc -``` - -Then re-verify with `onecli version`. - -Point the CLI at the local OneCLI instance, the ONECLI_URL was output from the install script above: -```bash -onecli config set api-host ${ONECLI_URL} -``` - -Ensure `.env` has the OneCLI URL (create the file if it doesn't exist): -```bash -grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=${ONECLI_URL}' >> .env -``` - -Check if a secret already exists: -```bash -onecli secrets list -``` - -If an Anthropic secret is listed, confirm with user: keep or reconfigure? If keeping, skip to step 5. - -AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**? - -1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token." -2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com." - -#### Subscription path - -Tell the user: - -> Run `claude setup-token` in another terminal. It will output a token — copy it but don't paste it here. - -Then stop and wait for the user to confirm they have the token. Do NOT proceed until they respond. - -Once they confirm, they register it with OneCLI. AskUserQuestion with two options: - -1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} and add the secret in the UI. Use type 'anthropic' and paste your token as the value." -2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`" - -#### API key path - -Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one. - -Then AskUserQuestion with two options: - -1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} and add the secret in the UI." -2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`" - -#### After either path - -Ask them to let you know when done. - -**If the user's response happens to contain a token or key** (starts with `sk-ant-`): handle it gracefully — run the `onecli secrets create` command with that value on their behalf. - -**After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again. - -## 5. Set Up Channels - -Show the full list of available channels in plain text (do NOT use AskUserQuestion — it limits to 4 options). Ask which one they want to start with. They can add more later with `/customize`. - -Channels where the agent gets its own identity (name and avatar) are marked as recommended. - -1. Discord *(recommended — agent gets own identity)* -2. Slack *(recommended — agent gets own identity)* -3. Telegram *(recommended — agent gets own identity)* -4. Microsoft Teams *(recommended — agent gets own identity)* -5. Webex *(recommended — agent gets own identity)* -6. WhatsApp -7. WhatsApp Cloud API -8. iMessage -9. GitHub -10. Linear -11. Google Chat -12. Resend (email) -13. Matrix - -**Delegate to the selected channel's skill.** Each channel skill handles its own package installation, authentication, registration, and configuration. - -Invoke the matching skill: - -- **Discord:** Invoke `/add-discord` -- **Slack:** Invoke `/add-slack` -- **Telegram:** Invoke `/add-telegram` -- **GitHub:** Invoke `/add-github` -- **Linear:** Invoke `/add-linear` -- **Microsoft Teams:** Invoke `/add-teams` -- **Google Chat:** Invoke `/add-gchat` -- **WhatsApp Cloud API:** Invoke `/add-whatsapp-cloud` -- **WhatsApp Baileys:** Invoke `/add-whatsapp` -- **Resend:** Invoke `/add-resend` -- **Matrix:** Invoke `/add-matrix` -- **Webex:** Invoke `/add-webex` -- **iMessage:** Invoke `/add-imessage` - -The skill will: -1. Install the Chat SDK adapter package -2. Uncomment the channel import in `src/channels/index.ts` -3. Collect credentials/tokens and write to `.env` -4. Build and verify - -**After the channel skill completes**, install dependencies and rebuild — channel merges may introduce new packages: - -```bash -pnpm install && pnpm run build -``` - -If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 5a. - -## 6. Mount Allowlist - -Set empty mount allowlist (agents only access their own workspace). Users can configure mounts later with `/manage-mounts`. - -```bash -pnpm exec tsx setup/index.ts --step mounts -- --empty -``` - -## 7. Start Service - -If service already running: unload first. -- macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` -- Linux: `systemctl --user stop nanoclaw` (or `systemctl stop nanoclaw` if root) - -Run `pnpm exec tsx setup/index.ts --step service` and parse the status block. - -**If FALLBACK=wsl_no_systemd:** WSL without systemd detected. Tell user they can either enable systemd in WSL (`echo -e "[boot]\nsystemd=true" | sudo tee /etc/wsl.conf` then restart WSL) or use the generated `start-nanoclaw.sh` wrapper. - -**If DOCKER_GROUP_STALE=true:** The user was added to the docker group after their session started — the systemd service can't reach the Docker socket. Ask user to run these two commands: - -1. Immediate fix: `sudo setfacl -m u:$(whoami):rw /var/run/docker.sock` -2. Persistent fix (re-applies after every Docker restart): -```bash -sudo mkdir -p /etc/systemd/system/docker.service.d -sudo tee /etc/systemd/system/docker.service.d/socket-acl.conf << 'EOF' -[Service] -ExecStartPost=/usr/bin/setfacl -m u:USERNAME:rw /var/run/docker.sock -EOF -sudo systemctl daemon-reload -``` -Replace `USERNAME` with the actual username (from `whoami`). Run the two `sudo` commands separately — the `tee` heredoc first, then `daemon-reload`. After user confirms setfacl ran, re-run the service step. - -**If SERVICE_LOADED=false:** -- Read `logs/setup.log` for the error. -- macOS: check `launchctl list | grep nanoclaw`. If PID=`-` and status non-zero, read `logs/nanoclaw.error.log`. -- Linux: check `systemctl --user status nanoclaw`. -- Re-run the service step after fixing. - -## 7a. Wire Channels to Agent Groups - -The service is now running, so polling-based adapters (Telegram) can observe inbound messages — required for pairing. - -Invoke `/manage-channels` to wire the installed channels to agent groups. This step: -1. Creates the agent group(s) and assigns a name to the assistant -2. Resolves each channel's platform-specific ID (Telegram via pairing code; other channels via the platform's own ID lookup) -3. Decides the isolation level — whether channels share an agent, session, or are fully separate - -The `/manage-channels` skill reads each channel's `## Channel Info` section from its SKILL.md for platform-specific guidance (terminology, how to find IDs, recommended isolation). - -**This step is required.** Without it, channels are installed but not wired — messages will be silently dropped because the router has no agent group to route to. - -## 7b. Dashboard & Web Applications - -AskUserQuestion: Do you want to create a dashboard and build web applications? - -1. **Yes (recommended)** — description: "Get a NanoClaw dashboard to monitor your agents and build custom websites however you want. Deploys to Vercel." -2. **Not now** — description: "You can add this later with `/add-vercel`." - -If yes: invoke `/add-vercel`. - -## 8. Verify - -Run `pnpm exec tsx setup/index.ts --step verify` and parse the status block. - -**If STATUS=failed, fix each:** -- SERVICE=stopped → `pnpm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup) -- SERVICE=not_found → re-run step 7 -- CREDENTIALS=missing → re-run step 4 (check `onecli secrets list`) -- CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`) -- REGISTERED_GROUPS=0 → re-invoke `/manage-channels` from step 7a -Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log` - -## Troubleshooting - -**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), credential system not running (check `curl ${ONECLI_URL}/api/health`), missing channel credentials (re-invoke channel skill). - -**Container agent fails ("Claude Code process exited with code 1"):** Ensure Docker is running — `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`. - -**No response to messages:** Check trigger pattern. Main channel doesn't need prefix. Check DB: `pnpm exec tsx setup/index.ts --step verify`. Check `logs/nanoclaw.log`. - -**Channel not connecting:** Verify the channel's credentials are set in `.env`. Channels auto-enable when their credentials are present. For WhatsApp: check `store/auth/creds.json` exists. For token-based channels: check token values in `.env`. Restart the service after any `.env` change. - -**Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` | Linux: `systemctl --user stop nanoclaw` - - -## 9. Diagnostics - -1. Use the Read tool to read `.claude/skills/setup/diagnostics.md`. -2. Follow every step in that file before completing setup. - -## 10. Fork Setup - -Only run this after the user has confirmed 2-way messaging works. - -Check `git remote -v`. If `origin` points to `qwibitai/nanoclaw` (not a fork), ask in plain text: - -> We recommend forking NanoClaw so you can push your customizations and pull updates easily. Would you like to set up a fork now? - -If yes: instruct the user to fork `qwibitai/nanoclaw` on GitHub (they need to do this in their browser), then ask for their GitHub username. Run: -```bash -git remote rename origin upstream -git remote add origin https://github.com//nanoclaw.git -git push --force origin main -``` - -If no: skip — upstream is already configured from step 0. +If they hit an error partway through, it will offer Claude-assisted recovery inline — no need to come back here. diff --git a/.claude/skills/setup/diagnostics.md b/.claude/skills/setup/diagnostics.md deleted file mode 100644 index 26d79b1..0000000 --- a/.claude/skills/setup/diagnostics.md +++ /dev/null @@ -1,49 +0,0 @@ -# Diagnostics - -Gather system info: - -```bash -node -p "require('./package.json').version" -uname -s -uname -m -node -p "process.versions.node.split('.')[0]" -``` - -Check if the user migrated from OpenClaw during this setup session (i.e. `/migrate-from-openclaw` was invoked). If you're unsure (e.g. after context compaction), check for `migration-state.md` in the project root — it exists during and sometimes after migration. - -Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP addresses. - -```json -{ - "api_key": "phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP", - "event": "setup_complete", - "distinct_id": "", - "properties": { - "success": true, - "nanoclaw_version": "1.2.21", - "os_platform": "darwin", - "arch": "arm64", - "node_major_version": 22, - "channels_selected": ["telegram", "whatsapp"], - "migrated_from_openclaw": false, - "error_count": 0, - "failed_step": null - } -} -``` - -Show the entire JSON to the user and ask via AskUserQuestion: **Yes** / **No** / **Never ask again** - -**Yes**: -```bash -curl -s -X POST https://us.i.posthog.com/capture/ -H 'Content-Type: application/json' -d @/tmp/nanoclaw-diagnostics.json -rm /tmp/nanoclaw-diagnostics.json -``` - -**No**: `rm /tmp/nanoclaw-diagnostics.json` - -**Never ask again**: -1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out` -2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out` -3. Remove the `## 9. Diagnostics` section from `.claude/skills/setup/SKILL.md` and the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md` -4. `rm /tmp/nanoclaw-diagnostics.json` diff --git a/.claude/skills/setup/setup-permissions.json b/.claude/skills/setup/setup-permissions.json deleted file mode 100644 index a263d4c..0000000 --- a/.claude/skills/setup/setup-permissions.json +++ /dev/null @@ -1,34 +0,0 @@ -[ - "Bash(bash setup.sh*)", - "Bash(git remote *)", - "Bash(npx tsx setup/index.ts*)", - "Bash(npx tsx scripts/init-first-agent.ts*)", - "Bash(npm install @chat-adapter/*)", - "Bash(npm install chat-adapter-imessage*)", - "Bash(npm install @bitbasti/chat-adapter-webex*)", - "Bash(npm install @resend/chat-sdk-adapter*)", - "Bash(npm install @whiskeysockets/baileys*)", - "Bash(npm install @beeper/chat-adapter-matrix*)", - "Bash(npm install @nanoco/nanoclaw-dashboard*)", - "Bash(npm ci*)", - "Bash(npm run build*)", - "Bash(curl -fsSL onecli.sh*)", - "Bash(onecli *)", - "Bash(grep -q *)", - "Bash(echo *>> .env)", - "Bash(ls *)", - "Bash(cat ~/.config/nanoclaw/*)", - "Bash(tail *logs/*)", - "Bash(launchctl *nanoclaw*)", - "Bash(sqlite3 data/*)", - "Bash(docker info*)", - "Bash(docker logs *)", - "Bash(mkdir -p *)", - "Bash(cp .env *)", - "Bash(rsync -a .claude/skills/*)", - "Bash(head *)", - "Bash(xattr *)", - "Bash(find ~/.npm *)", - "Bash(which onecli*)", - "Bash(./container/build.sh*)" -] diff --git a/.claude/skills/update-nanoclaw/diagnostics.md b/.claude/skills/update-nanoclaw/diagnostics.md index 8b06aa4..551842e 100644 --- a/.claude/skills/update-nanoclaw/diagnostics.md +++ b/.claude/skills/update-nanoclaw/diagnostics.md @@ -43,7 +43,6 @@ rm /tmp/nanoclaw-diagnostics.json **No**: `rm /tmp/nanoclaw-diagnostics.json` **Never ask again**: -1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out` -2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out` -3. Remove the `## 9. Diagnostics` section from `.claude/skills/setup/SKILL.md` and the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md` -4. `rm /tmp/nanoclaw-diagnostics.json` +1. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out` +2. Remove the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md` +3. `rm /tmp/nanoclaw-diagnostics.json` From 16421cc022624d7abccd20653c2cc8948b0033b5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 23:19:03 +0300 Subject: [PATCH 117/185] fix(setup): fall back to npm install when corepack is missing Some Node installs (older nvm, node@22 keg-only on brew, minimal distro packages) don't ship corepack, so the bootstrap was dying with "corepack: command not found" before pnpm could land on PATH. Now guards the corepack call and falls back to `npm install -g pnpm@`, reading the version from package.json's packageManager field. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup.sh | 49 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/setup.sh b/setup.sh index 9a81531..e11e073 100755 --- a/setup.sh +++ b/setup.sh @@ -85,19 +85,44 @@ install_deps() { # is invisible but corepack still blocks on stdin. Auto-accept. export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 - # Enable corepack so `pnpm` shim lands on PATH. - log "Enabling corepack" - corepack enable >> "$LOG_FILE" 2>&1 || true + # Preferred path: enable corepack so `pnpm` shim lands on PATH. + if command -v corepack >/dev/null 2>&1; then + log "Enabling corepack" + corepack enable >> "$LOG_FILE" 2>&1 || true - # On Linux/WSL with system-wide Node (e.g. apt-installed to /usr/bin), - # corepack needs root to symlink /usr/bin/pnpm. Retry with sudo when pnpm - # isn't on PATH. macOS Homebrew installs land in a user-writable prefix, - # and a sudo retry there would create root-owned shims inside /opt/homebrew - # that later break brew — so the retry is Linux-only. - if ! command -v pnpm >/dev/null 2>&1 && [ "$PLATFORM" = "linux" ] \ - && command -v sudo >/dev/null 2>&1; then - log "pnpm not on PATH after corepack enable — retrying with sudo" - sudo corepack enable >> "$LOG_FILE" 2>&1 || true + # On Linux/WSL with system-wide Node (e.g. apt-installed to /usr/bin), + # corepack needs root to symlink /usr/bin/pnpm. macOS Homebrew installs + # land in a user-writable prefix, and a sudo retry there would create + # root-owned shims inside /opt/homebrew that later break brew — so the + # retry is Linux-only. + if ! command -v pnpm >/dev/null 2>&1 && [ "$PLATFORM" = "linux" ] \ + && command -v sudo >/dev/null 2>&1; then + log "pnpm not on PATH after corepack enable — retrying with sudo" + sudo corepack enable >> "$LOG_FILE" 2>&1 || true + fi + else + log "corepack not available — will fall back to npm-install pnpm" + fi + + # Fallback: some Node installs (older nvm, node@22 keg-only, minimal + # distro packages) don't include corepack. Install pnpm directly at the + # version pinned via package.json's `packageManager` field. + if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then + local pinned + pinned=$(grep -E '"packageManager"' "$PROJECT_ROOT/package.json" 2>/dev/null \ + | head -1 \ + | sed -E 's/.*"pnpm@([^"]+)".*/\1/') + [ -z "$pinned" ] && pinned="latest" + log "Installing pnpm@${pinned} via npm" + npm install -g "pnpm@${pinned}" >> "$LOG_FILE" 2>&1 \ + || ([ "$PLATFORM" = "linux" ] && command -v sudo >/dev/null 2>&1 \ + && sudo npm install -g "pnpm@${pinned}" >> "$LOG_FILE" 2>&1) \ + || true + fi + + if ! command -v pnpm >/dev/null 2>&1; then + log "pnpm not on PATH after corepack + npm fallback" + return fi log "Running pnpm install --frozen-lockfile" From d97a0e1484724c930f357498684b700a25536475 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 00:21:15 +0300 Subject: [PATCH 118/185] fix(setup): resolve channels remote dynamically, don't assume `origin` Forks that keep the upstream nanoclaw repo under a non-origin remote name (typically `upstream`, with `origin` pointing at the user's fork) hit "git fetch origin channels failed" when adding a channel, because the fork doesn't carry the channels branch. New setup/lib/channels-remote.sh walks `git remote -v` for a url matching qwibitai/nanoclaw, auto-adds `upstream` if none matches, and honors NANOCLAW_CHANNELS_REMOTE as an override. Wired into the four add-*.sh scripts that setup:auto invokes (discord, telegram, whatsapp, teams). Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-discord.sh | 12 +++++++++--- setup/add-teams.sh | 12 +++++++++--- setup/add-telegram.sh | 12 +++++++++--- setup/add-whatsapp.sh | 12 +++++++++--- setup/lib/channels-remote.sh | 38 ++++++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 setup/lib/channels-remote.sh diff --git a/setup/add-discord.sh b/setup/add-discord.sh index 1cd247a..74ce9a7 100755 --- a/setup/add-discord.sh +++ b/setup/add-discord.sh @@ -16,7 +16,13 @@ cd "$PROJECT_ROOT" # Keep in sync with .claude/skills/add-discord/SKILL.md. ADAPTER_VERSION="@chat-adapter/discord@4.26.0" -CHANNELS_BRANCH="origin/channels" + +# Resolve which remote carries the channels branch — handles forks where +# upstream lives on a different remote than `origin`. +# shellcheck source=setup/lib/channels-remote.sh +source "$PROJECT_ROOT/setup/lib/channels-remote.sh" +CHANNELS_REMOTE=$(resolve_channels_remote) +CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels" emit_status() { local status=$1 error=${2:-} @@ -54,8 +60,8 @@ ADAPTER_ALREADY_INSTALLED=true if need_install; then ADAPTER_ALREADY_INSTALLED=false log "Fetching channels branch…" - git fetch origin channels >&2 2>/dev/null || { - emit_status failed "git fetch origin channels failed" + git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || { + emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed" exit 1 } diff --git a/setup/add-teams.sh b/setup/add-teams.sh index f116f24..99ceb4a 100755 --- a/setup/add-teams.sh +++ b/setup/add-teams.sh @@ -19,7 +19,13 @@ cd "$PROJECT_ROOT" # Keep in sync with .claude/skills/add-teams/SKILL.md. ADAPTER_VERSION="@chat-adapter/teams@4.26.0" -CHANNELS_BRANCH="origin/channels" + +# Resolve which remote carries the channels branch — handles forks where +# upstream lives on a different remote than `origin`. +# shellcheck source=setup/lib/channels-remote.sh +source "$PROJECT_ROOT/setup/lib/channels-remote.sh" +CHANNELS_REMOTE=$(resolve_channels_remote) +CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels" emit_status() { local status=$1 error=${2:-} @@ -61,8 +67,8 @@ ADAPTER_ALREADY_INSTALLED=true if need_install; then ADAPTER_ALREADY_INSTALLED=false log "Fetching channels branch…" - git fetch origin channels >&2 2>/dev/null || { - emit_status failed "git fetch origin channels failed" + git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || { + emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed" exit 1 } diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 361960f..0d7fd5c 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -16,7 +16,13 @@ cd "$PROJECT_ROOT" # Keep in sync with .claude/skills/add-telegram/SKILL.md. ADAPTER_VERSION="@chat-adapter/telegram@4.26.0" -CHANNELS_BRANCH="origin/channels" + +# Resolve which remote carries the channels branch — handles forks where +# upstream lives on a different remote than `origin`. +# shellcheck source=setup/lib/channels-remote.sh +source "$PROJECT_ROOT/setup/lib/channels-remote.sh" +CHANNELS_REMOTE=$(resolve_channels_remote) +CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels" emit_status() { local status=$1 error=${2:-} @@ -53,8 +59,8 @@ ADAPTER_ALREADY_INSTALLED=true if need_install; then ADAPTER_ALREADY_INSTALLED=false log "Fetching channels branch…" - git fetch origin channels >&2 2>/dev/null || { - emit_status failed "git fetch origin channels failed" + git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || { + emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed" exit 1 } diff --git a/setup/add-whatsapp.sh b/setup/add-whatsapp.sh index d04d372..c7356af 100755 --- a/setup/add-whatsapp.sh +++ b/setup/add-whatsapp.sh @@ -20,7 +20,13 @@ BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16" QRCODE_VERSION="qrcode@1.5.4" QRCODE_TYPES_VERSION="@types/qrcode@1.5.6" PINO_VERSION="pino@9.6.0" -CHANNELS_BRANCH="origin/channels" + +# Resolve which remote carries the channels branch — handles forks where +# upstream lives on a different remote than `origin`. +# shellcheck source=setup/lib/channels-remote.sh +source "$PROJECT_ROOT/setup/lib/channels-remote.sh" +CHANNELS_REMOTE=$(resolve_channels_remote) +CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels" emit_status() { local status=$1 error=${2:-} @@ -47,8 +53,8 @@ ADAPTER_ALREADY_INSTALLED=true if need_install; then ADAPTER_ALREADY_INSTALLED=false log "Fetching channels branch…" - git fetch origin channels >&2 2>/dev/null || { - emit_status failed "git fetch origin channels failed" + git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || { + emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed" exit 1 } diff --git a/setup/lib/channels-remote.sh b/setup/lib/channels-remote.sh new file mode 100644 index 0000000..6da0159 --- /dev/null +++ b/setup/lib/channels-remote.sh @@ -0,0 +1,38 @@ +# channels-remote.sh — resolve the git remote that carries the `channels` +# branch. Source this file and call `resolve_channels_remote`; echoes the +# remote name (e.g. `origin` or `upstream`). +# +# Typical fork setups keep the upstream nanoclaw repo under a remote named +# `upstream`, with `origin` pointing at the user's fork. The channels branch +# only lives upstream, so a hardcoded `git fetch origin channels` fails for +# forks. This helper walks `git remote -v`, picks the remote whose URL points +# at qwibitai/nanoclaw, and prints its name. +# +# Fallback: if no existing remote matches, add `upstream` pointing at +# github.com/qwibitai/nanoclaw and return that — keeps forks without an +# explicit upstream configured working on the first try. +# +# Explicit override: set NANOCLAW_CHANNELS_REMOTE= to skip detection. + +resolve_channels_remote() { + if [ -n "${NANOCLAW_CHANNELS_REMOTE:-}" ]; then + printf '%s' "$NANOCLAW_CHANNELS_REMOTE" + return 0 + fi + + local remote url + while IFS=$'\t' read -r remote url; do + case "$url" in + *qwibitai/nanoclaw*) + printf '%s' "$remote" + return 0 + ;; + esac + done < <(git remote -v 2>/dev/null | awk '$3 == "(fetch)" { print $1"\t"$2 }') + + # No matching remote — add `upstream` and use it. Silent on failure so + # callers see the eventual `git fetch` error rather than a cryptic + # remote-add failure. + git remote add upstream https://github.com/qwibitai/nanoclaw.git 2>/dev/null || true + printf '%s' "upstream" +} From 6cd261a26dbfdf5752a640c60fcbca366c8c38ac Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 01:05:03 +0300 Subject: [PATCH 119/185] chore(container): loosen /home/node to 0777 Co-Authored-By: Claude Opus 4.7 (1M context) --- container/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/container/Dockerfile b/container/Dockerfile index f492f1c..4b4cf22 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -111,7 +111,7 @@ RUN chmod +x /app/entrypoint.sh # ---- Workspace + permissions ------------------------------------------------- RUN mkdir -p /workspace/group /workspace/extra && \ chown -R node:node /workspace && \ - chmod 755 /home/node + chmod 777 /home/node USER node WORKDIR /workspace/group From 22c2beff3c48b2bb4a0e3dc935362fc81eccf842 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Apr 2026 22:05:25 +0000 Subject: [PATCH 120/185] chore: bump version to 2.0.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 36cc2b4..31802c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.1", + "version": "2.0.2", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 8326b4c0be99cb0bff3cb090359e5eda4a8f7728 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 01:48:13 +0300 Subject: [PATCH 121/185] fix(setup): offer to reuse an existing OneCLI instead of clobbering it Before: setup/onecli.ts ran `curl -fsSL onecli.sh/install | sh` unconditionally. For users with OneCLI already running and bound to a specific listener (host-accessible, shared with other apps), re-running the installer rebound the gateway and broke those consumers. Now: auto.ts probes for an existing install (`onecli version` + `onecli config get api-host`). If detected, clack asks: use the existing instance (recommended) or install a fresh one. The new --reuse flag in the onecli step skips the installer, reads the configured api-host, writes ONECLI_URL to .env, and moves on without touching the running gateway. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 82 ++++++++++++++++++++++++++++++++++++++++++++++--- setup/onecli.ts | 82 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 153 insertions(+), 11 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 203b0bf..ea5dec3 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -115,10 +115,44 @@ async function main(): Promise { 4, ), ); - const res = await runQuietStep('onecli', { - running: "Setting up OneCLI, your agent's vault…", - done: 'OneCLI vault ready.', - }); + + // Respect an existing OneCLI install. Re-running the installer would + // rebind the listener and knock any other app using that gateway + // offline — confirm with the user before doing that. + const existing = detectExistingOnecli(); + let reuse = false; + if (existing) { + const choice = ensureAnswer( + await p.select({ + message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`, + options: [ + { + value: 'reuse', + label: 'Use the existing instance', + hint: 'recommended — keeps other apps bound to this vault working', + }, + { + value: 'fresh', + label: 'Install a fresh instance for NanoClaw', + hint: 'reinstalls onecli; other apps may need to reconnect', + }, + ], + }), + ) as 'reuse' | 'fresh'; + setupLog.userInput('onecli_choice', choice); + reuse = choice === 'reuse'; + } + + const res = await runQuietStep( + 'onecli', + { + running: reuse + ? 'Hooking up to your existing OneCLI…' + : "Setting up OneCLI, your agent's vault…", + done: 'OneCLI vault ready.', + }, + reuse ? ['--reuse'] : [], + ); if (!res.ok) { const err = res.terminal?.fields.ERROR; if (err === 'onecli_not_on_path_after_install') { @@ -691,6 +725,46 @@ function anthropicSecretExists(): boolean { } } +/** + * Probe the host for a working OneCLI install so we can offer to reuse it + * instead of re-running the installer (which rebinds the listener and breaks + * any other app already using that gateway). + */ +function detectExistingOnecli(): { version: string; apiHost: string } | null { + try { + const ver = spawnSync('onecli', ['version'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (ver.status !== 0) return null; + const version = (ver.stdout ?? '').trim(); + if (!version) return null; + + const host = spawnSync('onecli', ['config', 'get', 'api-host'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (host.status !== 0) return null; + const raw = (host.stdout ?? '').trim(); + if (!raw) return null; + + // onecli 1.3+ emits JSON by default. Older versions would print raw text. + try { + const parsed = JSON.parse(raw) as { data?: unknown; value?: unknown }; + const val = parsed.data ?? parsed.value; + if (typeof val === 'string' && val.trim()) { + return { version, apiHost: val.trim() }; + } + } catch { + // not JSON — try to extract a URL directly + } + const m = raw.match(/https?:\/\/[\w.\-]+(?::\d+)?/); + return m ? { version, apiHost: m[0] } : null; + } catch { + return null; + } +} + function runInheritScript(cmd: string, args: string[]): Promise { return new Promise((resolve) => { const child = spawn(cmd, args, { stdio: 'inherit' }); diff --git a/setup/onecli.ts b/setup/onecli.ts index c4ce83f..6be722a 100644 --- a/setup/onecli.ts +++ b/setup/onecli.ts @@ -1,13 +1,15 @@ /** * Step: onecli — Install + configure the OneCLI gateway and CLI. * - * Aggregates what the old /setup + /init-onecli skills ran as loose shell - * commands. Idempotent: skips install if `onecli` already works, and safely - * re-applies PATH, api-host, and .env updates. + * Two modes: + * (default) run the OneCLI installer, configure api-host, write .env. + * --reuse skip the installer; reuse the onecli instance already running + * on the host. Required for users who have other apps bound to + * an existing gateway, since re-running the installer rebinds + * the listener and breaks those consumers. * - * Emits ONECLI_URL so /new-setup SKILL.md can forward it downstream (e.g. as - * ${ONECLI_URL} in status messages). Polls /health to give downstream steps - * (auth, service) a ready gateway. + * Emits ONECLI_URL and polls /health so downstream steps (auth, service) + * get a ready gateway. */ import { execFileSync, execSync } from 'child_process'; import fs from 'fs'; @@ -37,6 +39,32 @@ function onecliVersion(): string | null { } } +/** + * Ask the installed onecli CLI for its configured api-host. Returns null if + * onecli isn't on PATH, errors, or has no api-host configured. + * + * Tolerates both JSON output (onecli 1.3+) and older raw-text output. + */ +export function getOnecliApiHost(): string | null { + try { + const out = execFileSync('onecli', ['config', 'get', 'api-host'], { + encoding: 'utf-8', + env: childEnv(), + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + try { + const parsed = JSON.parse(out) as { data?: unknown; value?: unknown }; + const val = parsed.data ?? parsed.value; + if (typeof val === 'string' && val.trim()) return val.trim(); + } catch { + // not JSON — fall through to URL extraction + } + return extractUrlFromOutput(out); + } catch { + return null; + } +} + function extractUrlFromOutput(output: string): string | null { const match = output.match(/https?:\/\/[\w.\-]+(?::\d+)?/); return match ? match[0] : null; @@ -106,9 +134,49 @@ async function pollHealth(url: string, timeoutMs: number): Promise { return false; } -export async function run(_args: string[]): Promise { +export async function run(args: string[]): Promise { + const reuse = args.includes('--reuse'); ensureShellProfilePath(); + if (reuse) { + // Reuse-mode: don't touch the running gateway at all. Just verify it + // exists, read its api-host, write ONECLI_URL to .env, and move on. + const version = onecliVersion(); + if (!version) { + emitStatus('ONECLI', { + INSTALLED: false, + STATUS: 'failed', + ERROR: 'onecli_not_found_for_reuse', + HINT: 'onecli not on PATH. Re-run setup and choose "install fresh".', + LOG: 'logs/setup.log', + }); + process.exit(1); + } + const url = getOnecliApiHost(); + if (!url) { + emitStatus('ONECLI', { + INSTALLED: true, + STATUS: 'failed', + ERROR: 'onecli_api_host_not_configured', + HINT: 'Existing onecli has no api-host set. Run `onecli config set api-host ` or re-run setup with install-fresh.', + LOG: 'logs/setup.log', + }); + process.exit(1); + } + writeEnvOnecliUrl(url); + log.info('Reusing existing OneCLI', { url }); + const healthy = await pollHealth(url, 5000); + emitStatus('ONECLI', { + INSTALLED: true, + REUSED: true, + ONECLI_URL: url, + HEALTHY: healthy, + STATUS: 'success', + LOG: 'logs/setup.log', + }); + return; + } + log.info('Installing OneCLI gateway and CLI'); const res = installOnecli(); if (!res.ok) { From cdb94427969cdd59c7dee48194f77fa99a2a5174 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 09:31:49 +0300 Subject: [PATCH 122/185] docs(readme): clone into nanoclaw-v2 in Quick Start Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 53ac2b0..a728c0f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ NanoClaw provides that same core functionality, but in a codebase small enough t ## Quick Start ```bash -git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw && bash nanoclaw.sh +git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2 && cd nanoclaw-v2 && bash nanoclaw.sh ``` `nanoclaw.sh` walks you from a fresh machine to a named agent you can message. It installs Node, pnpm, and Docker if missing, registers your Anthropic credential with OneCLI, builds the agent container, and pairs your first channel (Telegram, Discord, WhatsApp, or a local CLI). If a step fails, Claude Code is invoked automatically to diagnose and resume from where it broke. From 601fc7c39678462d94098c8915ef320da1dfe466 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 09:33:39 +0300 Subject: [PATCH 123/185] docs(readme): split Quick Start into separate commands Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a728c0f..de61f6d 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ NanoClaw provides that same core functionality, but in a codebase small enough t ## Quick Start ```bash -git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2 && cd nanoclaw-v2 && bash nanoclaw.sh +git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2 +cd nanoclaw-v2 +bash nanoclaw.sh ``` `nanoclaw.sh` walks you from a fresh machine to a named agent you can message. It installs Node, pnpm, and Docker if missing, registers your Anthropic credential with OneCLI, builds the agent container, and pairs your first channel (Telegram, Discord, WhatsApp, or a local CLI). If a step fails, Claude Code is invoked automatically to diagnose and resume from where it broke. From 564000dcaeaa730e77b6175d5c286ddc2e7e86f6 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 09:55:28 +0300 Subject: [PATCH 124/185] docs(readme-ja): align Japanese README with v2 English Co-Authored-By: Claude Opus 4.7 (1M context) --- README_ja.md | 166 +++++++++++++++++++-------------------------------- 1 file changed, 63 insertions(+), 103 deletions(-) diff --git a/README_ja.md b/README_ja.md index 5ae2b11..947db95 100644 --- a/README_ja.md +++ b/README_ja.md @@ -8,92 +8,56 @@

nanoclaw.dev  •   + ドキュメント  •   English  •   中文  •   Discord  •   - 34.9k tokens, 17% of context window + repo tokens

-> **注意:** この日本語訳は v1 時点のもので、最新の v2 アーキテクチャは反映されていません。最新の内容は [README.md](README.md) をご覧ください。 - ---- - -

🐳 Dockerサンドボックスで動作

-

各エージェントはマイクロVM内の独立したコンテナで実行されます。
ハイパーバイザーレベルの分離。ミリ秒で起動。複雑なセットアップ不要。

- -**macOS (Apple Silicon)** -```bash -curl -fsSL https://nanoclaw.dev/install-docker-sandboxes.sh | bash -``` - -**Windows (WSL)** -```bash -curl -fsSL https://nanoclaw.dev/install-docker-sandboxes-windows.sh | bash -``` - -> 現在、macOS(Apple Silicon)とWindows(x86)に対応しています。Linux対応は近日公開予定。 - -

発表記事を読む →  ·  手動セットアップガイド →

- --- ## NanoClawを作った理由 -[OpenClaw](https://github.com/openclaw/openclaw)は素晴らしいプロジェクトですが、理解しきれない複雑なソフトウェアに自分の生活へのフルアクセスを与えたまま安心して眠れるとは思えませんでした。OpenClawは約50万行のコード、53の設定ファイル、70以上の依存関係を持っています。セキュリティはアプリケーションレベル(許可リスト、ペアリングコード)であり、真のOS レベルの分離ではありません。すべてが共有メモリを持つ1つのNodeプロセスで動作します。 +[OpenClaw](https://github.com/openclaw/openclaw)は素晴らしいプロジェクトですが、自分が理解しきれない複雑なソフトウェアに生活へのフルアクセスを与えたまま安心して眠れるとは思えませんでした。OpenClawは約50万行のコード、53の設定ファイル、70以上の依存関係を持っています。セキュリティはアプリケーションレベル(許可リスト、ペアリングコード)であり、真のOSレベルの分離ではありません。すべてが共有メモリを持つ1つのNodeプロセスで動作します。 -NanoClawは同じコア機能を提供しますが、理解できる規模のコードベースで実現しています:1つのプロセスと少数のファイル。Claudeエージェントは単なるパーミッションチェックの背後ではなく、ファイルシステム分離された独自のLinuxコンテナで実行されます。 +NanoClawは同じコア機能を提供しますが、理解できる規模のコードベースで実現しています。1つのプロセスと少数のファイル。Claudeエージェントは単なるパーミッションチェックの背後ではなく、ファイルシステム分離された独自のLinuxコンテナで実行されます。 ## クイックスタート ```bash -gh repo fork qwibitai/nanoclaw --clone -cd nanoclaw -claude +git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2 +cd nanoclaw-v2 +bash nanoclaw.sh ``` -
-GitHub CLIなしの場合 - -1. GitHub上で[qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw)をフォーク(Forkボタンをクリック) -2. `git clone https://github.com/<あなたのユーザー名>/nanoclaw.git` -3. `cd nanoclaw` -4. `claude` - -
- -その後、`/setup`を実行します。Claude Codeがすべてを処理します:依存関係、認証、コンテナセットアップ、サービス設定。 - -> **注意:** `/`で始まるコマンド(`/setup`、`/add-whatsapp`など)は[Claude Codeスキル](https://code.claude.com/docs/en/skills)です。通常のターミナルではなく、`claude` CLIプロンプト内で入力してください。Claude Codeをインストールしていない場合は、[claude.com/product/claude-code](https://claude.com/product/claude-code)から入手してください。 +`nanoclaw.sh`は、まっさらなマシンから、メッセージを送れる名前付きエージェントが動く状態までを一気通貫で案内します。NodeやpnpmやDockerが無ければインストールし、AnthropicクレデンシャルをOneCLIに登録し、エージェントコンテナをビルドし、最初のチャネル(Telegram、Discord、WhatsApp、またはローカルCLI)とペアリングします。途中でステップが失敗すれば自動的にClaude Codeが呼び出され、原因を診断して中断箇所から再開します。 ## 設計思想 -**理解できる規模。** 1つのプロセス、少数のソースファイル、マイクロサービスなし。NanoClawのコードベース全体を理解したい場合は、Claude Codeに説明を求めるだけです。 +**理解できる規模。** 1つのプロセス、少数のソースファイル、マイクロサービスなし。NanoClawのコードベース全体を把握したいなら、Claude Codeに説明を求めれば十分です。 -**分離によるセキュリティ。** エージェントはLinuxコンテナ(macOSではApple Container、またはDocker)で実行され、明示的にマウントされたものだけが見えます。コマンドはホストではなくコンテナ内で実行されるため、Bashアクセスは安全です。 +**分離によるセキュリティ。** エージェントはLinuxコンテナで実行され、明示的にマウントされたものだけが見えます。コマンドはホストではなくコンテナ内で実行されるため、Bashアクセスも安全です。 -**個人ユーザー向け。** NanoClawはモノリシックなフレームワークではなく、各ユーザーのニーズに正確にフィットするソフトウェアです。肥大化するのではなく、オーダーメイドになるよう設計されています。自分のフォークを作成し、Claude Codeにニーズに合わせて変更させます。 +**個人ユーザー向け。** NanoClawはモノリシックなフレームワークではなく、各ユーザーのニーズに正確にフィットするソフトウェアです。肥大化するのではなく、オーダーメイドであるよう設計されています。自分のフォークを作り、Claude Codeにニーズに合わせて変更させます。 -**カスタマイズ=コード変更。** 設定ファイルの肥大化なし。動作を変えたい?コードを変更するだけ。コードベースは変更しても安全な規模です。 +**カスタマイズ=コード変更。** 設定の肥大化はありません。動作を変えたいならコードを変える。コードベースは変更しても安全な規模です。 -**AIネイティブ。** -- インストールウィザードなし — Claude Codeがセットアップを案内。 -- モニタリングダッシュボードなし — Claudeに状況を聞くだけ。 -- デバッグツールなし — 問題を説明すればClaudeが修正。 +**AIネイティブ、設計としてハイブリッド。** インストールとオンボーディングは最適化されたスクリプトのパスで、速く決定的です。判断が必要なところ(インストール失敗、対話的な決定、カスタマイズ)では、制御はシームレスにClaude Codeへ渡されます。セットアップ以降も、監視ダッシュボードやデバッグUIは用意しません。問題をチャットで説明すれば、Claude Codeが処理します。 -**機能追加ではなくスキル。** コードベースに機能(例:Telegram対応)を追加する代わりに、コントリビューターは`/add-telegram`のような[Claude Codeスキル](https://code.claude.com/docs/en/skills)を提出し、あなたのフォークを変換します。あなたが必要なものだけを正確に実行するクリーンなコードが手に入ります。 +**機能ではなくスキル。** トランクにはレジストリとインフラのみを同梱し、個別のチャネルアダプターや代替プロバイダーは含めません。チャネル(Discord、Slack、Telegram、WhatsAppなど)は長期運用される`channels`ブランチに、代替プロバイダー(OpenCode、Ollama)は`providers`ブランチに置かれます。`/add-telegram`や`/add-opencode`などを実行すると、スキルが必要なモジュールだけを正確にフォークへコピーします。要求していない機能は一切入りません。 -**最高のハーネス、最高のモデル。** NanoClawはClaude Agent SDK上で動作します。つまり、Claude Codeを直接実行しているということです。Claude Codeは高い能力を持ち、そのコーディングと問題解決能力によってNanoClawを変更・拡張し、各ユーザーに合わせてカスタマイズできます。 +**最高のハーネス、最高のモデル。** NanoClawはAnthropic公式のClaude Agent SDK経由でネイティブにClaude Codeを使用します。最新のClaudeモデルとClaude Codeの全ツールセット(自分のNanoClawフォークを変更・拡張する能力を含む)が手に入ります。他プロバイダーはドロップイン・オプションです。OpenAIのCodex(ChatGPTサブスクリプションまたはAPIキー)向けには`/add-codex`、OpenCode経由のOpenRouter、Google、DeepSeekなどには`/add-opencode`、ローカルのオープンウェイトモデルには`/add-ollama-provider`。プロバイダーはエージェントグループごとに設定可能です。 ## サポート機能 -- **マルチチャネルメッセージング** - WhatsApp、Telegram、Discord、Slack、Gmailからアシスタントと会話。`/add-whatsapp`や`/add-telegram`などのスキルでチャネルを追加。1つでも複数でも同時に実行可能。 -- **グループごとの分離コンテキスト** - 各グループは独自の`CLAUDE.md`メモリ、分離されたファイルシステムを持ち、そのファイルシステムのみがマウントされた専用コンテナサンドボックスで実行。 -- **メインチャネル** - 管理制御用のプライベートチャネル(セルフチャット)。各グループは完全に分離。 -- **スケジュールタスク** - Claudeを実行し、メッセージを返せる定期ジョブ。 -- **Webアクセス** - Webからのコンテンツ検索・取得。 -- **コンテナ分離** - エージェントは[Dockerサンドボックス](https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes)(マイクロVM分離)、Apple Container(macOS)、またはDocker(macOS/Linux)でサンドボックス化。 -- **エージェントスウォーム** - 複雑なタスクで協力する専門エージェントチームを起動。 -- **オプション連携** - Gmail(`/add-gmail`)などをスキルで追加。 +- **マルチチャネルメッセージング** — WhatsApp、Telegram、Discord、Slack、Microsoft Teams、iMessage、Matrix、Google Chat、Webex、Linear、GitHub、WeChat、Resend経由のメール。`/add-`スキルでオンデマンドにインストール。1つでも複数でも同時に実行可能。 +- **柔軟な分離モデル** — チャネルごとに専用エージェントを割り当てて完全プライバシーを確保することも、複数チャネルで1つのエージェントを共有して会話は分離しつつメモリを統一することも、複数チャネルを1つの共有セッションにまとめて会話を横断させることもできます。`/manage-channels`でチャネル単位に選択。[docs/isolation-model.md](docs/isolation-model.md)参照。 +- **エージェントごとのワークスペース** — 各エージェントグループは独自の`CLAUDE.md`、独自のメモリ、独自のコンテナ、そしてあなたが許可したマウントのみを持ちます。明示的に配線しない限り、境界を越えるものはありません。 +- **スケジュールタスク** — Claudeを実行し、結果を返信できる定期ジョブ。 +- **Webアクセス** — Webからの検索とコンテンツ取得。 +- **コンテナ分離** — エージェントはDockerでサンドボックス化されます(macOS/Linux/WSL2)。[Docker Sandboxes](docs/docker-sandboxes.md)によるマイクロVM分離や、macOSネイティブのオプトインとしてApple Containerも選択可能です。 +- **クレデンシャルのセキュリティ** — エージェントは生のAPIキーを保持しません。アウトバウンドリクエストは[OneCLI Agent Vault](https://github.com/onecli/onecli)を経由し、リクエスト時に認証情報を注入して、エージェントごとのポリシーとレート制限を適用します。 ## 使い方 @@ -105,7 +69,7 @@ claude @Andy 毎週月曜の朝8時に、Hacker NewsとTechCrunchからAI関連のニュースをまとめてブリーフィングを送って ``` -メインチャネル(セルフチャット)から、グループやタスクを管理できます: +所有または管理しているチャネルからは、グループやタスクを管理できます: ``` @Andy 全グループのスケジュールタスクを一覧表示して @Andy 月曜のブリーフィングタスクを一時停止して @@ -114,14 +78,14 @@ claude ## カスタマイズ -NanoClawは設定ファイルを使いません。変更するには、Claude Codeに伝えるだけです: +NanoClawは設定ファイルを使いません。変更したいときは、Claude Codeにやりたいことを伝えるだけです: - 「トリガーワードを@Bobに変更して」 - 「今後はレスポンスをもっと短く直接的にして」 - 「おはようと言ったらカスタム挨拶を追加して」 - 「会話の要約を毎週保存して」 -または`/customize`を実行してガイド付きの変更を行えます。 +または`/customize`を実行すればガイド付きで変更できます。 コードベースは十分に小さいため、Claudeが安全に変更できます。 @@ -129,105 +93,101 @@ NanoClawは設定ファイルを使いません。変更するには、Claude Co **機能を追加するのではなく、スキルを追加してください。** -Telegram対応を追加したい場合、コアコードベースにTelegramを追加するPRを作成しないでください。代わりに、NanoClawをフォークし、ブランチでコード変更を行い、PRを開いてください。あなたのPRから`skill/telegram`ブランチを作成し、他のユーザーが自分のフォークにマージできるようにします。 +新しいチャネルやエージェントプロバイダーを追加したい場合、トランクには追加しないでください。新しいチャネルアダプターは`channels`ブランチに、新しいエージェントプロバイダーは`providers`ブランチに追加します。ユーザーはそれぞれのフォークで`/add-`スキルを実行し、スキルが必要なモジュールを標準パスへコピーし、登録を配線し、依存関係をピン留めします。 -ユーザーは自分のフォークで`/add-telegram`を実行するだけで、あらゆるユースケースに対応しようとする肥大化したシステムではなく、必要なものだけを正確に実行するクリーンなコードが手に入ります。 +こうすることでトランクは純粋なレジストリ/インフラのまま保たれ、どのフォークもスリムなままです。ユーザーは求めたチャネルとプロバイダーだけを受け取り、それ以外は入りません。 ### RFS(スキル募集) -私たちが求めているスキル: +私たちが見たいスキル: **コミュニケーションチャネル** -- `/add-signal` - Signalをチャネルとして追加 - -**セッション管理** -- `/clear` - 会話をコンパクト化する`/clear`コマンドの追加(同一セッション内で重要な情報を保持しながらコンテキストを要約)。Claude Agent SDKを通じてプログラム的にコンパクト化をトリガーする方法の解明が必要。 +- `/add-signal` — Signalをチャネルとして追加 ## 必要条件 -- macOSまたはLinux -- Node.js 20以上 -- [Claude Code](https://claude.ai/download) -- [Apple Container](https://github.com/apple/container)(macOS)または[Docker](https://docker.com/products/docker-desktop)(macOS/Linux) +- macOSまたはLinux(WindowsはWSL2経由) +- Node.js 20以上とpnpm 10以上(インストーラーが未インストールなら両方をインストールします) +- [Docker Desktop](https://docker.com/products/docker-desktop)(macOS/Windows)または Docker Engine(Linux) +- [Claude Code](https://claude.ai/download)(`/customize`、`/debug`、セットアップ時のエラー復旧、全ての`/add-`スキルで使用) ## アーキテクチャ ``` -チャネル --> SQLite --> ポーリングループ --> コンテナ(Claude Agent SDK) --> レスポンス +メッセージングアプリ → ホストプロセス(ルーター) → inbound.db → コンテナ(Bun、Claude Agent SDK) → outbound.db → ホストプロセス(配信) → メッセージングアプリ ``` -単一のNode.jsプロセス。チャネルはスキルで追加され、起動時に自己登録します — オーケストレーターは認証情報が存在するチャネルを接続します。エージェントはファイルシステム分離された独立したLinuxコンテナで実行されます。マウントされたディレクトリのみアクセス可能。グループごとのメッセージキューと同時実行制御。ファイルシステム経由のIPC。 +単一のNodeホストがセッションごとのエージェントコンテナをオーケストレーションします。メッセージが到着すると、ホストはエンティティモデル(ユーザー → メッセージンググループ → エージェントグループ → セッション)に沿ってルーティングし、セッションの`inbound.db`に書き込み、コンテナを起こします。コンテナ内部のagent-runnerは`inbound.db`をポーリングしてClaudeを実行し、レスポンスを`outbound.db`に書き込みます。ホストは`outbound.db`をポーリングし、チャネルアダプターを通じて配信します。 -詳細なアーキテクチャについては、[docs/SPEC.md](docs/SPEC.md)を参照してください。 +セッションごとに2つのSQLiteファイル、各ファイルにライターは1つだけ — クロスマウントの競合なし、IPCなし、stdinパイプなし。チャネルと代替プロバイダーは起動時に自己登録します。トランクはレジストリとChat SDKブリッジを同梱し、アダプター本体はフォークごとにスキルでインストールされます。 + +詳しいアーキテクチャ説明は[docs/architecture.md](docs/architecture.md)を、3階層の分離モデルについては[docs/isolation-model.md](docs/isolation-model.md)を参照してください。 主要ファイル: -- `src/index.ts` - オーケストレーター:状態、メッセージループ、エージェント呼び出し -- `src/channels/registry.ts` - チャネルレジストリ(起動時の自己登録) -- `src/ipc.ts` - IPCウォッチャーとタスク処理 -- `src/router.ts` - メッセージフォーマットとアウトバウンドルーティング -- `src/group-queue.ts` - グローバル同時実行制限付きのグループごとのキュー -- `src/container-runner.ts` - ストリーミングエージェントコンテナの起動 -- `src/task-scheduler.ts` - スケジュールタスクの実行 -- `src/db.ts` - SQLite操作(メッセージ、グループ、セッション、状態) -- `groups/*/CLAUDE.md` - グループごとのメモリ +- `src/index.ts` — エントリーポイント:DB初期化、チャネルアダプター、配信ポーリング、sweep +- `src/router.ts` — インバウンドルーティング:メッセージンググループ → エージェントグループ → セッション → `inbound.db` +- `src/delivery.ts` — `outbound.db`をポーリングし、アダプター経由で配信、システムアクションを処理 +- `src/host-sweep.ts` — 60秒ごとのsweep:ストール検出、期限到来メッセージの起動、繰り返し +- `src/session-manager.ts` — セッションの解決、`inbound.db`と`outbound.db`のオープン +- `src/container-runner.ts` — エージェントグループごとのコンテナ起動、OneCLIによるクレデンシャル注入 +- `src/db/` — セントラルDB(ユーザー、ロール、エージェントグループ、メッセージンググループ、配線、マイグレーション) +- `src/channels/` — チャネルアダプターのインフラ(アダプターは`/add-`スキルでインストール) +- `src/providers/` — ホスト側プロバイダー設定(`claude`はバンドル、その他はスキル経由) +- `container/agent-runner/` — Bun製agent-runner:ポーリングループ、MCPツール、プロバイダー抽象化 +- `groups//` — エージェントグループごとのファイルシステム(`CLAUDE.md`、スキル、コンテナ設定) ## FAQ **なぜDockerなのか?** -Dockerはクロスプラットフォーム対応(macOS、Linux、さらにWSL2経由のWindows)と成熟したエコシステムを提供します。macOSでは、`/convert-to-apple-container`でオプションとしてApple Containerに切り替え、より軽量なネイティブランタイムを使用できます。 +Dockerはクロスプラットフォーム対応(macOS、Linux、WSL2経由のWindows)と成熟したエコシステムを提供します。macOSでは、`/convert-to-apple-container`でオプションとしてApple Containerに切り替え、より軽量なネイティブランタイムを使えます。さらに強い分離が必要なら、[Docker Sandboxes](docs/docker-sandboxes.md)が各コンテナをマイクロVM内で動作させます。 -**Linuxで実行できますか?** +**LinuxやWindowsで実行できますか?** -はい。DockerがデフォルトのランタイムでmacOSとLinuxの両方で動作します。`/setup`を実行するだけです。 +はい。Dockerがデフォルトのランタイムで、macOS、Linux、Windows(WSL2経由)で動作します。`bash nanoclaw.sh`を実行するだけです。 **セキュリティは大丈夫ですか?** -エージェントはアプリケーションレベルのパーミッションチェックの背後ではなく、コンテナで実行されます。明示的にマウントされたディレクトリのみアクセスできます。実行するものをレビューすべきですが、コードベースは十分に小さいため実際にレビュー可能です。完全なセキュリティモデルについては[docs/SECURITY.md](docs/SECURITY.md)を参照してください。 +エージェントはアプリケーションレベルのパーミッションチェックではなく、コンテナ内で実行されます。明示的にマウントされたディレクトリのみアクセス可能です。クレデンシャルはコンテナに渡されず、アウトバウンドAPIリクエストは[OneCLI Agent Vault](https://github.com/onecli/onecli)を経由し、プロキシレベルで認証を注入し、レートリミットやアクセスポリシーをサポートします。実行するものはレビューすべきですが、コードベースは実際にレビュー可能な規模です。完全なセキュリティモデルについては[セキュリティドキュメント](https://docs.nanoclaw.dev/concepts/security)を参照してください。 **なぜ設定ファイルがないのか?** -設定の肥大化を避けたいからです。すべてのユーザーがNanoClawをカスタマイズし、汎用的なシステムを設定するのではなく、コードが必要なことを正確に実行するようにすべきです。設定ファイルが欲しい場合は、Claudeに追加するよう伝えることができます。 +設定の肥大化を避けたいからです。すべてのユーザーがNanoClawをカスタマイズし、汎用的なシステムを設定するのではなくコードが自分の望み通りに動くようにすべきです。設定ファイルが欲しければClaudeに追加するよう伝えれば実現できます。 **サードパーティやオープンソースモデルを使えますか?** -はい。NanoClawはClaude API互換のモデルエンドポイントに対応しています。`.env`ファイルで以下の環境変数を設定してください: +はい。推奨される方法は`/add-opencode`(OpenCode設定経由でOpenRouter、OpenAI、Google、DeepSeekなど)か`/add-ollama-provider`(Ollama経由でローカルのオープンウェイトモデル)です。どちらもエージェントグループごとに設定可能なので、同じインストール内で異なるエージェントが異なるバックエンドで動作できます。 + +一時的な実験用には、Claude API互換のエンドポイントも`.env`で利用できます: ```bash ANTHROPIC_BASE_URL=https://your-api-endpoint.com ANTHROPIC_AUTH_TOKEN=your-token-here ``` -以下が使用可能です: -- [Ollama](https://ollama.ai)とAPIプロキシ経由のローカルモデル -- [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai)等でホストされたオープンソースモデル -- Anthropic互換APIのカスタムモデルデプロイメント - -注意:最高の互換性のため、モデルはAnthropic APIフォーマットに対応している必要があります。 - **問題のデバッグ方法は?** Claude Codeに聞いてください。「スケジューラーが動いていないのはなぜ?」「最近のログには何がある?」「このメッセージに返信がなかったのはなぜ?」これがNanoClawの基盤となるAIネイティブなアプローチです。 **セットアップがうまくいかない場合は?** -問題がある場合、セットアップ中にClaudeが動的に修正を試みます。それでもうまくいかない場合は、`claude`を実行してから`/debug`を実行してください。Claudeが他のユーザーにも影響する可能性のある問題を見つけた場合は、セットアップのSKILL.mdを修正するPRを開いてください。 +ステップが失敗した場合、`nanoclaw.sh`は診断と再開のためにClaude Codeへ制御を渡します。それでも解決しなければ、`claude`を実行して`/debug`を呼び出してください。他のユーザーにも影響しそうな問題をClaudeが特定した場合は、該当のセットアップステップまたはスキルにPRを送ってください。 **どのような変更がコードベースに受け入れられますか?** -セキュリティ修正、バグ修正、明確な改善のみが基本設定に受け入れられます。それだけです。 +ベース設定に受け入れられるのは、セキュリティ修正、バグ修正、明確な改善のみです。それだけです。 -それ以外のすべて(新機能、OS互換性、ハードウェアサポート、機能拡張)はスキルとしてコントリビューションすべきです。 +それ以外(新機能、OS互換性、ハードウェアサポート、拡張など)は、`channels`または`providers`ブランチのスキルとしてコントリビュートしてください。 -これにより、基本システムを最小限に保ち、すべてのユーザーが不要な機能を継承することなく、自分のインストールをカスタマイズできます。 +これにより、ベースシステムを最小限に保ち、全ユーザーが不要な機能を継承することなく自分のインストールをカスタマイズできます。 ## コミュニティ -質問やアイデアは?[Discordに参加](https://discord.gg/VDdww8qS42)してください。 +質問やアイデアがありますか?[Discordに参加](https://discord.gg/VDdww8qS42)してください。 ## 変更履歴 -破壊的変更と移行ノートについては[CHANGELOG.md](CHANGELOG.md)を参照してください。 +破壊的変更については[CHANGELOG.md](CHANGELOG.md)を、完全なリリース履歴はドキュメントサイトの[full release history](https://docs.nanoclaw.dev/changelog)を参照してください。 ## ライセンス From 4f6d62a65e5e9f36da0c588b82aa7e7a3c0814d9 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 09:55:34 +0300 Subject: [PATCH 125/185] docs(readme-zh): align Chinese README with v2 English Co-Authored-By: Claude Opus 4.7 (1M context) --- README_zh.md | 166 ++++++++++++++++++++++++--------------------------- 1 file changed, 78 insertions(+), 88 deletions(-) diff --git a/README_zh.md b/README_zh.md index b86d7ad..9db5c28 100644 --- a/README_zh.md +++ b/README_zh.md @@ -3,93 +3,87 @@

- NanoClaw —— 您的专属 Claude 助手,在容器中安全运行。它轻巧易懂,并能根据您的个人需求灵活定制。 + 一个将智能体安全运行在独立容器中的 AI 助手。轻量、易于理解,并可根据您的需求完全定制。

nanoclaw.dev  •   + 文档  •   English  •   日本語  •   Discord  •   - 34.9k tokens, 17% of context window + repo tokens

-> **注意:** 此中文翻译对应 v1 版本,已不反映最新的 v2 架构。请参考 [README.md](README.md) 获取最新内容。 +--- -通过 Claude Code,NanoClaw 可以动态重写自身代码,根据您的需求定制功能。 +## 我为什么创建 NanoClaw -**新功能:** 首个支持 [Agent Swarms(智能体集群)](https://code.claude.com/docs/en/agent-teams) 的 AI 助手。可轻松组建智能体团队,在您的聊天中高效协作。 +[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解、却能访问我个人隐私的复杂软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(白名单、配对码),而非真正的操作系统级隔离。所有东西都在一个共享内存的 Node 进程中运行。 -## 我为什么创建这个项目 - -[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解却能访问我个人隐私的软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(通过白名单、配对码实现),而非操作系统级别的隔离。所有东西都在一个共享内存的 Node 进程中运行。 - -NanoClaw 用一个您能快速理解的代码库,为您提供了同样的核心功能。只有一个进程,少数几个文件。智能体(Agent)运行在具有文件系统隔离的真实 Linux 容器中,而不是依赖于权限检查。 +NanoClaw 用一个您能轻松理解的代码库提供了同样的核心功能:一个进程,少数几个文件。Claude 智能体运行在具有文件系统隔离的独立 Linux 容器中,而不是仅靠权限检查。 ## 快速开始 ```bash -git clone https://github.com/qwibitai/nanoclaw.git -cd nanoclaw -claude +git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2 +cd nanoclaw-v2 +bash nanoclaw.sh ``` -然后运行 `/setup`。Claude Code 会处理一切:依赖安装、身份验证、容器设置、服务配置。 - -> **注意:** 以 `/` 开头的命令(如 `/setup`、`/add-whatsapp`)是 [Claude Code 技能](https://code.claude.com/docs/en/skills)。请在 `claude` CLI 提示符中输入,而非在普通终端中。 +`nanoclaw.sh` 会把您从一台全新机器一直带到一个可以直接发消息的命名智能体。它会在缺失时安装 Node、pnpm 和 Docker,向 OneCLI 注册您的 Anthropic 凭据,构建智能体容器,并配对您的第一个渠道(Telegram、Discord、WhatsApp 或本地 CLI)。如果某一步失败,会自动调用 Claude Code 进行诊断并从中断处继续。 ## 设计哲学 -**小巧易懂:** 单一进程,少量源文件。无微服务、无消息队列、无复杂抽象层。让 Claude Code 引导您轻松上手。 +**小到可以理解。** 单一进程,少量源文件,无微服务。如果您想了解完整的 NanoClaw 代码库,直接让 Claude Code 给您讲一遍就行。 -**通过隔离保障安全:** 智能体运行在 Linux 容器(在 macOS 上是 Apple Container,或 Docker)中。它们只能看到被明确挂载的内容。即便通过 Bash 访问也十分安全,因为所有命令都在容器内执行,不会直接操作您的宿主机。 +**通过隔离实现安全。** 智能体运行在 Linux 容器中,只能看到明确挂载的内容。Bash 访问是安全的,因为命令在容器内执行,而不是在您的宿主机上。 -**为单一用户打造:** 这不是一个框架,是一个完全符合您个人需求的、可工作的软件。您可以 Fork 本项目,然后让 Claude Code 根据您的精确需求进行修改和适配。 +**为个人用户打造。** NanoClaw 不是一个单体框架,而是能精确匹配每个用户需求的软件。它被设计成量身定制的,而不是臃肿膨胀。您创建自己的 fork,让 Claude Code 按您的需求修改它。 -**定制即代码修改:** 没有繁杂的配置文件。想要不同的行为?直接修改代码。代码库足够小,这样做是安全的。 +**定制 = 修改代码。** 没有配置膨胀。想要不同的行为?改代码。代码库小到改动是安全的。 -**AI 原生:** 无安装向导(由 Claude Code 指导安装)。无需监控仪表盘,直接询问 Claude 即可了解系统状况。无调试工具(描述问题,Claude 会修复它)。 +**AI 原生,混合式设计。** 安装与上手流程走的是经过优化的脚本路径,快速且确定。当某一步需要判断(安装失败、引导决策、定制化)时,控制权会无缝地交给 Claude Code。安装之后也不提供监控仪表盘或调试 UI:您在聊天中描述问题,Claude Code 来处理。 -**技能(Skills)优于功能(Features):** 贡献者不应该向代码库添加新功能(例如支持 Telegram)。相反,他们应该贡献像 `/add-telegram` 这样的 [Claude Code 技能](https://code.claude.com/docs/en/skills),这些技能可以改造您的 fork。最终,您得到的是只做您需要事情的整洁代码。 +**技能优于功能。** 主干只发布注册表和基础设施,不包含具体的渠道适配器或替代智能体提供者。各个渠道(Discord、Slack、Telegram、WhatsApp……)放在长期存在的 `channels` 分支上;替代提供者(OpenCode、Ollama)放在 `providers` 分支上。您运行 `/add-telegram`、`/add-opencode` 等,技能会把您所需要的模块精确地复制到您的 fork 里。不会出现您没要求的功能。 -**最好的工具套件,最好的模型:** 本项目运行在 Claude Agent SDK 之上,这意味着您直接运行的就是 Claude Code。Claude Code 高度强大,其编码和问题解决能力使其能够修改和扩展 NanoClaw,为每个用户量身定制。 +**最强的 harness,最强的模型。** NanoClaw 通过 Anthropic 官方的 Claude Agent SDK 原生使用 Claude Code,所以您能用上最新的 Claude 模型以及 Claude Code 的完整工具集——包括修改和扩展自己的 NanoClaw fork 的能力。其他提供者是可插拔选项:`/add-codex` 对应 OpenAI 的 Codex(ChatGPT 订阅或 API key),`/add-opencode` 通过 OpenCode 接入 OpenRouter、Google、DeepSeek 等,`/add-ollama-provider` 用于本地开源权重模型。提供者可按智能体组单独配置。 ## 功能支持 -- **多渠道消息** - 通过 WhatsApp、Telegram、Discord、Slack 或 Gmail 与您的助手对话。使用 `/add-whatsapp` 或 `/add-telegram` 等技能添加渠道,可同时运行一个或多个。 -- **隔离的群组上下文** - 每个群组都拥有独立的 `CLAUDE.md` 记忆和隔离的文件系统。它们在各自的容器沙箱中运行,且仅挂载所需的文件系统。 -- **主频道** - 您的私有频道(self-chat),用于管理控制;其他所有群组都完全隔离 -- **计划任务** - 运行 Claude 的周期性作业,并可以给您回发消息 -- **网络访问** - 搜索和抓取网页内容 -- **容器隔离** - 智能体在 Apple Container (macOS) 或 Docker (macOS/Linux) 的沙箱中运行 -- **智能体集群(Agent Swarms)** - 启动多个专业智能体团队,协作完成复杂任务(首个支持此功能的个人 AI 助手) -- **可选集成** - 通过技能添加 Gmail (`/add-gmail`) 等更多功能 +- **多渠道消息** — WhatsApp、Telegram、Discord、Slack、Microsoft Teams、iMessage、Matrix、Google Chat、Webex、Linear、GitHub、WeChat,以及通过 Resend 的邮件。按需通过 `/add-` 技能安装。可同时运行一个或多个。 +- **灵活的隔离模式** — 可为每个渠道配一个独立智能体以获得完全隐私,也可让一个智能体在多个渠道上共享、统一记忆但会话独立,或者把多个渠道合并到一个共享会话里,让一场对话横跨多个入口。通过 `/manage-channels` 按渠道选择。详见 [docs/isolation-model.md](docs/isolation-model.md)。 +- **每个智能体的独立工作区** — 每个智能体组都有自己的 `CLAUDE.md`、自己的记忆、自己的容器,以及您允许的挂载点。除非您明确接线,否则不会有东西越过边界。 +- **计划任务** — 运行 Claude 的周期性作业,可以给您回发消息。 +- **网络访问** — 搜索和抓取网页内容。 +- **容器隔离** — 智能体在 Docker(macOS/Linux/WSL2)中沙箱化运行,可选 [Docker Sandboxes](docs/docker-sandboxes.md) 的微虚拟机隔离,或在 macOS 上选用 Apple Container 作为原生运行时。 +- **凭据安全** — 智能体不持有原始 API key。出站请求经由 [OneCLI 的 Agent Vault](https://github.com/onecli/onecli),在请求时注入凭据,并按每个智能体执行策略和速率限制。 ## 使用方法 -使用触发词(默认为 `@Andy`)与您的助手对话: +用触发词(默认为 `@Andy`)与您的助手对话: ``` -@Andy 每周一到周五早上9点,给我发一份销售渠道的概览(需要访问我的 Obsidian vault 文件夹) -@Andy 每周五回顾过去一周的 git 历史,如果与 README 有出入,就更新它 -@Andy 每周一早上8点,从 Hacker News 和 TechCrunch 收集关于 AI 发展的资讯,然后发给我一份简报 +@Andy 每个工作日早上 9 点给我发一份销售渠道概览(可以访问我的 Obsidian vault 文件夹) +@Andy 每周五回顾过去一周的 git 历史,如果与 README 有出入就更新它 +@Andy 每周一早上 8 点,从 Hacker News 和 TechCrunch 收集 AI 相关资讯,给我发一份简报 ``` -在主频道(您的self-chat)中,可以管理群组和任务: +在您拥有或管理的渠道里,还可以管理群组和任务: ``` -@Andy 列出所有群组的计划任务 +@Andy 列出所有群组里的计划任务 @Andy 暂停周一简报任务 @Andy 加入"家庭聊天"群组 ``` ## 定制 -没有需要学习的配置文件。直接告诉 Claude Code 您想要什么: +NanoClaw 不用配置文件。想改就直接告诉 Claude Code: - "把触发词改成 @Bob" -- "记住以后回答要更简短直接" -- "当我说早上好的时候,加一个自定义的问候" -- "每周存储一次对话摘要" +- "以后回答请更简短、更直接" +- "我说早上好的时候加一个自定义问候" +- "每周保存一次会话摘要" 或者运行 `/customize` 进行引导式修改。 @@ -97,107 +91,103 @@ claude ## 贡献 -**不要添加功能,而是添加技能。** +**不要加功能,要加技能。** -如果您想添加 Telegram 支持,不要创建一个 PR 同时添加 Telegram 和 WhatsApp。而是贡献一个技能文件 (`.claude/skills/add-telegram/SKILL.md`),教 Claude Code 如何改造一个 NanoClaw 安装以使用 Telegram。 +如果您想添加新的渠道或智能体提供者,不要把它加到主干上。新的渠道适配器进入 `channels` 分支;新的智能体提供者进入 `providers` 分支。用户在自己的 fork 上运行 `/add-` 技能,由技能把相关模块复制到标准路径、接好注册、固定依赖版本。 -然后用户在自己的 fork 上运行 `/add-telegram`,就能得到只做他们需要事情的整洁代码,而不是一个试图支持所有用例的臃肿系统。 +这样主干始终保持为纯粹的注册表和基础设施,每个 fork 也都保持精简——用户只获得他们要求的渠道和提供者,其它什么也不会混进来。 -### RFS (技能征集) +### RFS(技能征集) 我们希望看到的技能: **通信渠道** -- `/add-signal` - 添加 Signal 作为渠道 - -**会话管理** -- `/clear` - 添加一个 `/clear` 命令,用于压缩会话(在同一会话中总结上下文,同时保留关键信息)。这需要研究如何通过 Claude Agent SDK 以编程方式触发压缩。 +- `/add-signal` — 添加 Signal 作为渠道 ## 系统要求 -- macOS 或 Linux -- Node.js 20+ -- [Claude Code](https://claude.ai/download) -- [Apple Container](https://github.com/apple/container) (macOS) 或 [Docker](https://docker.com/products/docker-desktop) (macOS/Linux) +- macOS 或 Linux(Windows 通过 WSL2) +- Node.js 20+ 和 pnpm 10+(安装脚本会在缺失时自动安装) +- [Docker Desktop](https://docker.com/products/docker-desktop)(macOS/Windows)或 Docker Engine(Linux) +- [Claude Code](https://claude.ai/download),用于 `/customize`、`/debug`、安装过程中的错误恢复以及所有 `/add-` 技能 ## 架构 ``` -渠道 --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) --> 响应 +消息应用 → 主机进程(路由器) → inbound.db → 容器(Bun、Claude Agent SDK) → outbound.db → 主机进程(投递) → 消息应用 ``` -单一 Node.js 进程。渠道通过技能添加,启动时自注册 — 编排器连接具有凭据的渠道。智能体在具有文件系统隔离的 Linux 容器中执行。每个群组的消息队列带有并发控制。通过文件系统进行 IPC。 +单一 Node 主机编排每个会话的智能体容器。当一条消息到来时,主机按实体模型(用户 → 消息组 → 智能体组 → 会话)进行路由,写入该会话的 `inbound.db`,并唤醒容器。容器内部的 agent-runner 轮询 `inbound.db`,调用 Claude,并把响应写入 `outbound.db`。主机轮询 `outbound.db`,通过渠道适配器投递回去。 -完整架构详情请见 [docs/SPEC.md](docs/SPEC.md)。 +每个会话两个 SQLite 文件,每个文件只有一个写入者——没有跨挂载的锁争用,没有 IPC,没有 stdin 管道。渠道和替代提供者在启动时自注册;主干提供注册表和 Chat SDK 桥接,而适配器本身在每个 fork 里通过技能安装。 + +完整架构说明见 [docs/architecture.md](docs/architecture.md);三级隔离模型见 [docs/isolation-model.md](docs/isolation-model.md)。 关键文件: -- `src/index.ts` - 编排器:状态管理、消息循环、智能体调用 -- `src/channels/registry.ts` - 渠道注册表(启动时自注册) -- `src/ipc.ts` - IPC 监听与任务处理 -- `src/router.ts` - 消息格式化与出站路由 -- `src/group-queue.ts` - 带全局并发限制的群组队列 -- `src/container-runner.ts` - 生成流式智能体容器 -- `src/task-scheduler.ts` - 运行计划任务 -- `src/db.ts` - SQLite 操作(消息、群组、会话、状态) -- `groups/*/CLAUDE.md` - 各群组的记忆 +- `src/index.ts` — 入口:数据库初始化、渠道适配器、投递轮询、sweep +- `src/router.ts` — 入站路由:消息组 → 智能体组 → 会话 → `inbound.db` +- `src/delivery.ts` — 轮询 `outbound.db`,通过适配器投递,处理系统动作 +- `src/host-sweep.ts` — 60 秒 sweep:失效检测、到期消息唤醒、循环任务 +- `src/session-manager.ts` — 解析会话,打开 `inbound.db` / `outbound.db` +- `src/container-runner.ts` — 为每个智能体组启动容器,OneCLI 凭据注入 +- `src/db/` — 中心数据库(用户、角色、智能体组、消息组、接线、迁移) +- `src/channels/` — 渠道适配器基础设施(适配器通过 `/add-` 技能安装) +- `src/providers/` — 主机侧提供者配置(`claude` 内置,其他通过技能安装) +- `container/agent-runner/` — Bun 版 agent-runner:轮询循环、MCP 工具、提供者抽象 +- `groups//` — 每个智能体组的文件系统(`CLAUDE.md`、技能、容器配置) ## FAQ -**为什么是 Docker?** +**为什么用 Docker?** -Docker 提供跨平台支持(macOS 和 Linux)和成熟的生态系统。在 macOS 上,您可以选择通过运行 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量级的原生运行时体验。 +Docker 提供跨平台支持(macOS、Linux、Windows via WSL2)和成熟的生态。在 macOS 上,您可以选择通过 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量的原生运行时。如需更强隔离,[Docker Sandboxes](docs/docker-sandboxes.md) 会把每个容器放到一台微虚拟机里运行。 -**我可以在 Linux 上运行吗?** +**我可以在 Linux 或 Windows 上运行吗?** -可以。Docker 是默认的容器运行时,在 macOS 和 Linux 上都可以使用。只需运行 `/setup`。 +可以。Docker 是默认运行时,可在 macOS、Linux 以及 Windows(通过 WSL2)上工作。运行 `bash nanoclaw.sh` 就行。 **这个项目安全吗?** -智能体在容器中运行,而不是在应用级别的权限检查之后。它们只能访问被明确挂载的目录。您仍然应该审查您运行的代码,但这个代码库小到您真的可以做到。完整的安全模型请见 [docs/SECURITY.md](docs/SECURITY.md)。 +智能体运行在容器里,而不是躲在应用级权限检查之后。它们只能访问明确挂载的目录。凭据不会进入容器——出站 API 请求通过 [OneCLI 的 Agent Vault](https://github.com/onecli/onecli) 在代理层注入认证,并支持速率限制和访问策略。您仍然应该审查自己要运行的代码,但代码库小到您真的能做到。完整的安全模型见 [安全文档](https://docs.nanoclaw.dev/concepts/security)。 **为什么没有配置文件?** -我们不希望配置泛滥。每个用户都应该定制它,让代码完全符合他们的需求,而不是去配置一个通用的系统。如果您喜欢用配置文件,告诉 Claude 让它加上。 +我们不想让配置泛滥。每位用户都应该定制 NanoClaw,让代码精确地做他们想要的事,而不是去配置一个通用系统。如果您更喜欢有配置文件,可以让 Claude 给您加。 **我可以使用第三方或开源模型吗?** -可以。NanoClaw 支持任何 API 兼容的模型端点。在 `.env` 文件中设置以下环境变量: +可以。推荐做法是 `/add-opencode`(通过 OpenCode 配置接入 OpenRouter、OpenAI、Google、DeepSeek 等)或 `/add-ollama-provider`(通过 Ollama 使用本地开源权重模型)。两者都可以按智能体组单独配置,所以同一套安装里不同的智能体可以运行在不同的后端上。 + +对于一次性实验,任何 Claude API 兼容的端点也可以通过 `.env` 使用: ```bash ANTHROPIC_BASE_URL=https://your-api-endpoint.com ANTHROPIC_AUTH_TOKEN=your-token-here ``` -这使您能够使用: -- 通过 [Ollama](https://ollama.ai) 配合 API 代理运行的本地模型 -- 托管在 [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai) 等平台上的开源模型 -- 兼容 Anthropic API 格式的自定义模型部署 - -注意:为获得最佳兼容性,模型需支持 Anthropic API 格式。 - **我该如何调试问题?** -问 Claude Code。"为什么计划任务没有运行?" "最近的日志里有什么?" "为什么这条消息没有得到回应?" 这就是 AI 原生的方法。 +问 Claude Code。"为什么计划任务没运行?""最近的日志里有什么?""为什么这条消息没有得到回复?"这就是 NanoClaw 底层的 AI 原生方式。 -**为什么我的安装不成功?** +**为什么安装对我不成功?** -如果遇到问题,安装过程中 Claude 会尝试动态修复。如果问题仍然存在,运行 `claude`,然后运行 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请开一个 PR 来修改 setup SKILL.md。 +如果某一步失败,`nanoclaw.sh` 会把控制权交给 Claude Code 进行诊断并从中断处继续。如果还是没解决,运行 `claude`,然后 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请对相关的安装步骤或技能提 PR。 -**什么样的代码更改会被接受?** +**什么样的更改会被接受进代码库?** -安全修复、bug 修复,以及对基础配置的明确改进。仅此而已。 +进入基础配置的只会是:安全修复、bug 修复、明显的改进。仅此而已。 -其他一切(新功能、操作系统兼容性、硬件支持、增强功能)都应该作为技能来贡献。 +其他一切(新能力、操作系统兼容、硬件支持、增强)都应作为技能贡献到 `channels` 或 `providers` 分支。 -这使得基础系统保持最小化,并让每个用户可以定制他们的安装,而无需继承他们不想要的功能。 +这样基础系统保持最小化,每位用户都可以定制自己的安装,而不必继承他们不想要的功能。 ## 社区 -有任何疑问或建议?欢迎[加入 Discord 社区](https://discord.gg/VDdww8qS42)与我们交流。 +有问题或想法?欢迎[加入 Discord](https://discord.gg/VDdww8qS42)。 ## 更新日志 -破坏性变更和迁移说明请见 [CHANGELOG.md](CHANGELOG.md)。 +破坏性变更见 [CHANGELOG.md](CHANGELOG.md),完整发布历史见文档站的 [full release history](https://docs.nanoclaw.dev/changelog)。 ## 许可证 From 7a9401ddf2d18a938524026e03ff5a1b5fbde037 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 10:10:09 +0300 Subject: [PATCH 126/185] feat(setup): per-checkout service name and docker image tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two NanoClaw installs on the same host used to fight over the shared `com.nanoclaw` launchd label / `nanoclaw.service` systemd unit and the `nanoclaw-agent:latest` docker tag — the second install silently rewrote the service pointer and rebuilt the image out from under the first. Introduces a deterministic per-checkout slug (sha1(projectRoot)[:8]) and namespaces everything off it: - Service: `com.nanoclaw-v2-` / `nanoclaw-v2-.service` - Image: `nanoclaw-agent-v2-:latest` (base), `nanoclaw-agent-v2-:` (per-group) New shared helpers: src/install-slug.ts (host) + setup/lib/install-slug.sh (bash). Both compute the same slug so verify/probe/add-*.sh/build.sh/container-runner all agree. Any v1 `com.nanoclaw` service left on the host stays untouched and can coexist. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/build.sh | 8 +++++++- setup/add-discord.sh | 8 +++++--- setup/add-teams.sh | 8 +++++--- setup/add-telegram.sh | 8 +++++--- setup/auto.ts | 10 ++++++---- setup/channels/whatsapp.ts | 8 +++++--- setup/container.ts | 3 ++- setup/lib/install-slug.sh | 37 +++++++++++++++++++++++++++++++++++++ setup/probe.sh | 12 +++++++++--- setup/service.test.ts | 17 +++++++++-------- setup/service.ts | 24 ++++++++++++++++-------- setup/verify.ts | 12 ++++++++---- src/config.ts | 8 +++++++- src/container-runner.ts | 4 ++-- src/install-slug.ts | 33 +++++++++++++++++++++++++++++++++ 15 files changed, 156 insertions(+), 44 deletions(-) create mode 100644 setup/lib/install-slug.sh create mode 100644 src/install-slug.ts diff --git a/container/build.sh b/container/build.sh index fd5210d..ae0c3d9 100755 --- a/container/build.sh +++ b/container/build.sh @@ -9,9 +9,15 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$SCRIPT_DIR" -IMAGE_NAME="nanoclaw-agent" +# Derive the image name from the project root so two NanoClaw installs on the +# same host don't overwrite each other's `nanoclaw-agent:latest` tag. Matches +# setup/lib/install-slug.sh + src/install-slug.ts. +# shellcheck source=../setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" +IMAGE_NAME="$(container_image_base)" TAG="${1:-latest}" CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" diff --git a/setup/add-discord.sh b/setup/add-discord.sh index 74ce9a7..fa614fd 100755 --- a/setup/add-discord.sh +++ b/setup/add-discord.sh @@ -110,13 +110,15 @@ mkdir -p data/env cp .env data/env/env log "Restarting service so the new adapter picks up the credentials…" +# shellcheck source=setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" case "$(uname -s)" in Darwin) - launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true + launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true ;; Linux) - systemctl --user restart nanoclaw >&2 2>/dev/null \ - || sudo systemctl restart nanoclaw >&2 2>/dev/null \ + systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \ + || sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \ || true ;; esac diff --git a/setup/add-teams.sh b/setup/add-teams.sh index 99ceb4a..273cad6 100755 --- a/setup/add-teams.sh +++ b/setup/add-teams.sh @@ -119,13 +119,15 @@ mkdir -p data/env cp .env data/env/env log "Restarting service so the new adapter picks up the credentials…" +# shellcheck source=setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" case "$(uname -s)" in Darwin) - launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true + launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true ;; Linux) - systemctl --user restart nanoclaw >&2 2>/dev/null \ - || sudo systemctl restart nanoclaw >&2 2>/dev/null \ + systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \ + || sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \ || true ;; esac diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 0d7fd5c..c81fc6d 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -144,13 +144,15 @@ cp .env data/env/env # non-interactive install. log "Restarting service so the new adapter picks up the token…" +# shellcheck source=setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" case "$(uname -s)" in Darwin) - launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true + launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true ;; Linux) - systemctl --user restart nanoclaw >&2 2>/dev/null \ - || sudo systemctl restart nanoclaw >&2 2>/dev/null \ + systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \ + || sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \ || true ;; esac diff --git a/setup/auto.ts b/setup/auto.ts index ea5dec3..6ac726c 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -32,6 +32,7 @@ import { runTelegramChannel } from './channels/telegram.js'; import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { offerClaudeAssist } from './lib/claude-assist.js'; +import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { claudeCliAvailable, resolveTimezoneViaClaude, @@ -298,13 +299,14 @@ async function main(): Promise { } const service = res.terminal?.fields.SERVICE; if (service === 'running_other_checkout') { + const label = getLaunchdLabel(); notes.push( wrapForGutter( [ '• Your NanoClaw service is running from a different folder on this machine.', ' Point it at this checkout with:', - ' launchctl bootout gui/$(id -u)/com.nanoclaw', - ' launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.plist', + ` launchctl bootout gui/$(id -u)/${label}`, + ` launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/${label}.plist`, ].join('\n'), 6, ), @@ -408,8 +410,8 @@ function renderPingFailureNote(result: PingResult): void { 6, ), '', - k.dim(' macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw'), - k.dim(' Linux: systemctl --user restart nanoclaw'), + k.dim(` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`), + k.dim(` Linux: systemctl --user restart ${getSystemdUnit()}`), ].join('\n') : wrapForGutter( 'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 29c70e3..933a82a 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -33,6 +33,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; import { type Block, type StepResult, @@ -358,17 +359,18 @@ async function restartService(): Promise { if (platform === 'darwin') { spawnSync( 'launchctl', - ['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/com.nanoclaw`], + ['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`], { stdio: 'ignore' }, ); } else if (platform === 'linux') { + const unit = getSystemdUnit(); const user = spawnSync( 'systemctl', - ['--user', 'restart', 'nanoclaw'], + ['--user', 'restart', unit], { stdio: 'ignore' }, ); if (user.status !== 0) { - spawnSync('sudo', ['systemctl', 'restart', 'nanoclaw'], { + spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore', }); } diff --git a/setup/container.ts b/setup/container.ts index a2e6433..978ae1a 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -7,6 +7,7 @@ import path from 'path'; import { setTimeout as sleep } from 'timers/promises'; import { log } from '../src/log.js'; +import { getDefaultContainerImage } from '../src/install-slug.js'; import { commandExists, getPlatform } from './platform.js'; import { emitStatus } from './status.js'; @@ -81,7 +82,7 @@ function parseArgs(args: string[]): { runtime: string } { export async function run(args: string[]): Promise { const projectRoot = process.cwd(); const { runtime } = parseArgs(args); - const image = 'nanoclaw-agent:latest'; + const image = getDefaultContainerImage(projectRoot); const logFile = path.join(projectRoot, 'logs', 'setup.log'); if (runtime !== 'docker') { diff --git a/setup/lib/install-slug.sh b/setup/lib/install-slug.sh new file mode 100644 index 0000000..736d339 --- /dev/null +++ b/setup/lib/install-slug.sh @@ -0,0 +1,37 @@ +# install-slug.sh — shell mirror of setup/lib/install-slug.ts. +# +# Source this file after $PROJECT_ROOT is set: +# +# source "$PROJECT_ROOT/setup/lib/install-slug.sh" +# label=$(launchd_label) # com.nanoclaw-v2- +# unit=$(systemd_unit) # nanoclaw-v2- +# image=$(container_image_base) # nanoclaw-agent-v2- +# +# Slug is sha1(PROJECT_ROOT)[:8] — must match the TS helper exactly so both +# halves of setup name things consistently. + +_nanoclaw_install_slug() { + local root="${NANOCLAW_PROJECT_ROOT:-${PROJECT_ROOT:-$PWD}}" + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$root" | shasum | cut -c 1-8 + elif command -v sha1sum >/dev/null 2>&1; then + printf '%s' "$root" | sha1sum | cut -c 1-8 + else + # Fallback: hash the path with something deterministic-ish. Not ideal — + # but shasum is present on every modern macOS/Linux, so this is just + # belt-and-braces against a truly minimal system. + printf '%s' "$root" | od -An -tx1 | tr -d ' \n' | cut -c 1-8 + fi +} + +launchd_label() { + printf 'com.nanoclaw-v2-%s' "$(_nanoclaw_install_slug)" +} + +systemd_unit() { + printf 'nanoclaw-v2-%s' "$(_nanoclaw_install_slug)" +} + +container_image_base() { + printf 'nanoclaw-agent-v2-%s' "$(_nanoclaw_install_slug)" +} diff --git a/setup/probe.sh b/setup/probe.sh index 6f40fff..f4cbf3f 100755 --- a/setup/probe.sh +++ b/setup/probe.sh @@ -19,7 +19,13 @@ START_S=$(date +%s) PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" LOCAL_BIN="$HOME/.local/bin" -AGENT_IMAGE="nanoclaw-agent:latest" + +# Per-checkout install names (match setup/lib/install-slug.ts). +# shellcheck source=setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" +LAUNCHD_LABEL=$(launchd_label) +SYSTEMD_UNIT=$(systemd_unit) +AGENT_IMAGE="$(container_image_base):latest" export PATH="$LOCAL_BIN:$PATH" @@ -144,7 +150,7 @@ probe_service_status() { macos) command_exists launchctl || { echo "not_configured"; return; } local line - line=$(with_timeout launchctl list 2>/dev/null | grep "com.nanoclaw") || { + line=$(with_timeout launchctl list 2>/dev/null | grep "$LAUNCHD_LABEL") || { echo "not_configured"; return; } local pid pid=$(echo "$line" | awk '{print $1}') @@ -156,7 +162,7 @@ probe_service_status() { ;; linux|wsl) command_exists systemctl || { echo "not_configured"; return; } - if with_timeout systemctl --user is-active nanoclaw >/dev/null 2>&1; then + if with_timeout systemctl --user is-active "$SYSTEMD_UNIT" >/dev/null 2>&1; then echo "running" elif with_timeout systemctl --user cat nanoclaw >/dev/null 2>&1; then echo "stopped" diff --git a/setup/service.test.ts b/setup/service.test.ts index 9168fe1..9bc899e 100644 --- a/setup/service.test.ts +++ b/setup/service.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest'; import path from 'path'; +import { getLaunchdLabel } from '../src/install-slug.js'; + /** * Tests for service configuration generation. * @@ -14,12 +16,13 @@ function generatePlist( projectRoot: string, homeDir: string, ): string { + const label = getLaunchdLabel(projectRoot); return ` Label - com.nanoclaw + ${label} ProgramArguments ${nodePath} @@ -73,13 +76,11 @@ WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`; } describe('plist generation', () => { - it('contains the correct label', () => { - const plist = generatePlist( - '/usr/local/bin/node', - '/home/user/nanoclaw', - '/home/user', - ); - expect(plist).toContain('com.nanoclaw'); + it('contains the slug-scoped label', () => { + const projectRoot = '/home/user/nanoclaw'; + const plist = generatePlist('/usr/local/bin/node', projectRoot, '/home/user'); + expect(plist).toContain(`${getLaunchdLabel(projectRoot)}`); + expect(plist).toMatch(/com\.nanoclaw-v2-[0-9a-f]{8}<\/string>/); }); it('uses the correct node path', () => { diff --git a/setup/service.ts b/setup/service.ts index f5ad855..7930461 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -10,6 +10,7 @@ import os from 'os'; import path from 'path'; import { log } from '../src/log.js'; +import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { commandExists, getPlatform, @@ -74,11 +75,14 @@ function setupLaunchd( nodePath: string, homeDir: string, ): void { + // Per-checkout service label so multiple NanoClaw installs can coexist + // without clobbering each other's plist. + const label = getLaunchdLabel(projectRoot); const plistPath = path.join( homeDir, 'Library', 'LaunchAgents', - 'com.nanoclaw.plist', + `${label}.plist`, ); fs.mkdirSync(path.dirname(plistPath), { recursive: true }); @@ -87,7 +91,7 @@ function setupLaunchd( Label - com.nanoclaw + ${label} ProgramArguments ${nodePath} @@ -146,13 +150,14 @@ function setupLaunchd( let serviceLoaded = false; try { const output = execSync('launchctl list', { encoding: 'utf-8' }); - serviceLoaded = output.includes('com.nanoclaw'); + serviceLoaded = output.includes(label); } catch { // launchctl list failed } emitStatus('SETUP_SERVICE', { SERVICE_TYPE: 'launchd', + SERVICE_LABEL: label, NODE_PATH: nodePath, PROJECT_PATH: projectRoot, PLIST_PATH: plistPath, @@ -225,13 +230,15 @@ function setupSystemd( homeDir: string, ): void { const runningAsRoot = isRoot(); + const unitName = getSystemdUnit(projectRoot); + const unitFileName = `${unitName}.service`; // Root uses system-level service, non-root uses user-level let unitPath: string; let systemctlPrefix: string; if (runningAsRoot) { - unitPath = '/etc/systemd/system/nanoclaw.service'; + unitPath = `/etc/systemd/system/${unitFileName}`; systemctlPrefix = 'systemctl'; log.info('Running as root — installing system-level systemd unit'); } else { @@ -247,7 +254,7 @@ function setupSystemd( } const unitDir = path.join(homeDir, '.config', 'systemd', 'user'); fs.mkdirSync(unitDir, { recursive: true }); - unitPath = path.join(unitDir, 'nanoclaw.service'); + unitPath = path.join(unitDir, unitFileName); systemctlPrefix = 'systemctl --user'; } @@ -328,7 +335,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; } try { - execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' }); + execSync(`${systemctlPrefix} enable ${unitName}`, { stdio: 'ignore' }); } catch (err) { log.error('systemctl enable failed', { err }); } @@ -339,7 +346,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; // `restart` on a stopped unit is equivalent to `start`, so this is safe // as a first-install path too. try { - execSync(`${systemctlPrefix} restart nanoclaw`, { stdio: 'ignore' }); + execSync(`${systemctlPrefix} restart ${unitName}`, { stdio: 'ignore' }); } catch (err) { log.error('systemctl restart failed', { err }); } @@ -347,7 +354,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; // Verify let serviceLoaded = false; try { - execSync(`${systemctlPrefix} is-active nanoclaw`, { stdio: 'ignore' }); + execSync(`${systemctlPrefix} is-active ${unitName}`, { stdio: 'ignore' }); serviceLoaded = true; } catch { // Not active @@ -355,6 +362,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; emitStatus('SETUP_SERVICE', { SERVICE_TYPE: runningAsRoot ? 'systemd-system' : 'systemd-user', + SERVICE_UNIT: unitName, NODE_PATH: nodePath, PROJECT_PATH: projectRoot, UNIT_PATH: unitPath, diff --git a/setup/verify.ts b/setup/verify.ts index ab0b80e..281b243 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -15,6 +15,7 @@ import { DATA_DIR } from '../src/config.js'; import { readEnvFile } from '../src/env.js'; import { log } from '../src/log.js'; import { pingCliAgent } from './lib/agent-ping.js'; +import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { getPlatform, getServiceManager, @@ -45,10 +46,13 @@ export async function run(_args: string[]): Promise { let runningFromPath: string | null = null; const mgr = getServiceManager(); + const launchdLabel = getLaunchdLabel(projectRoot); + const systemdUnit = getSystemdUnit(projectRoot); + if (mgr === 'launchd') { try { const output = execSync('launchctl list', { encoding: 'utf-8' }); - const line = output.split('\n').find((l) => l.includes('com.nanoclaw')); + const line = output.split('\n').find((l) => l.includes(launchdLabel)); if (line) { const pidField = line.trim().split(/\s+/)[0]; if (pidField !== '-' && pidField) { @@ -67,11 +71,11 @@ export async function run(_args: string[]): Promise { } else if (mgr === 'systemd') { const prefix = isRoot() ? 'systemctl' : 'systemctl --user'; try { - execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' }); + execSync(`${prefix} is-active ${systemdUnit}`, { stdio: 'ignore' }); service = 'running'; try { const pidStr = execSync( - `${prefix} show nanoclaw -p MainPID --value`, + `${prefix} show ${systemdUnit} -p MainPID --value`, { encoding: 'utf-8' }, ).trim(); const pid = Number(pidStr); @@ -86,7 +90,7 @@ export async function run(_args: string[]): Promise { const output = execSync(`${prefix} list-unit-files`, { encoding: 'utf-8', }); - if (output.includes('nanoclaw')) { + if (output.includes(systemdUnit)) { service = 'stopped'; } } catch { diff --git a/src/config.ts b/src/config.ts index 96b782a..1decd94 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import os from 'os'; import path from 'path'; import { readEnvFile } from './env.js'; +import { getContainerImageBase, getDefaultContainerImage } from './install-slug.js'; import { isValidTimezone } from './timezone.js'; // Read config values from .env (falls back to process.env). @@ -22,7 +23,12 @@ export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); -export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; +// Per-checkout image tag so two installs on the same host don't share +// `nanoclaw-agent:latest` and clobber each other on rebuild. +export const CONTAINER_IMAGE_BASE = + process.env.CONTAINER_IMAGE_BASE || getContainerImageBase(PROJECT_ROOT); +export const CONTAINER_IMAGE = + process.env.CONTAINER_IMAGE || getDefaultContainerImage(PROJECT_ROOT); export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL; diff --git a/src/container-runner.ts b/src/container-runner.ts index 8291d42..177a818 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -9,7 +9,7 @@ import path from 'path'; import { OneCLI } from '@onecli-sh/sdk'; -import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, ONECLI_API_KEY, ONECLI_URL, TIMEZONE } from './config.js'; +import { CONTAINER_IMAGE, CONTAINER_IMAGE_BASE, DATA_DIR, GROUPS_DIR, ONECLI_API_KEY, ONECLI_URL, TIMEZONE } from './config.js'; import { readContainerConfig, writeContainerConfig } from './container-config.js'; import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { composeGroupClaudeMd } from './claude-md-compose.js'; @@ -469,7 +469,7 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise } dockerfile += 'USER node\n'; - const imageTag = `nanoclaw-agent:${agentGroupId}`; + const imageTag = `${CONTAINER_IMAGE_BASE}:${agentGroupId}`; log.info('Building per-agent-group image', { agentGroupId, imageTag, apt: aptPackages, npm: npmPackages }); diff --git a/src/install-slug.ts b/src/install-slug.ts new file mode 100644 index 0000000..8d6443a --- /dev/null +++ b/src/install-slug.ts @@ -0,0 +1,33 @@ +/** + * Per-checkout install identifiers. Lets two NanoClaw installs coexist on + * one host without clobbering each other's service registration or the + * shared `nanoclaw-agent:latest` docker image tag. + * + * Slug is sha1(projectRoot)[:8] — deterministic per checkout path, stable + * across re-runs, unique enough across installs. + */ +import { createHash } from 'crypto'; + +export function getInstallSlug(projectRoot: string = process.cwd()): string { + return createHash('sha1').update(projectRoot).digest('hex').slice(0, 8); +} + +/** launchd Label + plist basename. e.g. `com.nanoclaw-v2-ab12cd34`. */ +export function getLaunchdLabel(projectRoot?: string): string { + return `com.nanoclaw-v2-${getInstallSlug(projectRoot)}`; +} + +/** systemd unit name (no .service suffix). e.g. `nanoclaw-v2-ab12cd34`. */ +export function getSystemdUnit(projectRoot?: string): string { + return `nanoclaw-v2-${getInstallSlug(projectRoot)}`; +} + +/** Docker image base (no tag). e.g. `nanoclaw-agent-v2-ab12cd34`. */ +export function getContainerImageBase(projectRoot?: string): string { + return `nanoclaw-agent-v2-${getInstallSlug(projectRoot)}`; +} + +/** Default full container image reference with `:latest` tag. */ +export function getDefaultContainerImage(projectRoot?: string): string { + return `${getContainerImageBase(projectRoot)}:latest`; +} From 3d44001633b6ef7ab3608c1af2a2e52796b15d15 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 07:10:26 +0000 Subject: [PATCH 127/185] chore: bump version to 2.0.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 31802c7..1fe44f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.2", + "version": "2.0.3", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 72aba8c7babeb35777b50e69202f5679e33afe6e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 07:10:29 +0000 Subject: [PATCH 128/185] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?28k=20tokens=20=C2=B7=2064%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 20db68f..3fc904e 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 127k tokens, 64% of context window + + 128k tokens, 64% of context window @@ -15,8 +15,8 @@ tokens - - 127k + + 128k From 5f1b3e5cade2f09933d6ffbe8ffa660a736b9ddf Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 10:10:40 +0300 Subject: [PATCH 129/185] style: apply prettier formatting to install-slug additions --- src/config.ts | 6 ++---- src/container-runner.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/config.ts b/src/config.ts index 1decd94..79a1ce9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,10 +25,8 @@ export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); // Per-checkout image tag so two installs on the same host don't share // `nanoclaw-agent:latest` and clobber each other on rebuild. -export const CONTAINER_IMAGE_BASE = - process.env.CONTAINER_IMAGE_BASE || getContainerImageBase(PROJECT_ROOT); -export const CONTAINER_IMAGE = - process.env.CONTAINER_IMAGE || getDefaultContainerImage(PROJECT_ROOT); +export const CONTAINER_IMAGE_BASE = process.env.CONTAINER_IMAGE_BASE || getContainerImageBase(PROJECT_ROOT); +export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || getDefaultContainerImage(PROJECT_ROOT); export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL; diff --git a/src/container-runner.ts b/src/container-runner.ts index 177a818..646b118 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -9,7 +9,15 @@ import path from 'path'; import { OneCLI } from '@onecli-sh/sdk'; -import { CONTAINER_IMAGE, CONTAINER_IMAGE_BASE, DATA_DIR, GROUPS_DIR, ONECLI_API_KEY, ONECLI_URL, TIMEZONE } from './config.js'; +import { + CONTAINER_IMAGE, + CONTAINER_IMAGE_BASE, + DATA_DIR, + GROUPS_DIR, + ONECLI_API_KEY, + ONECLI_URL, + TIMEZONE, +} from './config.js'; import { readContainerConfig, writeContainerConfig } from './container-config.js'; import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { composeGroupClaudeMd } from './claude-md-compose.js'; From 8a19ad019a2699c79158b6f0c2d1d2b24f33f468 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 07:11:04 +0000 Subject: [PATCH 130/185] chore: bump version to 2.0.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1fe44f1..3084fe8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.3", + "version": "2.0.4", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 56ef5b4461a8c02392ff737d4e9756d2ec801c93 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 10:35:12 +0300 Subject: [PATCH 131/185] feat(setup): clarify setup flow from user-feedback session - Container step: duration hint + 3-line rolling output window with 60s stall detector that offers "keep waiting" vs "ask Claude" - First chat: reframed as a try-out with sandbox-model explainer (wakes on message, sleeps when idle, context persists) - Timezone: auto-detected non-UTC zones now get an explicit confirm from the user instead of silent persist - Outro: added always-on warning + prominent "check your DM" banner when a channel was configured; directive last line - Discord: always show token-location reminder even when user says they have one; new "do you have a server?" branch walks through server creation if not - All select prompts: custom brightSelect renderer keeps inactive option labels at full brightness (was dim gray); adds @clack/core as a direct dep Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 1 + pnpm-lock.yaml | 3 + setup/auto.ts | 156 +++++++++++++++++++----- setup/channels/discord.ts | 77 +++++++++++- setup/channels/teams.ts | 7 +- setup/channels/whatsapp.ts | 3 +- setup/container.ts | 28 +++-- setup/lib/bright-select.ts | 119 ++++++++++++++++++ setup/lib/role-prompt.ts | 7 +- setup/lib/runner.ts | 32 ++++- setup/lib/windowed-runner.ts | 229 +++++++++++++++++++++++++++++++++++ 11 files changed, 611 insertions(+), 51 deletions(-) create mode 100644 setup/lib/bright-select.ts create mode 100644 setup/lib/windowed-runner.ts diff --git a/package.json b/package.json index 31802c7..8ec2983 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:watch": "vitest" }, "dependencies": { + "@clack/core": "^1.2.0", "@clack/prompts": "^1.2.0", "@onecli-sh/sdk": "^0.3.1", "better-sqlite3": "11.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e3de02..3f74033 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@clack/core': + specifier: ^1.2.0 + version: 1.2.0 '@clack/prompts': specifier: ^1.2.0 version: 1.2.0 diff --git a/setup/auto.ts b/setup/auto.ts index ea5dec3..958650a 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -31,7 +31,9 @@ import { runTeamsChannel } from './channels/teams.js'; import { runTelegramChannel } from './channels/telegram.js'; import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; +import { brightSelect } from './lib/bright-select.js'; import { offerClaudeAssist } from './lib/claude-assist.js'; +import { runWindowedStep } from './lib/windowed-runner.js'; import { claudeCliAvailable, resolveTimezoneViaClaude, @@ -78,7 +80,13 @@ async function main(): Promise { 4, ), ); - const res = await runQuietStep('container', { + p.log.message( + dimWrap( + 'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.', + 4, + ), + ); + const res = await runWindowedStep('container', { running: "Preparing your assistant's sandbox…", done: 'Sandbox ready.', failed: "Couldn't prepare the sandbox.", @@ -123,7 +131,7 @@ async function main(): Promise { let reuse = false; if (existing) { const choice = ensureAnswer( - await p.select({ + await brightSelect({ message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`, options: [ { @@ -265,15 +273,17 @@ async function main(): Promise { await runTimezoneStep(); } + let channelChoice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip' = + 'skip'; if (!skip.has('channel')) { - const choice = await askChannelChoice(); - if (choice === 'telegram') { + channelChoice = await askChannelChoice(); + if (channelChoice === 'telegram') { await runTelegramChannel(displayName!); - } else if (choice === 'discord') { + } else if (channelChoice === 'discord') { await runDiscordChannel(displayName!); - } else if (choice === 'whatsapp') { + } else if (channelChoice === 'whatsapp') { await runWhatsAppChannel(displayName!); - } else if (choice === 'teams') { + } else if (channelChoice === 'teams') { await runTeamsChannel(displayName!); } else { p.log.info( @@ -357,9 +367,51 @@ async function main(): Promise { .map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`) .join('\n'); p.note(nextSteps, 'Try these'); + + // Always-on warning goes before the "check your DMs" directive so the + // caveat doesn't land after the user's already looked away at their phone. + p.note( + wrapForGutter( + "NanoClaw runs on this machine. It's only reachable while this computer is on and connected to the internet. For always-on availability, run it on a cloud VM — or keep this machine awake.", + 6, + ), + 'Heads up', + ); + setupLog.complete(Date.now() - RUN_START); phEmit('setup_completed', { duration_ms: Date.now() - RUN_START }); - p.outro(k.green("You're ready! Enjoy NanoClaw.")); + + const dmTarget = channelDmLabel(channelChoice); + if (dmTarget) { + // Bright framed banner (not dim) — the whole point of the feedback was + // that the welcome-message signal was too easy to miss. Use p.note so it + // renders with a visible box, cyan-bold the directive line, and put it + // as the last thing before outro. + p.note( + `${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, + 'Go say hi', + ); + p.outro(k.green("You're set.")); + } else { + p.outro(k.green("You're ready! Chat with `pnpm run chat hi`.")); + } +} + +function channelDmLabel( + choice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip', +): string | null { + switch (choice) { + case 'telegram': + return 'Telegram'; + case 'discord': + return 'Discord DMs'; + case 'whatsapp': + return 'WhatsApp'; + case 'teams': + return 'Teams'; + default: + return null; + } } // ─── first-chat step ─────────────────────────────────────────────────── @@ -422,15 +474,39 @@ function renderPingFailureNote(result: PingResult): void { * Chat loop. Each message is piped through `pnpm run chat`, which uses * the same Unix-socket path the ping just exercised, so output streams * back inline as the agent replies. An empty input ends the loop. + * + * The intro note teaches the sandbox mental model — users reported being + * confused about what the terminal chat *is* (vs the phone channel they'd + * set up next) and what happens to the agent when they walk away. We + * explain once, then offer "message or Enter to continue" so the chat is + * clearly optional. */ async function runFirstChat(): Promise { + p.note( + wrapForGutter( + [ + 'Your assistant runs in a sandbox on this machine.', + 'It wakes up when you send a message and goes back to sleep when', + "you're not talking — so it isn't burning resources in the background.", + 'Its memory and environment persist between conversations.', + ].join(' '), + 6, + ), + 'How this works', + ); + let first = true; while (true) { const answer = ensureAnswer( await p.text({ - message: 'Say something to your assistant', - placeholder: 'press Enter with nothing to continue', + message: first + ? 'Try a quick hello — or press Enter to continue setup' + : 'Another message? Press Enter to continue setup', + placeholder: first + ? 'e.g. "hi, what can you do?"' + : 'press Enter to continue', }), ); + first = false; const text = ((answer as string | undefined) ?? '').trim(); if (!text) return; await sendChatMessage(text); @@ -463,7 +539,7 @@ async function runAuthStep(): Promise { } const method = ensureAnswer( - await p.select({ + await brightSelect({ message: 'How would you like to connect to Claude?', options: [ { @@ -591,31 +667,49 @@ async function runTimezoneStep(): Promise { resolvedTz === 'Etc/UTC' || resolvedTz === 'Universal'; + // Three branches: + // - no TZ detected: ask where they are (or leave as UTC) + // - detected UTC: confirm (likely VPS, but worth checking) + // - detected specific zone: confirm explicitly rather than silently + // persisting — users shouldn't be surprised the agent "already knew" + // their timezone from system settings they didn't think about. if (!needsInput && !isUtc && resolvedTz && resolvedTz !== 'none') { - return; + const confirmed = ensureAnswer( + await p.confirm({ + message: `I detected ${resolvedTz} from your computer settings. Is that right?`, + initialValue: true, + }), + ); + setupLog.userInput('timezone_confirm_detected', String(confirmed)); + if (confirmed) return; } - // Either autodetect failed outright, or it landed on UTC and we should - // check that's really what the user wants before leaving it there. const message = needsInput ? "Your system didn't expose a timezone. Which one are you in?" - : "Your system reports UTC as the timezone. Is that right, or are you somewhere else?"; + : !isUtc + ? "Where are you, then?" + : "Your system reports UTC as the timezone. Is that right, or are you somewhere else?"; - const choice = ensureAnswer( - await p.select({ - message, - options: needsInput - ? [ - { value: 'answer', label: "I'll tell you where I am" }, - { value: 'keep', label: 'Leave it as UTC' }, - ] - : [ - { value: 'keep', label: 'Keep UTC', hint: 'remote server / happy with UTC' }, - { value: 'answer', label: "I'm somewhere else" }, - ], - }), - ) as 'keep' | 'answer'; - setupLog.userInput('timezone_choice', choice); + // For the non-UTC "detected-but-wrong" branch we skip the select and jump + // straight to the free-text prompt — the user already said "not that". + let choice: 'keep' | 'answer' = 'answer'; + if (needsInput || isUtc) { + choice = ensureAnswer( + await brightSelect({ + message, + options: needsInput + ? [ + { value: 'answer', label: "I'll tell you where I am" }, + { value: 'keep', label: 'Leave it as UTC' }, + ] + : [ + { value: 'keep', label: 'Keep UTC', hint: 'remote server / happy with UTC' }, + { value: 'answer', label: "I'm somewhere else" }, + ], + }), + ) as 'keep' | 'answer'; + setupLog.userInput('timezone_choice', choice); + } if (choice === 'keep') return; @@ -694,7 +788,7 @@ async function askChannelChoice(): Promise< 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip' > { const choice = ensureAnswer( - await p.select({ + await brightSelect({ message: 'Want to chat with your assistant from your phone?', options: [ { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index f26dc23..3668686 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -27,6 +27,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; @@ -46,9 +47,14 @@ interface AppInfo { } export async function runDiscordChannel(displayName: string): Promise { - if (!(await askHasBotToken())) { + const hasBot = await askHasBotToken(); + if (!hasBot) { await walkThroughBotCreation(); } + // Even users who said "yes" often can't find the token on demand — the + // Dev Portal resets it if you don't store it, and people forget which + // app it belongs to. A quick reminder before the paste prompt is cheap. + showTokenLocationReminder(hasBot); const token = await collectDiscordToken(); const botUsername = await validateDiscordToken(token); @@ -56,6 +62,13 @@ export async function runDiscordChannel(displayName: string): Promise { const ownerUserId = await resolveOwnerUserId(app.owner); + // Before inviting: do they have a server to invite into? Walkthrough if + // not — a fresh Discord account without a server makes the invite page a + // dead end. + if (!(await askHasDiscordServer())) { + await walkThroughServerCreation(); + } + await promptInviteBot(app.applicationId, botUsername); const install = await runQuietChild( @@ -129,7 +142,7 @@ export async function runDiscordChannel(displayName: string): Promise { async function askHasBotToken(): Promise { const answer = ensureAnswer( - await p.select({ + await brightSelect({ message: 'Do you already have a Discord bot?', options: [ { value: 'yes', label: 'Yes, I have a bot token ready' }, @@ -165,6 +178,66 @@ async function walkThroughBotCreation(): Promise { ); } +function showTokenLocationReminder(hasExistingBot: boolean): void { + // If we just walked them through creating a bot, they're staring at the + // token. If they came in with an existing one, they may still need a nudge + // to find it — tokens in the Dev Portal aren't visible after first reveal, + // and "Reset Token" issues a new one. + if (hasExistingBot) { + p.note( + [ + "Where to find your bot token:", + '', + ' 1. discord.com/developers/applications → pick your app', + ' 2. "Bot" tab → "Reset Token" (the old one stops working)', + ' 3. Copy the new token', + ].join('\n'), + 'Reminder', + ); + } +} + +async function askHasDiscordServer(): Promise { + const answer = ensureAnswer( + await brightSelect({ + message: 'Do you have a Discord server you can add the bot to?', + options: [ + { value: 'yes', label: 'Yes, I have a server' }, + { value: 'no', label: "No, walk me through creating one" }, + ], + }), + ); + setupLog.userInput('discord_has_server', String(answer)); + return answer === 'yes'; +} + +async function walkThroughServerCreation(): Promise { + // Discord doesn't have a stable deep-link for "create server" so we open + // the web client and rely on the + button being visible. The steps below + // are the same whether they're in the desktop app or the browser. + const url = 'https://discord.com/channels/@me'; + p.note( + [ + "A Discord server is just a private space for you and the bot. Free and takes 30 seconds.", + '', + ' 1. In Discord, click the "+" at the bottom of the server list', + ' 2. Choose "Create My Own" → "For me and my friends"', + ' 3. Give it any name (e.g. "NanoClaw")', + '', + k.dim(url), + ].join('\n'), + 'Create a Discord server', + ); + await confirmThenOpen(url, 'Press Enter to open Discord'); + + ensureAnswer( + await p.confirm({ + message: "Server created?", + initialValue: true, + }), + ); +} + async function collectDiscordToken(): Promise { const answer = ensureAnswer( await p.password({ diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index be29cea..fb4d878 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -30,6 +30,7 @@ import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen } from '../lib/browser.js'; import { isHelpEscape, @@ -223,7 +224,7 @@ async function askAppType(args: { }): Promise<'SingleTenant' | 'MultiTenant'> { while (true) { const choice = ensureAnswer( - await p.select({ + await brightSelect({ message: 'Which account type did you pick?', options: [ { @@ -515,7 +516,7 @@ async function finishWithHandoff( ); const choice = ensureAnswer( - await p.select({ + await brightSelect({ message: 'Ready to finish?', options: [ { @@ -571,7 +572,7 @@ async function stepGate(args: { }): Promise { while (true) { const choice = ensureAnswer( - await p.select({ + await brightSelect({ message: 'How did that go?', options: [ { value: 'done', label: "Done — let's continue" }, diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 29c70e3..f24207a 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -33,6 +33,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { brightSelect } from '../lib/bright-select.js'; import { type Block, type StepResult, @@ -148,7 +149,7 @@ export async function runWhatsAppChannel(displayName: string): Promise { async function askAuthMethod(): Promise { const choice = ensureAnswer( - await p.select({ + await brightSelect({ message: 'How would you like to authenticate with WhatsApp?', options: [ { diff --git a/setup/container.ts b/setup/container.ts index a2e6433..a15ddb4 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -174,19 +174,31 @@ export async function run(args: string[]): Promise { // .env is optional; absence is normal on a fresh checkout } - // Build + // Build — stdio inherit so the parent setup runner can tail docker's + // per-step output and render it in a rolling window. Previously we used + // execSync which buffered everything; users couldn't tell whether a + // 3–10 minute build was making progress or hung. let buildOk = false; log.info('Building container', { runtime, buildArgs }); - try { - const argsStr = buildArgs.length > 0 ? ' ' + buildArgs.join(' ') : ''; - execSync(`${buildCmd}${argsStr} -t ${image} .`, { + const buildRes = spawnSync( + buildCmd.split(' ')[0], + [ + ...buildCmd.split(' ').slice(1), + ...buildArgs.flatMap((a) => a.split(' ')), + '-t', + image, + '.', + ], + { cwd: path.join(projectRoot, 'container'), - stdio: ['ignore', 'pipe', 'pipe'], - }); + stdio: 'inherit', + }, + ); + if (buildRes.status === 0) { buildOk = true; log.info('Container build succeeded'); - } catch (err) { - log.error('Container build failed', { err }); + } else { + log.error('Container build failed', { exitCode: buildRes.status }); } // Test diff --git a/setup/lib/bright-select.ts b/setup/lib/bright-select.ts new file mode 100644 index 0000000..94c4838 --- /dev/null +++ b/setup/lib/bright-select.ts @@ -0,0 +1,119 @@ +/** + * A drop-in alternative to `@clack/prompts`' `p.select` that renders + * unselected option labels at full brightness instead of dim gray. + * + * Why this exists: clack styles inactive options with `styleText("dim", …)` + * inline in its render function. There is no configuration hook to override + * it, and the feedback was clear — non-selected options in the setup flow + * were "too light, need stronger font weight". So we write our own render + * against `@clack/core`'s `SelectPrompt`, keeping the visual shell of clack + * (diamond header, `│` gutter, cyan in-progress / green on submit) but + * leaving the label un-dimmed. Only the bullet and hint stay dim, which + * gives enough contrast for the cursor to read as "active". + * + * Not a full clack-feature clone: no search, no maxItems paging, no custom + * bar characters. Just the bits the NanoClaw setup menus actually use. + */ +import { SelectPrompt } from '@clack/core'; +import { isCancel } from '@clack/prompts'; +import { styleText } from 'node:util'; + +const BULLET_ACTIVE = '●'; +const BULLET_INACTIVE = '○'; +const BAR = '│'; +const CAP_BOT = '└'; +const DIAMOND = '◆'; +const DIAMOND_CANCEL = '■'; +const DIAMOND_SUBMIT = '◇'; + +type PromptState = 'initial' | 'active' | 'error' | 'cancel' | 'submit'; + +function stateColor(state: PromptState): 'cyan' | 'green' | 'red' | 'yellow' { + switch (state) { + case 'submit': + return 'green'; + case 'cancel': + return 'red'; + case 'error': + return 'yellow'; + default: + return 'cyan'; + } +} + +function headerIcon(state: PromptState): string { + switch (state) { + case 'submit': + return styleText('green', DIAMOND_SUBMIT); + case 'cancel': + return styleText('red', DIAMOND_CANCEL); + default: + return styleText('cyan', DIAMOND); + } +} + +export interface BrightSelectOption { + value: T; + label?: string; + hint?: string; +} + +export interface BrightSelectOptions { + message: string; + options: BrightSelectOption[]; + initialValue?: T; +} + +/** + * Matches the return shape of `p.select` — resolves to the selected value + * on submit, or to clack's cancel symbol on Ctrl-C / Esc. Callers pass + * the result through `ensureAnswer(...)` the same way they do for + * `p.select`. + */ +export function brightSelect( + opts: BrightSelectOptions, +): Promise { + const { message, options, initialValue } = opts; + + return new SelectPrompt({ + options: options as Array<{ value: T; label?: string; hint?: string }>, + initialValue, + render() { + const st = this.state as PromptState; + const color = stateColor(st); + const bar = styleText(color, BAR); + const grayBar = styleText('gray', BAR); + + const lines: string[] = []; + lines.push(grayBar); + lines.push(`${headerIcon(st)} ${message}`); + + if (st === 'submit' || st === 'cancel') { + const selected = + options.find((o) => o.value === this.value)?.label ?? + String(this.value ?? ''); + const shown = + st === 'cancel' + ? styleText(['strikethrough', 'dim'], selected) + : styleText('dim', selected); + lines.push(`${grayBar} ${shown}`); + return lines.join('\n'); + } + + const cursor = (this as unknown as { cursor: number }).cursor; + options.forEach((opt, idx) => { + const label = opt.label ?? String(opt.value); + const hint = opt.hint ? ` ${styleText('dim', `(${opt.hint})`)}` : ''; + const marker = + idx === cursor + ? styleText('green', BULLET_ACTIVE) + : styleText('dim', BULLET_INACTIVE); + lines.push(`${bar} ${marker} ${label}${hint}`); + }); + lines.push(styleText(color, CAP_BOT)); + return lines.join('\n'); + }, + }).prompt() as Promise; +} + +export { isCancel }; diff --git a/setup/lib/role-prompt.ts b/setup/lib/role-prompt.ts index c5ac537..7344ac1 100644 --- a/setup/lib/role-prompt.ts +++ b/setup/lib/role-prompt.ts @@ -8,8 +8,7 @@ * surfaces admin/member for the edge cases (shared instance, collaborators * with limited access), but hitting Enter assigns owner. */ -import * as p from '@clack/prompts'; - +import { brightSelect } from './bright-select.js'; import { ensureAnswer } from './runner.js'; export type OperatorRole = 'owner' | 'admin' | 'member'; @@ -18,7 +17,7 @@ export async function askOperatorRole( channelLabel: string, ): Promise { const choice = ensureAnswer( - await p.select({ + await brightSelect({ message: `How should this ${channelLabel} account be registered?`, initialValue: 'owner', options: [ @@ -39,6 +38,6 @@ export async function askOperatorRole( }, ], }), - ) as OperatorRole; + ); return choice; } diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index d8d3765..1e02d0d 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -102,12 +102,19 @@ export class StatusStream { * raw log file (level 3) and parsed for status blocks (level 2 summary). * The onBlock callback fires per status block as they close so the UI can * react mid-stream. + * + * `onLine`, if provided, fires for every line from stdout + stderr (minus + * status-block control lines) so callers can render a rolling tail. Status + * block lines are still parsed by the `StatusStream` — they're just + * excluded from the line feed so they don't fill the user-facing window + * with `=== NANOCLAW SETUP: …` noise. */ export function spawnStep( stepName: string, extra: string[], onBlock: (block: Block) => void, rawLogPath: string, + onLine?: (line: string) => void, ): Promise { return new Promise((resolve) => { const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName]; @@ -118,13 +125,34 @@ export function spawnStep( const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); raw.write(`# ${stepName} — ${new Date().toISOString()}\n\n`); + // Per-line forwarder for the optional onLine callback. We keep our own + // buffer (separate from StatusStream's) so the parser still gets raw + // chunks and isn't forced through a line-by-line path it doesn't need. + let lineBuf = ''; + const pushLines = (chunk: string): void => { + if (!onLine) return; + lineBuf += chunk; + let idx: number; + while ((idx = lineBuf.indexOf('\n')) !== -1) { + const line = lineBuf.slice(0, idx).replace(/\r/g, ''); + lineBuf = lineBuf.slice(idx + 1); + if (line.startsWith('=== NANOCLAW SETUP:')) continue; + if (line.startsWith('=== END ===')) continue; + if (line.trim()) onLine(line); + } + }; + child.stdout.on('data', (chunk: Buffer) => { - stream.write(chunk.toString('utf-8')); + const s = chunk.toString('utf-8'); + stream.write(s); raw.write(chunk); + pushLines(s); }); child.stderr.on('data', (chunk: Buffer) => { - stream.transcript += chunk.toString('utf-8'); + const s = chunk.toString('utf-8'); + stream.transcript += s; raw.write(chunk); + pushLines(s); }); child.on('close', (code) => { diff --git a/setup/lib/windowed-runner.ts b/setup/lib/windowed-runner.ts new file mode 100644 index 0000000..875aba6 --- /dev/null +++ b/setup/lib/windowed-runner.ts @@ -0,0 +1,229 @@ +/** + * Windowed step runner: shows a fixed-height rolling tail of a long step's + * output so the user can see it's making progress, plus a stall detector + * that interrupts with a "keep waiting or ask for help?" prompt when the + * output stream goes silent for too long. + * + * Used for the container build (3–10 minutes on a fresh machine, no user + * feedback with a plain spinner). Models the UI on claude-assist.ts's + * 3-line action window — a single-line spinner header sitting above three + * gutter-prefixed lines of the most recent output, redrawn in place via + * ANSI cursor controls. + * + * Stall detection: a silence timer resets on every new line. When it hits + * STALL_THRESHOLD_MS we pause the render, show `offerClaudeAssist` with + * the step's raw log, and either resume (user said "keep waiting") or + * let the step run its course while giving them the exit path. + */ +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import { offerClaudeAssist } from './claude-assist.js'; +import { emit as phEmit } from './diagnostics.js'; +import type { StepResult, SpinnerLabels } from './runner.js'; +import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js'; +import * as setupLog from '../logs.js'; +import { fitToWidth } from './theme.js'; + +const WINDOW_SIZE = 3; +const SPINNER_FRAMES = ['◒', '◐', '◓', '◑']; +const HIDE_CURSOR = '\x1b[?25l'; +const SHOW_CURSOR = '\x1b[?25h'; +const STALL_THRESHOLD_MS = 60_000; + +/** + * Run a step with a 3-line rolling tail + stall detector. Same signature + * shape as `runQuietStep` (so auto.ts can swap them), but tails the + * child's stdout/stderr into a fixed-height window. + */ +export async function runWindowedStep( + stepName: string, + labels: SpinnerLabels, + extra: string[] = [], +): Promise { + const rawLog = setupLog.stepRawLog(stepName); + const start = Date.now(); + phEmit('step_started', { step: stepName }); + + const result = await runUnderWindow(stepName, labels, extra, rawLog); + + const durationMs = Date.now() - start; + writeStepEntry(stepName, result, durationMs, rawLog); + phEmit('step_completed', { + step: stepName, + status: outcomeStatus(result), + duration_ms: durationMs, + }); + return { ...result, rawLog, durationMs }; +} + +function outcomeStatus(result: StepResult): 'success' | 'skipped' | 'failed' { + const rawStatus = result.terminal?.fields.STATUS; + if (!result.ok) return 'failed'; + return rawStatus === 'skipped' ? 'skipped' : 'success'; +} + +/** + * The core render + spawn loop. Kept separate from `runWindowedStep` so + * the logging bookkeeping (writeStepEntry, phEmit) lives with the + * public-facing wrapper and this function stays focused on terminal IO. + */ +async function runUnderWindow( + stepName: string, + labels: SpinnerLabels, + extra: string[], + rawLog: string, +): Promise { + const out = process.stdout; + const start = Date.now(); + const actions: string[] = []; + let frameIdx = 0; + let lastLineAt = Date.now(); + let stallPromptActive = false; + let handledStall = false; + + const redraw = (): void => { + if (stallPromptActive) return; + out.write(`\x1b[${WINDOW_SIZE + 1}A`); + const elapsed = Math.round((Date.now() - start) / 1000); + const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length]; + const suffix = ` (${elapsed}s)`; + const header = fitToWidth(labels.running, suffix); + out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`); + + for (let i = 0; i < WINDOW_SIZE; i++) { + const idx = actions.length - WINDOW_SIZE + i; + const action = idx >= 0 ? actions[idx] : ''; + out.write('\x1b[2K'); + if (action) { + out.write(`${k.gray('│')} ${k.dim(fitToWidth(action, ''))}`); + } else { + out.write(k.gray('│')); + } + out.write('\n'); + } + }; + + const clearBlock = (): void => { + out.write(`\x1b[${WINDOW_SIZE + 1}A`); + for (let i = 0; i < WINDOW_SIZE + 1; i++) { + out.write('\x1b[2K\n'); + } + out.write(`\x1b[${WINDOW_SIZE + 1}A`); + }; + + out.write(HIDE_CURSOR); + for (let i = 0; i < WINDOW_SIZE + 1; i++) out.write('\n'); + redraw(); + + const restoreCursorOnExit = (): void => { + out.write(SHOW_CURSOR); + }; + process.once('exit', restoreCursorOnExit); + + const frameTick = setInterval(() => { + frameIdx++; + redraw(); + }, 250); + + const stallCheck = setInterval(() => { + if (handledStall || stallPromptActive) return; + if (Date.now() - lastLineAt < STALL_THRESHOLD_MS) return; + handledStall = true; + void handleStall(stepName, rawLog, { + pauseRender: () => { + stallPromptActive = true; + clearBlock(); + out.write(SHOW_CURSOR); + }, + resumeRender: () => { + out.write(HIDE_CURSOR); + for (let i = 0; i < WINDOW_SIZE + 1; i++) out.write('\n'); + stallPromptActive = false; + lastLineAt = Date.now(); + redraw(); + }, + }); + }, 5_000); + + const onLine = (line: string): void => { + lastLineAt = Date.now(); + // Strip ANSI escape sequences — Docker Buildx writes color codes that + // mangle the rolling window layout when replayed in a narrow cell. + // eslint-disable-next-line no-control-regex + const clean = line.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').trim(); + if (clean) actions.push(clean); + redraw(); + }; + + const result = await spawnStep(stepName, extra, () => {}, rawLog, onLine); + + clearInterval(frameTick); + clearInterval(stallCheck); + clearBlock(); + out.write(SHOW_CURSOR); + process.off('exit', restoreCursorOnExit); + + const elapsed = Math.round((Date.now() - start) / 1000); + const suffix = ` (${elapsed}s)`; + if (result.ok) { + const isSkipped = result.terminal?.fields.STATUS === 'skipped'; + const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; + p.log.success(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`); + } else { + const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed'); + p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`); + dumpTranscriptOnFailure(result.transcript); + } + return result; +} + +async function handleStall( + stepName: string, + rawLog: string, + render: { pauseRender: () => void; resumeRender: () => void }, +): Promise { + render.pauseRender(); + p.log.warn( + `This looks stuck — no output from the ${stepName} step for the last 60 seconds.`, + ); + phEmit('step_stalled', { step: stepName }); + + const { ensureAnswer } = await import('./runner.js'); + const { brightSelect } = await import('./bright-select.js'); + + const choice = ensureAnswer( + await brightSelect<'wait' | 'help'>({ + message: "What now?", + options: [ + { + value: 'wait', + label: "Keep waiting", + hint: "large images can take 5–10 minutes", + }, + { + value: 'help', + label: 'Ask Claude to take a look', + hint: 'reads the raw build log and suggests a fix', + }, + ], + }), + ); + + if (choice === 'help') { + // offerClaudeAssist runs its own spinner and may propose a fix command. + // We don't attempt to restart the stalled build from here — if Claude + // proposes a command the user accepts, they can retry setup afterwards. + await offerClaudeAssist({ + stepName, + msg: `The ${stepName} step has produced no output for 60 seconds.`, + hint: 'It may be hung on a slow network pull or a failing Dockerfile step.', + rawLogPath: rawLog, + }); + // Keep the spinner going — the underlying process is still running, + // and cancelling it here would race with Claude's investigation. The + // user can Ctrl-C if they want to bail. + } + + render.resumeRender(); +} From 7f4583d0fe41cb28de59e9b8e01722d8c58082d2 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 10:50:19 +0300 Subject: [PATCH 132/185] fix(setup): add npm global prefix bin to PATH after fallback install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When corepack enable fails with EACCES (common when Node is installed to a system-writable prefix like /usr/local that the user doesn't own), we fall back to `npm install -g pnpm`. But npm's global prefix isn't always on the shell's PATH — users often set `npm config set prefix ~/.npm-global` to avoid sudo, and the resulting bin dir isn't picked up by `command -v`. Install succeeded, but pnpm "wasn't there" for the follow-up `pnpm install`. Now after the npm fallback we query `npm config get prefix` and prepend `/bin` to PATH. Mirror the same lookup in nanoclaw.sh right before `exec pnpm run setup:auto` — setup.sh's PATH mutation doesn't propagate back, and the hand-off needs pnpm visible too. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 11 +++++++++++ setup.sh | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/nanoclaw.sh b/nanoclaw.sh index 0bf1938..f8b58e7 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -245,6 +245,17 @@ fi # wipe it. export NANOCLAW_BOOTSTRAPPED=1 +# setup.sh may have just installed pnpm via npm into a prefix that's not on +# our PATH (custom `npm config set prefix`, or the default prefix missing +# from the shell's login PATH). Its PATH mutation doesn't propagate back +# to us — so replay the same lookup here before the exec. +if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then + NPM_PREFIX="$(npm config get prefix 2>/dev/null)" + if [ -n "$NPM_PREFIX" ] && [ -x "$NPM_PREFIX/bin/pnpm" ]; then + export PATH="$NPM_PREFIX/bin:$PATH" + fi +fi + # --silent suppresses pnpm's `> nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts` # preamble so the flow continues visually from "Basics installed" straight # into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly. diff --git a/setup.sh b/setup.sh index e11e073..9ca73c1 100755 --- a/setup.sh +++ b/setup.sh @@ -120,6 +120,20 @@ install_deps() { || true fi + # `npm install -g` writes to npm's global prefix, which isn't always on the + # shell PATH — common on macOS where the user has `npm config set prefix + # ~/.npm-global` to avoid sudo, or on Linux where /usr/local/bin isn't in + # PATH. Discover the prefix and prepend its bin dir so `command -v pnpm` + # sees the new install. + if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then + local npm_prefix + npm_prefix=$(npm config get prefix 2>/dev/null) + if [ -n "$npm_prefix" ] && [ -x "$npm_prefix/bin/pnpm" ]; then + export PATH="$npm_prefix/bin:$PATH" + log "Prepended npm prefix bin to PATH: $npm_prefix/bin" + fi + fi + if ! command -v pnpm >/dev/null 2>&1; then log "pnpm not on PATH after corepack + npm fallback" return From 910342fd80dfec714962c803c6daa1a920588d9c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 10:59:12 +0300 Subject: [PATCH 133/185] =?UTF-8?q?style(setup):=20lift=20text=20weight=20?= =?UTF-8?q?=E2=80=94=20prose=20becomes=20regular,=20outcomes=20bold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dimmed explanatory prose blocks were hard to read against dark terminals. Shift the weight ladder up a notch: - dimWrap() no longer dims. Multi-line prose (the step-intro copy, etc.) renders at the terminal's regular weight. - Spinner outcome labels (done/failed/skipped) are now bold via runUnderSpinner, so each step's headline reads stronger than the body copy around it. - Un-dim two command-hint blocks in auto.ts (docker-group setfacl + service restart; the socket-error remediation commands) — those are commands the user may need to type. Dim is still used where it helps — (Ns) spinner timings, URLs, short inline parentheticals — and for the preview/debug blocks dim is explicitly reserved for: dumpTranscriptOnFailure tail and claude-assist streams. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 14 ++++++-------- setup/lib/runner.ts | 6 ++++-- setup/lib/theme.ts | 15 ++++++--------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index b5b0113..369f05a 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -215,10 +215,8 @@ async function main(): Promise { "NanoClaw's permissions need a tweak before it can reach Docker.", ); p.log.message( - k.dim( - ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + - ' systemctl --user restart nanoclaw', - ), + ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + + ` systemctl --user restart ${getSystemdUnit()}`, ); } } @@ -442,13 +440,13 @@ async function confirmAssistantResponds(): Promise { const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${elapsed}s)`; if (result === 'ok') { - s.stop(`${fitToWidth('Your assistant is ready.', suffix)}${k.dim(suffix)}`); + s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`); } else { const msg = result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time."; - s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`, 1); + s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1); } return result; } @@ -462,8 +460,8 @@ function renderPingFailureNote(result: PingResult): void { 6, ), '', - k.dim(` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`), - k.dim(` Linux: systemctl --user restart ${getSystemdUnit()}`), + ` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`, + ` Linux: systemctl --user restart ${getSystemdUnit()}`, ].join('\n') : wrapForGutter( 'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index 1e02d0d..c1599e4 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -322,10 +322,12 @@ async function runUnderSpinner< if (result.ok) { const isSkipped = result.terminal?.fields.STATUS === 'skipped'; const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; - s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`); + // Bold the outcome so the step's headline reads stronger than the prose + // body copy around it. The trailing `(Ns)` timing stays dim. + s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`); } else { const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed'); - s.stop(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`, 1); + s.stop(`${k.bold(fitToWidth(failMsg, suffix))}${k.dim(suffix)}`, 1); dumpTranscriptOnFailure(result.transcript); } return result; diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index 6f21d15..35b5ca3 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -58,17 +58,14 @@ export function wrapForGutter(text: string, gutter: number): string { } /** - * Wrap + dim together. Needed instead of `k.dim(wrapForGutter(...))` - * because clack resets styling at each line break when rendering - * multi-line log content — a single outer dim envelope only colors the - * first line. Applying dim per-line gives each wrapped row its own - * `\x1b[2m…\x1b[0m` envelope so the whole block reads as one block. + * Wrap multi-line explanatory prose to the clack gutter. Previously + * dimmed its output (hence the name) — that made body copy hard to read + * against dark terminals. Dim is now reserved for preview/debug blocks + * (failure transcript tails, claude-assist streams); prose renders at + * the terminal's regular weight. */ export function dimWrap(text: string, gutter: number): string { - return wrapForGutter(text, gutter) - .split('\n') - .map((line) => k.dim(line)) - .join('\n'); + return wrapForGutter(text, gutter); } const ANSI_RE = /\x1b\[[0-9;]*m/g; From 990d243dbd5ae78838e09d67270d7dedd7561264 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 11:10:30 +0300 Subject: [PATCH 134/185] fix(setup): bypass rate-limited GitHub API when installing onecli CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upstream onecli.sh/cli/install script resolves the latest release via api.github.com/repos/onecli/onecli-cli/releases/latest — anonymous callers get throttled to 60 req/hour per IP, and once exhausted the installer dies with "curl: (56) 403 / Error: could not determine latest release". Shared IPs (corporate NAT, public Wi-Fi) hit this without ever running the installer themselves. Reproduced locally: rate_limit remaining=0 → upstream installer returns the exact user error. Fallback path when upstream fails: 1. Resolve version via `curl -fsSL -o /dev/null -w '%{url_effective}' \ https://github.com/onecli/onecli-cli/releases/latest`. That endpoint 302s to /tag/vX.Y.Z — parses the version without an API call. 2. If the redirect probe also fails, install a pinned fallback version (ONECLI_CLI_FALLBACK_VERSION, currently 1.3.0). 3. Download the archive from /releases/download/vX.Y.Z/… directly (the CDN path isn't API-throttled), extract, and install to /usr/local/bin or ~/.local/bin mirroring upstream's install-dir logic. Gateway install (onecli.sh/install, docker-compose based) is untouched — it doesn't hit the API. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/onecli.ts | 132 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 122 insertions(+), 10 deletions(-) diff --git a/setup/onecli.ts b/setup/onecli.ts index 6be722a..3ceb1e8 100644 --- a/setup/onecli.ts +++ b/setup/onecli.ts @@ -97,25 +97,137 @@ function writeEnvOnecliUrl(url: string): void { fs.writeFileSync(envFile, content); } +// Last-known-good CLI release. Used only if BOTH the upstream installer +// and the redirect-based version probe fail. Bump deliberately when a +// new CLI release ships. +const ONECLI_CLI_FALLBACK_VERSION = '1.3.0'; +const ONECLI_CLI_REPO = 'onecli/onecli-cli'; + function installOnecli(): { stdout: string; ok: boolean } { - // OneCLI's own install script handles gateway + CLI + PATH. - // We run the two canonical installers in sequence and capture stdout so - // we can extract the printed URL as a fallback to `onecli config get`. let stdout = ''; + + // Gateway install (docker-compose based, no rate-limit concerns). + const gw = runInstall('curl -fsSL onecli.sh/install | sh'); + stdout += gw.stdout; + if (!gw.ok) { + log.error('OneCLI gateway install failed', { stderr: gw.stderr }); + return { stdout: stdout + (gw.stderr ?? ''), ok: false }; + } + + // CLI install. The upstream script calls the GitHub releases API + // (api.github.com) to resolve the latest tag — which 403s anonymous + // callers after 60 requests/hour per IP. Try upstream first; on failure + // resolve the version ourselves (via HTTP redirect, which isn't + // API-throttled) and download the release archive directly. + const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh'); + stdout += upstream.stdout; + if (upstream.ok) return { stdout, ok: true }; + + log.warn('Upstream CLI installer failed — falling back to direct download', { + stderr: upstream.stderr, + }); + stdout += (upstream.stderr ?? '') + '\n'; + + const fallback = installOnecliCliDirect(); + stdout += fallback.stdout; + if (!fallback.ok) { + log.error('OneCLI CLI install failed (both upstream and direct fallback)'); + return { stdout, ok: false }; + } + return { stdout, ok: true }; +} + +function runInstall(cmd: string): { stdout: string; stderr?: string; ok: boolean } { try { - stdout += execSync('curl -fsSL onecli.sh/install | sh', { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - stdout += execSync('curl -fsSL onecli.sh/cli/install | sh', { + const stdout = execSync(cmd, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], }); return { stdout, ok: true }; } catch (err) { const e = err as { stdout?: string; stderr?: string }; - log.error('OneCLI install failed', { stderr: e.stderr }); - return { stdout: stdout + (e.stdout ?? '') + (e.stderr ?? ''), ok: false }; + return { stdout: e.stdout ?? '', stderr: e.stderr, ok: false }; + } +} + +/** + * Reinstate the OneCLI CLI install without hitting GitHub's rate-limited + * releases API. Resolves the version via the HTTP redirect from + * /releases/latest → /releases/tag/vX.Y.Z, then downloads the archive + * directly. Falls back to ONECLI_CLI_FALLBACK_VERSION if the redirect + * probe also fails. + */ +function installOnecliCliDirect(): { stdout: string; ok: boolean } { + const lines: string[] = []; + const append = (s: string): void => { + lines.push(s); + }; + + const osName = + process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null; + if (!osName) { + append(`Unsupported platform: ${process.platform}`); + return { stdout: lines.join('\n'), ok: false }; + } + const arch = + process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null; + if (!arch) { + append(`Unsupported arch: ${process.arch}`); + return { stdout: lines.join('\n'), ok: false }; + } + + let version: string | null = null; + try { + const redirect = execSync( + `curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/${ONECLI_CLI_REPO}/releases/latest`, + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }, + ).trim(); + const m = redirect.match(/\/tag\/v?([^/]+)$/); + if (m) version = m[1]; + } catch { + // redirect probe failed — we'll pin the fallback + } + if (!version) { + version = ONECLI_CLI_FALLBACK_VERSION; + append(`Version probe failed; installing pinned fallback ${version}.`); + } else { + append(`Resolved onecli CLI ${version} via release redirect.`); + } + + const archive = `onecli_${version}_${osName}_${arch}.tar.gz`; + const url = `https://github.com/${ONECLI_CLI_REPO}/releases/download/v${version}/${archive}`; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onecli-')); + const archivePath = path.join(tmpDir, archive); + + try { + append(`Downloading ${url}`); + execSync( + `curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`, + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + execSync(`tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(tmpDir)}`, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let installDir = '/usr/local/bin'; + try { + fs.accessSync(installDir, fs.constants.W_OK); + } catch { + installDir = LOCAL_BIN; + fs.mkdirSync(installDir, { recursive: true }); + } + const binSrc = path.join(tmpDir, 'onecli'); + const binDest = path.join(installDir, 'onecli'); + fs.copyFileSync(binSrc, binDest); + fs.chmodSync(binDest, 0o755); + append(`onecli ${version} installed to ${binDest}.`); + return { stdout: lines.join('\n'), ok: true }; + } catch (err) { + const e = err as { stdout?: string; stderr?: string; message?: string }; + append(`Direct install failed: ${e.stderr ?? e.message ?? String(err)}`); + return { stdout: lines.join('\n'), ok: false }; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); } } From dee7e0be32e92a676aee5d36d7095a967b02e338 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 11:23:37 +0300 Subject: [PATCH 135/185] feat(setup): Yes-default + session-persist on claude-assist, quieter first-chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three UX tweaks after watching a user walk through setup: 1. Claude-assist "Run this command?" now defaults to Yes. After Claude has already been asked to diagnose + explained the fix, the vast majority of users want to run it — the No-default added friction without proportional safety. 2. claude-assist persists its session across failures in one setup run. First invocation captures session_id from the stream-json init event; subsequent invocations pass --resume . Claude sees prior failures as conversation history instead of treating each hiccup as a blank-slate ticket. 3. First-chat flow no longer drops the user into a free-text chat loop by default. Instead: explain what the ping/pong check is doing, wait for the pong, then offer "Continue with setup" (recommended, default) or "Pause here and chat with your agent from the terminal" (opt-in). The free-text loop is still reachable, just not the default path. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 25 ++++++++++++++++- setup/lib/claude-assist.ts | 55 +++++++++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 16 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 369f05a..4becf6e 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -246,10 +246,33 @@ async function main(): Promise { ); } if (!skip.has('first-chat')) { + p.log.message( + dimWrap( + "Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.", + 4, + ), + ); const ping = await confirmAssistantResponds(); if (ping === 'ok') { phEmit('first_chat_ready'); - await runFirstChat(); + const next = ensureAnswer( + await p.select({ + message: 'What next?', + options: [ + { + value: 'continue', + label: 'Continue with setup', + hint: 'recommended', + }, + { + value: 'chat', + label: 'Pause here and chat with your agent from the terminal', + }, + ], + }), + ) as 'continue' | 'chat'; + setupLog.userInput('first_chat_choice', next); + if (next === 'chat') await runFirstChat(); } else { phEmit('first_chat_failed', { reason: ping }); renderPingFailureNote(ping); diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 551d938..c2b0367 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -115,7 +115,7 @@ export async function offerClaudeAssist( const run = ensureAnswer( await p.confirm({ message: 'Run this command? (you can edit it before executing)', - initialValue: false, + initialValue: true, }), ); if (!run) return false; @@ -279,18 +279,24 @@ async function queryClaudeUnderSpinner( // No hard timeout — debugging can take a long time, and the cost of // cutting Claude off mid-investigation is worse than letting the // spinner run. The user can Ctrl-C if they want to abort. - const child = spawn( - 'claude', - [ - '-p', - '--output-format', - 'stream-json', - '--verbose', - '--permission-mode', - 'bypassPermissions', - ], - { cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'] }, - ); + // + // Resume the same session on repeat invocations so Claude carries + // context across failures in one setup run. + const claudeArgs = [ + '-p', + '--output-format', + 'stream-json', + '--verbose', + '--permission-mode', + 'bypassPermissions', + ]; + if (claudeSessionId) { + claudeArgs.push('--resume', claudeSessionId); + } + const child = spawn('claude', claudeArgs, { + cwd: projectRoot, + stdio: ['pipe', 'pipe', 'pipe'], + }); child.stdout.on('data', (c: Buffer) => { lineBuf += c.toString('utf-8'); @@ -301,6 +307,16 @@ async function queryClaudeUnderSpinner( if (!line.trim()) continue; try { const event = JSON.parse(line) as StreamEvent; + // Capture the session id on the very first claude invocation of + // this process so later calls can --resume it. + if ( + !claudeSessionId && + event.type === 'system' && + event.subtype === 'init' && + typeof event.session_id === 'string' + ) { + claudeSessionId = event.session_id; + } handleStreamEvent(event, { setAction: (a) => { actions.push(a); @@ -335,10 +351,14 @@ async function queryClaudeUnderSpinner( } // Minimal shape of the stream-json events we care about. Claude emits -// many more, but we only read tool_use blocks (for breadcrumbs) and text -// blocks (to reassemble the final REASON/COMMAND answer). +// many more, but we only read tool_use blocks (for breadcrumbs), text +// blocks (to reassemble the final REASON/COMMAND answer), and the +// session_id on the init event so follow-up invocations can resume the +// same conversation. interface StreamEvent { type: string; + subtype?: string; + session_id?: string; message?: { content?: Array< | { type: 'text'; text: string } @@ -347,6 +367,11 @@ interface StreamEvent { }; } +// The session id from the first claude-assist invocation in this process. +// Subsequent invocations pass `--resume ` so Claude sees prior failures +// as conversation history instead of treating each failure in isolation. +let claudeSessionId: string | null = null; + function handleStreamEvent( event: StreamEvent, cb: { setAction: (a: string) => void; appendText: (t: string) => void }, From 2383bde80fc621d4ecb52db90a3335ad713bb85a Mon Sep 17 00:00:00 2001 From: Lazer Cohen Date: Thu, 23 Apr 2026 12:12:30 +0300 Subject: [PATCH 136/185] fix(container): scope orphan reaper by install label so peers don't kill each other Two installs on the same host could trash each other's containers: the reaper used `docker ps --filter name=nanoclaw-`, a substring match that picked up every install's containers. A crash-looping peer (e.g. a legacy v1 plist respawning ~6k times) would call cleanupOrphans on every boot and kill the healthy install's session containers within seconds of spawn. - Stamp `--label nanoclaw-install=` onto every spawned container. - cleanupOrphans filters by that label; healthy peers are left alone. - Setup preflight enumerates `com.nanoclaw*` launchd plists / nanoclaw user systemd units, probes state/runs, and unloads any that are crash-looping (state != running AND runs > 10) before installing this install's service. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/peer-cleanup.ts | 186 ++++++++++++++++++++++++++++++++++ setup/service.ts | 14 +++ src/config.ts | 6 +- src/container-runner.ts | 3 +- src/container-runtime.test.ts | 12 +++ src/container-runtime.ts | 20 +++- 6 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 setup/peer-cleanup.ts diff --git a/setup/peer-cleanup.ts b/setup/peer-cleanup.ts new file mode 100644 index 0000000..10b22b9 --- /dev/null +++ b/setup/peer-cleanup.ts @@ -0,0 +1,186 @@ +/** + * Detect and clean up unhealthy NanoClaw peer services. + * + * Runs as a setup preflight before we install our own service. A crash-looping + * peer install (typically the legacy v1 `com.nanoclaw` plist) silently trashes + * this install's containers on every respawn because its `cleanupOrphans()` + * reaps anything matching `nanoclaw-`. We scope our reaper by label now, but + * we still need to stop the peer from killing us on its way down. + * + * A peer is "unhealthy" when: + * - launchd: `state != running` AND `runs > UNHEALTHY_RUNS_THRESHOLD` + * - systemd: unit is in `failed` state, OR `activating` with many restarts + * + * Healthy peers are left alone — multiple installs can coexist fine now that + * container-reaper is label-scoped. + */ +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; +import { log } from '../src/log.js'; + +const UNHEALTHY_RUNS_THRESHOLD = 10; + +export interface PeerStatus { + label: string; + configPath: string; + state: string; + runs: number; + unhealthy: boolean; +} + +export interface PeerCleanupResult { + checked: PeerStatus[]; + unloaded: PeerStatus[]; + failures: Array<{ label: string; err: string }>; +} + +/** + * Scan for peer NanoClaw services and unload any that are crash-looping. + * Returns a summary suitable for emitStatus / setup-log reporting. + */ +export function cleanupUnhealthyPeers(projectRoot: string = process.cwd()): PeerCleanupResult { + const platform = os.platform(); + if (platform === 'darwin') { + return cleanupLaunchdPeers(projectRoot); + } + if (platform === 'linux') { + return cleanupSystemdPeers(projectRoot); + } + return { checked: [], unloaded: [], failures: [] }; +} + +// ---- launchd (macOS) -------------------------------------------------------- + +function cleanupLaunchdPeers(projectRoot: string): PeerCleanupResult { + const ownLabel = getLaunchdLabel(projectRoot); + const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents'); + const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] }; + + let plists: string[]; + try { + plists = fs + .readdirSync(agentsDir) + .filter((f) => /^com\.nanoclaw.*\.plist$/.test(f)) + .map((f) => path.join(agentsDir, f)); + } catch { + return result; + } + + const uid = process.getuid?.() ?? 0; + + for (const plistPath of plists) { + const label = path.basename(plistPath, '.plist'); + if (label === ownLabel) continue; + + const status = probeLaunchdPeer(label, plistPath, uid); + if (!status) continue; + result.checked.push(status); + + if (!status.unhealthy) continue; + + try { + execFileSync('launchctl', ['unload', plistPath], { stdio: 'pipe' }); + log.info('Unloaded unhealthy peer launchd service', { + label, + state: status.state, + runs: status.runs, + plistPath, + }); + result.unloaded.push(status); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn('Failed to unload peer launchd service', { label, err: message }); + result.failures.push({ label, err: message }); + } + } + + return result; +} + +function probeLaunchdPeer(label: string, plistPath: string, uid: number): PeerStatus | null { + let output: string; + try { + output = execFileSync('launchctl', ['print', `gui/${uid}/${label}`], { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf-8', + }); + } catch { + // Not loaded → not currently a threat. Skip silently. + return null; + } + + const state = /^\s*state\s*=\s*(.+?)\s*$/m.exec(output)?.[1] ?? 'unknown'; + const runsStr = /^\s*runs\s*=\s*(\d+)/m.exec(output)?.[1]; + const runs = runsStr ? parseInt(runsStr, 10) : 0; + + const unhealthy = state !== 'running' && runs > UNHEALTHY_RUNS_THRESHOLD; + return { label, configPath: plistPath, state, runs, unhealthy }; +} + +// ---- systemd (Linux) -------------------------------------------------------- + +function cleanupSystemdPeers(projectRoot: string): PeerCleanupResult { + const ownUnit = getSystemdUnit(projectRoot); + const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user'); + const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] }; + + let units: string[]; + try { + units = fs + .readdirSync(unitDir) + .filter((f) => /^nanoclaw.*\.service$/.test(f)) + .map((f) => f.replace(/\.service$/, '')); + } catch { + return result; + } + + for (const unit of units) { + if (unit === ownUnit) continue; + + const status = probeSystemdPeer(unit); + if (!status) continue; + result.checked.push(status); + + if (!status.unhealthy) continue; + + try { + execFileSync('systemctl', ['--user', 'disable', '--now', `${unit}.service`], { stdio: 'pipe' }); + log.info('Disabled unhealthy peer systemd unit', { + unit, + state: status.state, + runs: status.runs, + }); + result.unloaded.push(status); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn('Failed to disable peer systemd unit', { unit, err: message }); + result.failures.push({ label: unit, err: message }); + } + } + + return result; +} + +function probeSystemdPeer(unit: string): PeerStatus | null { + const unitPath = path.join(os.homedir(), '.config', 'systemd', 'user', `${unit}.service`); + try { + const output = execFileSync( + 'systemctl', + ['--user', 'show', '--property=ActiveState,NRestarts', `${unit}.service`], + { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' }, + ); + const activeState = /^ActiveState=(.+)$/m.exec(output)?.[1]?.trim() ?? 'unknown'; + const restartsStr = /^NRestarts=(\d+)/m.exec(output)?.[1]; + const runs = restartsStr ? parseInt(restartsStr, 10) : 0; + + const unhealthy = + activeState === 'failed' || (activeState !== 'active' && runs > UNHEALTHY_RUNS_THRESHOLD); + return { label: unit, configPath: unitPath, state: activeState, runs, unhealthy }; + } catch { + return null; + } +} diff --git a/setup/service.ts b/setup/service.ts index 7930461..777c0c5 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -11,6 +11,7 @@ import path from 'path'; import { log } from '../src/log.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; +import { cleanupUnhealthyPeers } from './peer-cleanup.js'; import { commandExists, getPlatform, @@ -53,6 +54,19 @@ export async function run(_args: string[]): Promise { fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true }); + // Peer preflight — a crash-looping peer install (most often the legacy v1 + // `com.nanoclaw` plist) will keep trashing this install's containers on + // every respawn via its own cleanupOrphans. Detect and unload any peer + // that's unhealthy before we install our service. Healthy peers are left + // alone now that container reaping is install-label-scoped. + const peerReport = cleanupUnhealthyPeers(projectRoot); + if (peerReport.unloaded.length > 0) { + log.warn('Unloaded unhealthy peer NanoClaw services', { + count: peerReport.unloaded.length, + labels: peerReport.unloaded.map((p) => p.label), + }); + } + if (platform === 'macos') { setupLaunchd(projectRoot, nodePath, homeDir); } else if (platform === 'linux') { diff --git a/src/config.ts b/src/config.ts index 79a1ce9..a82d4f5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,7 @@ import os from 'os'; import path from 'path'; import { readEnvFile } from './env.js'; -import { getContainerImageBase, getDefaultContainerImage } from './install-slug.js'; +import { getContainerImageBase, getDefaultContainerImage, getInstallSlug } from './install-slug.js'; import { isValidTimezone } from './timezone.js'; // Read config values from .env (falls back to process.env). @@ -27,6 +27,10 @@ export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); // `nanoclaw-agent:latest` and clobber each other on rebuild. export const CONTAINER_IMAGE_BASE = process.env.CONTAINER_IMAGE_BASE || getContainerImageBase(PROJECT_ROOT); export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || getDefaultContainerImage(PROJECT_ROOT); +// Install slug — stamped onto every spawned container via --label so +// cleanupOrphans only reaps containers from this install, not peers. +export const INSTALL_SLUG = getInstallSlug(PROJECT_ROOT); +export const CONTAINER_INSTALL_LABEL = `nanoclaw-install=${INSTALL_SLUG}`; export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL; diff --git a/src/container-runner.ts b/src/container-runner.ts index 646b118..71e2064 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -12,6 +12,7 @@ import { OneCLI } from '@onecli-sh/sdk'; import { CONTAINER_IMAGE, CONTAINER_IMAGE_BASE, + CONTAINER_INSTALL_LABEL, DATA_DIR, GROUPS_DIR, ONECLI_API_KEY, @@ -389,7 +390,7 @@ async function buildContainerArgs( providerContribution: ProviderContainerContribution, agentIdentifier?: string, ): Promise { - const args: string[] = ['run', '--rm', '--name', containerName]; + const args: string[] = ['run', '--rm', '--name', containerName, '--label', CONTAINER_INSTALL_LABEL]; // Environment — only vars read by code we don't own. // Everything NanoClaw-specific is in container.json (read by runner at startup). diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index 47d9744..f6f6e8a 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -24,6 +24,7 @@ import { ensureContainerRuntimeRunning, cleanupOrphans, } from './container-runtime.js'; +import { CONTAINER_INSTALL_LABEL } from './config.js'; import { log } from './log.js'; beforeEach(() => { @@ -84,6 +85,17 @@ describe('ensureContainerRuntimeRunning', () => { // --- cleanupOrphans --- describe('cleanupOrphans', () => { + it('filters ps by the install label so peers are not reaped', () => { + mockExecSync.mockReturnValueOnce(''); + + cleanupOrphans(); + + expect(mockExecSync).toHaveBeenCalledWith( + `${CONTAINER_RUNTIME_BIN} ps --filter label=${CONTAINER_INSTALL_LABEL} --format '{{.Names}}'`, + expect.any(Object), + ); + }); + it('stops orphaned nanoclaw containers', () => { // docker ps returns container names, one per line mockExecSync.mockReturnValueOnce('nanoclaw-group1-111\nnanoclaw-group2-222\n'); diff --git a/src/container-runtime.ts b/src/container-runtime.ts index 5e68426..82ddb5e 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -5,6 +5,7 @@ import { execSync } from 'child_process'; import os from 'os'; +import { CONTAINER_INSTALL_LABEL } from './config.js'; import { log } from './log.js'; /** The container runtime binary name. */ @@ -56,13 +57,22 @@ export function ensureContainerRuntimeRunning(): void { } } -/** Kill orphaned NanoClaw containers from previous runs. */ +/** + * Kill orphaned NanoClaw containers from THIS install's previous runs. + * + * Scoped by label `nanoclaw-install=` so a crash-looping peer install + * cannot reap our containers, and we cannot reap theirs. The label is + * stamped onto every container at spawn time — see container-runner.ts. + */ export function cleanupOrphans(): void { try { - const output = execSync(`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`, { - stdio: ['pipe', 'pipe', 'pipe'], - encoding: 'utf-8', - }); + const output = execSync( + `${CONTAINER_RUNTIME_BIN} ps --filter label=${CONTAINER_INSTALL_LABEL} --format '{{.Names}}'`, + { + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf-8', + }, + ); const orphans = output.trim().split('\n').filter(Boolean); for (const name of orphans) { try { From d8b1f52f2b61f7104f28d3667bab79b934a121e7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 09:52:56 +0000 Subject: [PATCH 137/185] chore: bump version to 2.0.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1d67485..09053c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.4", + "version": "2.0.5", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 3101f65a722325e6c54e55c2edc50adde413e9d0 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 11:09:30 +0300 Subject: [PATCH 138/185] feat(setup): add Slack and iMessage channel flows (experimental) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack: interactive driver walks through app creation, validates the bot token via auth.test, installs the adapter, and prints a post-install checklist for the webhook URL + Event Subscriptions config. No welcome DM since Slack needs a public URL before inbound events work — the driver's own "finish in Slack" note replaces the outro "check your DMs" banner. iMessage: picks local (macOS) vs remote (Photon) mode. Local mode opens the node binary's directory in Finder so the user can drag it into Full Disk Access. Remote mode prompts for Photon URL + API key. Asks for the operator's phone/email, then wires the first agent including a welcome iMessage. Both marked "(experimental)" in the askChannelChoice picker. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-imessage.sh | 160 +++++++++++++++++++ setup/add-slack.sh | 125 +++++++++++++++ setup/auto.ts | 50 ++++-- setup/channels/imessage.ts | 314 +++++++++++++++++++++++++++++++++++++ setup/channels/slack.ts | 249 +++++++++++++++++++++++++++++ setup/lib/claude-assist.ts | 4 + 6 files changed, 891 insertions(+), 11 deletions(-) create mode 100755 setup/add-imessage.sh create mode 100755 setup/add-slack.sh create mode 100644 setup/channels/imessage.ts create mode 100644 setup/channels/slack.ts diff --git a/setup/add-imessage.sh b/setup/add-imessage.sh new file mode 100755 index 0000000..ea19862 --- /dev/null +++ b/setup/add-imessage.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# +# Install the iMessage adapter, persist mode/creds to .env + data/env/env, +# and restart the service. Non-interactive — the Full Disk Access walkthrough +# (local mode) and Photon URL/key prompts (remote mode) live in +# setup/channels/imessage.ts. Creds come in via env vars: +# IMESSAGE_LOCAL 'true' | 'false' (required) +# IMESSAGE_ENABLED 'true' (required when IMESSAGE_LOCAL=true) +# IMESSAGE_SERVER_URL (required when IMESSAGE_LOCAL=false) +# IMESSAGE_API_KEY (required when IMESSAGE_LOCAL=false) +# +# Emits exactly one status block on stdout (ADD_IMESSAGE) at the end. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Keep in sync with .claude/skills/add-imessage/SKILL.md. +ADAPTER_VERSION="chat-adapter-imessage@0.1.1" + +# Resolve which remote carries the channels branch — handles forks where +# upstream lives on a different remote than `origin`. +# shellcheck source=setup/lib/channels-remote.sh +source "$PROJECT_ROOT/setup/lib/channels-remote.sh" +CHANNELS_REMOTE=$(resolve_channels_remote) +CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels" + +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + local mode=${IMESSAGE_LOCAL:-} + echo "=== NANOCLAW SETUP: ADD_IMESSAGE ===" + echo "STATUS: ${status}" + echo "ADAPTER_VERSION: ${ADAPTER_VERSION}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$mode" ] && echo "MODE: $([ "$mode" = "true" ] && echo local || echo remote)" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-imessage] $*" >&2; } + +# Validate creds based on mode. +if [ -z "${IMESSAGE_LOCAL:-}" ]; then + emit_status failed "IMESSAGE_LOCAL env var not set (expected true|false)" + exit 1 +fi +if [ "${IMESSAGE_LOCAL}" = "true" ]; then + if [ -z "${IMESSAGE_ENABLED:-}" ]; then + emit_status failed "IMESSAGE_ENABLED env var not set for local mode" + exit 1 + fi + if [ "$(uname -s)" != "Darwin" ]; then + emit_status failed "local mode requires macOS" + exit 1 + fi +else + if [ -z "${IMESSAGE_SERVER_URL:-}" ]; then + emit_status failed "IMESSAGE_SERVER_URL env var not set for remote mode" + exit 1 + fi + if [ -z "${IMESSAGE_API_KEY:-}" ]; then + emit_status failed "IMESSAGE_API_KEY env var not set for remote mode" + exit 1 + fi +fi + +need_install() { + [ ! -f src/channels/imessage.ts ] && return 0 + ! grep -q "^import './imessage.js';" src/channels/index.ts 2>/dev/null && return 0 + return 1 +} + +ADAPTER_ALREADY_INSTALLED=true +if need_install; then + ADAPTER_ALREADY_INSTALLED=false + log "Fetching channels branch…" + git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || { + emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed" + exit 1 + } + + log "Copying adapter from ${CHANNELS_BRANCH}…" + git show "${CHANNELS_BRANCH}:src/channels/imessage.ts" > src/channels/imessage.ts + + # Append self-registration import if missing. + if ! grep -q "^import './imessage.js';" src/channels/index.ts; then + echo "import './imessage.js';" >> src/channels/index.ts + fi + + log "Installing ${ADAPTER_VERSION}…" + pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${ADAPTER_VERSION} failed" + exit 1 + } + + log "Building…" + pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 + } +else + log "Adapter files already installed — skipping install phase." +fi + +touch .env +upsert_env() { + local key=$1 value=$2 + if grep -q "^${key}=" .env; then + awk -v k="$key" -v v="$value" \ + 'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \ + .env > .env.tmp && mv .env.tmp .env + else + echo "${key}=${value}" >> .env + fi +} + +remove_env() { + local key=$1 + if grep -q "^${key}=" .env 2>/dev/null; then + grep -v "^${key}=" .env > .env.tmp && mv .env.tmp .env + fi +} + +# Write the canonical keys for the chosen mode, strip the opposite mode's +# keys so stale values can't confuse the adapter's factory. +upsert_env IMESSAGE_LOCAL "$IMESSAGE_LOCAL" +if [ "$IMESSAGE_LOCAL" = "true" ]; then + upsert_env IMESSAGE_ENABLED "$IMESSAGE_ENABLED" + remove_env IMESSAGE_SERVER_URL + remove_env IMESSAGE_API_KEY +else + upsert_env IMESSAGE_SERVER_URL "$IMESSAGE_SERVER_URL" + upsert_env IMESSAGE_API_KEY "$IMESSAGE_API_KEY" + remove_env IMESSAGE_ENABLED +fi + +# Container reads from data/env/env (the host mounts it). +mkdir -p data/env +cp .env data/env/env + +log "Restarting service so the new adapter picks up the creds…" +# shellcheck source=setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" +case "$(uname -s)" in + Darwin) + launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true + ;; + Linux) + systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \ + || sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \ + || true + ;; +esac + +# Give the adapter a moment to open chat.db (local) or handshake with +# Photon (remote) before emitting success. +sleep 3 + +emit_status success diff --git a/setup/add-slack.sh b/setup/add-slack.sh new file mode 100755 index 0000000..3eea3e5 --- /dev/null +++ b/setup/add-slack.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# +# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET to +# .env + data/env/env, and restart the service. Non-interactive — the +# operator-facing app creation walkthrough + credential paste live in +# setup/channels/slack.ts. Credentials come in via env vars: +# SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET. +# +# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty +# progress messages go to stderr so setup:auto's raw-log capture sees the full +# story without cluttering the final block for the parser. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Keep in sync with .claude/skills/add-slack/SKILL.md. +ADAPTER_VERSION="@chat-adapter/slack@4.26.0" + +# Resolve which remote carries the channels branch — handles forks where +# upstream lives on a different remote than `origin`. +# shellcheck source=setup/lib/channels-remote.sh +source "$PROJECT_ROOT/setup/lib/channels-remote.sh" +CHANNELS_REMOTE=$(resolve_channels_remote) +CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels" + +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + echo "=== NANOCLAW SETUP: ADD_SLACK ===" + echo "STATUS: ${status}" + echo "ADAPTER_VERSION: ${ADAPTER_VERSION}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-slack] $*" >&2; } + +if [ -z "${SLACK_BOT_TOKEN:-}" ]; then + emit_status failed "SLACK_BOT_TOKEN env var not set" + exit 1 +fi +if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then + emit_status failed "SLACK_SIGNING_SECRET env var not set" + exit 1 +fi + +need_install() { + [ ! -f src/channels/slack.ts ] && return 0 + ! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0 + return 1 +} + +ADAPTER_ALREADY_INSTALLED=true +if need_install; then + ADAPTER_ALREADY_INSTALLED=false + log "Fetching channels branch…" + git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || { + emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed" + exit 1 + } + + log "Copying adapter from ${CHANNELS_BRANCH}…" + git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts + + # Append self-registration import if missing. + if ! grep -q "^import './slack.js';" src/channels/index.ts; then + echo "import './slack.js';" >> src/channels/index.ts + fi + + log "Installing ${ADAPTER_VERSION}…" + pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${ADAPTER_VERSION} failed" + exit 1 + } + + log "Building…" + pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 + } +else + log "Adapter files already installed — skipping install phase." +fi + +# Persist credentials. auto.ts validates via auth.test before this point, so +# bad values here would be an internal bug rather than operator input. +touch .env +upsert_env() { + local key=$1 value=$2 + if grep -q "^${key}=" .env; then + awk -v k="$key" -v v="$value" \ + 'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \ + .env > .env.tmp && mv .env.tmp .env + else + echo "${key}=${value}" >> .env + fi +} +upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN" +upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET" + +# Container reads from data/env/env (the host mounts it). +mkdir -p data/env +cp .env data/env/env + +log "Restarting service so the new adapter picks up the credentials…" +# shellcheck source=setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" +case "$(uname -s)" in + Darwin) + launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true + ;; + Linux) + systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \ + || sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \ + || true + ;; +esac + +# Give the Slack adapter a moment to finish starting the webhook listener +# before emitting success. +sleep 3 + +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts index 4becf6e..4c20262 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -27,6 +27,8 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { runDiscordChannel } from './channels/discord.js'; +import { runIMessageChannel } from './channels/imessage.js'; +import { runSlackChannel } from './channels/slack.js'; import { runTeamsChannel } from './channels/teams.js'; import { runTelegramChannel } from './channels/telegram.js'; import { runWhatsAppChannel } from './channels/whatsapp.js'; @@ -48,6 +50,15 @@ import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); +type ChannelChoice = + | 'telegram' + | 'discord' + | 'whatsapp' + | 'teams' + | 'slack' + | 'imessage' + | 'skip'; + async function main(): Promise { printIntro(); initProgressionLog(); @@ -295,8 +306,7 @@ async function main(): Promise { await runTimezoneStep(); } - let channelChoice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip' = - 'skip'; + let channelChoice: ChannelChoice = 'skip'; if (!skip.has('channel')) { channelChoice = await askChannelChoice(); if (channelChoice === 'telegram') { @@ -307,10 +317,14 @@ async function main(): Promise { await runWhatsAppChannel(displayName!); } else if (channelChoice === 'teams') { await runTeamsChannel(displayName!); + } else if (channelChoice === 'slack') { + await runSlackChannel(displayName!); + } else if (channelChoice === 'imessage') { + await runIMessageChannel(displayName!); } else { p.log.info( wrapForGutter( - 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, or Slack).', + 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', 4, ), ); @@ -420,9 +434,7 @@ async function main(): Promise { } } -function channelDmLabel( - choice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip', -): string | null { +function channelDmLabel(choice: ChannelChoice): string | null { switch (choice) { case 'telegram': return 'Telegram'; @@ -432,6 +444,13 @@ function channelDmLabel( return 'WhatsApp'; case 'teams': return 'Teams'; + case 'imessage': + return 'iMessage'; + case 'slack': + // Slack install doesn't wire an agent or send a welcome DM — the + // driver prints its own "finish in your Slack app" note. Falling + // through to null avoids a misleading "check your Slack DMs" banner. + return null; default: return null; } @@ -807,16 +826,25 @@ async function askDisplayName(fallback: string): Promise { return value; } -async function askChannelChoice(): Promise< - 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip' -> { +async function askChannelChoice(): Promise { + const isMac = process.platform === 'darwin'; const choice = ensureAnswer( - await brightSelect({ + await brightSelect({ message: 'Want to chat with your assistant from your phone?', options: [ { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, { value: 'discord', label: 'Yes, connect Discord' }, { value: 'whatsapp', label: 'Yes, connect WhatsApp' }, + { + value: 'imessage', + label: 'Yes, connect iMessage (experimental)', + hint: isMac ? 'local macOS mode' : 'remote Photon only', + }, + { + value: 'slack', + label: 'Yes, connect Slack (experimental)', + hint: 'needs public URL', + }, { value: 'teams', label: 'Yes, connect Microsoft Teams', hint: 'complex setup' }, { value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" }, ], @@ -824,7 +852,7 @@ async function askChannelChoice(): Promise< ); setupLog.userInput('channel_choice', String(choice)); phEmit('channel_chosen', { channel: String(choice) }); - return choice as 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'; + return choice; } // ─── interactive / env helpers ───────────────────────────────────────── diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts new file mode 100644 index 0000000..d8b129f --- /dev/null +++ b/setup/channels/imessage.ts @@ -0,0 +1,314 @@ +/** + * iMessage channel flow for setup:auto. + * + * `runIMessageChannel(displayName)` covers both deployment modes: + * + * Local (macOS): the bot runs on this Mac and talks via the signed-in + * iMessage account. Reading chat.db needs Full Disk Access granted to + * the Node binary — we open the directory for them so they can drag + * the `node` file into System Settings. + * + * Remote (Photon API): the bot talks to a separate server (Photon) + * that owns an iMessage account on another Mac. Used when this host + * is Linux, or when the operator wants to keep their daily-driver + * Mac's chat history out of the loop. + * + * Flow: + * 1. Pick mode (auto-defaults to local on macOS, remote elsewhere) + * 2. Local: FDA walkthrough (open node bin directory, wait for ack) + * Remote: prompt for Photon server URL + API key + * 3. Ask for the phone or email the operator messages from — this is + * the platform-id for first-agent wiring + * 4. Install the adapter (setup/add-imessage.sh, non-interactive) + * 5. Wire the agent via scripts/init-first-agent.ts — the welcome + * iMessage goes out through the normal delivery path + * + * All output obeys the three-level contract. See docs/setup-flow.md. + */ +import { execSync } from 'child_process'; +import os from 'os'; +import path from 'path'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; +import { brightSelect } from '../lib/bright-select.js'; +import { askOperatorRole } from '../lib/role-prompt.js'; +import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; +import { wrapForGutter } from '../lib/theme.js'; + +const DEFAULT_AGENT_NAME = 'Nano'; + +type Mode = 'local' | 'remote'; + +interface RemoteCreds { + serverUrl: string; + apiKey: string; +} + +export async function runIMessageChannel(displayName: string): Promise { + const isMac = os.platform() === 'darwin'; + + const mode = await askMode(isMac); + let remoteCreds: RemoteCreds | null = null; + + if (mode === 'local') { + if (!isMac) { + await fail( + 'imessage', + "Local iMessage mode only works on macOS.", + 'Choose remote mode (Photon API) on Linux/WSL, or run setup from your Mac.', + ); + } + await walkThroughFullDiskAccess(); + } else { + remoteCreds = await collectRemoteCreds(); + } + + const handle = await askOperatorHandle(); + + const install = await runQuietChild( + 'imessage-install', + 'bash', + ['setup/add-imessage.sh'], + { + running: + mode === 'local' + ? "Connecting the iMessage adapter to this Mac…" + : `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}…`, + done: 'iMessage adapter installed.', + }, + { + env: + mode === 'local' + ? { IMESSAGE_LOCAL: 'true', IMESSAGE_ENABLED: 'true' } + : { + IMESSAGE_LOCAL: 'false', + IMESSAGE_SERVER_URL: remoteCreds!.serverUrl, + IMESSAGE_API_KEY: remoteCreds!.apiKey, + }, + extraFields: { MODE: mode }, + }, + ); + if (!install.ok) { + await fail( + 'imessage-install', + "Couldn't install the iMessage adapter.", + 'See logs/setup-steps/ for details, then retry setup.', + ); + } + + const role = await askOperatorRole('iMessage'); + setupLog.userInput('imessage_role', role); + + const agentName = await resolveAgentName(); + + const init = await runQuietChild( + 'init-first-agent', + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'imessage', + '--user-id', handle, + '--platform-id', handle, + '--display-name', displayName, + '--agent-name', agentName, + '--role', role, + ], + { + running: `Connecting ${agentName} to iMessage…`, + done: `${agentName} is ready. Check iMessage for a welcome message.`, + }, + { + extraFields: { + CHANNEL: 'imessage', + AGENT_NAME: agentName, + PLATFORM_ID: handle, + MODE: mode, + }, + }, + ); + if (!init.ok) { + await fail( + 'init-first-agent', + `Couldn't finish connecting ${agentName}.`, + 'Double-check Full Disk Access (local mode) or Photon credentials (remote), then retry.', + ); + } +} + +async function askMode(isMac: boolean): Promise { + const choice = ensureAnswer( + await brightSelect({ + message: 'How should iMessage run?', + initialValue: isMac ? 'local' : 'remote', + options: isMac + ? [ + { + value: 'local', + label: 'Local (this Mac)', + hint: "uses this machine's iMessage account", + }, + { + value: 'remote', + label: 'Remote (Photon API)', + hint: 'the bot lives on another server', + }, + ] + : [ + { + value: 'remote', + label: 'Remote (Photon API)', + hint: 'only option off macOS', + }, + ], + }), + ); + setupLog.userInput('imessage_mode', String(choice)); + return choice; +} + +/** + * Grant Full Disk Access to the Node binary the host runs under — without + * it, the adapter can't read chat.db and inbound messages never arrive. + * Opening the containing directory in Finder makes the drag-and-drop + * target obvious; falling back to printing the path keeps us working in + * SSH/headless contexts where `open` is a no-op. + */ +async function walkThroughFullDiskAccess(): Promise { + let nodePath = process.execPath; + try { + // `which node` picks up the user's shell-resolved node, which may differ + // from process.execPath (e.g. they launched setup under a different + // Node via `nvm`). If it succeeds and is resolvable, prefer it. + const which = execSync('which node', { encoding: 'utf-8' }).trim(); + if (which) nodePath = which; + } catch { + // fall back to process.execPath + } + const nodeDir = path.dirname(nodePath); + + p.note( + wrapForGutter( + [ + `iMessage needs Full Disk Access granted to the Node binary:`, + '', + ` ${nodePath}`, + '', + ' 1. System Settings → Privacy & Security → Full Disk Access', + ` 2. Click +, then drag the "node" file from the Finder window`, + ' we just opened for you', + ' 3. Toggle it on, then come back here', + ].join('\n'), + 6, + ), + 'Grant Full Disk Access', + ); + + try { + execSync(`open "${nodeDir}"`, { stdio: 'ignore' }); + } catch { + // No Finder (SSH/headless) — user sees the path in the note above. + } + + ensureAnswer( + await p.confirm({ + message: "Granted Full Disk Access?", + initialValue: true, + }), + ); + setupLog.userInput('imessage_fda_confirmed', 'true'); +} + +async function collectRemoteCreds(): Promise { + p.note( + [ + "Photon is a separate service that owns an iMessage account and", + "exposes it over HTTP. NanoClaw will talk to it via its API.", + '', + ' 1. Set up a Photon server: https://photon.im', + ' 2. Copy the server URL and API key from your Photon dashboard', + ].join('\n'), + 'Remote iMessage via Photon', + ); + + const urlAnswer = ensureAnswer( + await p.text({ + message: 'Photon server URL', + placeholder: 'https://photon.example.com', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'URL is required'; + if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://'; + return undefined; + }, + }), + ); + const serverUrl = (urlAnswer as string).trim(); + + const keyAnswer = ensureAnswer( + await p.password({ + message: 'Photon API key', + validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'), + }), + ); + const apiKey = (keyAnswer as string).trim(); + + setupLog.userInput('imessage_server_url', serverUrl); + setupLog.userInput( + 'imessage_api_key', + `${apiKey.slice(0, 4)}…${apiKey.slice(-4)}`, + ); + return { serverUrl, apiKey }; +} + +async function askOperatorHandle(): Promise { + p.note( + [ + "What phone number or email do you iMessage with?", + "That's where your assistant will send its welcome message.", + '', + k.dim(' • Phone: full E.164, e.g. +15551234567'), + k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'), + ].join('\n'), + 'Your iMessage handle', + ); + + const answer = ensureAnswer( + await p.text({ + message: 'Phone number or email', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'Required'; + const isPhone = /^\+\d{8,15}$/.test(t); + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t); + if (!isPhone && !isEmail) { + return "Use a +E.164 phone number or an email address"; + } + return undefined; + }, + }), + ); + const handle = (answer as string).trim(); + setupLog.userInput('imessage_handle', handle); + return handle; +} + +async function resolveAgentName(): Promise { + const preset = process.env.NANOCLAW_AGENT_NAME?.trim(); + if (preset) { + setupLog.userInput('agent_name', preset); + return preset; + } + const answer = ensureAnswer( + await p.text({ + message: 'What should your assistant be called?', + placeholder: DEFAULT_AGENT_NAME, + defaultValue: DEFAULT_AGENT_NAME, + }), + ); + const value = (answer as string).trim() || DEFAULT_AGENT_NAME; + setupLog.userInput('agent_name', value); + return value; +} diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts new file mode 100644 index 0000000..f66c29a --- /dev/null +++ b/setup/channels/slack.ts @@ -0,0 +1,249 @@ +/** + * Slack channel flow for setup:auto. + * + * `runSlackChannel(displayName)` walks the operator from a bare Slack + * workspace through a running bot, then stops before wiring an agent: + * + * 1. Walk through creating a Slack app (api.slack.com/apps) — scopes, + * event subscriptions, and signing secret + * 2. Paste the bot token + signing secret (clack password prompts) + * 3. Validate via auth.test → resolves workspace + bot identity + * 4. Install the adapter (setup/add-slack.sh, non-interactive) + * 5. Print the post-install checklist: set the public webhook URL in + * Slack's Event Subscriptions, DM the bot to bootstrap the channel, + * then `/manage-channels` to wire an agent. + * + * Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll), + * Slack needs a public Event Subscriptions URL for inbound events, and + * opening an unsolicited DM would need `im:write` scope we don't force + * the SKILL.md to require. Shipping a honest "here's what's left" note + * is better than a welcome DM the user won't receive until they + * configure the webhook anyway. + * + * All output obeys the three-level contract. See docs/setup-flow.md. + */ +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; +import { confirmThenOpen } from '../lib/browser.js'; +import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; +import { wrapForGutter } from '../lib/theme.js'; + +const SLACK_API = 'https://slack.com/api'; +const SLACK_APPS_URL = 'https://api.slack.com/apps'; + +interface WorkspaceInfo { + teamName: string; + teamId: string; + botName: string; + botUserId: string; +} + +// displayName is reserved for when we start wiring the first agent here. +// Kept to match the `runChannel(displayName)` signature every other +// channel driver uses, so auto.ts can dispatch without a branch. +export async function runSlackChannel(_displayName: string): Promise { + await walkThroughAppCreation(); + + const token = await collectBotToken(); + const signingSecret = await collectSigningSecret(); + const info = await validateSlackToken(token); + + const install = await runQuietChild( + 'slack-install', + 'bash', + ['setup/add-slack.sh'], + { + running: `Connecting Slack to @${info.botName} (${info.teamName})…`, + done: 'Slack adapter installed.', + }, + { + env: { + SLACK_BOT_TOKEN: token, + SLACK_SIGNING_SECRET: signingSecret, + }, + extraFields: { + BOT_NAME: info.botName, + TEAM_NAME: info.teamName, + TEAM_ID: info.teamId, + }, + }, + ); + if (!install.ok) { + await fail( + 'slack-install', + "Couldn't connect Slack.", + 'See logs/setup-steps/ for details, then retry setup.', + ); + } + + showPostInstallChecklist(info); +} + +async function walkThroughAppCreation(): Promise { + p.note( + [ + "You'll create a Slack app that the assistant talks through.", + "Free and stays inside the workspaces you pick.", + '', + ' 1. Create a new app "From scratch", name it, pick a workspace', + ' 2. OAuth & Permissions → add Bot Token Scopes:', + ' chat:write, channels:history, groups:history, im:history,', + ' channels:read, groups:read, users:read, reactions:write', + ' 3. App Home → enable "Messages Tab" and "Allow users to send', + ' slash commands and messages from the messages tab"', + ' 4. Basic Information → copy the "Signing Secret"', + ' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)', + '', + k.dim(SLACK_APPS_URL), + ].join('\n'), + 'Create a Slack app', + ); + await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings'); + + ensureAnswer( + await p.confirm({ + message: 'Got your bot token and signing secret?', + initialValue: true, + }), + ); +} + +async function collectBotToken(): Promise { + const answer = ensureAnswer( + await p.password({ + message: 'Paste your Slack bot token', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'Token is required'; + if (!t.startsWith('xoxb-')) return 'Bot tokens start with xoxb-'; + if (t.length < 24) return "That's shorter than a real Slack bot token"; + return undefined; + }, + }), + ); + const token = (answer as string).trim(); + setupLog.userInput( + 'slack_bot_token', + `${token.slice(0, 10)}…${token.slice(-4)}`, + ); + return token; +} + +async function collectSigningSecret(): Promise { + const answer = ensureAnswer( + await p.password({ + message: 'Paste your Slack signing secret', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'Signing secret is required'; + // Slack signing secrets are 32-char hex strings, but newer apps + // sometimes emit longer variants — leniently require hex only. + if (!/^[a-f0-9]{16,}$/i.test(t)) { + return 'Signing secrets are a string of hex characters'; + } + return undefined; + }, + }), + ); + const secret = (answer as string).trim(); + setupLog.userInput( + 'slack_signing_secret', + `${secret.slice(0, 4)}…${secret.slice(-4)}`, + ); + return secret; +} + +async function validateSlackToken(token: string): Promise { + const s = p.spinner(); + const start = Date.now(); + s.start('Checking your bot token…'); + try { + const res = await fetch(`${SLACK_API}/auth.test`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + const data = (await res.json()) as { + ok?: boolean; + team?: string; + team_id?: string; + user?: string; + user_id?: string; + error?: string; + }; + const elapsedS = Math.round((Date.now() - start) / 1000); + if (data.ok && data.team && data.user) { + s.stop( + `Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`, + ); + const info: WorkspaceInfo = { + teamName: data.team, + teamId: data.team_id ?? '', + botName: data.user, + botUserId: data.user_id ?? '', + }; + setupLog.step('slack-validate', 'success', Date.now() - start, { + BOT_NAME: info.botName, + BOT_USER_ID: info.botUserId, + TEAM_NAME: info.teamName, + TEAM_ID: info.teamId, + }); + return info; + } + const reason = data.error ?? `HTTP ${res.status}`; + s.stop(`Slack didn't accept that token: ${reason}`, 1); + setupLog.step('slack-validate', 'failed', Date.now() - start, { + ERROR: reason, + }); + await fail( + 'slack-validate', + "Slack didn't accept that token.", + reason === 'invalid_auth' || reason === 'token_revoked' + ? 'Copy the token again from OAuth & Permissions and retry setup.' + : `Slack said "${reason}". Check the token scopes and workspace install, then retry.`, + ); + } catch (err) { + const elapsedS = Math.round((Date.now() - start) / 1000); + s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('slack-validate', 'failed', Date.now() - start, { + ERROR: message, + }); + await fail( + 'slack-validate', + "Couldn't reach Slack.", + 'Check your internet connection and retry setup.', + ); + } +} + +function showPostInstallChecklist(info: WorkspaceInfo): void { + p.note( + wrapForGutter( + [ + `The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`, + '', + ' 1. A public URL so Slack can deliver events.', + ' NanoClaw serves a webhook on port 3000 by default — expose it', + ' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.', + '', + ' 2. In your Slack app → Event Subscriptions:', + ' • Toggle "Enable Events" on', + ` • Request URL: https:///webhook/slack`, + ' • Subscribe to bot events: message.channels, message.groups,', + ' message.im, app_mention', + ' • Save, then reinstall the app when Slack prompts', + '', + ` 3. DM @${info.botName} from Slack once — that bootstraps the`, + ' messaging group. Then run `/manage-channels` in `claude` to', + ' wire an agent to it.', + ].join('\n'), + 6, + ), + 'Finish setting up Slack', + ); +} diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index c2b0367..1651a9c 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -64,6 +64,10 @@ const STEP_FILES: Record = { 'telegram-validate': ['setup/channels/telegram.ts'], 'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'], 'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'], + 'slack-install': ['setup/add-slack.sh', 'setup/channels/slack.ts'], + 'slack-validate': ['setup/channels/slack.ts'], + 'imessage-install': ['setup/add-imessage.sh', 'setup/channels/imessage.ts'], + 'imessage': ['setup/channels/imessage.ts'], 'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'], 'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'], 'init-first-agent': [ From 61ca43d19313e92631674a208b14c321bd26584b Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:12 +0000 Subject: [PATCH 139/185] fix(discord): resolve user ID from DM interactions for approval clicks Discord puts the clicking user at interaction.member.user for guild interactions but interaction.user for DM interactions. The Gateway handler only checked interaction.member, so DM button clicks resolved to an empty user ID and were silently rejected as unauthorized. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/chat-sdk-bridge.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 5c120e0..6c9f802 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -105,7 +105,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter let setupConfig: ChannelSetup; let gatewayAbort: AbortController | null = null; - async function messageToInbound(message: ChatMessage, isMention: boolean): Promise { + async function messageToInbound(message: ChatMessage, isMention: boolean, isGroup?: boolean): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const serialized = message.toJSON() as Record; @@ -162,6 +162,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter content: serialized, timestamp: message.metadata.dateSent.toISOString(), isMention, + isGroup, }; } @@ -195,13 +196,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // wirings still fire on in-thread mentions. chat.onSubscribedMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true, true)); }); // @mention in an unsubscribed thread — SDK-confirmed bot mention. chat.onNewMention(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, true)); }); // DMs — by definition addressed to the bot. Thread id flows through @@ -216,7 +217,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown', threadId: thread.id, }); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, false)); }); // Plain messages in unsubscribed threads. @@ -231,7 +232,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // flood gate. chat.onNewMessage(/./, async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false, true)); }); // Handle button clicks (ask_user_question) @@ -501,7 +502,10 @@ async function handleForwardedEvent( // type 3 = MessageComponent (button/select) if (interaction.type === 3) { const customId = (interaction.data as Record)?.custom_id as string; - const user = (interaction.member as Record)?.user as Record | undefined; + // In guilds the clicker is at interaction.member.user; in DMs it's interaction.user directly. + const user = + ((interaction.member as Record)?.user as Record | undefined) ?? + (interaction.user as Record | undefined); const interactionId = interaction.id as string; const interactionToken = interaction.token as string; From d121cd1cd6b12a764ae79be479bc8d7950fddea4 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:23 +0000 Subject: [PATCH 140/185] fix(router): pass isGroup from adapter through to messaging group creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router hardcoded is_group=0 when auto-creating messaging groups, causing channel mentions to be misclassified as DMs. The Chat SDK bridge knows which handler fired (onDirectMessage vs onNewMention) so thread the signal through InboundMessage → InboundEvent → router. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/adapter.ts | 4 ++++ src/index.ts | 1 + src/router.ts | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index d8d8f9d..82247a1 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -56,6 +56,8 @@ export interface InboundEvent { * See InboundMessage.isMention for the full explanation. */ isMention?: boolean; + /** True when the source is a group/channel thread, false for DMs. */ + isGroup?: boolean; }; replyTo?: DeliveryAddress; } @@ -81,6 +83,8 @@ export interface InboundMessage { * router falls back to text-match against agent_group_name. */ isMention?: boolean; + /** True when the source is a group/channel thread, false for DMs. */ + isGroup?: boolean; } /** A file attachment to deliver alongside a message. */ diff --git a/src/index.ts b/src/index.ts index d3de4d9..ea9fba6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,6 +85,7 @@ async function main(): Promise { content: JSON.stringify(message.content), timestamp: message.timestamp, isMention: message.isMention, + isGroup: message.isGroup, }, }).catch((err) => { log.error('Failed to route inbound message', { channelType: adapter.channelType, err }); diff --git a/src/router.ts b/src/router.ts index 538c270..3cf0192 100644 --- a/src/router.ts +++ b/src/router.ts @@ -170,7 +170,7 @@ export async function routeInbound(event: InboundEvent): Promise { channel_type: event.channelType, platform_id: event.platformId, name: null, - is_group: 0, + is_group: event.message.isGroup ? 1 : 0, unknown_sender_policy: 'request_approval', denied_at: null, created_at: new Date().toISOString(), From 15f30682d79a4fd2721990ee6b126178da41afc0 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:34 +0000 Subject: [PATCH 141/185] fix(approvals): show human-readable names in approval cards Channel and sender approval cards showed raw platform IDs (e.g. discord:1475578393738219540:...) instead of readable context. Extract sender name from the event content for channel approvals, and use the channel type name for sender approvals. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/modules/permissions/channel-approval.ts | 20 ++++++++++++++++---- src/modules/permissions/sender-approval.ts | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index caef815..e4b2142 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -101,13 +101,25 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) return; } - const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat'; - const isGroup = originMg?.is_group === 1; + const isGroup = event.message?.isGroup ?? originMg?.is_group === 1; + + // Extract sender name from the event content for a human-readable card. + let senderName: string | undefined; + try { + const parsed = JSON.parse(event.message.content) as Record; + senderName = (parsed.senderName ?? parsed.sender) as string | undefined; + } catch { + // non-critical — fall through to generic wording + } const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message'; const question = isGroup - ? `Your agent was mentioned in ${originName} on ${originChannelType}. Wire it to ${target.name} and let it engage?` - : `Someone DM'd your agent on ${originChannelType} (${originName}). Wire it to ${target.name} and let it respond?`; + ? senderName + ? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` + : `Your agent was mentioned in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` + : senderName + ? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?` + : `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`; createPendingChannelApproval({ messaging_group_id: messagingGroupId, diff --git a/src/modules/permissions/sender-approval.ts b/src/modules/permissions/sender-approval.ts index e08123a..a20e14f 100644 --- a/src/modules/permissions/sender-approval.ts +++ b/src/modules/permissions/sender-approval.ts @@ -88,7 +88,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): const approvalId = generateId(); const senderDisplay = senderName && senderName.length > 0 ? senderName : senderIdentity; - const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat'; + const originName = originMg?.name ?? `a ${originChannelType} channel`; const title = '👤 New sender'; const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`; From 40f5683c3660d8f63982bfcb9f29f1157607a12e Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:45 +0000 Subject: [PATCH 142/185] fix(approvals): show correct post-click labels on channel/sender cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAskQuestionRender only checked pending_questions and pending_approvals, missing the channel and sender approval tables. Approval button clicks showed the raw value ("approve") instead of the selectedLabel ("✅ Wired"). Extend the lookup to also check pending_channel_approvals and pending_sender_approvals. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/db/sessions.ts | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/db/sessions.ts b/src/db/sessions.ts index bdca8a6..e9461ca 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -1,5 +1,5 @@ import type { PendingApproval, PendingQuestion, Session } from '../types.js'; -import { getDb } from './connection.js'; +import { getDb, hasTable } from './connection.js'; // ── Sessions ── @@ -192,6 +192,35 @@ export function getAskQuestionRender( const a = getDb().prepare('SELECT title, options_json FROM pending_approvals WHERE approval_id = ?').get(id) as | { title: string; options_json: string } | undefined; - if (!a || !a.title) return undefined; - return { title: a.title, options: JSON.parse(a.options_json) }; + if (a?.title) return { title: a.title, options: JSON.parse(a.options_json) }; + + // Channel-registration approval — options are fixed constants. + if (hasTable(getDb(), 'pending_channel_approvals')) { + const c = getDb().prepare('SELECT 1 FROM pending_channel_approvals WHERE messaging_group_id = ?').get(id); + if (c) { + return { + title: '📣 Channel registration', + options: [ + { label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' }, + { label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' }, + ], + }; + } + } + + // Unknown-sender approval — options are fixed constants. + if (hasTable(getDb(), 'pending_sender_approvals')) { + const s = getDb().prepare('SELECT 1 FROM pending_sender_approvals WHERE id = ?').get(id); + if (s) { + return { + title: '👤 New sender', + options: [ + { label: 'Allow', selectedLabel: '✅ Allowed', value: 'approve' }, + { label: 'Deny', selectedLabel: '❌ Denied', value: 'reject' }, + ], + }; + } + } + + return undefined; } From 3a9b98f1a46261e97137ca0ffaa784102dee30fa Mon Sep 17 00:00:00 2001 From: Misha Skvortsov Date: Thu, 23 Apr 2026 16:18:34 +0300 Subject: [PATCH 143/185] feat: add Atomic Chat MCP tool skill Exposes local Atomic Chat models (OpenAI-compatible API at 127.0.0.1:1337/v1) as tools to the container agent. Adds atomic_chat_list_models and atomic_chat_generate alongside the existing Ollama skill. Rebased on current main: - MCP server registered in agent-runner index.ts using bun (no tsc step in-image), sibling path to index.ts, env: {} with ATOMIC_CHAT_* forwarded when set. - allowedTools entry moved to providers/claude.ts TOOL_ALLOWLIST. - SKILL.md: drop obsolete per-group copy step (single RO mount supersedes it); use pnpm build. Made-with: Cursor Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-atomic-chat-tool/SKILL.md | 154 ++++++++++++ .env.example | 7 + .../agent-runner/src/atomic-chat-mcp-stdio.ts | 229 ++++++++++++++++++ container/agent-runner/src/index.ts | 8 + .../agent-runner/src/providers/claude.ts | 1 + src/container-runner.ts | 15 +- 6 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/add-atomic-chat-tool/SKILL.md create mode 100644 container/agent-runner/src/atomic-chat-mcp-stdio.ts diff --git a/.claude/skills/add-atomic-chat-tool/SKILL.md b/.claude/skills/add-atomic-chat-tool/SKILL.md new file mode 100644 index 0000000..d995519 --- /dev/null +++ b/.claude/skills/add-atomic-chat-tool/SKILL.md @@ -0,0 +1,154 @@ +--- +name: add-atomic-chat-tool +description: Add Atomic Chat MCP server so the container agent can call local models served by the Atomic Chat desktop app via its OpenAI-compatible API. +--- + +# Add Atomic Chat Integration + +This skill adds a stdio-based MCP server that exposes models running in the local [Atomic Chat](https://github.com/AtomicBot-ai/Atomic-Chat) desktop app as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by Atomic Chat on `http://127.0.0.1:1337/v1` (OpenAI-compatible). + +Tools exposed: +- `atomic_chat_list_models` — list models currently available in Atomic Chat (`GET /v1/models`) +- `atomic_chat_generate` — send a prompt to a specified model and return the response (`POST /v1/chat/completions`) + +Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library. + +## Phase 1: Pre-flight + +### Check if already applied + +Check if `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure). + +### Check prerequisites + +Verify Atomic Chat is installed and its local API server is running. On the host: + +```bash +curl -s http://127.0.0.1:1337/v1/models | head +``` + +If the request fails: + +1. Install Atomic Chat from the [latest release](https://github.com/AtomicBot-ai/Atomic-Chat/releases) (macOS only for now — `atomic-chat.dmg`). +2. Open the app. +3. Open **Settings → Local API Server** and make sure it's enabled on port `1337`. +4. Go to the **Hub** (or **Models**) tab and download at least one model (e.g. Llama 3.2 3B, Qwen 2.5 Coder 7B). +5. Load the model once by sending any message in Atomic Chat's UI to warm it up. + +## Phase 2: Apply Code Changes + +### Ensure upstream remote + +```bash +git remote -v +``` + +If `upstream` is missing, add it: + +```bash +git remote add upstream https://github.com/qwibitai/nanoclaw.git +``` + +### Merge the skill branch + +```bash +git fetch upstream skill/atomic-chat-tool +git merge upstream/skill/atomic-chat-tool +``` + +This merges in: +- `container/agent-runner/src/atomic-chat-mcp-stdio.ts` (Atomic Chat MCP server, run directly via `bun`) +- Atomic Chat MCP registration in `container/agent-runner/src/index.ts` (`mcpServers.atomic_chat`) +- `mcp__atomic_chat__*` added to `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts` +- `[ATOMIC]` log surfacing and `ATOMIC_CHAT_HOST` / `ATOMIC_CHAT_API_KEY` forwarding in `src/container-runner.ts` +- `ATOMIC_CHAT_HOST` / `ATOMIC_CHAT_API_KEY` stubs in `.env.example` + +If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. + +### Validate code changes + +```bash +pnpm run build +./container/build.sh +``` + +Build must be clean before proceeding. + +## Phase 3: Configure + +### Set Atomic Chat host (optional) + +By default, the MCP server connects to `http://host.docker.internal:1337` (Docker Desktop) with a fallback to `localhost`. To use a custom host, add to `.env`: + +```bash +ATOMIC_CHAT_HOST=http://your-atomic-chat-host:1337 +``` + +### Set API key (optional) + +Atomic Chat does **not require authentication** when running locally — leave this unset. Only set it if you've put Atomic Chat behind a reverse proxy that enforces auth: + +```bash +ATOMIC_CHAT_API_KEY=sk-... +``` + +### Restart the service + +```bash +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# Linux: systemctl --user restart nanoclaw +``` + +## Phase 4: Verify + +### Test inference + +Tell the user: + +> Send a message like: "use atomic chat to tell me the capital of France" +> +> The agent should use `atomic_chat_list_models` to find available models, then `atomic_chat_generate` to get a response. + +### Check logs if needed + +```bash +tail -f logs/nanoclaw.log | grep -i atomic +``` + +Look for: +- `[ATOMIC] Listing models...` — list request started +- `[ATOMIC] Found N models` — models discovered +- `[ATOMIC] >>> Generating with ` — generation started +- `[ATOMIC] <<< Done: | Xs | N tokens | M chars` — generation completed + +## Troubleshooting + +### Agent says "Atomic Chat is not installed" or tries to run a CLI + +The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means: +1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers` +2. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST` +3. The container wasn't rebuilt — run `./container/build.sh` + +### "Failed to connect to Atomic Chat" + +1. Verify the host API is reachable: `curl http://127.0.0.1:1337/v1/models` +2. Confirm the Local API Server is enabled in Atomic Chat's settings +3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:1337/v1/models` +4. If using a custom host, check `ATOMIC_CHAT_HOST` in `.env` + +### `model not found` / 404 on generate + +The model ID passed to `atomic_chat_generate` must exactly match one of the IDs returned by `atomic_chat_list_models`. Ask the agent to list models first, then pick one from that list. + +### Slow first response + +Atomic Chat lazy-loads models into memory on first use. The initial call may take longer while the model warms up. Subsequent calls against the same model are fast. + +### Agent doesn't use Atomic Chat tools + +The agent may not know about the tools. Try being explicit: "use the atomic_chat_generate tool with llama3.2-3b-instruct to answer: ..." + +### Context window or output size issues + +Atomic Chat respects each model's native context length. If you hit limits, pass `max_tokens` explicitly when calling `atomic_chat_generate`, or switch to a model with a larger context window in the Atomic Chat UI. diff --git a/.env.example b/.env.example index e69de29..61f2074 100644 --- a/.env.example +++ b/.env.example @@ -0,0 +1,7 @@ +# Atomic Chat MCP tool (skill/atomic-chat-tool) +# Override the host where Atomic Chat exposes its OpenAI-compatible API. +# Default: http://host.docker.internal:1337 (with fallback to localhost) +# ATOMIC_CHAT_HOST=http://host.docker.internal:1337 + +# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth. +# ATOMIC_CHAT_API_KEY= diff --git a/container/agent-runner/src/atomic-chat-mcp-stdio.ts b/container/agent-runner/src/atomic-chat-mcp-stdio.ts new file mode 100644 index 0000000..0198644 --- /dev/null +++ b/container/agent-runner/src/atomic-chat-mcp-stdio.ts @@ -0,0 +1,229 @@ +/** + * Atomic Chat MCP Server for NanoClaw + * Exposes local Atomic Chat models (OpenAI-compatible, /v1) as tools for the container agent. + * Uses host.docker.internal to reach the host's Atomic Chat desktop app from Docker. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +import fs from 'fs'; +import path from 'path'; + +const ATOMIC_CHAT_HOST = + process.env.ATOMIC_CHAT_HOST || 'http://host.docker.internal:1337'; +const ATOMIC_CHAT_API_KEY = process.env.ATOMIC_CHAT_API_KEY || ''; +const ATOMIC_CHAT_STATUS_FILE = '/workspace/ipc/atomic_chat_status.json'; + +function log(msg: string): void { + console.error(`[ATOMIC] ${msg}`); +} + +function writeStatus(status: string, detail?: string): void { + try { + const data = { status, detail, timestamp: new Date().toISOString() }; + const tmpPath = `${ATOMIC_CHAT_STATUS_FILE}.tmp`; + fs.mkdirSync(path.dirname(ATOMIC_CHAT_STATUS_FILE), { recursive: true }); + fs.writeFileSync(tmpPath, JSON.stringify(data)); + fs.renameSync(tmpPath, ATOMIC_CHAT_STATUS_FILE); + } catch { + /* best-effort */ + } +} + +async function atomicFetch( + apiPath: string, + options?: RequestInit, +): Promise { + const url = `${ATOMIC_CHAT_HOST}${apiPath}`; + const headers: Record = { + ...((options?.headers as Record) || {}), + }; + if (ATOMIC_CHAT_API_KEY) { + headers.Authorization = `Bearer ${ATOMIC_CHAT_API_KEY}`; + } + const finalOptions: RequestInit = { ...options, headers }; + try { + return await fetch(url, finalOptions); + } catch (err) { + // Fallback to localhost if host.docker.internal fails + if (ATOMIC_CHAT_HOST.includes('host.docker.internal')) { + const fallbackUrl = url.replace('host.docker.internal', 'localhost'); + return await fetch(fallbackUrl, finalOptions); + } + throw err; + } +} + +const server = new McpServer({ + name: 'atomic_chat', + version: '1.0.0', +}); + +server.tool( + 'atomic_chat_list_models', + 'List all models available in the local Atomic Chat desktop app. Use this to see which models are loaded before calling atomic_chat_generate.', + {}, + async () => { + log('Listing models...'); + writeStatus('listing', 'Listing available models'); + try { + const res = await atomicFetch('/v1/models'); + if (!res.ok) { + return { + content: [ + { + type: 'text' as const, + text: `Atomic Chat API error: ${res.status} ${res.statusText}`, + }, + ], + isError: true, + }; + } + + const data = (await res.json()) as { + data?: Array<{ id: string; owned_by?: string }>; + }; + const models = data.data || []; + + if (models.length === 0) { + return { + content: [ + { + type: 'text' as const, + text: 'No models available. Open Atomic Chat on the host and download a model from the Hub.', + }, + ], + }; + } + + const list = models + .map((m) => `- ${m.id}${m.owned_by ? ` (${m.owned_by})` : ''}`) + .join('\n'); + + log(`Found ${models.length} models`); + return { + content: [ + { type: 'text' as const, text: `Available models:\n${list}` }, + ], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to connect to Atomic Chat at ${ATOMIC_CHAT_HOST}: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, +); + +server.tool( + 'atomic_chat_generate', + 'Send a prompt to a local Atomic Chat model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use atomic_chat_list_models first to see available models.', + { + model: z + .string() + .describe( + 'The model ID as returned by atomic_chat_list_models (e.g. "llama3.2-3b-instruct")', + ), + prompt: z.string().describe('The prompt to send to the model'), + system: z + .string() + .optional() + .describe('Optional system prompt to set model behavior'), + temperature: z + .number() + .optional() + .describe('Sampling temperature (0.0–2.0). Defaults to model default.'), + max_tokens: z + .number() + .optional() + .describe('Maximum number of tokens to generate in the response.'), + }, + async (args) => { + log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`); + writeStatus('generating', `Generating with ${args.model}`); + try { + const messages: Array<{ role: string; content: string }> = []; + if (args.system) { + messages.push({ role: 'system', content: args.system }); + } + messages.push({ role: 'user', content: args.prompt }); + + const body: Record = { + model: args.model, + messages, + stream: false, + }; + if (args.temperature !== undefined) body.temperature = args.temperature; + if (args.max_tokens !== undefined) body.max_tokens = args.max_tokens; + + const startedAt = Date.now(); + const res = await atomicFetch('/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + return { + content: [ + { + type: 'text' as const, + text: `Atomic Chat error (${res.status}): ${errorText}`, + }, + ], + isError: true, + }; + } + + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + }; + + const response = data.choices?.[0]?.message?.content ?? ''; + const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); + const completionTokens = data.usage?.completion_tokens; + + const meta = `\n\n[${args.model} | ${elapsedSec}s${ + completionTokens !== undefined ? ` | ${completionTokens} tokens` : '' + }]`; + + log( + `<<< Done: ${args.model} | ${elapsedSec}s | ${ + completionTokens ?? '?' + } tokens | ${response.length} chars`, + ); + writeStatus( + 'done', + `${args.model} | ${elapsedSec}s | ${completionTokens ?? '?'} tokens`, + ); + + return { content: [{ type: 'text' as const, text: response + meta }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to call Atomic Chat: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, +); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 236be4c..2093f9a 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -79,6 +79,14 @@ async function main(): Promise { args: ['run', mcpServerPath], env: {}, }, + atomic_chat: { + command: 'bun', + args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')], + env: { + ...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}), + ...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}), + }, + }, }; for (const [name, serverConfig] of Object.entries(config.mcpServers)) { diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index fbb077c..d633c0f 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -55,6 +55,7 @@ const TOOL_ALLOWLIST = [ 'Skill', 'NotebookEdit', 'mcp__nanoclaw__*', + 'mcp__atomic_chat__*', ]; interface SDKUserMessage { diff --git a/src/container-runner.ts b/src/container-runner.ts index 71e2064..d92f5ac 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -139,7 +139,12 @@ async function spawnContainer(session: Session): Promise { // Log stderr container.stderr?.on('data', (data) => { for (const line of data.toString().trim().split('\n')) { - if (line) log.debug(line, { container: agentGroup.folder }); + if (!line) continue; + if (line.includes('[ATOMIC]')) { + log.info(line, { container: agentGroup.folder }); + } else { + log.debug(line, { container: agentGroup.folder }); + } } }); @@ -396,6 +401,14 @@ async function buildContainerArgs( // Everything NanoClaw-specific is in container.json (read by runner at startup). args.push('-e', `TZ=${TIMEZONE}`); + // Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337). + if (process.env.ATOMIC_CHAT_HOST) { + args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`); + } + if (process.env.ATOMIC_CHAT_API_KEY) { + args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`); + } + // Provider-contributed env vars (e.g. XDG_DATA_HOME, OPENCODE_*, NO_PROXY). if (providerContribution.env) { for (const [key, value] of Object.entries(providerContribution.env)) { From 97e356d243d7285b00bb2f5ca236349e9b98a02c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 13:21:49 +0000 Subject: [PATCH 144/185] chore: bump version to 2.0.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 09053c4..aa63756 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.5", + "version": "2.0.6", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From dd5bc85b02656fdaf8304c91518da151b139204f Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 16:29:10 +0300 Subject: [PATCH 145/185] refactor(skill/atomic-chat-tool): ship MCP file in skill folder, revert src edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial /add-atomic-chat-tool merge added src edits directly to main. That conflicts with the utility-skill pattern used elsewhere (e.g. /claw): the skill folder should ship the file and SKILL.md should instruct copy + idempotent edits at install time, not a git merge that carries src diffs. - Move container/agent-runner/src/atomic-chat-mcp-stdio.ts → .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts - Revert the atomic_chat mcpServers entry in agent-runner index.ts - Revert mcp__atomic_chat__* from TOOL_ALLOWLIST in providers/claude.ts - Revert ATOMIC_CHAT_* env forwarding and [ATOMIC] log elevation in src/container-runner.ts - Empty .env.example back out - Rewrite SKILL.md: copy the shipped file, then apply deterministic Edits (index.ts, providers/claude.ts, container-runner.ts, .env.example) with exact before/after snippets the installer agent can match. Main is now back to its pre-PR state for the tool; /add-atomic-chat-tool re-applies everything at install time. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-atomic-chat-tool/SKILL.md | 137 +++++++++++++++--- .../atomic-chat-mcp-stdio.ts | 0 .env.example | 7 - container/agent-runner/src/index.ts | 8 - .../agent-runner/src/providers/claude.ts | 1 - src/container-runner.ts | 15 +- 6 files changed, 114 insertions(+), 54 deletions(-) rename {container/agent-runner/src => .claude/skills/add-atomic-chat-tool}/atomic-chat-mcp-stdio.ts (100%) diff --git a/.claude/skills/add-atomic-chat-tool/SKILL.md b/.claude/skills/add-atomic-chat-tool/SKILL.md index d995519..6a6d858 100644 --- a/.claude/skills/add-atomic-chat-tool/SKILL.md +++ b/.claude/skills/add-atomic-chat-tool/SKILL.md @@ -13,6 +13,8 @@ Tools exposed: Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library. +The skill ships the MCP server source in this folder and copies it into the agent-runner tree at install time, then wires it up with small edits to `index.ts`, `providers/claude.ts`, and `container-runner.ts`. No branch merge — all edits are additive and idempotent. + ## Phase 1: Pre-flight ### Check if already applied @@ -37,42 +39,128 @@ If the request fails: ## Phase 2: Apply Code Changes -### Ensure upstream remote +### Copy the MCP server source ```bash -git remote -v +cp .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts ``` -If `upstream` is missing, add it: +### Register the MCP server in the agent-runner + +Edit `container/agent-runner/src/index.ts`. Find the `mcpServers` object that currently looks like this: + +```ts + const mcpServers: Record }> = { + nanoclaw: { + command: 'bun', + args: ['run', mcpServerPath], + env: {}, + }, + }; +``` + +Add an `atomic_chat` entry alongside `nanoclaw`: + +```ts + const mcpServers: Record }> = { + nanoclaw: { + command: 'bun', + args: ['run', mcpServerPath], + env: {}, + }, + atomic_chat: { + command: 'bun', + args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')], + env: { + ...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}), + ...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}), + }, + }, + }; +``` + +### Add the tool glob to the allowlist + +Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in the `TOOL_ALLOWLIST` array and add `'mcp__atomic_chat__*',` on the following line: + +```ts + 'mcp__nanoclaw__*', + 'mcp__atomic_chat__*', +]; +``` + +### Forward host env vars into the container + +Edit `src/container-runner.ts` in `buildContainerArgs`. Find the `TZ` env line: + +```ts + args.push('-e', `TZ=${TIMEZONE}`); +``` + +Add ATOMIC_CHAT forwarding right after it: + +```ts + args.push('-e', `TZ=${TIMEZONE}`); + + // Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337). + if (process.env.ATOMIC_CHAT_HOST) { + args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`); + } + if (process.env.ATOMIC_CHAT_API_KEY) { + args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`); + } +``` + +### Surface `[ATOMIC]` log lines at info level + +In the same file, find the stderr logger: + +```ts + container.stderr?.on('data', (data) => { + for (const line of data.toString().trim().split('\n')) { + if (line) log.debug(line, { container: agentGroup.folder }); + } + }); +``` + +Replace it with: + +```ts + container.stderr?.on('data', (data) => { + for (const line of data.toString().trim().split('\n')) { + if (!line) continue; + if (line.includes('[ATOMIC]')) { + log.info(line, { container: agentGroup.folder }); + } else { + log.debug(line, { container: agentGroup.folder }); + } + } + }); +``` + +### Add env-var stubs to `.env.example` + +Append to `.env.example`: ```bash -git remote add upstream https://github.com/qwibitai/nanoclaw.git +# Atomic Chat MCP tool (.claude/skills/add-atomic-chat-tool) +# Override the host where Atomic Chat exposes its OpenAI-compatible API. +# Default: http://host.docker.internal:1337 (with fallback to localhost) +# ATOMIC_CHAT_HOST=http://host.docker.internal:1337 + +# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth. +# ATOMIC_CHAT_API_KEY= ``` -### Merge the skill branch - -```bash -git fetch upstream skill/atomic-chat-tool -git merge upstream/skill/atomic-chat-tool -``` - -This merges in: -- `container/agent-runner/src/atomic-chat-mcp-stdio.ts` (Atomic Chat MCP server, run directly via `bun`) -- Atomic Chat MCP registration in `container/agent-runner/src/index.ts` (`mcpServers.atomic_chat`) -- `mcp__atomic_chat__*` added to `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts` -- `[ATOMIC]` log surfacing and `ATOMIC_CHAT_HOST` / `ATOMIC_CHAT_API_KEY` forwarding in `src/container-runner.ts` -- `ATOMIC_CHAT_HOST` / `ATOMIC_CHAT_API_KEY` stubs in `.env.example` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - ### Validate code changes ```bash pnpm run build +pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit ./container/build.sh ``` -Build must be clean before proceeding. +All three must be clean before proceeding. ## Phase 3: Configure @@ -126,9 +214,10 @@ Look for: ### Agent says "Atomic Chat is not installed" or tries to run a CLI The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means: -1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers` -2. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST` -3. The container wasn't rebuilt — run `./container/build.sh` +1. The MCP server wasn't copied — check `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists +2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers` +3. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST` +4. The container wasn't rebuilt — run `./container/build.sh` ### "Failed to connect to Atomic Chat" diff --git a/container/agent-runner/src/atomic-chat-mcp-stdio.ts b/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts similarity index 100% rename from container/agent-runner/src/atomic-chat-mcp-stdio.ts rename to .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts diff --git a/.env.example b/.env.example index 61f2074..e69de29 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +0,0 @@ -# Atomic Chat MCP tool (skill/atomic-chat-tool) -# Override the host where Atomic Chat exposes its OpenAI-compatible API. -# Default: http://host.docker.internal:1337 (with fallback to localhost) -# ATOMIC_CHAT_HOST=http://host.docker.internal:1337 - -# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth. -# ATOMIC_CHAT_API_KEY= diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 2093f9a..236be4c 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -79,14 +79,6 @@ async function main(): Promise { args: ['run', mcpServerPath], env: {}, }, - atomic_chat: { - command: 'bun', - args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')], - env: { - ...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}), - ...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}), - }, - }, }; for (const [name, serverConfig] of Object.entries(config.mcpServers)) { diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index d633c0f..fbb077c 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -55,7 +55,6 @@ const TOOL_ALLOWLIST = [ 'Skill', 'NotebookEdit', 'mcp__nanoclaw__*', - 'mcp__atomic_chat__*', ]; interface SDKUserMessage { diff --git a/src/container-runner.ts b/src/container-runner.ts index d92f5ac..71e2064 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -139,12 +139,7 @@ async function spawnContainer(session: Session): Promise { // Log stderr container.stderr?.on('data', (data) => { for (const line of data.toString().trim().split('\n')) { - if (!line) continue; - if (line.includes('[ATOMIC]')) { - log.info(line, { container: agentGroup.folder }); - } else { - log.debug(line, { container: agentGroup.folder }); - } + if (line) log.debug(line, { container: agentGroup.folder }); } }); @@ -401,14 +396,6 @@ async function buildContainerArgs( // Everything NanoClaw-specific is in container.json (read by runner at startup). args.push('-e', `TZ=${TIMEZONE}`); - // Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337). - if (process.env.ATOMIC_CHAT_HOST) { - args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`); - } - if (process.env.ATOMIC_CHAT_API_KEY) { - args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`); - } - // Provider-contributed env vars (e.g. XDG_DATA_HOME, OPENCODE_*, NO_PROXY). if (providerContribution.env) { for (const [key, value] of Object.entries(providerContribution.env)) { From 438dedad77c27adf648d20cf1f4a508eb806d3a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 13:30:51 +0000 Subject: [PATCH 146/185] chore: bump version to 2.0.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aa63756..77920c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.6", + "version": "2.0.7", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 539af750d461a344b57ea3d80707fe781e95d873 Mon Sep 17 00:00:00 2001 From: cheats1314 <3030240693@qq.com> Date: Thu, 23 Apr 2026 22:22:18 +0800 Subject: [PATCH 147/185] fix(setup): detect registered groups from v2 central db Align the environment check with the v2 setup flow so existing wired agent groups are detected from data/v2.db instead of the retired v1 store. This prevents setup from reporting no registered groups on valid v2 installs and adds regression coverage for both v2 and pre-migration state. Co-Authored-By: Claude Opus 4.7 --- setup/environment.test.ts | 97 +++++++++++++++++++++------------------ setup/environment.ts | 47 ++++++++++--------- 2 files changed, 78 insertions(+), 66 deletions(-) diff --git a/setup/environment.test.ts b/setup/environment.test.ts index deda62f..7765693 100644 --- a/setup/environment.test.ts +++ b/setup/environment.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'fs'; +import os from 'os'; +import path from 'path'; import Database from 'better-sqlite3'; @@ -17,58 +19,63 @@ describe('environment detection', () => { }); }); -describe('registered groups DB query', () => { - let db: Database.Database; +describe('detectRegisteredGroups', () => { + let tempDir: string; beforeEach(() => { - db = new Database(':memory:'); - db.exec(`CREATE TABLE IF NOT EXISTS registered_groups ( - jid TEXT PRIMARY KEY, - name TEXT NOT NULL, - folder TEXT NOT NULL UNIQUE, - trigger_pattern TEXT NOT NULL, - added_at TEXT NOT NULL, - container_config TEXT, - requires_trigger INTEGER DEFAULT 1 - )`); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-test-')); + fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true }); }); - it('returns 0 for empty table', () => { - const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { count: number }; - expect(row.count).toBe(0); + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); }); - it('returns correct count after inserts', () => { - db.prepare( - `INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run( - '123@g.us', - 'Group 1', - 'group-1', - '@Andy', - '2024-01-01T00:00:00.000Z', - 1, - ); + it('returns false when no registration state exists', async () => { + const { detectRegisteredGroups } = await import('./environment.js'); + expect(detectRegisteredGroups(tempDir)).toBe(false); + }); - db.prepare( - `INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run( - '456@g.us', - 'Group 2', - 'group-2', - '@Andy', - '2024-01-01T00:00:00.000Z', - 1, - ); + it('detects pre-migration registered_groups.json', async () => { + const { detectRegisteredGroups } = await import('./environment.js'); + fs.writeFileSync(path.join(tempDir, 'data', 'registered_groups.json'), '[]'); + expect(detectRegisteredGroups(tempDir)).toBe(true); + }); - const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { count: number }; - expect(row.count).toBe(2); + it('returns false for an empty v2 central DB', async () => { + const { detectRegisteredGroups } = await import('./environment.js'); + const db = new Database(path.join(tempDir, 'data', 'v2.db')); + db.exec(` + CREATE TABLE agent_groups (id TEXT PRIMARY KEY); + CREATE TABLE messaging_group_agents ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL, + agent_group_id TEXT NOT NULL + ); + `); + db.close(); + + expect(detectRegisteredGroups(tempDir)).toBe(false); + }); + + it('detects wired agent groups in the v2 central DB', async () => { + const { detectRegisteredGroups } = await import('./environment.js'); + const db = new Database(path.join(tempDir, 'data', 'v2.db')); + db.exec(` + CREATE TABLE agent_groups (id TEXT PRIMARY KEY); + CREATE TABLE messaging_group_agents ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL, + agent_group_id TEXT NOT NULL + ); + `); + db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-1'); + db.prepare( + 'INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id) VALUES (?, ?, ?)', + ).run('mga-1', 'mg-1', 'ag-1'); + db.close(); + + expect(detectRegisteredGroups(tempDir)).toBe(true); }); }); diff --git a/setup/environment.ts b/setup/environment.ts index 4a83665..6986396 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -7,11 +7,35 @@ import path from 'path'; import Database from 'better-sqlite3'; -import { STORE_DIR } from '../src/config.js'; import { log } from '../src/log.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { emitStatus } from './status.js'; +export function detectRegisteredGroups(projectRoot: string): boolean { + if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { + return true; + } + + const dbPath = path.join(projectRoot, 'data', 'v2.db'); + if (!fs.existsSync(dbPath)) return false; + + let db: Database.Database | null = null; + try { + db = new Database(dbPath, { readonly: true }); + const row = db + .prepare( + `SELECT COUNT(DISTINCT ag.id) as count FROM agent_groups ag + JOIN messaging_group_agents mga ON mga.agent_group_id = ag.id`, + ) + .get() as { count: number }; + return row.count > 0; + } catch { + return false; + } finally { + db?.close(); + } +} + export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); @@ -39,26 +63,7 @@ export async function run(_args: string[]): Promise { const authDir = path.join(projectRoot, 'store', 'auth'); const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0; - let hasRegisteredGroups = false; - // Check JSON file first (pre-migration) - if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { - hasRegisteredGroups = true; - } else { - // Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed) - const dbPath = path.join(STORE_DIR, 'messages.db'); - if (fs.existsSync(dbPath)) { - try { - const db = new Database(dbPath, { readonly: true }); - const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { count: number }; - if (row.count > 0) hasRegisteredGroups = true; - db.close(); - } catch { - // Table might not exist yet - } - } - } + const hasRegisteredGroups = detectRegisteredGroups(projectRoot); // Check for existing OpenClaw installation const homedir = (await import('os')).homedir(); From bee80b007200833eef4f87780a770092e95d7330 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 15:12:02 +0000 Subject: [PATCH 148/185] fix(container): clear orphan heartbeat before spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a container exits, its .heartbeat file is left behind with the mtime of its last SDK activity. When the same session spawns a new container, the host sweep's ceiling check reads that stale mtime and kills the freshly-spawned container within seconds — before the new instance has had time to touch the file itself. The sweep already has a carve-out for "no heartbeat file" (treated as a fresh spawn, given grace), so simply removing the orphan at spawn time restores the intended semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/container-runner.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index 71e2064..8815b11 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -36,7 +36,7 @@ import { type ProviderContainerContribution, type VolumeMount, } from './providers/provider-container-registry.js'; -import { markContainerRunning, markContainerStopped, sessionDir, writeSessionRouting } from './session-manager.js'; +import { heartbeatPath, markContainerRunning, markContainerStopped, sessionDir, writeSessionRouting } from './session-manager.js'; import type { AgentGroup, Session } from './types.js'; const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY }); @@ -131,6 +131,12 @@ async function spawnContainer(session: Session): Promise { log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName }); + // Clear any orphan heartbeat from a previous container instance — the + // sweep's ceiling check treats a missing file as "fresh spawn, give grace" + // (host-sweep.ts line 87). Without this, the stale mtime can trigger an + // immediate kill before the new container touches the file itself. + fs.rmSync(heartbeatPath(agentGroup.id, session.id), { force: true }); + const container = spawn(CONTAINER_RUNTIME_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] }); activeContainers.set(session.id, { process: container, containerName }); From 209061f54f6a8804ad6fd50f4ddf7d5a140b408e Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 15:12:16 +0000 Subject: [PATCH 149/185] fix(sweep): wake before reset + idempotent retry for orphan claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a container exits with an unresolved processing_ack claim, the sweep's crashed-container cleanup would reset the matching inbound message with tries++ and a future process_after. dueCount then dropped to 0, so the wake step never fired — and the next sweep tick found the same orphan claim, bumped tries again, and pushed process_after further out. The message reached MAX_TRIES and was marked failed without any container ever being spawned. Two changes: 1. Reorder sweep so the wake step runs before crashed-container cleanup. A fresh container clears orphan 'processing' rows on its own startup (container/agent-runner/src/db/connection.ts), so once we get it running the claim resolves itself. 2. Make resetStuckProcessingRows idempotent: if a message already has process_after set to a future time, skip the retry bump. The wake path will pick it up when the backoff elapses. Requires returning process_after from getMessageForRetry. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/db/session-db.ts | 8 ++++---- src/host-sweep.ts | 34 ++++++++++++++++++++++++---------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/db/session-db.ts b/src/db/session-db.ts index aea255d..48e9297 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -139,10 +139,10 @@ export function getMessageForRetry( db: Database.Database, messageId: string, status: string, -): { id: string; tries: number } | undefined { - return db.prepare('SELECT id, tries FROM messages_in WHERE id = ? AND status = ?').get(messageId, status) as - | { id: string; tries: number } - | undefined; +): { id: string; tries: number; processAfter: string | null } | undefined { + return db + .prepare('SELECT id, tries, process_after as processAfter FROM messages_in WHERE id = ? AND status = ?') + .get(messageId, status) as { id: string; tries: number; processAfter: string | null } | undefined; } export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): void { diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 1a2901c..4dc2fb7 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -159,23 +159,31 @@ async function sweepSession(session: Session): Promise { syncProcessingAcks(inDb, outDb); } - const alive = isContainerRunning(session.id); - - // 2. Crashed-container cleanup: processing rows left behind get retried. - if (!alive && outDb) { - resetStuckProcessingRows(inDb, outDb, session, 'container not running'); + // 2. Wake a container if work is due and nothing is running. Ordered + // before the crashed-container cleanup so a fresh container gets a chance + // to clean its own orphan processing_ack rows on startup (see + // container/agent-runner/src/db/connection.ts). Otherwise the reset path + // would keep bumping process_after into the future, dueCount would stay 0, + // and the wake would never fire. + const dueCount = countDueMessages(inDb); + if (dueCount > 0 && !isContainerRunning(session.id)) { + log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); + await wakeContainer(session); } + const alive = isContainerRunning(session.id); + // 3. Running-container SLA: absolute ceiling + per-claim stuck rules. if (alive && outDb) { enforceRunningContainerSla(inDb, outDb, session, agentGroup.id); } - // 4. Wake a container if new work is due and nothing is running. - const dueCount = countDueMessages(inDb); - if (dueCount > 0 && !isContainerRunning(session.id)) { - log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); - await wakeContainer(session); + // 4. Crashed-container cleanup: processing rows left behind get retried. + // Only fires when wake in step 2 didn't pick up the work (no due messages, + // or wake failed). resetStuckProcessingRows itself is idempotent — it + // skips messages already scheduled for a future retry. + if (!alive && outDb) { + resetStuckProcessingRows(inDb, outDb, session, 'container not running'); } // 5. Recurrence fanout for completed recurring tasks. @@ -246,10 +254,16 @@ function resetStuckProcessingRows( reason: string, ): void { const claims = getProcessingClaims(outDb); + const now = Date.now(); for (const { message_id } of claims) { const msg = getMessageForRetry(inDb, message_id, 'pending'); if (!msg) continue; + // Already rescheduled for a future retry — don't bump tries again. The + // wake path (sweep step 2) will fire when process_after elapses and a + // fresh container will clean the orphan claim on startup. + if (msg.processAfter && Date.parse(msg.processAfter) > now) continue; + if (msg.tries >= MAX_TRIES) { markMessageFailed(inDb, msg.id); log.warn('Message marked as failed after max retries', { From 237876c2c6f7012fcbd6d8505b8b8e5dea33b2d3 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 15:12:56 +0000 Subject: [PATCH 150/185] chore(format): wrap session-manager import in container-runner Pre-commit prettier reformatted this in the working tree but didn't re-stage. Keeping it in a separate commit to avoid amending a prior commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/container-runner.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index 8815b11..fca88c4 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -36,7 +36,13 @@ import { type ProviderContainerContribution, type VolumeMount, } from './providers/provider-container-registry.js'; -import { heartbeatPath, markContainerRunning, markContainerStopped, sessionDir, writeSessionRouting } from './session-manager.js'; +import { + heartbeatPath, + markContainerRunning, + markContainerStopped, + sessionDir, + writeSessionRouting, +} from './session-manager.js'; import type { AgentGroup, Session } from './types.js'; const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY }); From ff277c0d492face410ae0b789dbe4259723fb207 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 16:56:21 +0000 Subject: [PATCH 151/185] fix(chat-sdk-bridge): encode option index in callback_data for Telegram 64-byte cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ask_question cards failed to deliver on Telegram whenever any option had a non-trivial value (e.g. an ISO datetime, a URL, or a long token). Telegram limits inline-keyboard callback_data to 64 bytes, and the previous encoding embedded both the questionId and the full option value in each button's actionId plus a second copy as value, producing payloads well over the cap. The adapter threw ValidationError, delivery was marked permanently failed, and the agent sat waiting on an answer that never reached the user. Fix: - Button id is now `ncq::` and button value is the stringified index. Callback payloads shrink from ~100 bytes to ~40 and fit Telegram's cap for any option list with <100 items. - Both callback-decode sites (Chat SDK `onAction` for Telegram/Slack/ etc., and the Discord Gateway interaction handler) resolve the index back to the real option value via `getAskQuestionRender(questionId)` before dispatching to the host's onAction — so response handlers (pending_questions, pending_approvals) are unchanged and still receive the canonical value. - `resolveSelectedOption` helper has a backward-compat fallback: non-numeric tails are treated as literal values so any card delivered under the old encoding still resolves if the user clicks it after deploy. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/chat-sdk-bridge.ts | 42 +++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 5c120e0..7123c0f 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -81,6 +81,26 @@ export interface ChatSdkBridgeConfig { * chunk boundary will render as two independent blocks on the receiving * platform, which is the same behavior as manually re-opening a fence. */ +/** + * Decode the actual option value from a button callback. Buttons are encoded + * with an integer index (to keep under Telegram's 64-byte callback_data cap), + * and the real value is looked up via `getAskQuestionRender(questionId)`. + * Falls back to treating the tail as a literal value so old in-flight cards + * (encoded before this shortening landed) still resolve. + */ +function resolveSelectedOption( + render: { options: NormalizedOption[] } | undefined, + eventValue: string | undefined, + tail: string | undefined, +): string { + const candidate = eventValue ?? tail ?? ''; + if (render && /^\d+$/.test(candidate)) { + const idx = Number(candidate); + if (render.options[idx]) return render.options[idx].value; + } + return candidate; +} + export function splitForLimit(text: string, limit: number): string[] { if (text.length <= limit) return [text]; const chunks: string[] = []; @@ -240,11 +260,15 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const parts = event.actionId.split(':'); if (parts.length < 3) return; const questionId = parts[1]; - const selectedOption = event.value || ''; + const tail = parts.slice(2).join(':'); const userId = event.user?.userId || ''; // Resolve render metadata BEFORE dispatching onAction (which deletes the row). const render = getAskQuestionRender(questionId); + // New format: button id/value is an integer index into options (kept + // short to fit Telegram's 64-byte callback_data cap). Old format: + // the full value is embedded in actionId/value directly. + const selectedOption = resolveSelectedOption(render, event.value, tail); const title = render?.title ?? '❓ Question'; const matched = render?.options.find((o) => o.value === selectedOption); const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)'; @@ -348,8 +372,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter children: [ CardText(question), Actions( - options.map((opt) => - Button({ id: `ncq:${questionId}:${opt.value}`, label: opt.label, value: opt.value }), + // Encode button id/value with the option index rather than the + // full value. Telegram caps callback_data at 64 bytes, and + // long values (e.g. ISO datetimes, URLs) push the JSON payload + // well past that. The onAction handlers resolve the index back + // to the real value via getAskQuestionRender(questionId). + options.map((opt, idx) => + Button({ id: `ncq:${questionId}:${idx}`, label: opt.label, value: String(idx) }), ), ), ], @@ -507,12 +536,12 @@ async function handleForwardedEvent( // Parse the selected option from custom_id let questionId: string | undefined; - let selectedOption: string | undefined; + let tail: string | undefined; if (customId?.startsWith('ncq:')) { const colonIdx = customId.indexOf(':', 4); // after "ncq:" if (colonIdx !== -1) { questionId = customId.slice(4, colonIdx); - selectedOption = customId.slice(colonIdx + 1); + tail = customId.slice(colonIdx + 1); } } @@ -521,6 +550,9 @@ async function handleForwardedEvent( ((interaction.message as Record)?.embeds as Array>) || []; const originalDescription = (originalEmbeds[0]?.description as string) || ''; const render = questionId ? getAskQuestionRender(questionId) : undefined; + // Discord custom_id mirrors the new index-based encoding (see Button + // construction). Decode back to the real option value for downstream. + const selectedOption = resolveSelectedOption(render, tail, tail); const cardTitle = render?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question'); const matchedOpt = render?.options.find((o) => o.value === selectedOption); const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId; From 97868af5a7529da909eb4e2bc43760f71722957a Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 17:05:41 +0000 Subject: [PATCH 152/185] fix(delivery): make pending_questions/approvals insert idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createPendingQuestion and createPendingApproval both run before the adapter delivery call. When delivery fails and the retry loop reinvokes deliverMessage with the same questionId/approvalId, the second attempt hit UNIQUE constraint on the pending_questions.question_id (or pending_approvals.approval_id) and threw — so the retry never reached the send step, and every subsequent retry failed the same way until max-attempts marked the message permanently failed. Switch both inserts to INSERT OR IGNORE. Return bool indicating whether a new row was actually inserted so delivery.ts can avoid logging "Pending question created" twice for the same card. Symptom that surfaced this: a send-layer ValidationError on one attempt followed by SqliteError on every subsequent attempt, with the user seeing neither the card nor a follow-up. Seen in conjunction with the Telegram 64-byte callback_data limit (fixed separately in #1942/chat-sdk-bridge), but the idempotency gap applies to any transient delivery failure — rate limits, network blips, adapter 5xx — and is worth fixing on its own. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/db/sessions.ts | 25 +++++++++++++++++++------ src/delivery.ts | 6 ++++-- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/db/sessions.ts b/src/db/sessions.ts index bdca8a6..af765f9 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -97,10 +97,16 @@ export function deleteSession(id: string): void { // ── Pending Questions ── -export function createPendingQuestion(pq: PendingQuestion): void { - getDb() +/** + * Insert a pending question row. Idempotent: when delivery fails and retries, + * the second attempt calls this with the same question_id — without `OR + * IGNORE` that would throw UNIQUE and prevent the retry from reaching the + * actual send step. Returns true if a new row was inserted. + */ +export function createPendingQuestion(pq: PendingQuestion): boolean { + const result = getDb() .prepare( - `INSERT INTO pending_questions (question_id, session_id, message_out_id, platform_id, channel_type, thread_id, title, options_json, created_at) + `INSERT OR IGNORE INTO pending_questions (question_id, session_id, message_out_id, platform_id, channel_type, thread_id, title, options_json, created_at) VALUES (@question_id, @session_id, @message_out_id, @platform_id, @channel_type, @thread_id, @title, @options_json, @created_at)`, ) .run({ @@ -114,6 +120,7 @@ export function createPendingQuestion(pq: PendingQuestion): void { options_json: JSON.stringify(pq.options), created_at: pq.created_at, }); + return result.changes > 0; } export function getPendingQuestion(questionId: string): PendingQuestion | undefined { @@ -131,16 +138,21 @@ export function deletePendingQuestion(questionId: string): void { // ── Pending Approvals ── +/** + * Insert a pending approval row. Idempotent for the same reason as + * createPendingQuestion: delivery retries with the same approval_id must not + * fail on UNIQUE before the send step gets a chance to succeed. + */ export function createPendingApproval( pa: Partial & Pick< PendingApproval, 'approval_id' | 'request_id' | 'action' | 'payload' | 'created_at' | 'title' | 'options_json' >, -): void { - getDb() +): boolean { + const result = getDb() .prepare( - `INSERT INTO pending_approvals + `INSERT OR IGNORE INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status, title, options_json) @@ -159,6 +171,7 @@ export function createPendingApproval( status: 'pending', ...pa, }); + return result.changes > 0; } export function getPendingApproval(approvalId: string): PendingApproval | undefined { diff --git a/src/delivery.ts b/src/delivery.ts index 2e193d4..036153a 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -321,7 +321,7 @@ async function deliverMessage( questionId: content.questionId, }); } else { - createPendingQuestion({ + const inserted = createPendingQuestion({ question_id: content.questionId, session_id: session.id, message_out_id: msg.id, @@ -332,7 +332,9 @@ async function deliverMessage( options: normalizeOptions(rawOptions as never), created_at: new Date().toISOString(), }); - log.info('Pending question created', { questionId: content.questionId, sessionId: session.id }); + if (inserted) { + log.info('Pending question created', { questionId: content.questionId, sessionId: session.id }); + } } } From 0ec56b732dafad275015261ac3ca574f61b3b052 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 21:35:00 +0300 Subject: [PATCH 153/185] docs(add-codex): add skill for installing Codex provider from providers branch Mirrors the /add-opencode and /add-ollama-provider pattern. Copies the add-codex SKILL.md from the providers branch onto trunk so the skill is discoverable without a manual branch copy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-codex/SKILL.md | 164 ++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 .claude/skills/add-codex/SKILL.md diff --git a/.claude/skills/add-codex/SKILL.md b/.claude/skills/add-codex/SKILL.md new file mode 100644 index 0000000..a5484d5 --- /dev/null +++ b/.claude/skills/add-codex/SKILL.md @@ -0,0 +1,164 @@ +--- +name: add-codex +description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner). +--- + +# Codex agent provider + +NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`). + +Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image. + +The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in. + +## Install + +### Pre-flight + +If all of the following are already present, skip to **Configuration**: + +- `src/providers/codex.ts` +- `container/agent-runner/src/providers/codex.ts` +- `container/agent-runner/src/providers/codex-app-server.ts` +- `container/agent-runner/src/providers/codex.factory.test.ts` +- `import './codex.js';` line in `src/providers/index.ts` +- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts` +- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile` + +Missing pieces — continue below. All steps are idempotent; re-running is safe. + +### 1. Fetch the providers branch + +```bash +git fetch origin providers +``` + +### 2. Copy the Codex source files + +Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed): + +```bash +git show origin/providers:src/providers/codex.ts > src/providers/codex.ts +git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts +git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts +git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts +``` + +### 3. Append the self-registration imports + +Each barrel gets one line — alphabetical placement keeps diffs small. + +`src/providers/index.ts`: + +```typescript +import './codex.js'; +``` + +`container/agent-runner/src/providers/index.ts`: + +```typescript +import './codex.js'; +``` + +### 4. Add the Codex CLI to the container Dockerfile + +Two edits to `container/Dockerfile`, both idempotent (skip if already present): + +**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`: + +```dockerfile +ARG CODEX_VERSION=0.121.0 +``` + +**(b)** In the `pnpm install -g` block (around line 80), append `"@openai/codex@${CODEX_VERSION}"` to the list: + +```dockerfile + pnpm install -g \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + "@openai/codex@${CODEX_VERSION}" \ + "agent-browser@${AGENT_BROWSER_VERSION}" \ + "vercel@${VERCEL_VERSION}" +``` + +Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`. + +### 5. Build + +```bash +pnpm run build # host +pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck +./container/build.sh # agent image +``` + +## Configuration + +Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup. + +### Option A — ChatGPT subscription (recommended for individuals) + +On the host (not inside the container), run Codex's OAuth login: + +```bash +codex login +``` + +This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched. + +No `.env` variables required for this mode. + +### Option B — API key (recommended for CI or API billing) + +```env +OPENAI_API_KEY=sk-... +CODEX_MODEL=gpt-5.4-mini +``` + +The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription. + +### Option C — BYO OpenAI-compatible endpoint (experimental) + +Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc. + +```env +OPENAI_API_KEY=... +OPENAI_BASE_URL=https://api.groq.com/openai/v1 +CODEX_MODEL=llama-3.3-70b-versatile +``` + +Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration. + +**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing. + +### Per group / per session + +Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `codex` for groups or sessions that should use Codex. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group). + +`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group. + +Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers. + +## Operational notes + +- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions. +- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config. +- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error. +- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode). +- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped. +- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has. + +## Verify + +```bash +grep -q "./codex.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK" +grep -q "./codex.js" src/providers/index.ts && echo "host barrel: OK" +grep -q "@openai/codex@" container/Dockerfile && echo "Dockerfile install: OK" +cd container/agent-runner && bun test src/providers/codex.factory.test.ts && cd - +``` + +After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. Successful round-trip looks like: + +- `init` event with a stable thread ID as continuation +- One or more `activity` / `progress` events during the turn +- `result` event with the model's reply + +If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm. From e5a7a330843f1e5373e0849c2a78a0ff13672759 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 21:38:16 +0300 Subject: [PATCH 154/185] =?UTF-8?q?docs(add-codex):=20fix=20Dockerfile=20i?= =?UTF-8?q?nstall=20step=20=E2=80=94=20separate=20RUN=20block,=20not=20com?= =?UTF-8?q?bined=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior instruction told users to append "@openai/codex@${CODEX_VERSION}" to a single combined `pnpm install -g` block. That block no longer exists on main — the Dockerfile splits each global CLI (vercel, agent-browser, claude-code) into its own RUN layer for cache granularity. Update the skill to add a standalone RUN block for Codex that matches the existing pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-codex/SKILL.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.claude/skills/add-codex/SKILL.md b/.claude/skills/add-codex/SKILL.md index a5484d5..17910b7 100644 --- a/.claude/skills/add-codex/SKILL.md +++ b/.claude/skills/add-codex/SKILL.md @@ -70,14 +70,11 @@ Two edits to `container/Dockerfile`, both idempotent (skip if already present): ARG CODEX_VERSION=0.121.0 ``` -**(b)** In the `pnpm install -g` block (around line 80), append `"@openai/codex@${CODEX_VERSION}"` to the list: +**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call: ```dockerfile - pnpm install -g \ - "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ - "@openai/codex@${CODEX_VERSION}" \ - "agent-browser@${AGENT_BROWSER_VERSION}" \ - "vercel@${VERCEL_VERSION}" +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "@openai/codex@${CODEX_VERSION}" ``` Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`. From c6d2f45f93d3189d0206aecb614e44e64da5afb5 Mon Sep 17 00:00:00 2001 From: Doug Daniels Date: Thu, 23 Apr 2026 14:37:10 -0400 Subject: [PATCH 155/185] feat: add Signal channel adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native Signal adapter using signal-cli TCP JSON-RPC daemon. No Chat SDK bridge or npm dependencies — uses only Node.js builtins. Features: - DM and group message support - Voice message detection (placeholder text; transcription via /add-voice-transcription skill) - Typing indicators (DMs only) - Mention detection via text match - Managed daemon lifecycle (auto-start/stop signal-cli) - Echo suppression for outbound messages Also fixes init-first-agent.ts to skip channel-prefixing for phone numbers (+...) and Signal group IDs (group:...), which are native platform IDs that adapters send without a channel prefix. Install via /add-signal skill. Uses /init-first-agent for channel wiring. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-signal/SKILL.md | 121 +++++ scripts/init-first-agent.ts | 24 +- src/channels/index.ts | 1 + src/channels/signal.test.ts | 627 ++++++++++++++++++++++++ src/channels/signal.ts | 744 +++++++++++++++++++++++++++++ 5 files changed, 1513 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/add-signal/SKILL.md create mode 100644 src/channels/signal.test.ts create mode 100644 src/channels/signal.ts diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md new file mode 100644 index 0000000..92c7800 --- /dev/null +++ b/.claude/skills/add-signal/SKILL.md @@ -0,0 +1,121 @@ +--- +name: add-signal +description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge. +--- + +# Add Signal Channel + +Adds Signal messaging support via a native adapter that communicates with a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon using JSON-RPC. + +## Prerequisites + +- **signal-cli** installed and a Signal account linked + - macOS: `brew install signal-cli` + - Linux: download from [GitHub releases](https://github.com/AsamK/signal-cli/releases) + - Link your account: `signal-cli -a +1YOURNUMBER link` (follow the QR instructions) + +## Install + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/signal.ts` exists +- `src/channels/signal.test.ts` exists +- `src/channels/index.ts` contains `import './signal.js';` + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the skill branch + +```bash +git fetch origin skill/signal +``` + +### 2. Copy the adapter and tests + +```bash +git show origin/skill/signal:src/channels/signal.ts > src/channels/signal.ts +git show origin/skill/signal:src/channels/signal.test.ts > src/channels/signal.test.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './signal.js'; +``` + +### 4. Build + +```bash +pnpm run build +``` + +No npm packages to install — the adapter uses only Node.js builtins (`node:net`, `node:child_process`, `node:fs`). + +## Credentials + +Add to `.env`: + +```env +SIGNAL_ACCOUNT=+1YOURNUMBER +``` + +### Optional settings + +```env +# TCP daemon host and port (default: 127.0.0.1:7583) +SIGNAL_HTTP_HOST=127.0.0.1 +SIGNAL_HTTP_PORT=7583 + +# Whether NanoClaw manages the daemon lifecycle (default: true) +# Set to false if you run signal-cli daemon externally +SIGNAL_MANAGE_DAEMON=true + +# signal-cli data directory (default: ~/.local/share/signal-cli) +SIGNAL_DATA_DIR=~/.local/share/signal-cli +``` + +### Sync to container + +```bash +mkdir -p data/env && cp .env data/env/env +``` + +### Restart + +```bash +# macOS +launchctl kickstart -k gui/$(id -u)/com.nanoclaw + +# Linux +systemctl --user restart nanoclaw +``` + +## Next Steps + +Run `/init-first-agent` to create an agent and wire it to your Signal DM. Signal is direct-addressable — your phone number is the platform ID: + +- **User ID**: your Signal phone number (e.g. `+15551234567`) +- **Platform ID**: same as user ID for DMs (e.g. `+15551234567`) +- **For group chats**: use `group:` — find group IDs via `signal-cli -a +1YOURNUMBER listGroups` + +`/init-first-agent` handles user creation, owner role, agent group, messaging group wiring, and the welcome DM. It's idempotent — safe to run again for additional agents. + +## Channel Info + +| Field | Value | +|-------|-------| +| **Type** | `signal` | +| **Thread support** | No (Signal has no thread model) | +| **Platform ID format** | DM: `+15555550123` / Group: `group:` | +| **Mention detection** | Text-match against agent group name (no SDK-level mentions) | +| **Typing indicators** | DMs only | +| **Typical use** | Personal assistant via Signal DMs or small group chats | +| **Isolation** | Recommended: one agent per Signal account | + +### Voice Messages + +Voice attachments are detected but not transcribed by default. The agent receives `[Voice Message]` as the message text. Run `/add-voice-transcription` to enable automatic local transcription via parakeet-mlx. diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index dcb99b5..fc61b9c 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -137,13 +137,29 @@ function namespacedUserId(channel: string, raw: string): string { return raw.includes(':') ? raw : `${channel}:${raw}`; } +/** + * Determine whether a platform ID needs a channel-type prefix. + * + * Chat SDK adapters (Telegram, Discord, Slack, Teams, etc.) namespace their + * platform IDs with a channel prefix: "telegram:123456", "discord:guild:chan". + * The router stores `channel_type` and `platform_id` in separate columns, but + * Chat SDK adapters send the prefixed form as the platform_id, so this script + * must match that format. + * + * Native adapters (Signal, WhatsApp) use their own ID formats and send them + * as-is — no channel prefix. Signal sends raw phone numbers (+15551234567) + * for DMs and "group:" for group chats. WhatsApp sends JIDs containing + * '@' (@s.whatsapp.net, @g.us). Prefixing these would cause + * a mismatch between what the adapter sends and what the DB stores, breaking + * message routing. + */ function namespacedPlatformId(channel: string, raw: string): string { if (raw.startsWith(`${channel}:`)) return raw; - // Adapters using native JID format (WhatsApp: @s.whatsapp.net, - // @g.us) store platform_id without a channel prefix. The '@' is - // the discriminator — telegram/discord platform_ids don't contain it - // except after a channel prefix, which is already handled above. + // Native WhatsApp JIDs contain '@' — no prefix needed. if (raw.includes('@')) return raw; + // Native Signal IDs: phone numbers (+...) and group IDs (group:...). + if (raw.startsWith('+') || raw.startsWith('group:')) return raw; + // Chat SDK adapters — add the channel prefix. return `${channel}:${raw}`; } diff --git a/src/channels/index.ts b/src/channels/index.ts index e9b3bd1..b75016f 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -7,3 +7,4 @@ // self-registration import below. import './cli.js'; +import './signal.js'; diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts new file mode 100644 index 0000000..c7ffff1 --- /dev/null +++ b/src/channels/signal.test.ts @@ -0,0 +1,627 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +// --- Mocks --- + +vi.mock('./channel-registry.js', () => ({ registerChannelAdapter: vi.fn() })); +vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); +vi.mock('../log.js', () => ({ + log: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), + execFileSync: vi.fn(), +})); + +// --- TCP socket mock --- + +import { EventEmitter } from 'events'; + +const tcpRef = vi.hoisted(() => ({ + rpcResponses: new Map(), + fakeSocket: null as any, +})); + +function createFakeSocket(): EventEmitter & { + write: ReturnType; + destroy: ReturnType; + destroyed: boolean; +} { + const sock = new EventEmitter() as any; + sock.destroyed = false; + sock.destroy = vi.fn(() => { + sock.destroyed = true; + sock.emit('close'); + }); + sock.write = vi.fn((data: string) => { + try { + const req = JSON.parse(data.trim()); + const result = tcpRef.rpcResponses.get(req.method) ?? { ok: true }; + const response = JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n'; + setImmediate(() => sock.emit('data', Buffer.from(response))); + } catch { + /* ignore */ + } + }); + return sock; +} + +vi.mock('node:net', () => ({ + createConnection: vi.fn((_port: number, _host: string, cb?: () => void) => { + const sock = createFakeSocket(); + tcpRef.fakeSocket = sock; + if (cb) setImmediate(cb); + return sock; + }), +})); + +import type { ChannelSetup } from './adapter.js'; +import { createSignalAdapter } from './signal.js'; + +// --- Test helpers --- + +function createMockSetup() { + return { + onInbound: vi.fn() as unknown as ChannelSetup['onInbound'] & ReturnType, + onInboundEvent: vi.fn() as unknown as ChannelSetup['onInboundEvent'] & ReturnType, + onMetadata: vi.fn() as unknown as ChannelSetup['onMetadata'] & ReturnType, + onAction: vi.fn() as unknown as ChannelSetup['onAction'] & ReturnType, + }; +} + +function createAdapter() { + return createSignalAdapter({ + cliPath: 'signal-cli', + account: '+15551234567', + tcpHost: '127.0.0.1', + tcpPort: 7583, + manageDaemon: false, + signalDataDir: '/tmp/signal-cli-test-data', + }); +} + +function getRpcCalls(): Array<{ + method: string; + params: Record; + id: string; +}> { + if (!tcpRef.fakeSocket) return []; + return tcpRef.fakeSocket.write.mock.calls + .map((c: any[]) => { + try { + return JSON.parse(c[0].trim()); + } catch { + return null; + } + }) + .filter(Boolean); +} + +function getRpcCallsForMethod(method: string) { + return getRpcCalls().filter((c) => c.method === method); +} + +function pushEvent(envelope: Record) { + if (!tcpRef.fakeSocket) throw new Error('TCP socket not connected'); + const notification = + JSON.stringify({ + jsonrpc: '2.0', + method: 'receive', + params: { envelope }, + }) + '\n'; + tcpRef.fakeSocket.emit('data', Buffer.from(notification)); +} + +// --- Tests --- + +describe('SignalAdapter', () => { + beforeEach(() => { + vi.clearAllMocks(); + tcpRef.rpcResponses.clear(); + tcpRef.fakeSocket = null; + tcpRef.rpcResponses.set('send', { timestamp: 1234567890 }); + tcpRef.rpcResponses.set('sendTyping', {}); + }); + + afterEach(() => { + try { + tcpRef.fakeSocket?.destroy(); + } catch { + // already closed + } + }); + + // --- Connection lifecycle --- + + describe('connection lifecycle', () => { + it('connects when daemon is reachable', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + expect(adapter.isConnected()).toBe(true); + expect(tcpRef.fakeSocket).not.toBeNull(); + + await adapter.teardown(); + }); + + it('isConnected() returns false before setup', () => { + const adapter = createAdapter(); + expect(adapter.isConnected()).toBe(false); + }); + + it('disconnects cleanly', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + expect(adapter.isConnected()).toBe(true); + + await adapter.teardown(); + expect(adapter.isConnected()).toBe(false); + }); + + it('throws NetworkError if daemon is unreachable', async () => { + const { createConnection } = await import('node:net'); + vi.mocked(createConnection).mockImplementationOnce((...args: any[]) => { + const sock = createFakeSocket(); + setImmediate(() => sock.emit('error', new Error('Connection refused'))); + return sock as any; + }); + + const adapter = createAdapter(); + await expect(adapter.setup(createMockSetup())).rejects.toThrow(/not reachable/); + }); + }); + + // --- Inbound message handling --- + + describe('inbound message handling', () => { + it('delivers DM via onInbound', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + sourceName: 'Alice', + dataMessage: { + timestamp: 1700000000000, + message: 'Hello from Signal', + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(cfg.onMetadata).toHaveBeenCalledWith('+15555550123', 'Alice', false); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550123', + null, + expect.objectContaining({ + id: '1700000000000', + kind: 'chat', + content: expect.objectContaining({ + text: 'Hello from Signal', + sender: '+15555550123', + senderName: 'Alice', + }), + }), + ); + + await adapter.teardown(); + }); + + it('delivers group message with group platformId', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550999', + sourceName: 'Bob', + dataMessage: { + timestamp: 1700000000000, + message: 'Group hello', + groupInfo: { groupId: 'abc123', groupName: 'Family' }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(cfg.onMetadata).toHaveBeenCalledWith('group:abc123', 'Family', true); + expect(cfg.onInbound).toHaveBeenCalledWith( + 'group:abc123', + null, + expect.objectContaining({ + content: expect.objectContaining({ + text: 'Group hello', + sender: '+15555550999', + }), + }), + ); + + await adapter.teardown(); + }); + + it('skips sync messages (own outbound)', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15551234567', + syncMessage: { + sentMessage: { + timestamp: 1700000000000, + message: 'My own message', + destination: '+15555550123', + }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + + it('processes Note to Self sync messages as inbound', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15551234567', + syncMessage: { + sentMessage: { + timestamp: 1700000000000, + message: 'Hello Bee', + destinationNumber: '+15551234567', + }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15551234567', + null, + expect.objectContaining({ + content: expect.objectContaining({ + text: 'Hello Bee', + senderName: 'Me', + isFromMe: true, + }), + }), + ); + + await adapter.teardown(); + }); + + it('skips empty messages', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + dataMessage: { timestamp: 1700000000000, message: ' ' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + + it('skips echoed outbound messages', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Echo test' }, + }); + + pushEvent({ + sourceNumber: '+15555550123', + dataMessage: { timestamp: 1700000000000, message: 'Echo test' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + + it('skips messages with attachments but no text', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + sourceName: 'Alice', + dataMessage: { + timestamp: 1700000000000, + attachments: [{ id: 'att123abc', contentType: 'image/jpeg', size: 50000 }], + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + }); + + // --- Quote context --- + + describe('quote context', () => { + it('populates reply_to fields from quoted messages', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + sourceName: 'Alice', + dataMessage: { + timestamp: 1700000000000, + message: 'I disagree', + quote: { + id: 1699999999000, + authorNumber: '+15555550888', + text: 'Pineapple belongs on pizza', + }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550123', + null, + expect.objectContaining({ + content: expect.objectContaining({ + text: 'I disagree', + replyToSenderName: '+15555550888', + replyToMessageContent: 'Pineapple belongs on pizza', + replyToMessageId: '1699999999000', + }), + }), + ); + + await adapter.teardown(); + }); + }); + + // --- deliver --- + + describe('deliver', () => { + it('sends DM via TCP RPC', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + + const last = sendCalls[sendCalls.length - 1]; + expect(last.params).toEqual( + expect.objectContaining({ + recipient: ['+15555550123'], + message: 'Hello', + account: '+15551234567', + }), + ); + + await adapter.teardown(); + }); + + it('sends group message via groupId', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.deliver('group:abc123', null, { + kind: 'text', + content: { text: 'Group msg' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params).toEqual( + expect.objectContaining({ + groupId: 'abc123', + message: 'Group msg', + }), + ); + + await adapter.teardown(); + }); + + it('chunks long messages', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + const longText = 'x'.repeat(5000); + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: longText }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(1); + + await adapter.teardown(); + }); + + it('extracts text from string content', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: 'Plain string content', + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Plain string content'); + + await adapter.teardown(); + }); + }); + + // --- Text styles --- + + describe('text styles', () => { + it('sends bold text with textStyle parameter', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello **world**' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Hello world'); + expect(last.params.textStyle).toEqual(['6:5:BOLD']); + + await adapter.teardown(); + }); + + it('sends inline code with MONOSPACE style', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Run `npm test` now' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Run npm test now'); + expect(last.params.textStyle).toEqual(['4:8:MONOSPACE']); + + await adapter.teardown(); + }); + + it('sends plain text without textStyle', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'No formatting here' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('No formatting here'); + expect(last.params.textStyle).toBeUndefined(); + + await adapter.teardown(); + }); + + it('falls back to original markup when textStyle is rejected', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + let sendCount = 0; + tcpRef.fakeSocket.write.mockImplementation((data: string) => { + try { + const req = JSON.parse(data.trim()); + if (req.method === 'send') { + sendCount++; + if (sendCount === 1) { + const response = + JSON.stringify({ + jsonrpc: '2.0', + id: req.id, + error: { message: 'Unknown parameter: textStyle' }, + }) + '\n'; + setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response))); + return; + } + } + const response = + JSON.stringify({ + jsonrpc: '2.0', + id: req.id, + result: { ok: true }, + }) + '\n'; + setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response))); + } catch { + /* ignore */ + } + }); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello **world**' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBe(2); + expect(sendCalls[1].params.message).toBe('Hello **world**'); + expect(sendCalls[1].params.textStyle).toBeUndefined(); + + await adapter.teardown(); + }); + }); + + // --- setTyping --- + + describe('setTyping', () => { + it('sends typing indicator for DMs', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.setTyping!('+15555550123', null); + + expect(getRpcCallsForMethod('sendTyping')).toHaveLength(1); + + await adapter.teardown(); + }); + + it('skips typing for groups', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.setTyping!('group:abc123', null); + + expect(getRpcCallsForMethod('sendTyping')).toHaveLength(0); + + await adapter.teardown(); + }); + }); + + // --- Adapter properties --- + + describe('adapter properties', () => { + it('has channelType "signal"', () => { + const adapter = createAdapter(); + expect(adapter.channelType).toBe('signal'); + }); + + it('does not support threads', () => { + const adapter = createAdapter(); + expect(adapter.supportsThreads).toBe(false); + }); + }); +}); diff --git a/src/channels/signal.ts b/src/channels/signal.ts new file mode 100644 index 0000000..300b7a6 --- /dev/null +++ b/src/channels/signal.ts @@ -0,0 +1,744 @@ +/** + * Signal channel adapter for NanoClaw v2. + * + * Uses signal-cli's TCP JSON-RPC daemon for bidirectional messaging. + * Requires signal-cli (https://github.com/AsamK/signal-cli) installed + * and a linked account. + * + * Ported from v1 — see v1 source for commit history. + */ +import { execFileSync, spawn } from 'node:child_process'; +import { readFileSync, existsSync } from 'node:fs'; +import { createConnection, type Socket } from 'node:net'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; +import { registerChannelAdapter } from './channel-registry.js'; +import { readEnvFile } from '../env.js'; +import { log } from '../log.js'; + +// --------------------------------------------------------------------------- +// Signal CLI daemon management +// --------------------------------------------------------------------------- + +interface DaemonHandle { + stop: () => void; + exited: Promise; + isExited: () => boolean; +} + +function spawnSignalDaemon(cliPath: string, account: string, host: string, port: number): DaemonHandle { + const args: string[] = []; + if (account) args.push('-a', account); + args.push('daemon', '--tcp', `${host}:${port}`, '--no-receive-stdout'); + args.push('--receive-mode', 'on-start'); + + const child = spawn(cliPath, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let exited = false; + + const exitedPromise = new Promise((resolve) => { + child.once('exit', (code, signal) => { + exited = true; + if (code !== 0 && code !== null) { + const reason = signal ? `signal ${signal}` : `code ${code}`; + log.error('signal-cli daemon exited', { reason }); + } + resolve(); + }); + child.on('error', (err) => { + exited = true; + log.error('signal-cli spawn error', { err }); + resolve(); + }); + }); + + child.stdout?.on('data', (data: Buffer) => { + for (const line of data.toString().split(/\r?\n/)) { + if (line.trim()) log.debug('signal-cli stdout', { line: line.trim() }); + } + }); + child.stderr?.on('data', (data: Buffer) => { + for (const line of data.toString().split(/\r?\n/)) { + if (!line.trim()) continue; + if (/\b(ERROR|WARN|FAILED|SEVERE)\b/i.test(line)) { + log.warn('signal-cli stderr', { line: line.trim() }); + } else { + log.debug('signal-cli stderr', { line: line.trim() }); + } + } + }); + + return { + stop: () => { + if (!child.killed && !exited) child.kill('SIGTERM'); + }, + exited: exitedPromise, + isExited: () => exited, + }; +} + +// --------------------------------------------------------------------------- +// TCP JSON-RPC client for signal-cli daemon (--tcp mode) +// +// signal-cli 0.14.x --tcp exposes a newline-delimited JSON-RPC socket. +// Requests are sent as JSON + newline; responses and push notifications +// (inbound messages) arrive the same way. +// --------------------------------------------------------------------------- + +const RPC_TIMEOUT_MS = 15_000; + +class SignalTcpClient { + private socket: Socket | null = null; + private buffer = ''; + private pending = new Map< + string, + { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + timer: ReturnType; + } + >(); + private onNotification: ((method: string, params: unknown) => void) | null = null; + + constructor( + private host: string, + private port: number, + ) {} + + connect(onNotification?: (method: string, params: unknown) => void): Promise { + this.onNotification = onNotification ?? null; + return new Promise((resolve, reject) => { + const sock = createConnection(this.port, this.host, () => { + this.socket = sock; + resolve(); + }); + sock.on('error', (err) => { + if (!this.socket) { + reject(err); + return; + } + log.warn('Signal TCP socket error', { err }); + }); + sock.on('data', (chunk) => this.onData(chunk)); + sock.on('close', () => { + this.socket = null; + for (const [, p] of this.pending) { + clearTimeout(p.timer); + p.reject(new Error('Signal TCP connection closed')); + } + this.pending.clear(); + }); + }); + } + + async rpc(method: string, params?: Record): Promise { + if (!this.socket) throw new Error('Signal TCP not connected'); + const id = Math.random().toString(36).slice(2); + const msg = JSON.stringify({ jsonrpc: '2.0', method, params, id }) + '\n'; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Signal RPC timeout: ${method}`)); + }, RPC_TIMEOUT_MS); + + this.pending.set(id, { + resolve: resolve as (v: unknown) => void, + reject, + timer, + }); + this.socket!.write(msg); + }); + } + + close() { + this.socket?.destroy(); + this.socket = null; + } + + isConnected(): boolean { + return this.socket !== null && !this.socket.destroyed; + } + + private onData(chunk: Buffer) { + this.buffer += chunk.toString(); + let newlineIdx = this.buffer.indexOf('\n'); + while (newlineIdx !== -1) { + const line = this.buffer.slice(0, newlineIdx).trim(); + this.buffer = this.buffer.slice(newlineIdx + 1); + if (line) this.handleLine(line); + newlineIdx = this.buffer.indexOf('\n'); + } + } + + private handleLine(line: string) { + let parsed: any; + try { + parsed = JSON.parse(line); + } catch { + log.debug('Signal TCP: unparseable line', { line: line.slice(0, 200) }); + return; + } + + if (parsed.id && this.pending.has(parsed.id)) { + const p = this.pending.get(parsed.id)!; + this.pending.delete(parsed.id); + clearTimeout(p.timer); + if (parsed.error) { + p.reject(new Error(parsed.error.message ?? 'Signal RPC error')); + } else { + p.resolve(parsed.result); + } + return; + } + + if (parsed.method && this.onNotification) { + this.onNotification(parsed.method, parsed.params); + } + } +} + +async function signalTcpCheck(host: string, port: number): Promise { + return new Promise((resolve) => { + const sock = createConnection(port, host, () => { + sock.destroy(); + resolve(true); + }); + sock.on('error', () => resolve(false)); + setTimeout(() => { + sock.destroy(); + resolve(false); + }, 5000); + }); +} + +// --------------------------------------------------------------------------- +// Echo cache +// --------------------------------------------------------------------------- + +const ECHO_TTL_MS = 10_000; + +class EchoCache { + private entries = new Map(); + + remember(text: string) { + const key = text.trim(); + if (!key) return; + this.entries.set(key, Date.now()); + this.cleanup(); + } + + isEcho(text: string): boolean { + const key = text.trim(); + if (!key) return false; + const ts = this.entries.get(key); + if (!ts) return false; + if (Date.now() - ts > ECHO_TTL_MS) { + this.entries.delete(key); + return false; + } + this.entries.delete(key); + return true; + } + + private cleanup() { + const now = Date.now(); + for (const [key, ts] of this.entries) { + if (now - ts > ECHO_TTL_MS) this.entries.delete(key); + } + } +} + +// --------------------------------------------------------------------------- +// Signal envelope types +// --------------------------------------------------------------------------- + +interface SignalQuote { + id?: number; + authorNumber?: string; + authorUuid?: string; + text?: string; +} + +interface SignalDataMessage { + timestamp?: number; + message?: string; + groupInfo?: { groupId?: string; groupName?: string; type?: string }; + quote?: SignalQuote; + attachments?: Array<{ + id?: string; + contentType?: string; + filename?: string; + size?: number; + }>; +} + +interface SignalEnvelope { + source?: string; + sourceName?: string; + sourceNumber?: string; + sourceUuid?: string; + dataMessage?: SignalDataMessage; + syncMessage?: { + sentMessage?: SignalDataMessage & { + destination?: string; + destinationNumber?: string; + }; + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function chunkText(text: string, limit: number): string[] { + const chunks: string[] = []; + let remaining = text; + while (remaining.length > 0) { + if (remaining.length <= limit) { + chunks.push(remaining); + break; + } + let splitAt = remaining.lastIndexOf('\n', limit); + if (splitAt <= 0) splitAt = limit; + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).replace(/^\n/, ''); + } + return chunks; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// --------------------------------------------------------------------------- +// Signal text styles — convert Markdown to Signal's offset-based formatting +// --------------------------------------------------------------------------- + +interface SignalTextStyle { + style: 'BOLD' | 'ITALIC' | 'STRIKETHROUGH' | 'MONOSPACE' | 'SPOILER'; + start: number; + length: number; +} + +interface StyledText { + text: string; + textStyles: SignalTextStyle[]; +} + +function parseSignalStyles(input: string): StyledText { + const styles: SignalTextStyle[] = []; + + const patterns: Array<{ + regex: RegExp; + style: SignalTextStyle['style']; + }> = [ + { regex: /```([\s\S]*?)```/g, style: 'MONOSPACE' }, + { regex: /`([^`]+)`/g, style: 'MONOSPACE' }, + { regex: /\*\*(.+?)\*\*/g, style: 'BOLD' }, + { regex: /\*(.+?)\*/g, style: 'BOLD' }, + { regex: /_(.+?)_/g, style: 'ITALIC' }, + { regex: /~~(.+?)~~/g, style: 'STRIKETHROUGH' }, + { regex: /\|\|(.+?)\|\|/g, style: 'SPOILER' }, + ]; + + let text = input; + + for (const { regex, style } of patterns) { + const nextText: string[] = []; + let lastIndex = 0; + let offset = 0; + + for (const match of text.matchAll(regex)) { + const fullMatch = match[0]; + const innerText = match[1]; + const matchStart = match.index!; + + nextText.push(text.slice(lastIndex, matchStart)); + const plainStart = matchStart - offset; + + nextText.push(innerText); + styles.push({ style, start: plainStart, length: innerText.length }); + + const stripped = fullMatch.length - innerText.length; + offset += stripped; + lastIndex = matchStart + fullMatch.length; + } + + nextText.push(text.slice(lastIndex)); + text = nextText.join(''); + } + + return { text, textStyles: styles }; +} + +// --------------------------------------------------------------------------- +// SignalAdapter — v2 ChannelAdapter implementation +// --------------------------------------------------------------------------- + +/** + * Platform ID format: + * DM: phone number or UUID (e.g. "+15555550123") + * Group: "group:" (e.g. "group:abc123") + * + * channelType is always "signal". The router combines channelType + platformId + * to look up or create the messaging_group. + */ +export function createSignalAdapter(config: { + cliPath: string; + account: string; + tcpHost: string; + tcpPort: number; + manageDaemon: boolean; + signalDataDir: string; +}): ChannelAdapter { + let daemon: DaemonHandle | null = null; + let tcp: SignalTcpClient | null = null; + let connected = false; + const echoCache = new EchoCache(); + let setup: ChannelSetup | null = null; + + // -- inbound handling -- + + function handleNotification(method: string, params: unknown): void { + if (method === 'receive') { + const envelope = (params as any)?.envelope; + if (envelope) { + handleEnvelope(envelope).catch((err) => { + log.error('Signal: error handling envelope', { err }); + }); + } + } + } + + async function handleEnvelope(envelope: SignalEnvelope): Promise { + if (!setup) return; + + // Sync messages (sent from another device) + const syncSent = envelope.syncMessage?.sentMessage; + if (syncSent) { + const dest = (syncSent.destinationNumber ?? syncSent.destination ?? '').trim(); + // "Note to Self" — destination is our own account + if (dest === config.account) { + const text = (syncSent.message ?? '').trim(); + if (!text) return; + if (echoCache.isEcho(text)) return; + const platformId = config.account; + const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString(); + + setup.onMetadata(platformId, 'Note to Self', false); + + const msg: InboundMessage = { + id: String(syncSent.timestamp ?? Date.now()), + kind: 'chat', + content: { + text, + sender: config.account, + senderId: `signal:${config.account}`, + senderName: 'Me', + isFromMe: true, + ...(syncSent.quote ? quoteToContent(syncSent.quote) : {}), + }, + timestamp, + }; + await setup.onInbound(platformId, null, msg); + return; + } + // Other sync messages are our outbound — skip + return; + } + + const dataMessage = envelope.dataMessage; + if (!dataMessage) return; + + const text = (dataMessage.message ?? '').trim(); + + // Check for voice attachments + const hasVoice = !text && dataMessage.attachments?.some((a) => a.contentType?.startsWith('audio/')); + + if (!text && !hasVoice) return; + + const sender = (envelope.sourceNumber ?? envelope.sourceUuid ?? envelope.source ?? '').trim(); + if (!sender) return; + + if (text && echoCache.isEcho(text)) { + log.debug('Signal: skipping echo'); + return; + } + + const senderName = (envelope.sourceName?.trim() || sender).trim(); + const groupInfo = dataMessage.groupInfo; + const isGroup = Boolean(groupInfo?.groupId); + const groupId = groupInfo?.groupId; + + const platformId = isGroup ? `group:${groupId}` : sender; + const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString(); + + const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName); + + setup.onMetadata(platformId, chatName, isGroup); + + let content = text; + + // Voice attachment — log path, deliver placeholder text. + // v2 does not have built-in transcription; a future MCP tool could handle this. + if (hasVoice) { + const audio = dataMessage.attachments?.find((a) => a.contentType?.startsWith('audio/')); + if (audio?.id) { + const attachmentPath = join(config.signalDataDir, 'attachments', audio.id); + if (existsSync(attachmentPath)) { + log.info('Signal: voice attachment received', { + platformId, + attachmentId: audio.id, + path: attachmentPath, + }); + content = '[Voice Message]'; + } else { + log.warn('Signal: voice attachment file not found', { + id: audio.id, + path: attachmentPath, + }); + content = '[Voice Message - file not found]'; + } + } else { + content = '[Voice Message]'; + } + } + + const msg: InboundMessage = { + id: String(dataMessage.timestamp ?? Date.now()), + kind: 'chat', + content: { + text: content, + sender, + senderId: `signal:${sender}`, + senderName, + ...(dataMessage.quote ? quoteToContent(dataMessage.quote) : {}), + }, + timestamp, + }; + await setup.onInbound(platformId, null, msg); + + log.info('Signal message received', { platformId, sender: senderName }); + } + + function quoteToContent(quote: SignalQuote): Record { + return { + replyToSenderName: quote.authorNumber ?? 'someone', + replyToMessageContent: quote.text || undefined, + replyToMessageId: quote.id ? String(quote.id) : undefined, + }; + } + + // -- send helpers -- + + async function sendText(platformId: string, text: string): Promise { + if (!connected || !tcp) return; + + echoCache.remember(text); + + const MAX_CHUNK = 4000; + const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK); + + for (const chunk of chunks) { + try { + const { text: plainText, textStyles } = parseSignalStyles(chunk); + const params: Record = { message: plainText }; + if (config.account) params.account = config.account; + if (textStyles.length > 0) { + params.textStyle = textStyles.map((s) => `${s.start}:${s.length}:${s.style}`); + } + + if (platformId.startsWith('group:')) { + params.groupId = platformId.slice('group:'.length); + } else { + params.recipient = [platformId]; + } + + try { + await tcp.rpc('send', params); + } catch (styledErr) { + if (textStyles.length > 0) { + log.debug('Signal: textStyle rejected, retrying with markup'); + delete params.textStyle; + params.message = chunk; + await tcp.rpc('send', params); + } else { + throw styledErr; + } + } + } catch (err) { + log.error('Signal: send failed', { platformId, err }); + } + } + + log.info('Signal message sent', { platformId, length: text.length }); + } + + async function waitForDaemon(): Promise { + const maxWait = 30_000; + const pollInterval = 1000; + const start = Date.now(); + + while (Date.now() - start < maxWait) { + if (daemon?.isExited()) return false; + const ok = await signalTcpCheck(config.tcpHost, config.tcpPort); + if (ok) return true; + await sleep(pollInterval); + } + return false; + } + + // -- adapter -- + + const adapter: ChannelAdapter = { + name: 'signal', + channelType: 'signal', + supportsThreads: false, + + async setup(cfg: ChannelSetup): Promise { + setup = cfg; + + if (config.manageDaemon) { + daemon = spawnSignalDaemon(config.cliPath, config.account, config.tcpHost, config.tcpPort); + const ready = await waitForDaemon(); + if (!ready) { + daemon.stop(); + throw new Error('Signal daemon failed to start. Is signal-cli installed and your account linked?'); + } + } else { + const ok = await signalTcpCheck(config.tcpHost, config.tcpPort); + if (!ok) { + const err = new Error( + `Signal daemon not reachable at ${config.tcpHost}:${config.tcpPort}. Start it manually or set SIGNAL_MANAGE_DAEMON=true`, + ); + (err as any).name = 'NetworkError'; + throw err; + } + } + + tcp = new SignalTcpClient(config.tcpHost, config.tcpPort); + await tcp.connect(handleNotification); + + try { + await tcp.rpc('updateProfile', { + name: 'NanoClaw', + account: config.account, + }); + } catch { + log.debug('Signal: could not set profile name'); + } + + try { + await tcp.rpc('updateConfiguration', { + typingIndicators: true, + account: config.account, + }); + } catch { + log.debug('Signal: could not enable typing indicators'); + } + + connected = true; + log.info('Signal channel connected', { + account: config.account, + host: config.tcpHost, + port: config.tcpPort, + }); + }, + + async teardown(): Promise { + connected = false; + tcp?.close(); + tcp = null; + if (daemon && config.manageDaemon) { + daemon.stop(); + await daemon.exited; + } + daemon = null; + log.info('Signal channel disconnected'); + }, + + isConnected(): boolean { + return connected; + }, + + async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise { + const content = message.content as Record | string | undefined; + let text: string | null = null; + if (typeof content === 'string') { + text = content; + } else if (content && typeof content === 'object' && typeof content.text === 'string') { + text = content.text; + } + if (!text) return undefined; + + await sendText(platformId, text); + return undefined; + }, + + async setTyping(platformId: string, _threadId: string | null): Promise { + if (!connected || !tcp) return; + if (platformId.startsWith('group:')) return; + + try { + const params: Record = { recipient: [platformId] }; + if (config.account) params.account = config.account; + await tcp.rpc('sendTyping', params); + } catch (err) { + log.debug('Signal: typing indicator failed', { platformId, err }); + } + }, + }; + + return adapter; +} + +// --------------------------------------------------------------------------- +// Self-registration +// --------------------------------------------------------------------------- + +const DEFAULT_TCP_HOST = '127.0.0.1'; +const DEFAULT_TCP_PORT = 7583; + +registerChannelAdapter('signal', { + factory: () => { + const envVars = readEnvFile([ + 'SIGNAL_ACCOUNT', + 'SIGNAL_HTTP_HOST', + 'SIGNAL_HTTP_PORT', + 'SIGNAL_MANAGE_DAEMON', + 'SIGNAL_DATA_DIR', + ]); + + const account = process.env.SIGNAL_ACCOUNT || envVars.SIGNAL_ACCOUNT || ''; + if (!account) { + log.debug('Signal: SIGNAL_ACCOUNT not set, skipping channel'); + return null; + } + + const cliPath = 'signal-cli'; + const tcpHost = process.env.SIGNAL_HTTP_HOST || envVars.SIGNAL_HTTP_HOST || DEFAULT_TCP_HOST; + const tcpPort = parseInt(process.env.SIGNAL_HTTP_PORT || envVars.SIGNAL_HTTP_PORT || String(DEFAULT_TCP_PORT), 10); + const manageDaemon = (process.env.SIGNAL_MANAGE_DAEMON || envVars.SIGNAL_MANAGE_DAEMON || 'true') === 'true'; + + const signalDataDir = + process.env.SIGNAL_DATA_DIR || envVars.SIGNAL_DATA_DIR || join(homedir(), '.local', 'share', 'signal-cli'); + + if (manageDaemon && cliPath === 'signal-cli') { + try { + execFileSync('which', ['signal-cli'], { stdio: 'ignore' }); + } catch { + log.debug('Signal: signal-cli binary not found, skipping channel'); + return null; + } + } + + return createSignalAdapter({ + cliPath, + account, + tcpHost, + tcpPort, + manageDaemon, + signalDataDir, + }); + }, +}); From bd032c2b83236e39041e4c8b9b9dae5658ff1887 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 19:35:59 +0000 Subject: [PATCH 156/185] chore: bump version to 2.0.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 77920c4..e358b1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.7", + "version": "2.0.8", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 2861009d95eaf9ffda3f587e1b1740be78a539d5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 19:36:03 +0000 Subject: [PATCH 157/185] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?29k=20tokens=20=C2=B7=2064%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 3fc904e..fd25267 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 128k tokens, 64% of context window + + 129k tokens, 64% of context window @@ -15,8 +15,8 @@ tokens - - 128k + + 129k From 5d32efbce4fc49de4e827792d7cbc05ae6439a07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 19:37:49 +0000 Subject: [PATCH 158/185] chore: bump version to 2.0.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e358b1d..098e01f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.8", + "version": "2.0.9", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 5f3bd9c880a06881fa66896d5f182df3eb3d97d5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 22:54:27 +0300 Subject: [PATCH 159/185] fix(signal): address review feedback from #1953 Correctness fixes: - parseSignalStyles now uses a recursive walker so nested styles (e.g. **bold with `code` inside**) produce correct offsets against the final plain text. Previous impl recorded styles against intermediate text and didn't reindex when later passes stripped prefix characters. - *single-asterisk* maps to ITALIC (was BOLD, divergent from standard Markdown). _underscore_ also maps to ITALIC. - EchoCache keys on (platformId, text) so an outbound "hi" to Alice no longer drops a real "hi" inbound from Bob. - On TCP socket close, flip adapter connected=false and log a warning so operators see lost daemon connections instead of silently failing sends. - signalTcpCheck clears its 5s timeout on success so successful checks don't leak a setTimeout handle. Config hygiene: - Rename SIGNAL_HTTP_HOST/PORT to SIGNAL_TCP_HOST/PORT (transport is TCP JSON-RPC, not HTTP) and add SIGNAL_CLI_PATH for non-PATH installs. - Remove unused readFileSync import. - Log a warning in deliver() when outbound files are dropped (native adapter doesn't forward attachments to signal-cli yet). Tests: - Nested style offset correctness - *italic* and _italic_ ITALIC mapping - Cross-recipient echo isolation - Same-recipient echo still suppressed - isConnected() flips on socket close - Outbound-files warn-and-drop path SKILL.md realigned to the add-telegram / add-whatsapp template: fetches from the `channels` branch (not a `skill/*` branch), lists pre-flight idempotency checks, adds Features / Troubleshooting sections. Added VERIFY.md and REMOVE.md siblings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-signal/REMOVE.md | 13 ++ .claude/skills/add-signal/SKILL.md | 103 ++++++++------ .claude/skills/add-signal/VERIFY.md | 5 + src/channels/signal.test.ts | 159 ++++++++++++++++++++++ src/channels/signal.ts | 199 +++++++++++++++++++--------- 5 files changed, 375 insertions(+), 104 deletions(-) create mode 100644 .claude/skills/add-signal/REMOVE.md create mode 100644 .claude/skills/add-signal/VERIFY.md diff --git a/.claude/skills/add-signal/REMOVE.md b/.claude/skills/add-signal/REMOVE.md new file mode 100644 index 0000000..db37ade --- /dev/null +++ b/.claude/skills/add-signal/REMOVE.md @@ -0,0 +1,13 @@ +# Remove Signal + +1. Comment out `import './signal.js'` in `src/channels/index.ts` +2. Remove `SIGNAL_ACCOUNT` (and any other `SIGNAL_*` vars) from `.env` +3. Rebuild and restart + +If you also want to unlink the Signal account from `signal-cli`: + +```bash +signal-cli -a +1YOURNUMBER removeDevice --deviceId +``` + +(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.) diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md index 92c7800..e6d41aa 100644 --- a/.claude/skills/add-signal/SKILL.md +++ b/.claude/skills/add-signal/SKILL.md @@ -5,38 +5,40 @@ description: Add Signal channel integration via signal-cli TCP daemon. Native ad # Add Signal Channel -Adds Signal messaging support via a native adapter that communicates with a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon using JSON-RPC. +Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge, no npm deps — only Node.js builtins. ## Prerequisites -- **signal-cli** installed and a Signal account linked - - macOS: `brew install signal-cli` - - Linux: download from [GitHub releases](https://github.com/AsamK/signal-cli/releases) - - Link your account: `signal-cli -a +1YOURNUMBER link` (follow the QR instructions) +`signal-cli` installed and a Signal account linked: + +- macOS: `brew install signal-cli` +- Linux: download from [GitHub releases](https://github.com/AsamK/signal-cli/releases) +- Link your account: `signal-cli -a +1YOURNUMBER link` (follow the QR instructions) ## Install +NanoClaw doesn't ship channels in trunk. This skill copies the Signal adapter and its tests in from the `channels` branch. + ### Pre-flight (idempotent) Skip to **Credentials** if all of these are already in place: -- `src/channels/signal.ts` exists -- `src/channels/signal.test.ts` exists +- `src/channels/signal.ts` and `src/channels/signal.test.ts` both exist - `src/channels/index.ts` contains `import './signal.js';` Otherwise continue. Every step below is safe to re-run. -### 1. Fetch the skill branch +### 1. Fetch the channels branch ```bash -git fetch origin skill/signal +git fetch origin channels ``` ### 2. Copy the adapter and tests ```bash -git show origin/skill/signal:src/channels/signal.ts > src/channels/signal.ts -git show origin/skill/signal:src/channels/signal.test.ts > src/channels/signal.test.ts +git show origin/channels:src/channels/signal.ts > src/channels/signal.ts +git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts ``` ### 3. Append the self-registration import @@ -59,30 +61,31 @@ No npm packages to install — the adapter uses only Node.js builtins (`node:net Add to `.env`: -```env +```bash SIGNAL_ACCOUNT=+1YOURNUMBER ``` ### Optional settings -```env +```bash # TCP daemon host and port (default: 127.0.0.1:7583) -SIGNAL_HTTP_HOST=127.0.0.1 -SIGNAL_HTTP_PORT=7583 +SIGNAL_TCP_HOST=127.0.0.1 +SIGNAL_TCP_PORT=7583 -# Whether NanoClaw manages the daemon lifecycle (default: true) -# Set to false if you run signal-cli daemon externally +# Path to the signal-cli binary (default: resolved on PATH) +SIGNAL_CLI_PATH=/usr/local/bin/signal-cli + +# Whether NanoClaw manages the daemon lifecycle (default: true). +# Set to false if you run signal-cli daemon externally. SIGNAL_MANAGE_DAEMON=true # signal-cli data directory (default: ~/.local/share/signal-cli) SIGNAL_DATA_DIR=~/.local/share/signal-cli ``` -### Sync to container +**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network. -```bash -mkdir -p data/env && cp .env data/env/env -``` +Sync to container: `mkdir -p data/env && cp .env data/env/env` ### Restart @@ -96,26 +99,50 @@ systemctl --user restart nanoclaw ## Next Steps -Run `/init-first-agent` to create an agent and wire it to your Signal DM. Signal is direct-addressable — your phone number is the platform ID: +If you're in the middle of `/setup`, return to the setup flow now. -- **User ID**: your Signal phone number (e.g. `+15551234567`) -- **Platform ID**: same as user ID for DMs (e.g. `+15551234567`) -- **For group chats**: use `group:` — find group IDs via `signal-cli -a +1YOURNUMBER listGroups` - -`/init-first-agent` handles user creation, owner role, agent group, messaging group wiring, and the welcome DM. It's idempotent — safe to run again for additional agents. +Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group. Signal is direct-addressable — your phone number is the platform ID. ## Channel Info -| Field | Value | -|-------|-------| -| **Type** | `signal` | -| **Thread support** | No (Signal has no thread model) | -| **Platform ID format** | DM: `+15555550123` / Group: `group:` | -| **Mention detection** | Text-match against agent group name (no SDK-level mentions) | -| **Typing indicators** | DMs only | -| **Typical use** | Personal assistant via Signal DMs or small group chats | -| **Isolation** | Recommended: one agent per Signal account | +- **type**: `signal` +- **terminology**: Signal has "chats" (1:1 DMs) and "groups." +- **how-to-find-id**: DMs use your phone number (e.g. `+15555550123`). Groups use `group:` — find group IDs via `signal-cli -a +1YOURNUMBER listGroups`. +- **supports-threads**: no +- **typical-use**: Personal assistant via Signal DMs or small group chats +- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically be separate. -### Voice Messages +### Features -Voice attachments are detected but not transcribed by default. The agent receives `[Voice Message]` as the message text. Run `/add-voice-transcription` to enable automatic local transcription via parakeet-mlx. +- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles) +- Quoted replies — `replyTo*` fields populated from Signal quotes +- Typing indicators — DMs only (Signal doesn't support group typing) +- Echo suppression — outbound messages are matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops +- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true` +- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx + +Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions. + +## Troubleshooting + +### Daemon not reachable + +```bash +grep "Signal" logs/nanoclaw.log | tail +``` + +If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`: +- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`) +- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting + +If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`. + +### Bot not responding + +1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1` +2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"` +3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) + +### Lost connection mid-session + +If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped us. There's no auto-reconnect yet — restart the service to re-establish. diff --git a/.claude/skills/add-signal/VERIFY.md b/.claude/skills/add-signal/VERIFY.md new file mode 100644 index 0000000..b1ae851 --- /dev/null +++ b/.claude/skills/add-signal/VERIFY.md @@ -0,0 +1,5 @@ +# Verify Signal + +Send a message to your own Signal number (Note to Self) from another device, or have someone send your linked number a DM. The bot should respond within a few seconds. + +If nothing happens, tail `logs/nanoclaw.log` for `Signal channel connected` and `Signal message received`. diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts index c7ffff1..f5dabfa 100644 --- a/src/channels/signal.test.ts +++ b/src/channels/signal.test.ts @@ -583,6 +583,165 @@ describe('SignalAdapter', () => { await adapter.teardown(); }); + + it('tracks nested styles with correct offsets', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: '**bold with `code` inside**' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('bold with code inside'); + // BOLD covers the full inner span, MONOSPACE points at "code" in the + // final plain text (offset 10, length 4) — not the intermediate text. + const styles = (last.params.textStyle as string[]).slice().sort(); + expect(styles).toEqual(['0:21:BOLD', '10:4:MONOSPACE']); + + await adapter.teardown(); + }); + + it('maps *single-asterisk* to ITALIC', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello *world*' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Hello world'); + expect(last.params.textStyle).toEqual(['6:5:ITALIC']); + + await adapter.teardown(); + }); + + it('maps _underscore_ to ITALIC', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'hey _there_' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('hey there'); + expect(last.params.textStyle).toEqual(['4:5:ITALIC']); + + await adapter.teardown(); + }); + }); + + // --- Echo cache --- + + describe('echo cache', () => { + it('does not drop same-text inbound from a different recipient', async () => { + // Bot sends "Hello" to Alice. Immediately after, Bob sends "Hello" from + // a different DM. Bob's message must still route — the earlier echo key + // was scoped to Alice. + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello' }, + }); + + pushEvent({ + sourceNumber: '+15555550999', + sourceName: 'Bob', + dataMessage: { timestamp: 1700000000000, message: 'Hello' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550999', + null, + expect.objectContaining({ + content: expect.objectContaining({ text: 'Hello', sender: '+15555550999' }), + }), + ); + + await adapter.teardown(); + }); + + it('still skips echo on the same recipient', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Echo test' }, + }); + + pushEvent({ + sourceNumber: '+15555550123', + dataMessage: { timestamp: 1700000000000, message: 'Echo test' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + }); + + // --- Connection drop --- + + describe('connection drop', () => { + it('flips isConnected to false when the socket closes', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + expect(adapter.isConnected()).toBe(true); + + // Simulate the daemon dropping the TCP connection. + tcpRef.fakeSocket.destroy(); + await new Promise((r) => setTimeout(r, 20)); + + expect(adapter.isConnected()).toBe(false); + + await adapter.teardown(); + }); + }); + + // --- Outbound files --- + + describe('outbound files', () => { + it('logs a warning and drops unsupported file attachments', async () => { + const { log } = await import('../log.js'); + const warnMock = log.warn as unknown as ReturnType; + + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + warnMock.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'with an attachment' }, + files: [{ filename: 'hi.txt', data: Buffer.from('hi') }], + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + expect(warnMock).toHaveBeenCalledWith( + 'Signal: outbound files not supported, dropping', + expect.objectContaining({ platformId: '+15555550123', count: 1 }), + ); + + await adapter.teardown(); + }); }); // --- setTyping --- diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 300b7a6..20cba81 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -8,7 +8,7 @@ * Ported from v1 — see v1 source for commit history. */ import { execFileSync, spawn } from 'node:child_process'; -import { readFileSync, existsSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { createConnection, type Socket } from 'node:net'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -100,14 +100,19 @@ class SignalTcpClient { } >(); private onNotification: ((method: string, params: unknown) => void) | null = null; + private onClose: (() => void) | null = null; constructor( private host: string, private port: number, ) {} - connect(onNotification?: (method: string, params: unknown) => void): Promise { - this.onNotification = onNotification ?? null; + connect(handlers?: { + onNotification?: (method: string, params: unknown) => void; + onClose?: () => void; + }): Promise { + this.onNotification = handlers?.onNotification ?? null; + this.onClose = handlers?.onClose ?? null; return new Promise((resolve, reject) => { const sock = createConnection(this.port, this.host, () => { this.socket = sock; @@ -122,12 +127,14 @@ class SignalTcpClient { }); sock.on('data', (chunk) => this.onData(chunk)); sock.on('close', () => { + const wasConnected = this.socket !== null; this.socket = null; for (const [, p] of this.pending) { clearTimeout(p.timer); p.reject(new Error('Signal TCP connection closed')); } this.pending.clear(); + if (wasConnected) this.onClose?.(); }); }); } @@ -201,15 +208,17 @@ class SignalTcpClient { async function signalTcpCheck(host: string, port: number): Promise { return new Promise((resolve) => { - const sock = createConnection(port, host, () => { + let settled = false; + const finish = (result: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); sock.destroy(); - resolve(true); - }); - sock.on('error', () => resolve(false)); - setTimeout(() => { - sock.destroy(); - resolve(false); - }, 5000); + resolve(result); + }; + const sock = createConnection(port, host, () => finish(true)); + sock.on('error', () => finish(false)); + const timer = setTimeout(() => finish(false), 5000); }); } @@ -219,19 +228,35 @@ async function signalTcpCheck(host: string, port: number): Promise { const ECHO_TTL_MS = 10_000; +/** + * Per-recipient dedup for messages we sent ourselves. + * + * signal-cli echoes our own outbound back via syncMessage (and, for Note to + * Self, via sentMessage-with-self-destination). Without dedup, the agent sees + * its own replies as new inbound and loops. We remember `(platformId, text)` + * briefly after every send, and drop the first match within TTL. + * + * Keying on text alone is not enough: if we send "hi" to Alice and Bob then + * sends "hi" from a different chat, Bob's real message gets silently dropped. + */ class EchoCache { private entries = new Map(); - remember(text: string) { - const key = text.trim(); - if (!key) return; - this.entries.set(key, Date.now()); + private keyFor(platformId: string, text: string): string { + return `${platformId}\x00${text.trim()}`; + } + + remember(platformId: string, text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + this.entries.set(this.keyFor(platformId, trimmed), Date.now()); this.cleanup(); } - isEcho(text: string): boolean { - const key = text.trim(); - if (!key) return false; + isEcho(platformId: string, text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + const key = this.keyFor(platformId, trimmed); const ts = this.entries.get(key); if (!ts) return false; if (Date.now() - ts > ECHO_TTL_MS) { @@ -242,7 +267,7 @@ class EchoCache { return true; } - private cleanup() { + private cleanup(): void { const now = Date.now(); for (const [key, ts] of this.entries) { if (now - ts > ECHO_TTL_MS) this.entries.delete(key); @@ -325,49 +350,61 @@ interface StyledText { textStyles: SignalTextStyle[]; } +/** + * Convert Markdown-ish input to Signal's offset-based style ranges. + * + * Walks the input recursively: at each level we find the leftmost matching + * pattern, descend into its captured inner text (so `**bold with \`code\` + * inside**` stays bold-plus-monospace rather than leaking stripped markers), + * then continue past the match. Style offsets are recorded against the + * *output* text length as it's built, so nested styles always point at the + * right span of the final plain text. + */ function parseSignalStyles(input: string): StyledText { const styles: SignalTextStyle[] = []; - const patterns: Array<{ - regex: RegExp; - style: SignalTextStyle['style']; - }> = [ - { regex: /```([\s\S]*?)```/g, style: 'MONOSPACE' }, - { regex: /`([^`]+)`/g, style: 'MONOSPACE' }, - { regex: /\*\*(.+?)\*\*/g, style: 'BOLD' }, - { regex: /\*(.+?)\*/g, style: 'BOLD' }, - { regex: /_(.+?)_/g, style: 'ITALIC' }, - { regex: /~~(.+?)~~/g, style: 'STRIKETHROUGH' }, - { regex: /\|\|(.+?)\|\|/g, style: 'SPOILER' }, + // Ordering matters: longer/greedier delimiters first so `` ``` `` beats + // `` ` ``, `**` beats `*`. The italic-`*` pattern refuses to start on + // whitespace so `*` isn't mistakenly opened on " * " in list-like text. + const patterns: Array<{ regex: RegExp; style: SignalTextStyle['style'] }> = [ + { regex: /```([\s\S]+?)```/, style: 'MONOSPACE' }, + { regex: /`([^`]+)`/, style: 'MONOSPACE' }, + { regex: /\*\*([^]+?)\*\*/, style: 'BOLD' }, + { regex: /~~([^]+?)~~/, style: 'STRIKETHROUGH' }, + { regex: /\|\|([^]+?)\|\|/, style: 'SPOILER' }, + { regex: /\*([^*\s][^*]*?)\*/, style: 'ITALIC' }, + { regex: /_([^_\s][^_]*?)_/, style: 'ITALIC' }, ]; - let text = input; - - for (const { regex, style } of patterns) { - const nextText: string[] = []; - let lastIndex = 0; - let offset = 0; - - for (const match of text.matchAll(regex)) { - const fullMatch = match[0]; - const innerText = match[1]; - const matchStart = match.index!; - - nextText.push(text.slice(lastIndex, matchStart)); - const plainStart = matchStart - offset; - - nextText.push(innerText); - styles.push({ style, start: plainStart, length: innerText.length }); - - const stripped = fullMatch.length - innerText.length; - offset += stripped; - lastIndex = matchStart + fullMatch.length; + function walk(segment: string, outputBase: number): string { + let earliest: { start: number; match: RegExpExecArray; style: SignalTextStyle['style'] } | null = null; + for (const { regex, style } of patterns) { + const m = regex.exec(segment); + if (!m) continue; + if (earliest === null || m.index < earliest.start) { + earliest = { start: m.index, match: m, style }; + } } + if (!earliest) return segment; - nextText.push(text.slice(lastIndex)); - text = nextText.join(''); + const before = segment.slice(0, earliest.start); + const fullMatch = earliest.match[0]; + const inner = earliest.match[1]; + const afterStart = earliest.start + fullMatch.length; + const after = segment.slice(afterStart); + + const innerOut = walk(inner, outputBase + before.length); + styles.push({ + style: earliest.style, + start: outputBase + before.length, + length: innerOut.length, + }); + const afterOut = walk(after, outputBase + before.length + innerOut.length); + + return before + innerOut + afterOut; } + const text = walk(input, 0); return { text, textStyles: styles }; } @@ -421,8 +458,8 @@ export function createSignalAdapter(config: { if (dest === config.account) { const text = (syncSent.message ?? '').trim(); if (!text) return; - if (echoCache.isEcho(text)) return; const platformId = config.account; + if (echoCache.isEcho(platformId, text)) return; const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString(); setup.onMetadata(platformId, 'Note to Self', false); @@ -460,17 +497,17 @@ export function createSignalAdapter(config: { const sender = (envelope.sourceNumber ?? envelope.sourceUuid ?? envelope.source ?? '').trim(); if (!sender) return; - if (text && echoCache.isEcho(text)) { - log.debug('Signal: skipping echo'); - return; - } - const senderName = (envelope.sourceName?.trim() || sender).trim(); const groupInfo = dataMessage.groupInfo; const isGroup = Boolean(groupInfo?.groupId); const groupId = groupInfo?.groupId; const platformId = isGroup ? `group:${groupId}` : sender; + + if (text && echoCache.isEcho(platformId, text)) { + log.debug('Signal: skipping echo', { platformId }); + return; + } const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString(); const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName); @@ -534,7 +571,7 @@ export function createSignalAdapter(config: { async function sendText(platformId: string, text: string): Promise { if (!connected || !tcp) return; - echoCache.remember(text); + echoCache.remember(platformId, text); const MAX_CHUNK = 4000; const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK); @@ -617,7 +654,22 @@ export function createSignalAdapter(config: { } tcp = new SignalTcpClient(config.tcpHost, config.tcpPort); - await tcp.connect(handleNotification); + await tcp.connect({ + onNotification: handleNotification, + // Signal the adapter that the daemon dropped us. No auto-reconnect yet + // — subsequent deliver/setTyping calls short-circuit on `connected` + // and log rather than throw into the retry loop. Operators see this in + // logs/nanoclaw.log and can restart the service. + onClose: () => { + if (!connected) return; + connected = false; + log.warn('Signal channel lost TCP connection to signal-cli daemon', { + account: config.account, + host: config.tcpHost, + port: config.tcpPort, + }); + }, + }); try { await tcp.rpc('updateProfile', { @@ -662,6 +714,17 @@ export function createSignalAdapter(config: { }, async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise { + if (message.files && message.files.length > 0) { + // Native adapter doesn't yet forward file uploads to signal-cli's + // `send --attachment`. Don't silently swallow — operators need to see + // that an attachment was requested but not sent. + log.warn('Signal: outbound files not supported, dropping', { + platformId, + count: message.files.length, + filenames: message.files.map((f) => f.filename), + }); + } + const content = message.content as Record | string | undefined; let text: string | null = null; if (typeof content === 'string') { @@ -703,8 +766,9 @@ registerChannelAdapter('signal', { factory: () => { const envVars = readEnvFile([ 'SIGNAL_ACCOUNT', - 'SIGNAL_HTTP_HOST', - 'SIGNAL_HTTP_PORT', + 'SIGNAL_TCP_HOST', + 'SIGNAL_TCP_PORT', + 'SIGNAL_CLI_PATH', 'SIGNAL_MANAGE_DAEMON', 'SIGNAL_DATA_DIR', ]); @@ -715,14 +779,17 @@ registerChannelAdapter('signal', { return null; } - const cliPath = 'signal-cli'; - const tcpHost = process.env.SIGNAL_HTTP_HOST || envVars.SIGNAL_HTTP_HOST || DEFAULT_TCP_HOST; - const tcpPort = parseInt(process.env.SIGNAL_HTTP_PORT || envVars.SIGNAL_HTTP_PORT || String(DEFAULT_TCP_PORT), 10); + const cliPath = process.env.SIGNAL_CLI_PATH || envVars.SIGNAL_CLI_PATH || 'signal-cli'; + const tcpHost = process.env.SIGNAL_TCP_HOST || envVars.SIGNAL_TCP_HOST || DEFAULT_TCP_HOST; + const tcpPort = parseInt(process.env.SIGNAL_TCP_PORT || envVars.SIGNAL_TCP_PORT || String(DEFAULT_TCP_PORT), 10); const manageDaemon = (process.env.SIGNAL_MANAGE_DAEMON || envVars.SIGNAL_MANAGE_DAEMON || 'true') === 'true'; const signalDataDir = process.env.SIGNAL_DATA_DIR || envVars.SIGNAL_DATA_DIR || join(homedir(), '.local', 'share', 'signal-cli'); + // Only check for `signal-cli` on PATH when the operator left cliPath at + // the default AND asked us to manage the daemon. A custom absolute path + // is treated as an explicit promise and spawn will surface its own ENOENT. if (manageDaemon && cliPath === 'signal-cli') { try { execFileSync('which', ['signal-cli'], { stdio: 'ignore' }); From f351e460083b6128a9ee6d8d5108bb17706bd0fc Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 22:54:47 +0300 Subject: [PATCH 160/185] refactor(approvals): persist title+options on channel/sender approval tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAskQuestionRender used to hardcode the card title and option labels for pending_channel_approvals and pending_sender_approvals in the DB-access layer, duplicating wording that already lived in the approval modules. That caused a visible drift between the initial card title — picked per event in channel-approval.ts ("📣 Bot mentioned in new chat" vs. "💬 New direct message") — and the post-click render, which always showed the constant "📣 Channel registration". Mirror the pattern already used by pending_approvals: add title / options_json columns on both pending_*_approvals tables via migration 013, have the approval modules write them at creation time, and let getAskQuestionRender just SELECT. - Migration 013 ALTERs the two tables to add title + options_json. - PendingChannelApproval / PendingSenderApproval types and their create functions grow the two fields. - channel-approval.ts / sender-approval.ts normalize options once and pass both title and options_json into the insert. - getAskQuestionRender drops the hardcoded render objects and reads the stored values. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../013-approval-render-metadata.ts | 27 ++++++++++++++++ src/db/migrations/index.ts | 2 ++ src/db/sessions.ts | 32 ++++++------------- src/modules/permissions/channel-approval.ts | 5 ++- .../db/pending-channel-approvals.ts | 8 +++-- .../db/pending-sender-approvals.ts | 10 ++++-- src/modules/permissions/sender-approval.ts | 5 ++- 7 files changed, 61 insertions(+), 28 deletions(-) create mode 100644 src/db/migrations/013-approval-render-metadata.ts diff --git a/src/db/migrations/013-approval-render-metadata.ts b/src/db/migrations/013-approval-render-metadata.ts new file mode 100644 index 0000000..3a1af28 --- /dev/null +++ b/src/db/migrations/013-approval-render-metadata.ts @@ -0,0 +1,27 @@ +/** + * Persist ask_question render metadata (title + options_json) on + * `pending_channel_approvals` and `pending_sender_approvals`, mirroring the + * columns migration 003 / module-approvals-title-options added to + * `pending_approvals`. + * + * Before this, `getAskQuestionRender` hardcoded the title + option labels + * for these two tables in the DB-access layer — duplicating wording that + * also lived in the approval modules and causing a visible drift between + * the initial card title ("📣 Bot mentioned in new chat" / "💬 New direct + * message", chosen per event) and the post-click render ("📣 Channel + * registration", constant). Storing the render metadata alongside the row + * lets both sides read from the same source. + */ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +export const migration013: Migration = { + version: 13, + name: 'approval-render-metadata', + up(db: Database.Database) { + db.exec(`ALTER TABLE pending_channel_approvals ADD COLUMN title TEXT NOT NULL DEFAULT ''`); + db.exec(`ALTER TABLE pending_channel_approvals ADD COLUMN options_json TEXT NOT NULL DEFAULT '[]'`); + db.exec(`ALTER TABLE pending_sender_approvals ADD COLUMN title TEXT NOT NULL DEFAULT ''`); + db.exec(`ALTER TABLE pending_sender_approvals ADD COLUMN options_json TEXT NOT NULL DEFAULT '[]'`); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 33e6963..b46e678 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -9,6 +9,7 @@ import { migration009 } from './009-drop-pending-credentials.js'; import { migration010 } from './010-engage-modes.js'; import { migration011 } from './011-pending-sender-approvals.js'; import { migration012 } from './012-channel-registration.js'; +import { migration013 } from './013-approval-render-metadata.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; @@ -29,6 +30,7 @@ const migrations: Migration[] = [ migration010, migration011, migration012, + migration013, ]; export function runMigrations(db: Database.Database): void { diff --git a/src/db/sessions.ts b/src/db/sessions.ts index e9461ca..5c53ad5 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -194,32 +194,20 @@ export function getAskQuestionRender( | undefined; if (a?.title) return { title: a.title, options: JSON.parse(a.options_json) }; - // Channel-registration approval — options are fixed constants. + // Channel-registration + unknown-sender approvals persist title/options_json + // the same way pending_approvals does — just SELECT and return. if (hasTable(getDb(), 'pending_channel_approvals')) { - const c = getDb().prepare('SELECT 1 FROM pending_channel_approvals WHERE messaging_group_id = ?').get(id); - if (c) { - return { - title: '📣 Channel registration', - options: [ - { label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' }, - { label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' }, - ], - }; - } + const c = getDb() + .prepare('SELECT title, options_json FROM pending_channel_approvals WHERE messaging_group_id = ?') + .get(id) as { title: string; options_json: string } | undefined; + if (c?.title) return { title: c.title, options: JSON.parse(c.options_json) }; } - // Unknown-sender approval — options are fixed constants. if (hasTable(getDb(), 'pending_sender_approvals')) { - const s = getDb().prepare('SELECT 1 FROM pending_sender_approvals WHERE id = ?').get(id); - if (s) { - return { - title: '👤 New sender', - options: [ - { label: 'Allow', selectedLabel: '✅ Allowed', value: 'approve' }, - { label: 'Deny', selectedLabel: '❌ Denied', value: 'reject' }, - ], - }; - } + const s = getDb().prepare('SELECT title, options_json FROM pending_sender_approvals WHERE id = ?').get(id) as + | { title: string; options_json: string } + | undefined; + if (s?.title) return { title: s.title, options: JSON.parse(s.options_json) }; } return undefined; diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index e4b2142..8ab41bc 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -120,6 +120,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) : senderName ? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?` : `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`; + const options = normalizeOptions(APPROVAL_OPTIONS); createPendingChannelApproval({ messaging_group_id: messagingGroupId, @@ -127,6 +128,8 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) original_message: JSON.stringify(event), approver_user_id: delivery.userId, created_at: new Date().toISOString(), + title, + options_json: JSON.stringify(options), }); const adapter = getDeliveryAdapter(); @@ -151,7 +154,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) questionId: messagingGroupId, title, question, - options: normalizeOptions(APPROVAL_OPTIONS), + options, }), ); log.info('Channel registration card delivered', { diff --git a/src/modules/permissions/db/pending-channel-approvals.ts b/src/modules/permissions/db/pending-channel-approvals.ts index d3e665a..d402074 100644 --- a/src/modules/permissions/db/pending-channel-approvals.ts +++ b/src/modules/permissions/db/pending-channel-approvals.ts @@ -17,6 +17,10 @@ export interface PendingChannelApproval { original_message: string; approver_user_id: string; created_at: string; + /** Card title shown at creation and re-used by getAskQuestionRender on click. */ + title: string; + /** Normalized options (JSON-encoded NormalizedOption[]) — same shape persisted on pending_approvals. */ + options_json: string; } export function createPendingChannelApproval(row: PendingChannelApproval): void { @@ -24,11 +28,11 @@ export function createPendingChannelApproval(row: PendingChannelApproval): void .prepare( `INSERT INTO pending_channel_approvals ( messaging_group_id, agent_group_id, original_message, - approver_user_id, created_at + approver_user_id, created_at, title, options_json ) VALUES ( @messaging_group_id, @agent_group_id, @original_message, - @approver_user_id, @created_at + @approver_user_id, @created_at, @title, @options_json )`, ) .run(row); diff --git a/src/modules/permissions/db/pending-sender-approvals.ts b/src/modules/permissions/db/pending-sender-approvals.ts index 77a5699..4d32bf4 100644 --- a/src/modules/permissions/db/pending-sender-approvals.ts +++ b/src/modules/permissions/db/pending-sender-approvals.ts @@ -19,6 +19,10 @@ export interface PendingSenderApproval { original_message: string; approver_user_id: string; created_at: string; + /** Card title shown at creation and re-used by getAskQuestionRender on click. */ + title: string; + /** Normalized options (JSON-encoded NormalizedOption[]) — same shape persisted on pending_approvals. */ + options_json: string; } export function createPendingSenderApproval(row: PendingSenderApproval): void { @@ -26,11 +30,13 @@ export function createPendingSenderApproval(row: PendingSenderApproval): void { .prepare( `INSERT INTO pending_sender_approvals ( id, messaging_group_id, agent_group_id, sender_identity, - sender_name, original_message, approver_user_id, created_at + sender_name, original_message, approver_user_id, created_at, + title, options_json ) VALUES ( @id, @messaging_group_id, @agent_group_id, @sender_identity, - @sender_name, @original_message, @approver_user_id, @created_at + @sender_name, @original_message, @approver_user_id, @created_at, + @title, @options_json )`, ) .run(row); diff --git a/src/modules/permissions/sender-approval.ts b/src/modules/permissions/sender-approval.ts index a20e14f..fb3e24e 100644 --- a/src/modules/permissions/sender-approval.ts +++ b/src/modules/permissions/sender-approval.ts @@ -92,6 +92,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): const title = '👤 New sender'; const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`; + const options = normalizeOptions(APPROVAL_OPTIONS); createPendingSenderApproval({ id: approvalId, @@ -102,6 +103,8 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): original_message: JSON.stringify(event), approver_user_id: target.userId, created_at: new Date().toISOString(), + title, + options_json: JSON.stringify(options), }); const adapter = getDeliveryAdapter(); @@ -126,7 +129,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): questionId: approvalId, title, question, - options: APPROVAL_OPTIONS, + options, }), ); log.info('Unknown-sender approval card delivered', { From 2fd2bf3bdee3405b96e4db19ed71a771d36a588c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 22:56:31 +0300 Subject: [PATCH 161/185] chore(signal): move adapter source to channels branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signal adapter source (src/channels/signal.ts + signal.test.ts) now lives on the `channels` branch alongside all other channel adapters, per the trunk/channels split documented in CLAUDE.md and CONTRIBUTING.md ("Trunk does not ship any specific channel adapter"). The /add-signal skill fetches the file from origin/channels like every other channel. This PR to main therefore carries only: - .claude/skills/add-signal/{SKILL,VERIFY,REMOVE}.md — the skill itself - scripts/init-first-agent.ts — unrelated infra fix that benefits any native-ID channel (Signal, WhatsApp) by skipping the channel-prefix on platform IDs that already have their own format The fixed adapter source + tests were pushed to the channels branch in a parallel commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/index.ts | 1 - src/channels/signal.test.ts | 786 ---------------------------------- src/channels/signal.ts | 811 ------------------------------------ 3 files changed, 1598 deletions(-) delete mode 100644 src/channels/signal.test.ts delete mode 100644 src/channels/signal.ts diff --git a/src/channels/index.ts b/src/channels/index.ts index b75016f..e9b3bd1 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -7,4 +7,3 @@ // self-registration import below. import './cli.js'; -import './signal.js'; diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts deleted file mode 100644 index f5dabfa..0000000 --- a/src/channels/signal.test.ts +++ /dev/null @@ -1,786 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; - -// --- Mocks --- - -vi.mock('./channel-registry.js', () => ({ registerChannelAdapter: vi.fn() })); -vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); -vi.mock('../log.js', () => ({ - log: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -vi.mock('node:child_process', () => ({ - spawn: vi.fn(), - execFileSync: vi.fn(), -})); - -// --- TCP socket mock --- - -import { EventEmitter } from 'events'; - -const tcpRef = vi.hoisted(() => ({ - rpcResponses: new Map(), - fakeSocket: null as any, -})); - -function createFakeSocket(): EventEmitter & { - write: ReturnType; - destroy: ReturnType; - destroyed: boolean; -} { - const sock = new EventEmitter() as any; - sock.destroyed = false; - sock.destroy = vi.fn(() => { - sock.destroyed = true; - sock.emit('close'); - }); - sock.write = vi.fn((data: string) => { - try { - const req = JSON.parse(data.trim()); - const result = tcpRef.rpcResponses.get(req.method) ?? { ok: true }; - const response = JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n'; - setImmediate(() => sock.emit('data', Buffer.from(response))); - } catch { - /* ignore */ - } - }); - return sock; -} - -vi.mock('node:net', () => ({ - createConnection: vi.fn((_port: number, _host: string, cb?: () => void) => { - const sock = createFakeSocket(); - tcpRef.fakeSocket = sock; - if (cb) setImmediate(cb); - return sock; - }), -})); - -import type { ChannelSetup } from './adapter.js'; -import { createSignalAdapter } from './signal.js'; - -// --- Test helpers --- - -function createMockSetup() { - return { - onInbound: vi.fn() as unknown as ChannelSetup['onInbound'] & ReturnType, - onInboundEvent: vi.fn() as unknown as ChannelSetup['onInboundEvent'] & ReturnType, - onMetadata: vi.fn() as unknown as ChannelSetup['onMetadata'] & ReturnType, - onAction: vi.fn() as unknown as ChannelSetup['onAction'] & ReturnType, - }; -} - -function createAdapter() { - return createSignalAdapter({ - cliPath: 'signal-cli', - account: '+15551234567', - tcpHost: '127.0.0.1', - tcpPort: 7583, - manageDaemon: false, - signalDataDir: '/tmp/signal-cli-test-data', - }); -} - -function getRpcCalls(): Array<{ - method: string; - params: Record; - id: string; -}> { - if (!tcpRef.fakeSocket) return []; - return tcpRef.fakeSocket.write.mock.calls - .map((c: any[]) => { - try { - return JSON.parse(c[0].trim()); - } catch { - return null; - } - }) - .filter(Boolean); -} - -function getRpcCallsForMethod(method: string) { - return getRpcCalls().filter((c) => c.method === method); -} - -function pushEvent(envelope: Record) { - if (!tcpRef.fakeSocket) throw new Error('TCP socket not connected'); - const notification = - JSON.stringify({ - jsonrpc: '2.0', - method: 'receive', - params: { envelope }, - }) + '\n'; - tcpRef.fakeSocket.emit('data', Buffer.from(notification)); -} - -// --- Tests --- - -describe('SignalAdapter', () => { - beforeEach(() => { - vi.clearAllMocks(); - tcpRef.rpcResponses.clear(); - tcpRef.fakeSocket = null; - tcpRef.rpcResponses.set('send', { timestamp: 1234567890 }); - tcpRef.rpcResponses.set('sendTyping', {}); - }); - - afterEach(() => { - try { - tcpRef.fakeSocket?.destroy(); - } catch { - // already closed - } - }); - - // --- Connection lifecycle --- - - describe('connection lifecycle', () => { - it('connects when daemon is reachable', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - expect(adapter.isConnected()).toBe(true); - expect(tcpRef.fakeSocket).not.toBeNull(); - - await adapter.teardown(); - }); - - it('isConnected() returns false before setup', () => { - const adapter = createAdapter(); - expect(adapter.isConnected()).toBe(false); - }); - - it('disconnects cleanly', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - expect(adapter.isConnected()).toBe(true); - - await adapter.teardown(); - expect(adapter.isConnected()).toBe(false); - }); - - it('throws NetworkError if daemon is unreachable', async () => { - const { createConnection } = await import('node:net'); - vi.mocked(createConnection).mockImplementationOnce((...args: any[]) => { - const sock = createFakeSocket(); - setImmediate(() => sock.emit('error', new Error('Connection refused'))); - return sock as any; - }); - - const adapter = createAdapter(); - await expect(adapter.setup(createMockSetup())).rejects.toThrow(/not reachable/); - }); - }); - - // --- Inbound message handling --- - - describe('inbound message handling', () => { - it('delivers DM via onInbound', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550123', - sourceName: 'Alice', - dataMessage: { - timestamp: 1700000000000, - message: 'Hello from Signal', - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - - expect(cfg.onMetadata).toHaveBeenCalledWith('+15555550123', 'Alice', false); - expect(cfg.onInbound).toHaveBeenCalledWith( - '+15555550123', - null, - expect.objectContaining({ - id: '1700000000000', - kind: 'chat', - content: expect.objectContaining({ - text: 'Hello from Signal', - sender: '+15555550123', - senderName: 'Alice', - }), - }), - ); - - await adapter.teardown(); - }); - - it('delivers group message with group platformId', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550999', - sourceName: 'Bob', - dataMessage: { - timestamp: 1700000000000, - message: 'Group hello', - groupInfo: { groupId: 'abc123', groupName: 'Family' }, - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - - expect(cfg.onMetadata).toHaveBeenCalledWith('group:abc123', 'Family', true); - expect(cfg.onInbound).toHaveBeenCalledWith( - 'group:abc123', - null, - expect.objectContaining({ - content: expect.objectContaining({ - text: 'Group hello', - sender: '+15555550999', - }), - }), - ); - - await adapter.teardown(); - }); - - it('skips sync messages (own outbound)', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15551234567', - syncMessage: { - sentMessage: { - timestamp: 1700000000000, - message: 'My own message', - destination: '+15555550123', - }, - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - - it('processes Note to Self sync messages as inbound', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15551234567', - syncMessage: { - sentMessage: { - timestamp: 1700000000000, - message: 'Hello Bee', - destinationNumber: '+15551234567', - }, - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).toHaveBeenCalledWith( - '+15551234567', - null, - expect.objectContaining({ - content: expect.objectContaining({ - text: 'Hello Bee', - senderName: 'Me', - isFromMe: true, - }), - }), - ); - - await adapter.teardown(); - }); - - it('skips empty messages', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550123', - dataMessage: { timestamp: 1700000000000, message: ' ' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - - it('skips echoed outbound messages', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Echo test' }, - }); - - pushEvent({ - sourceNumber: '+15555550123', - dataMessage: { timestamp: 1700000000000, message: 'Echo test' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - - it('skips messages with attachments but no text', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550123', - sourceName: 'Alice', - dataMessage: { - timestamp: 1700000000000, - attachments: [{ id: 'att123abc', contentType: 'image/jpeg', size: 50000 }], - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - }); - - // --- Quote context --- - - describe('quote context', () => { - it('populates reply_to fields from quoted messages', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550123', - sourceName: 'Alice', - dataMessage: { - timestamp: 1700000000000, - message: 'I disagree', - quote: { - id: 1699999999000, - authorNumber: '+15555550888', - text: 'Pineapple belongs on pizza', - }, - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).toHaveBeenCalledWith( - '+15555550123', - null, - expect.objectContaining({ - content: expect.objectContaining({ - text: 'I disagree', - replyToSenderName: '+15555550888', - replyToMessageContent: 'Pineapple belongs on pizza', - replyToMessageId: '1699999999000', - }), - }), - ); - - await adapter.teardown(); - }); - }); - - // --- deliver --- - - describe('deliver', () => { - it('sends DM via TCP RPC', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(0); - - const last = sendCalls[sendCalls.length - 1]; - expect(last.params).toEqual( - expect.objectContaining({ - recipient: ['+15555550123'], - message: 'Hello', - account: '+15551234567', - }), - ); - - await adapter.teardown(); - }); - - it('sends group message via groupId', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.deliver('group:abc123', null, { - kind: 'text', - content: { text: 'Group msg' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params).toEqual( - expect.objectContaining({ - groupId: 'abc123', - message: 'Group msg', - }), - ); - - await adapter.teardown(); - }); - - it('chunks long messages', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - const longText = 'x'.repeat(5000); - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: longText }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(1); - - await adapter.teardown(); - }); - - it('extracts text from string content', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: 'Plain string content', - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(0); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('Plain string content'); - - await adapter.teardown(); - }); - }); - - // --- Text styles --- - - describe('text styles', () => { - it('sends bold text with textStyle parameter', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello **world**' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(0); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('Hello world'); - expect(last.params.textStyle).toEqual(['6:5:BOLD']); - - await adapter.teardown(); - }); - - it('sends inline code with MONOSPACE style', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Run `npm test` now' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('Run npm test now'); - expect(last.params.textStyle).toEqual(['4:8:MONOSPACE']); - - await adapter.teardown(); - }); - - it('sends plain text without textStyle', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'No formatting here' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('No formatting here'); - expect(last.params.textStyle).toBeUndefined(); - - await adapter.teardown(); - }); - - it('falls back to original markup when textStyle is rejected', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - let sendCount = 0; - tcpRef.fakeSocket.write.mockImplementation((data: string) => { - try { - const req = JSON.parse(data.trim()); - if (req.method === 'send') { - sendCount++; - if (sendCount === 1) { - const response = - JSON.stringify({ - jsonrpc: '2.0', - id: req.id, - error: { message: 'Unknown parameter: textStyle' }, - }) + '\n'; - setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response))); - return; - } - } - const response = - JSON.stringify({ - jsonrpc: '2.0', - id: req.id, - result: { ok: true }, - }) + '\n'; - setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response))); - } catch { - /* ignore */ - } - }); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello **world**' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBe(2); - expect(sendCalls[1].params.message).toBe('Hello **world**'); - expect(sendCalls[1].params.textStyle).toBeUndefined(); - - await adapter.teardown(); - }); - - it('tracks nested styles with correct offsets', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: '**bold with `code` inside**' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('bold with code inside'); - // BOLD covers the full inner span, MONOSPACE points at "code" in the - // final plain text (offset 10, length 4) — not the intermediate text. - const styles = (last.params.textStyle as string[]).slice().sort(); - expect(styles).toEqual(['0:21:BOLD', '10:4:MONOSPACE']); - - await adapter.teardown(); - }); - - it('maps *single-asterisk* to ITALIC', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello *world*' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('Hello world'); - expect(last.params.textStyle).toEqual(['6:5:ITALIC']); - - await adapter.teardown(); - }); - - it('maps _underscore_ to ITALIC', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'hey _there_' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('hey there'); - expect(last.params.textStyle).toEqual(['4:5:ITALIC']); - - await adapter.teardown(); - }); - }); - - // --- Echo cache --- - - describe('echo cache', () => { - it('does not drop same-text inbound from a different recipient', async () => { - // Bot sends "Hello" to Alice. Immediately after, Bob sends "Hello" from - // a different DM. Bob's message must still route — the earlier echo key - // was scoped to Alice. - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello' }, - }); - - pushEvent({ - sourceNumber: '+15555550999', - sourceName: 'Bob', - dataMessage: { timestamp: 1700000000000, message: 'Hello' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).toHaveBeenCalledWith( - '+15555550999', - null, - expect.objectContaining({ - content: expect.objectContaining({ text: 'Hello', sender: '+15555550999' }), - }), - ); - - await adapter.teardown(); - }); - - it('still skips echo on the same recipient', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Echo test' }, - }); - - pushEvent({ - sourceNumber: '+15555550123', - dataMessage: { timestamp: 1700000000000, message: 'Echo test' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - }); - - // --- Connection drop --- - - describe('connection drop', () => { - it('flips isConnected to false when the socket closes', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - expect(adapter.isConnected()).toBe(true); - - // Simulate the daemon dropping the TCP connection. - tcpRef.fakeSocket.destroy(); - await new Promise((r) => setTimeout(r, 20)); - - expect(adapter.isConnected()).toBe(false); - - await adapter.teardown(); - }); - }); - - // --- Outbound files --- - - describe('outbound files', () => { - it('logs a warning and drops unsupported file attachments', async () => { - const { log } = await import('../log.js'); - const warnMock = log.warn as unknown as ReturnType; - - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - warnMock.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'with an attachment' }, - files: [{ filename: 'hi.txt', data: Buffer.from('hi') }], - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(0); - expect(warnMock).toHaveBeenCalledWith( - 'Signal: outbound files not supported, dropping', - expect.objectContaining({ platformId: '+15555550123', count: 1 }), - ); - - await adapter.teardown(); - }); - }); - - // --- setTyping --- - - describe('setTyping', () => { - it('sends typing indicator for DMs', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.setTyping!('+15555550123', null); - - expect(getRpcCallsForMethod('sendTyping')).toHaveLength(1); - - await adapter.teardown(); - }); - - it('skips typing for groups', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.setTyping!('group:abc123', null); - - expect(getRpcCallsForMethod('sendTyping')).toHaveLength(0); - - await adapter.teardown(); - }); - }); - - // --- Adapter properties --- - - describe('adapter properties', () => { - it('has channelType "signal"', () => { - const adapter = createAdapter(); - expect(adapter.channelType).toBe('signal'); - }); - - it('does not support threads', () => { - const adapter = createAdapter(); - expect(adapter.supportsThreads).toBe(false); - }); - }); -}); diff --git a/src/channels/signal.ts b/src/channels/signal.ts deleted file mode 100644 index 20cba81..0000000 --- a/src/channels/signal.ts +++ /dev/null @@ -1,811 +0,0 @@ -/** - * Signal channel adapter for NanoClaw v2. - * - * Uses signal-cli's TCP JSON-RPC daemon for bidirectional messaging. - * Requires signal-cli (https://github.com/AsamK/signal-cli) installed - * and a linked account. - * - * Ported from v1 — see v1 source for commit history. - */ -import { execFileSync, spawn } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { createConnection, type Socket } from 'node:net'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; -import { registerChannelAdapter } from './channel-registry.js'; -import { readEnvFile } from '../env.js'; -import { log } from '../log.js'; - -// --------------------------------------------------------------------------- -// Signal CLI daemon management -// --------------------------------------------------------------------------- - -interface DaemonHandle { - stop: () => void; - exited: Promise; - isExited: () => boolean; -} - -function spawnSignalDaemon(cliPath: string, account: string, host: string, port: number): DaemonHandle { - const args: string[] = []; - if (account) args.push('-a', account); - args.push('daemon', '--tcp', `${host}:${port}`, '--no-receive-stdout'); - args.push('--receive-mode', 'on-start'); - - const child = spawn(cliPath, args, { stdio: ['ignore', 'pipe', 'pipe'] }); - let exited = false; - - const exitedPromise = new Promise((resolve) => { - child.once('exit', (code, signal) => { - exited = true; - if (code !== 0 && code !== null) { - const reason = signal ? `signal ${signal}` : `code ${code}`; - log.error('signal-cli daemon exited', { reason }); - } - resolve(); - }); - child.on('error', (err) => { - exited = true; - log.error('signal-cli spawn error', { err }); - resolve(); - }); - }); - - child.stdout?.on('data', (data: Buffer) => { - for (const line of data.toString().split(/\r?\n/)) { - if (line.trim()) log.debug('signal-cli stdout', { line: line.trim() }); - } - }); - child.stderr?.on('data', (data: Buffer) => { - for (const line of data.toString().split(/\r?\n/)) { - if (!line.trim()) continue; - if (/\b(ERROR|WARN|FAILED|SEVERE)\b/i.test(line)) { - log.warn('signal-cli stderr', { line: line.trim() }); - } else { - log.debug('signal-cli stderr', { line: line.trim() }); - } - } - }); - - return { - stop: () => { - if (!child.killed && !exited) child.kill('SIGTERM'); - }, - exited: exitedPromise, - isExited: () => exited, - }; -} - -// --------------------------------------------------------------------------- -// TCP JSON-RPC client for signal-cli daemon (--tcp mode) -// -// signal-cli 0.14.x --tcp exposes a newline-delimited JSON-RPC socket. -// Requests are sent as JSON + newline; responses and push notifications -// (inbound messages) arrive the same way. -// --------------------------------------------------------------------------- - -const RPC_TIMEOUT_MS = 15_000; - -class SignalTcpClient { - private socket: Socket | null = null; - private buffer = ''; - private pending = new Map< - string, - { - resolve: (value: unknown) => void; - reject: (err: Error) => void; - timer: ReturnType; - } - >(); - private onNotification: ((method: string, params: unknown) => void) | null = null; - private onClose: (() => void) | null = null; - - constructor( - private host: string, - private port: number, - ) {} - - connect(handlers?: { - onNotification?: (method: string, params: unknown) => void; - onClose?: () => void; - }): Promise { - this.onNotification = handlers?.onNotification ?? null; - this.onClose = handlers?.onClose ?? null; - return new Promise((resolve, reject) => { - const sock = createConnection(this.port, this.host, () => { - this.socket = sock; - resolve(); - }); - sock.on('error', (err) => { - if (!this.socket) { - reject(err); - return; - } - log.warn('Signal TCP socket error', { err }); - }); - sock.on('data', (chunk) => this.onData(chunk)); - sock.on('close', () => { - const wasConnected = this.socket !== null; - this.socket = null; - for (const [, p] of this.pending) { - clearTimeout(p.timer); - p.reject(new Error('Signal TCP connection closed')); - } - this.pending.clear(); - if (wasConnected) this.onClose?.(); - }); - }); - } - - async rpc(method: string, params?: Record): Promise { - if (!this.socket) throw new Error('Signal TCP not connected'); - const id = Math.random().toString(36).slice(2); - const msg = JSON.stringify({ jsonrpc: '2.0', method, params, id }) + '\n'; - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Signal RPC timeout: ${method}`)); - }, RPC_TIMEOUT_MS); - - this.pending.set(id, { - resolve: resolve as (v: unknown) => void, - reject, - timer, - }); - this.socket!.write(msg); - }); - } - - close() { - this.socket?.destroy(); - this.socket = null; - } - - isConnected(): boolean { - return this.socket !== null && !this.socket.destroyed; - } - - private onData(chunk: Buffer) { - this.buffer += chunk.toString(); - let newlineIdx = this.buffer.indexOf('\n'); - while (newlineIdx !== -1) { - const line = this.buffer.slice(0, newlineIdx).trim(); - this.buffer = this.buffer.slice(newlineIdx + 1); - if (line) this.handleLine(line); - newlineIdx = this.buffer.indexOf('\n'); - } - } - - private handleLine(line: string) { - let parsed: any; - try { - parsed = JSON.parse(line); - } catch { - log.debug('Signal TCP: unparseable line', { line: line.slice(0, 200) }); - return; - } - - if (parsed.id && this.pending.has(parsed.id)) { - const p = this.pending.get(parsed.id)!; - this.pending.delete(parsed.id); - clearTimeout(p.timer); - if (parsed.error) { - p.reject(new Error(parsed.error.message ?? 'Signal RPC error')); - } else { - p.resolve(parsed.result); - } - return; - } - - if (parsed.method && this.onNotification) { - this.onNotification(parsed.method, parsed.params); - } - } -} - -async function signalTcpCheck(host: string, port: number): Promise { - return new Promise((resolve) => { - let settled = false; - const finish = (result: boolean) => { - if (settled) return; - settled = true; - clearTimeout(timer); - sock.destroy(); - resolve(result); - }; - const sock = createConnection(port, host, () => finish(true)); - sock.on('error', () => finish(false)); - const timer = setTimeout(() => finish(false), 5000); - }); -} - -// --------------------------------------------------------------------------- -// Echo cache -// --------------------------------------------------------------------------- - -const ECHO_TTL_MS = 10_000; - -/** - * Per-recipient dedup for messages we sent ourselves. - * - * signal-cli echoes our own outbound back via syncMessage (and, for Note to - * Self, via sentMessage-with-self-destination). Without dedup, the agent sees - * its own replies as new inbound and loops. We remember `(platformId, text)` - * briefly after every send, and drop the first match within TTL. - * - * Keying on text alone is not enough: if we send "hi" to Alice and Bob then - * sends "hi" from a different chat, Bob's real message gets silently dropped. - */ -class EchoCache { - private entries = new Map(); - - private keyFor(platformId: string, text: string): string { - return `${platformId}\x00${text.trim()}`; - } - - remember(platformId: string, text: string): void { - const trimmed = text.trim(); - if (!trimmed) return; - this.entries.set(this.keyFor(platformId, trimmed), Date.now()); - this.cleanup(); - } - - isEcho(platformId: string, text: string): boolean { - const trimmed = text.trim(); - if (!trimmed) return false; - const key = this.keyFor(platformId, trimmed); - const ts = this.entries.get(key); - if (!ts) return false; - if (Date.now() - ts > ECHO_TTL_MS) { - this.entries.delete(key); - return false; - } - this.entries.delete(key); - return true; - } - - private cleanup(): void { - const now = Date.now(); - for (const [key, ts] of this.entries) { - if (now - ts > ECHO_TTL_MS) this.entries.delete(key); - } - } -} - -// --------------------------------------------------------------------------- -// Signal envelope types -// --------------------------------------------------------------------------- - -interface SignalQuote { - id?: number; - authorNumber?: string; - authorUuid?: string; - text?: string; -} - -interface SignalDataMessage { - timestamp?: number; - message?: string; - groupInfo?: { groupId?: string; groupName?: string; type?: string }; - quote?: SignalQuote; - attachments?: Array<{ - id?: string; - contentType?: string; - filename?: string; - size?: number; - }>; -} - -interface SignalEnvelope { - source?: string; - sourceName?: string; - sourceNumber?: string; - sourceUuid?: string; - dataMessage?: SignalDataMessage; - syncMessage?: { - sentMessage?: SignalDataMessage & { - destination?: string; - destinationNumber?: string; - }; - }; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function chunkText(text: string, limit: number): string[] { - const chunks: string[] = []; - let remaining = text; - while (remaining.length > 0) { - if (remaining.length <= limit) { - chunks.push(remaining); - break; - } - let splitAt = remaining.lastIndexOf('\n', limit); - if (splitAt <= 0) splitAt = limit; - chunks.push(remaining.slice(0, splitAt)); - remaining = remaining.slice(splitAt).replace(/^\n/, ''); - } - return chunks; -} - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -// --------------------------------------------------------------------------- -// Signal text styles — convert Markdown to Signal's offset-based formatting -// --------------------------------------------------------------------------- - -interface SignalTextStyle { - style: 'BOLD' | 'ITALIC' | 'STRIKETHROUGH' | 'MONOSPACE' | 'SPOILER'; - start: number; - length: number; -} - -interface StyledText { - text: string; - textStyles: SignalTextStyle[]; -} - -/** - * Convert Markdown-ish input to Signal's offset-based style ranges. - * - * Walks the input recursively: at each level we find the leftmost matching - * pattern, descend into its captured inner text (so `**bold with \`code\` - * inside**` stays bold-plus-monospace rather than leaking stripped markers), - * then continue past the match. Style offsets are recorded against the - * *output* text length as it's built, so nested styles always point at the - * right span of the final plain text. - */ -function parseSignalStyles(input: string): StyledText { - const styles: SignalTextStyle[] = []; - - // Ordering matters: longer/greedier delimiters first so `` ``` `` beats - // `` ` ``, `**` beats `*`. The italic-`*` pattern refuses to start on - // whitespace so `*` isn't mistakenly opened on " * " in list-like text. - const patterns: Array<{ regex: RegExp; style: SignalTextStyle['style'] }> = [ - { regex: /```([\s\S]+?)```/, style: 'MONOSPACE' }, - { regex: /`([^`]+)`/, style: 'MONOSPACE' }, - { regex: /\*\*([^]+?)\*\*/, style: 'BOLD' }, - { regex: /~~([^]+?)~~/, style: 'STRIKETHROUGH' }, - { regex: /\|\|([^]+?)\|\|/, style: 'SPOILER' }, - { regex: /\*([^*\s][^*]*?)\*/, style: 'ITALIC' }, - { regex: /_([^_\s][^_]*?)_/, style: 'ITALIC' }, - ]; - - function walk(segment: string, outputBase: number): string { - let earliest: { start: number; match: RegExpExecArray; style: SignalTextStyle['style'] } | null = null; - for (const { regex, style } of patterns) { - const m = regex.exec(segment); - if (!m) continue; - if (earliest === null || m.index < earliest.start) { - earliest = { start: m.index, match: m, style }; - } - } - if (!earliest) return segment; - - const before = segment.slice(0, earliest.start); - const fullMatch = earliest.match[0]; - const inner = earliest.match[1]; - const afterStart = earliest.start + fullMatch.length; - const after = segment.slice(afterStart); - - const innerOut = walk(inner, outputBase + before.length); - styles.push({ - style: earliest.style, - start: outputBase + before.length, - length: innerOut.length, - }); - const afterOut = walk(after, outputBase + before.length + innerOut.length); - - return before + innerOut + afterOut; - } - - const text = walk(input, 0); - return { text, textStyles: styles }; -} - -// --------------------------------------------------------------------------- -// SignalAdapter — v2 ChannelAdapter implementation -// --------------------------------------------------------------------------- - -/** - * Platform ID format: - * DM: phone number or UUID (e.g. "+15555550123") - * Group: "group:" (e.g. "group:abc123") - * - * channelType is always "signal". The router combines channelType + platformId - * to look up or create the messaging_group. - */ -export function createSignalAdapter(config: { - cliPath: string; - account: string; - tcpHost: string; - tcpPort: number; - manageDaemon: boolean; - signalDataDir: string; -}): ChannelAdapter { - let daemon: DaemonHandle | null = null; - let tcp: SignalTcpClient | null = null; - let connected = false; - const echoCache = new EchoCache(); - let setup: ChannelSetup | null = null; - - // -- inbound handling -- - - function handleNotification(method: string, params: unknown): void { - if (method === 'receive') { - const envelope = (params as any)?.envelope; - if (envelope) { - handleEnvelope(envelope).catch((err) => { - log.error('Signal: error handling envelope', { err }); - }); - } - } - } - - async function handleEnvelope(envelope: SignalEnvelope): Promise { - if (!setup) return; - - // Sync messages (sent from another device) - const syncSent = envelope.syncMessage?.sentMessage; - if (syncSent) { - const dest = (syncSent.destinationNumber ?? syncSent.destination ?? '').trim(); - // "Note to Self" — destination is our own account - if (dest === config.account) { - const text = (syncSent.message ?? '').trim(); - if (!text) return; - const platformId = config.account; - if (echoCache.isEcho(platformId, text)) return; - const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString(); - - setup.onMetadata(platformId, 'Note to Self', false); - - const msg: InboundMessage = { - id: String(syncSent.timestamp ?? Date.now()), - kind: 'chat', - content: { - text, - sender: config.account, - senderId: `signal:${config.account}`, - senderName: 'Me', - isFromMe: true, - ...(syncSent.quote ? quoteToContent(syncSent.quote) : {}), - }, - timestamp, - }; - await setup.onInbound(platformId, null, msg); - return; - } - // Other sync messages are our outbound — skip - return; - } - - const dataMessage = envelope.dataMessage; - if (!dataMessage) return; - - const text = (dataMessage.message ?? '').trim(); - - // Check for voice attachments - const hasVoice = !text && dataMessage.attachments?.some((a) => a.contentType?.startsWith('audio/')); - - if (!text && !hasVoice) return; - - const sender = (envelope.sourceNumber ?? envelope.sourceUuid ?? envelope.source ?? '').trim(); - if (!sender) return; - - const senderName = (envelope.sourceName?.trim() || sender).trim(); - const groupInfo = dataMessage.groupInfo; - const isGroup = Boolean(groupInfo?.groupId); - const groupId = groupInfo?.groupId; - - const platformId = isGroup ? `group:${groupId}` : sender; - - if (text && echoCache.isEcho(platformId, text)) { - log.debug('Signal: skipping echo', { platformId }); - return; - } - const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString(); - - const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName); - - setup.onMetadata(platformId, chatName, isGroup); - - let content = text; - - // Voice attachment — log path, deliver placeholder text. - // v2 does not have built-in transcription; a future MCP tool could handle this. - if (hasVoice) { - const audio = dataMessage.attachments?.find((a) => a.contentType?.startsWith('audio/')); - if (audio?.id) { - const attachmentPath = join(config.signalDataDir, 'attachments', audio.id); - if (existsSync(attachmentPath)) { - log.info('Signal: voice attachment received', { - platformId, - attachmentId: audio.id, - path: attachmentPath, - }); - content = '[Voice Message]'; - } else { - log.warn('Signal: voice attachment file not found', { - id: audio.id, - path: attachmentPath, - }); - content = '[Voice Message - file not found]'; - } - } else { - content = '[Voice Message]'; - } - } - - const msg: InboundMessage = { - id: String(dataMessage.timestamp ?? Date.now()), - kind: 'chat', - content: { - text: content, - sender, - senderId: `signal:${sender}`, - senderName, - ...(dataMessage.quote ? quoteToContent(dataMessage.quote) : {}), - }, - timestamp, - }; - await setup.onInbound(platformId, null, msg); - - log.info('Signal message received', { platformId, sender: senderName }); - } - - function quoteToContent(quote: SignalQuote): Record { - return { - replyToSenderName: quote.authorNumber ?? 'someone', - replyToMessageContent: quote.text || undefined, - replyToMessageId: quote.id ? String(quote.id) : undefined, - }; - } - - // -- send helpers -- - - async function sendText(platformId: string, text: string): Promise { - if (!connected || !tcp) return; - - echoCache.remember(platformId, text); - - const MAX_CHUNK = 4000; - const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK); - - for (const chunk of chunks) { - try { - const { text: plainText, textStyles } = parseSignalStyles(chunk); - const params: Record = { message: plainText }; - if (config.account) params.account = config.account; - if (textStyles.length > 0) { - params.textStyle = textStyles.map((s) => `${s.start}:${s.length}:${s.style}`); - } - - if (platformId.startsWith('group:')) { - params.groupId = platformId.slice('group:'.length); - } else { - params.recipient = [platformId]; - } - - try { - await tcp.rpc('send', params); - } catch (styledErr) { - if (textStyles.length > 0) { - log.debug('Signal: textStyle rejected, retrying with markup'); - delete params.textStyle; - params.message = chunk; - await tcp.rpc('send', params); - } else { - throw styledErr; - } - } - } catch (err) { - log.error('Signal: send failed', { platformId, err }); - } - } - - log.info('Signal message sent', { platformId, length: text.length }); - } - - async function waitForDaemon(): Promise { - const maxWait = 30_000; - const pollInterval = 1000; - const start = Date.now(); - - while (Date.now() - start < maxWait) { - if (daemon?.isExited()) return false; - const ok = await signalTcpCheck(config.tcpHost, config.tcpPort); - if (ok) return true; - await sleep(pollInterval); - } - return false; - } - - // -- adapter -- - - const adapter: ChannelAdapter = { - name: 'signal', - channelType: 'signal', - supportsThreads: false, - - async setup(cfg: ChannelSetup): Promise { - setup = cfg; - - if (config.manageDaemon) { - daemon = spawnSignalDaemon(config.cliPath, config.account, config.tcpHost, config.tcpPort); - const ready = await waitForDaemon(); - if (!ready) { - daemon.stop(); - throw new Error('Signal daemon failed to start. Is signal-cli installed and your account linked?'); - } - } else { - const ok = await signalTcpCheck(config.tcpHost, config.tcpPort); - if (!ok) { - const err = new Error( - `Signal daemon not reachable at ${config.tcpHost}:${config.tcpPort}. Start it manually or set SIGNAL_MANAGE_DAEMON=true`, - ); - (err as any).name = 'NetworkError'; - throw err; - } - } - - tcp = new SignalTcpClient(config.tcpHost, config.tcpPort); - await tcp.connect({ - onNotification: handleNotification, - // Signal the adapter that the daemon dropped us. No auto-reconnect yet - // — subsequent deliver/setTyping calls short-circuit on `connected` - // and log rather than throw into the retry loop. Operators see this in - // logs/nanoclaw.log and can restart the service. - onClose: () => { - if (!connected) return; - connected = false; - log.warn('Signal channel lost TCP connection to signal-cli daemon', { - account: config.account, - host: config.tcpHost, - port: config.tcpPort, - }); - }, - }); - - try { - await tcp.rpc('updateProfile', { - name: 'NanoClaw', - account: config.account, - }); - } catch { - log.debug('Signal: could not set profile name'); - } - - try { - await tcp.rpc('updateConfiguration', { - typingIndicators: true, - account: config.account, - }); - } catch { - log.debug('Signal: could not enable typing indicators'); - } - - connected = true; - log.info('Signal channel connected', { - account: config.account, - host: config.tcpHost, - port: config.tcpPort, - }); - }, - - async teardown(): Promise { - connected = false; - tcp?.close(); - tcp = null; - if (daemon && config.manageDaemon) { - daemon.stop(); - await daemon.exited; - } - daemon = null; - log.info('Signal channel disconnected'); - }, - - isConnected(): boolean { - return connected; - }, - - async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise { - if (message.files && message.files.length > 0) { - // Native adapter doesn't yet forward file uploads to signal-cli's - // `send --attachment`. Don't silently swallow — operators need to see - // that an attachment was requested but not sent. - log.warn('Signal: outbound files not supported, dropping', { - platformId, - count: message.files.length, - filenames: message.files.map((f) => f.filename), - }); - } - - const content = message.content as Record | string | undefined; - let text: string | null = null; - if (typeof content === 'string') { - text = content; - } else if (content && typeof content === 'object' && typeof content.text === 'string') { - text = content.text; - } - if (!text) return undefined; - - await sendText(platformId, text); - return undefined; - }, - - async setTyping(platformId: string, _threadId: string | null): Promise { - if (!connected || !tcp) return; - if (platformId.startsWith('group:')) return; - - try { - const params: Record = { recipient: [platformId] }; - if (config.account) params.account = config.account; - await tcp.rpc('sendTyping', params); - } catch (err) { - log.debug('Signal: typing indicator failed', { platformId, err }); - } - }, - }; - - return adapter; -} - -// --------------------------------------------------------------------------- -// Self-registration -// --------------------------------------------------------------------------- - -const DEFAULT_TCP_HOST = '127.0.0.1'; -const DEFAULT_TCP_PORT = 7583; - -registerChannelAdapter('signal', { - factory: () => { - const envVars = readEnvFile([ - 'SIGNAL_ACCOUNT', - 'SIGNAL_TCP_HOST', - 'SIGNAL_TCP_PORT', - 'SIGNAL_CLI_PATH', - 'SIGNAL_MANAGE_DAEMON', - 'SIGNAL_DATA_DIR', - ]); - - const account = process.env.SIGNAL_ACCOUNT || envVars.SIGNAL_ACCOUNT || ''; - if (!account) { - log.debug('Signal: SIGNAL_ACCOUNT not set, skipping channel'); - return null; - } - - const cliPath = process.env.SIGNAL_CLI_PATH || envVars.SIGNAL_CLI_PATH || 'signal-cli'; - const tcpHost = process.env.SIGNAL_TCP_HOST || envVars.SIGNAL_TCP_HOST || DEFAULT_TCP_HOST; - const tcpPort = parseInt(process.env.SIGNAL_TCP_PORT || envVars.SIGNAL_TCP_PORT || String(DEFAULT_TCP_PORT), 10); - const manageDaemon = (process.env.SIGNAL_MANAGE_DAEMON || envVars.SIGNAL_MANAGE_DAEMON || 'true') === 'true'; - - const signalDataDir = - process.env.SIGNAL_DATA_DIR || envVars.SIGNAL_DATA_DIR || join(homedir(), '.local', 'share', 'signal-cli'); - - // Only check for `signal-cli` on PATH when the operator left cliPath at - // the default AND asked us to manage the daemon. A custom absolute path - // is treated as an explicit promise and spawn will surface its own ENOENT. - if (manageDaemon && cliPath === 'signal-cli') { - try { - execFileSync('which', ['signal-cli'], { stdio: 'ignore' }); - } catch { - log.debug('Signal: signal-cli binary not found, skipping channel'); - return null; - } - } - - return createSignalAdapter({ - cliPath, - account, - tcpHost, - tcpPort, - manageDaemon, - signalDataDir, - }); - }, -}); From 78b0ad68f6dfd8fea5f7ed1a4cc41052c51085dd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 20:05:01 +0000 Subject: [PATCH 162/185] chore: bump version to 2.0.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 098e01f..20afddb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.9", + "version": "2.0.10", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 3fa001409edc7b4aac1a7abf6fd6021475c58185 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 23:19:30 +0300 Subject: [PATCH 163/185] feat(setup): wire Signal into the auto setup flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bash nanoclaw.sh` can now offer Signal as a channel choice, scan the signal-cli link QR in the terminal, and wire up the first agent end to end — mirroring the WhatsApp and Telegram flows. Pieces: - setup/add-signal.sh — non-interactive installer. Fetches src/channels/signal.ts + signal.test.ts from the channels branch, appends the self-registration import, installs qrcode (for the setup-flow QR render), and builds. Idempotent and standalone-runnable. - setup/signal-auth.ts — step runner. Spawns `signal-cli link --name NanoClaw`, watches stdout for the `sgnl://linkdevice?…` (or legacy `tsdevice://`) URL, emits SIGNAL_AUTH_QR with it. On exit 0, runs `signal-cli -o json listAccounts` and reports the new account via SIGNAL_AUTH STATUS=success. Pre-check via listAccounts returns STATUS=skipped if an account is already linked. - setup/channels/signal.ts — interactive driver. Probes for signal-cli (offering `brew install signal-cli` on macOS or linking GitHub releases on Linux if missing), runs add-signal.sh, renders each SIGNAL_AUTH_QR block as a terminal QR inside a clack spinner, persists SIGNAL_ACCOUNT to .env + data/env/env, restarts the service, then wires the first agent via init-first-agent. - setup/index.ts: register `signal-auth` in the STEPS map. - setup/auto.ts: add 'signal' to ChannelChoice, import the driver, add it to the channel picker (after WhatsApp, hint "needs signal-cli installed"), branch the dispatch, and map channelDmLabel. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-signal.sh | 95 +++++++++++ setup/auto.ts | 11 ++ setup/channels/signal.ts | 357 +++++++++++++++++++++++++++++++++++++++ setup/index.ts | 1 + setup/signal-auth.ts | 182 ++++++++++++++++++++ 5 files changed, 646 insertions(+) create mode 100755 setup/add-signal.sh create mode 100644 setup/channels/signal.ts create mode 100644 setup/signal-auth.ts diff --git a/setup/add-signal.sh b/setup/add-signal.sh new file mode 100755 index 0000000..8ebf2b9 --- /dev/null +++ b/setup/add-signal.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Install the Signal adapter in an already-running NanoClaw checkout. +# Non-interactive — the operator-facing "install signal-cli" + QR scan +# live in setup/channels/signal.ts. This script only: +# +# 1. Fetches src/channels/signal.ts + signal.test.ts from the channels +# branch. +# 2. Appends the self-registration import to src/channels/index.ts. +# 3. Installs qrcode (for setup-flow QR rendering — adapter itself has +# no npm deps). +# 4. Builds. +# +# SIGNAL_ACCOUNT is persisted separately by the driver once signal-cli +# link has produced a number; that keeps this script idempotent and +# re-runnable without re-auth. +# +# Emits exactly one status block on stdout (ADD_SIGNAL) at the end. All +# chatty progress goes to stderr so setup:auto's raw-log capture sees +# the full story without cluttering the final block for the parser. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Keep in sync with .claude/skills/add-signal/SKILL.md. +QRCODE_VERSION="qrcode@1.5.4" +QRCODE_TYPES_VERSION="@types/qrcode@1.5.6" + +# shellcheck source=setup/lib/channels-remote.sh +source "$PROJECT_ROOT/setup/lib/channels-remote.sh" +CHANNELS_REMOTE=$(resolve_channels_remote) +CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels" + +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + echo "=== NANOCLAW SETUP: ADD_SIGNAL ===" + echo "STATUS: ${status}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-signal] $*" >&2; } + +need_install() { + [ ! -f src/channels/signal.ts ] && return 0 + ! grep -q "^import './signal.js';" src/channels/index.ts 2>/dev/null && return 0 + return 1 +} + +ADAPTER_ALREADY_INSTALLED=true +if need_install; then + ADAPTER_ALREADY_INSTALLED=false + log "Fetching channels branch…" + git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || { + emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed" + exit 1 + } + + log "Copying adapter files from ${CHANNELS_BRANCH}…" + for f in \ + src/channels/signal.ts \ + src/channels/signal.test.ts + do + git show "${CHANNELS_BRANCH}:$f" > "$f" || { + emit_status failed "git show ${CHANNELS_BRANCH}:$f failed" + exit 1 + } + done + + if ! grep -q "^import './signal.js';" src/channels/index.ts; then + echo "import './signal.js';" >> src/channels/index.ts + fi +fi + +# qrcode is needed by setup/signal-auth.ts to render the linking URL as a +# terminal QR. Install idempotently — if it's already present (e.g. from a +# prior WhatsApp install) pnpm is a no-op. +if ! node -e "require.resolve('qrcode')" >/dev/null 2>&1; then + log "Installing ${QRCODE_VERSION}…" + pnpm install "${QRCODE_VERSION}" "${QRCODE_TYPES_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${QRCODE_VERSION} failed" + exit 1 + } +fi + +log "Building…" +pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 +} + +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts index 4c20262..cff2f63 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -28,6 +28,7 @@ import k from 'kleur'; import { runDiscordChannel } from './channels/discord.js'; import { runIMessageChannel } from './channels/imessage.js'; +import { runSignalChannel } from './channels/signal.js'; import { runSlackChannel } from './channels/slack.js'; import { runTeamsChannel } from './channels/teams.js'; import { runTelegramChannel } from './channels/telegram.js'; @@ -54,6 +55,7 @@ type ChannelChoice = | 'telegram' | 'discord' | 'whatsapp' + | 'signal' | 'teams' | 'slack' | 'imessage' @@ -315,6 +317,8 @@ async function main(): Promise { await runDiscordChannel(displayName!); } else if (channelChoice === 'whatsapp') { await runWhatsAppChannel(displayName!); + } else if (channelChoice === 'signal') { + await runSignalChannel(displayName!); } else if (channelChoice === 'teams') { await runTeamsChannel(displayName!); } else if (channelChoice === 'slack') { @@ -442,6 +446,8 @@ function channelDmLabel(choice: ChannelChoice): string | null { return 'Discord DMs'; case 'whatsapp': return 'WhatsApp'; + case 'signal': + return 'Signal'; case 'teams': return 'Teams'; case 'imessage': @@ -835,6 +841,11 @@ async function askChannelChoice(): Promise { { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, { value: 'discord', label: 'Yes, connect Discord' }, { value: 'whatsapp', label: 'Yes, connect WhatsApp' }, + { + value: 'signal', + label: 'Yes, connect Signal', + hint: 'needs signal-cli installed', + }, { value: 'imessage', label: 'Yes, connect iMessage (experimental)', diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts new file mode 100644 index 0000000..9e54cb9 --- /dev/null +++ b/setup/channels/signal.ts @@ -0,0 +1,357 @@ +/** + * Signal channel flow for setup:auto. + * + * `runSignalChannel(displayName)` owns the full branch from signal-cli + * presence check through the welcome DM: + * + * 1. Probe signal-cli on PATH (or SIGNAL_CLI_PATH). On macOS without it, + * offer `brew install signal-cli` inline. On Linux, surface the + * GitHub releases URL and bail with an actionable error. + * 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent). + * 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as + * a terminal QR the operator scans from Signal → Linked Devices. + * 4. Persist SIGNAL_ACCOUNT to .env (+ data/env/env). + * 5. Kick the service so the adapter picks up the new credentials. + * 6. Ask operator role + agent name. + * 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome + * DM path delivers the greeting through the adapter. + * + * Signal's `link` flow creates a *secondary* device. The phone number + * comes from the primary (the phone that scanned the QR); this host then + * sends/receives as that primary number. No registration of new numbers. + * + * Output obeys the three-level contract: clack UI for the user, structured + * entries in logs/setup.log, full raw output in per-step files under + * logs/setup-steps/. See docs/setup-flow.md. + */ +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; +import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; +import { + type Block, + type StepResult, + dumpTranscriptOnFailure, + ensureAnswer, + fail, + runQuietChild, + spawnStep, + writeStepEntry, +} from '../lib/runner.js'; +import { askOperatorRole } from '../lib/role-prompt.js'; + +const DEFAULT_AGENT_NAME = 'Nano'; + +export async function runSignalChannel(displayName: string): Promise { + await ensureSignalCli(); + + const install = await runQuietChild( + 'signal-install', + 'bash', + ['setup/add-signal.sh'], + { + running: 'Installing the Signal adapter…', + done: 'Signal adapter installed.', + skipped: 'Signal adapter already installed.', + }, + ); + if (!install.ok) { + await fail( + 'signal-install', + "Couldn't install the Signal adapter.", + 'See logs/setup-steps/ for details, then retry setup.', + ); + } + + const auth = await runSignalAuth(); + if (!auth.ok) { + const reason = auth.terminal?.fields.ERROR ?? 'unknown'; + await fail( + 'signal-auth', + `Signal link failed (${reason}).`, + reason === 'qr_timeout' + ? 'The code expired. Re-run setup to get a fresh one.' + : 'Re-run setup to try again.', + ); + } + + const account = auth.terminal?.fields.ACCOUNT; + if (!account) { + await fail( + 'signal-auth', + 'Linked with Signal but couldn\'t read the phone number back.', + 'Run `signal-cli listAccounts` to confirm, then re-run setup.', + ); + } + + writeSignalAccount(account!); + await restartService(); + + const role = await askOperatorRole('Signal'); + setupLog.userInput('signal_role', role); + + const agentName = await resolveAgentName(); + + const init = await runQuietChild( + 'init-first-agent', + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'signal', + '--user-id', account!, + '--platform-id', account!, + '--display-name', displayName, + '--agent-name', agentName, + '--role', role, + ], + { + running: `Connecting ${agentName} to Signal…`, + done: `${agentName} is ready. Check Signal for a welcome message.`, + }, + { + extraFields: { + CHANNEL: 'signal', + AGENT_NAME: agentName, + PLATFORM_ID: account!, + ROLE: role, + }, + }, + ); + if (!init.ok) { + await fail( + 'init-first-agent', + `Couldn't finish connecting ${agentName}.`, + 'You can retry later with `/manage-channels`.', + ); + } +} + +async function ensureSignalCli(): Promise { + const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli'; + const probe = spawnSync(cli, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (!probe.error && probe.status === 0) return; + + if (process.platform === 'darwin') { + p.note( + [ + "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + '', + 'The quickest way on macOS is Homebrew:', + '', + k.cyan(' brew install signal-cli'), + '', + "Install it in another terminal, then re-run setup.", + ].join('\n'), + 'signal-cli not found', + ); + } else { + p.note( + [ + "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + '', + 'Grab the latest release from GitHub:', + '', + k.cyan(' https://github.com/AsamK/signal-cli/releases'), + '', + "Install it, make sure `signal-cli --version` works, then re-run setup.", + ].join('\n'), + 'signal-cli not found', + ); + } + await fail( + 'signal-install', + 'signal-cli is required but not installed.', + 'Install it and re-run setup.', + ); +} + +async function runSignalAuth(): Promise< + StepResult & { rawLog: string; durationMs: number } +> { + const rawLog = setupLog.stepRawLog('signal-auth'); + const start = Date.now(); + const s = p.spinner(); + s.start('Starting Signal link…'); + let spinnerActive = true; + + const stopSpinner = (msg: string, code?: number): void => { + if (spinnerActive) { + s.stop(msg, code); + spinnerActive = false; + } + }; + + // Tracks how many lines the QR block occupies so we can wipe it in-place + // once linking succeeds (Signal's link URL doesn't rotate like WhatsApp's, + // but we still want to erase the QR from screen once it's served). + let qrLinesPrinted = 0; + + const result = await spawnStep( + 'signal-auth', + [], + (block: Block) => { + if (block.type === 'SIGNAL_AUTH_QR') { + const qr = block.fields.QR ?? ''; + if (!qr) return; + void renderQr(qr).then((lines) => { + stopSpinner('Scan this QR from Signal → Settings → Linked Devices.'); + process.stdout.write(lines.join('\n') + '\n'); + qrLinesPrinted = lines.length; + s.start('Waiting for you to scan…'); + spinnerActive = true; + }); + } else if (block.type === 'SIGNAL_AUTH') { + const status = block.fields.STATUS; + // Wipe the QR block regardless of outcome — it's either scanned + // and useless, or expired and misleading. + if (qrLinesPrinted > 0) { + process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`); + qrLinesPrinted = 0; + } + const account = block.fields.ACCOUNT; + if (status === 'skipped') { + stopSpinner( + account + ? `Signal already linked as ${k.cyan(account)}.` + : 'Signal already linked.', + ); + } else if (status === 'success') { + stopSpinner(`Signal linked as ${k.cyan(String(account ?? ''))}.`); + } else if (status === 'failed') { + const err = block.fields.ERROR ?? 'unknown'; + stopSpinner(`Signal link failed: ${err}`, 1); + } + } + }, + rawLog, + ); + const durationMs = Date.now() - start; + + if (spinnerActive) { + stopSpinner( + result.ok ? 'Done.' : 'Signal link ended unexpectedly.', + result.ok ? 0 : 1, + ); + if (!result.ok) dumpTranscriptOnFailure(result.transcript); + } + + writeStepEntry('signal-auth', result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** + * Render the raw linking URL as a block-art QR, returned line-by-line so + * the caller can count lines for in-place cleanup. Uses small-mode so the + * code stays scannable on 24-row terminals. If qrcode isn't installed + * (add-signal.sh should have handled it, but we're defensive), fall back + * to the raw URL and ask the user to paste it into an external renderer. + */ +async function renderQr(url: string): Promise { + try { + const QRCode = await import('qrcode'); + const qrText = await QRCode.toString(url, { type: 'terminal', small: true }); + const caption = k.dim( + ' Signal → Settings → Linked Devices → Link New Device → scan.', + ); + return [...qrText.trimEnd().split('\n'), '', caption]; + } catch { + return [ + 'Linking URL (render at https://qr.io or similar):', + '', + url, + '', + k.dim('Signal → Settings → Linked Devices → Link New Device → scan.'), + ]; + } +} + +/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */ +function writeSignalAccount(account: string): void { + const envPath = path.join(process.cwd(), '.env'); + let contents = ''; + try { + contents = fs.readFileSync(envPath, 'utf-8'); + } catch { + contents = ''; + } + if (/^SIGNAL_ACCOUNT=/m.test(contents)) { + contents = contents.replace( + /^SIGNAL_ACCOUNT=.*$/m, + `SIGNAL_ACCOUNT=${account}`, + ); + } else { + if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n'; + contents += `SIGNAL_ACCOUNT=${account}\n`; + } + fs.writeFileSync(envPath, contents); + + const containerEnvDir = path.join(process.cwd(), 'data', 'env'); + fs.mkdirSync(containerEnvDir, { recursive: true }); + fs.copyFileSync(envPath, path.join(containerEnvDir, 'env')); + + setupLog.userInput('signal_account', account); +} + +async function restartService(): Promise { + const s = p.spinner(); + s.start('Restarting NanoClaw so it sees your Signal account…'); + const start = Date.now(); + const platform = process.platform; + try { + if (platform === 'darwin') { + spawnSync( + 'launchctl', + ['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`], + { stdio: 'ignore' }, + ); + } else if (platform === 'linux') { + const unit = getSystemdUnit(); + const user = spawnSync('systemctl', ['--user', 'restart', unit], { + stdio: 'ignore', + }); + if (user.status !== 0) { + spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore' }); + } + } + // Give the adapter a moment to connect to signal-cli before + // init-first-agent's welcome DM hits the delivery path. + await new Promise((r) => setTimeout(r, 5000)); + const elapsed = Math.round((Date.now() - start) / 1000); + s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`); + setupLog.step('signal-restart', 'success', Date.now() - start, { + PLATFORM: platform, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + s.stop(`Restart may have failed: ${message}`, 1); + setupLog.step('signal-restart', 'failed', Date.now() - start, { + ERROR: message, + }); + // Non-fatal — the user can restart manually if init-first-agent fails. + } +} + +async function resolveAgentName(): Promise { + const preset = process.env.NANOCLAW_AGENT_NAME?.trim(); + if (preset) { + setupLog.userInput('agent_name', preset); + return preset; + } + const answer = ensureAnswer( + await p.text({ + message: 'What should your assistant be called?', + placeholder: DEFAULT_AGENT_NAME, + defaultValue: DEFAULT_AGENT_NAME, + }), + ); + const value = (answer as string).trim() || DEFAULT_AGENT_NAME; + setupLog.userInput('agent_name', value); + return value; +} diff --git a/setup/index.ts b/setup/index.ts index 25d1934..200b9e2 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -16,6 +16,7 @@ const STEPS: Record< register: () => import('./register.js'), groups: () => import('./groups.js'), 'whatsapp-auth': () => import('./whatsapp-auth.js'), + 'signal-auth': () => import('./signal-auth.js'), mounts: () => import('./mounts.js'), service: () => import('./service.js'), verify: () => import('./verify.js'), diff --git a/setup/signal-auth.ts b/setup/signal-auth.ts new file mode 100644 index 0000000..ce289db --- /dev/null +++ b/setup/signal-auth.ts @@ -0,0 +1,182 @@ +/** + * Step: signal-auth — link this host to an existing Signal account via + * signal-cli's QR-code flow. + * + * signal-cli `link` opens a bi-directional handshake with the Signal + * servers: it prints one line containing a linking URL (`sgnl://linkdevice?…` + * or older `tsdevice://linkdevice?…`), then blocks until either the user + * scans it from an existing Signal install, or the code expires. On + * success, a secondary account is created under the user's signal-cli + * data directory, associated with the phone number of the scanner. + * + * Methods: + * (no args) Spawn signal-cli link, emit SIGNAL_AUTH_QR + * with the URL, wait for completion. + * + * Block schema (parent parses these): + * SIGNAL_AUTH_QR { QR: "" } — one-shot + * SIGNAL_AUTH { STATUS: success, ACCOUNT: + } — terminal + * { STATUS: skipped, ACCOUNT, REASON: already-authenticated } + * { STATUS: failed, ERROR: } + * + * STATUS values match the runner's vocabulary (success/skipped/failed) so + * spawnStep recognises them and sets `ok` correctly; Signal-specific UI + * lives in setup/channels/signal.ts. + * + * If one or more accounts are already linked (discovered via + * `signal-cli -o json listAccounts`), the step emits SIGNAL_AUTH + * STATUS=skipped with the first account so the driver can reuse it. + * Selecting a different existing account is a driver concern. + */ +import { spawn, spawnSync } from 'child_process'; + +import { emitStatus } from './status.js'; + +const LINK_TIMEOUT_MS = 180_000; +const DEFAULT_DEVICE_NAME = 'NanoClaw'; + +interface SignalAccount { + account?: string; + registered?: boolean; +} + +function cliPath(): string { + return process.env.SIGNAL_CLI_PATH || 'signal-cli'; +} + +/** + * Query signal-cli for currently linked accounts. Empty array if none + * configured, no binary, or the call fails for any other reason. + */ +function listAccounts(): string[] { + const cli = cliPath(); + try { + const res = spawnSync(cli, ['-o', 'json', 'listAccounts'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (res.status !== 0) return []; + const parsed = JSON.parse(res.stdout || '[]') as SignalAccount[]; + return parsed + .filter((a) => a.registered !== false) + .map((a) => a.account ?? '') + .filter(Boolean); + } catch { + return []; + } +} + +export async function run(_args: string[]): Promise { + const cli = cliPath(); + + // Verify signal-cli exists before we commit to the long-running link. + // The driver checks too, but this keeps the step honest when run alone. + const probe = spawnSync(cli, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (probe.error || probe.status !== 0) { + emitStatus('SIGNAL_AUTH', { + STATUS: 'failed', + ERROR: 'signal-cli not found. Install signal-cli first.', + }); + return; + } + + const existing = listAccounts(); + if (existing.length > 0) { + emitStatus('SIGNAL_AUTH', { + STATUS: 'skipped', + ACCOUNT: existing[0], + REASON: 'already-authenticated', + }); + return; + } + + await new Promise((resolve) => { + let settled = false; + let qrEmitted = false; + + const finish = (block: Record, code: number): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + emitStatus('SIGNAL_AUTH', block); + resolve(); + setTimeout(() => process.exit(code), 500); + }; + + const timer = setTimeout(() => { + try { + child.kill('SIGTERM'); + } catch { + /* ignore */ + } + finish({ STATUS: 'failed', ERROR: 'qr_timeout' }, 1); + }, LINK_TIMEOUT_MS); + + const child = spawn(cli, ['link', '--name', DEFAULT_DEVICE_NAME], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + // stdout carries the URL on the first line; subsequent lines may print + // status like "Associated with: +1555…". We don't strictly need to parse + // the number — listAccounts after exit is the source of truth — but the + // URL match drives the QR emit, which is the whole point. + let stdoutBuf = ''; + const handleStdout = (chunk: Buffer): void => { + stdoutBuf += chunk.toString('utf-8'); + let idx: number; + while ((idx = stdoutBuf.indexOf('\n')) !== -1) { + const line = stdoutBuf.slice(0, idx).trim(); + stdoutBuf = stdoutBuf.slice(idx + 1); + if (!line) continue; + // Match both modern (sgnl://) and legacy (tsdevice://) schemes. + if (/^(sgnl|tsdevice):\/\/linkdevice\?/.test(line) && !qrEmitted) { + qrEmitted = true; + emitStatus('SIGNAL_AUTH_QR', { QR: line }); + } + } + }; + child.stdout.on('data', handleStdout); + + // Capture stderr for the transcript / log — signal-cli writes warnings + // and errors there. We don't emit on partial stderr lines since a + // successful link can still produce noise. + let stderrBuf = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderrBuf += chunk.toString('utf-8'); + }); + + child.on('error', (err) => { + finish({ STATUS: 'failed', ERROR: `spawn error: ${err.message}` }, 1); + }); + + child.on('close', (code) => { + // After a successful link, signal-cli exits 0 and the newly linked + // account shows up in listAccounts. Use that as the source of truth + // rather than scraping stdout — more robust across signal-cli versions. + if (code === 0) { + const post = listAccounts(); + if (post.length === 0) { + finish( + { STATUS: 'failed', ERROR: 'link exited 0 but no account registered' }, + 1, + ); + return; + } + finish({ STATUS: 'success', ACCOUNT: post[0] }, 0); + return; + } + + // Non-zero exit. Surface the last non-empty stderr line for context; + // signal-cli's own error messages are usually informative. + const lastErr = + stderrBuf + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .slice(-1)[0] ?? `signal-cli link exited with code ${code}`; + finish({ STATUS: 'failed', ERROR: lastErr }, 1); + }); + }); +} From ce28e7f5583959a8b827ee361af743b8266d0766 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 24 Apr 2026 01:27:20 +0300 Subject: [PATCH 164/185] docs(add-codex): bump CODEX_VERSION to 0.124.0 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-codex/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/add-codex/SKILL.md b/.claude/skills/add-codex/SKILL.md index 17910b7..3411bae 100644 --- a/.claude/skills/add-codex/SKILL.md +++ b/.claude/skills/add-codex/SKILL.md @@ -67,7 +67,7 @@ Two edits to `container/Dockerfile`, both idempotent (skip if already present): **(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`: ```dockerfile -ARG CODEX_VERSION=0.121.0 +ARG CODEX_VERSION=0.124.0 ``` **(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call: From 5845a5a98029c0a2d284e8607ead213a07eec499 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 22:47:10 +0000 Subject: [PATCH 165/185] fix(container-runner): honor agent_provider DB columns with session override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveProviderContribution read only containerConfig.provider (from each group's container.json) and ignored both agent_groups.agent_provider and sessions.agent_provider. The provider-install skills (opencode, codex) and CLAUDE.md document those DB columns as the source of truth with session-overrides-group precedence, but the code never consulted them — so setting `agent_provider = 'codex'` on a group had no effect, and the only way to route to a non-default provider was to edit the per-group JSON directly. Discovered while wiring up Codex: DB update landed but the spawned container kept running Claude. Extract a pure `resolveProviderName(session, group, containerConfig)` with the documented precedence: sessions.agent_provider → agent_groups.agent_provider → container.json `provider` → 'claude' `resolveProviderContribution` now calls it. The container.json fallback stays so existing installs that only set provider in JSON keep working. Empty strings treated as unset to avoid footguns when a DB-backed form writes '' for "no override." Added unit tests covering precedence, null-fallthrough, empty-string fallthrough, and case normalization. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/container-runner.test.ts | 32 ++++++++++++++++++++++++++++++++ src/container-runner.ts | 21 ++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/container-runner.test.ts diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts new file mode 100644 index 0000000..cd18a72 --- /dev/null +++ b/src/container-runner.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveProviderName } from './container-runner.js'; + +describe('resolveProviderName', () => { + it('prefers session over group and container.json', () => { + expect(resolveProviderName('codex', 'opencode', 'claude')).toBe('codex'); + }); + + it('falls back to group when session is null', () => { + expect(resolveProviderName(null, 'codex', 'claude')).toBe('codex'); + }); + + it('falls back to container.json when session and group are null', () => { + expect(resolveProviderName(null, null, 'opencode')).toBe('opencode'); + }); + + it('defaults to claude when nothing is set', () => { + expect(resolveProviderName(null, null, undefined)).toBe('claude'); + }); + + it('lowercases the resolved name', () => { + expect(resolveProviderName('CODEX', null, null)).toBe('codex'); + expect(resolveProviderName(null, 'OpenCode', null)).toBe('opencode'); + expect(resolveProviderName(null, null, 'Claude')).toBe('claude'); + }); + + it('treats empty string as unset (falls through)', () => { + expect(resolveProviderName('', 'codex', null)).toBe('codex'); + expect(resolveProviderName(null, '', 'opencode')).toBe('opencode'); + }); +}); diff --git a/src/container-runner.ts b/src/container-runner.ts index fca88c4..029b5fe 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -191,12 +191,31 @@ export function killContainer(sessionId: string, reason: string): void { } } +/** + * Resolve the provider name for a session using the precedence documented in + * the provider-install skills: + * + * sessions.agent_provider + * → agent_groups.agent_provider + * → container.json `provider` + * → 'claude' + * + * Pure so the precedence can be unit-tested without a DB or filesystem. + */ +export function resolveProviderName( + sessionProvider: string | null | undefined, + agentGroupProvider: string | null | undefined, + containerConfigProvider: string | null | undefined, +): string { + return (sessionProvider || agentGroupProvider || containerConfigProvider || 'claude').toLowerCase(); +} + function resolveProviderContribution( session: Session, agentGroup: AgentGroup, containerConfig: import('./container-config.js').ContainerConfig, ): { provider: string; contribution: ProviderContainerContribution } { - const provider = (containerConfig.provider || 'claude').toLowerCase(); + const provider = resolveProviderName(session.agent_provider, agentGroup.agent_provider, containerConfig.provider); const fn = getProviderContainerConfig(provider); const contribution = fn ? fn({ From a4346f566c87a25418aa5e783fc2a54089e11e6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 22:54:40 +0000 Subject: [PATCH 166/185] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?30k=20tokens=20=C2=B7=2065%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index fd25267..fd8a436 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 129k tokens, 64% of context window + + 130k tokens, 65% of context window @@ -15,8 +15,8 @@ tokens - - 129k + + 130k From d0c608c75114fb6ada970be3bc3f7212ad5bc47a Mon Sep 17 00:00:00 2001 From: Samantha Date: Thu, 23 Apr 2026 19:44:47 -0400 Subject: [PATCH 167/185] fix(setup): register step uses engage_mode columns dropped by migration 010 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 010-engage-modes (replace trigger_rules + response_scope with engage_mode/engage_pattern/sender_scope/ignored_message_policy) updated the schema and the production code paths, but missed setup/register.ts. The step still constructed a payload with the dropped columns. On any fresh v2 install, attempting to register a channel via: pnpm exec tsx setup/index.ts --step register -- --platform-id ... fails with: `Missing named parameter "engage_mode"`. This affects every flow that calls the register step — the /add- skills, /manage-channels, and the setup auto driver. Map old → new: - trigger_rules.pattern (string) → engage_mode='pattern', engage_pattern= - requiresTrigger=false (no pattern) → engage_mode='pattern', engage_pattern='.' (the "always" sentinel from migration 010) - requiresTrigger=true (no pattern) → engage_mode='mention' - response_scope='all' → sender_scope='all', ignored_message_policy='drop' (conservative default matching the migration backfill rule) Tested by registering three Telegram channels (one DM, two groups) on a fresh v2 install — all succeeded. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/register.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/setup/register.ts b/setup/register.ts index a308add..ff194fc 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -167,18 +167,16 @@ export async function run(args: string[]): Promise { if (!existing) { newlyWired = true; const mgaId = generateId('mga'); - const triggerRules = parsed.trigger - ? JSON.stringify({ - pattern: parsed.trigger, - requiresTrigger: parsed.requiresTrigger, - }) - : null; + const engageMode = parsed.trigger || !parsed.requiresTrigger ? 'pattern' : 'mention'; + const engagePattern = parsed.trigger ? parsed.trigger : (!parsed.requiresTrigger ? '.' : null); createMessagingGroupAgent({ id: mgaId, messaging_group_id: messagingGroup.id, agent_group_id: agentGroup.id, - trigger_rules: triggerRules, - response_scope: 'all', + engage_mode: engageMode, + engage_pattern: engagePattern, + sender_scope: 'all', + ignored_message_policy: 'drop', session_mode: parsed.sessionMode, priority: 0, created_at: new Date().toISOString(), From 9e33274e2a81121fbf65531108f8239bb4e1e465 Mon Sep 17 00:00:00 2001 From: grtwrn Date: Thu, 23 Apr 2026 20:43:02 -0400 Subject: [PATCH 168/185] skill(add-gmail-tool): OneCLI-native Gmail MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /add-gmail-tool — a Utility skill that installs Gmail as an MCP tool in NanoClaw v2 using OneCLI for credential injection. No raw OAuth tokens ever reach the container; the gateway swaps the "onecli-managed" stub bearer for the real token at request time. Scope (3 files): - container/Dockerfile: pnpm global-install of @gongrzhe/server-gmail-autoauth-mcp@1.1.11, pinned behind GMAIL_MCP_VERSION. Also pins zod-to-json-schema@3.22.5 to avoid an ERR_PACKAGE_PATH_NOT_EXPORTED crash: the MCP server's loose zod range resolves zod@3.24.x while zod-to-json-schema@3.25.x imports the zod/v3 subpath that only exists in zod>=3.25. - container/agent-runner/src/providers/claude.ts: adds 'mcp__gmail__*' to TOOL_ALLOWLIST so the agent can invoke the server's tools. - .claude/skills/add-gmail-tool/SKILL.md: pre-flight checks (OneCLI Gmail app connected, stubs present, mount allowlist covers ~/.gmail-mcp, agent secret-mode), per-group wiring in container.json (mount + mcpServers), verification steps, troubleshooting, removal instructions. Credits to gongrzhe for the MCP server and the add-atomic-chat-tool / add-vercel skill patterns. Addresses #1500 (proxy Gmail OAuth through credential proxy) on the Gmail side. Overlaps in intent with #1810 but stays surgical — no bundled unrelated changes. Tested end-to-end on Linux/Docker: CLI and WhatsApp self-chat agents can list labels, search/read/send mail via OneCLI-injected tokens. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-gmail-tool/SKILL.md | 229 ++++++++++++++++++ container/Dockerfile | 6 + .../agent-runner/src/providers/claude.ts | 1 + 3 files changed, 236 insertions(+) create mode 100644 .claude/skills/add-gmail-tool/SKILL.md diff --git a/.claude/skills/add-gmail-tool/SKILL.md b/.claude/skills/add-gmail-tool/SKILL.md new file mode 100644 index 0000000..095c285 --- /dev/null +++ b/.claude/skills/add-gmail-tool/SKILL.md @@ -0,0 +1,229 @@ +--- +name: add-gmail-tool +description: Add Gmail as an MCP tool (read, search, send, label, draft) using OneCLI-managed OAuth. The agent gets Gmail tools in every enabled group; OneCLI injects real tokens at request time so no raw credentials are ever in the container or on disk in usable form. +--- + +# Add Gmail Tool (OneCLI-native) + +This skill wires the [`@gongrzhe/server-gmail-autoauth-mcp`](https://www.npmjs.com/package/@gongrzhe/server-gmail-autoauth-mcp) stdio MCP server into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `gmail.googleapis.com` and injects the real OAuth bearer from its vault. + +Tools exposed (from `gmail-mcp@1.1.11`, surfaced to the agent as `mcp__gmail__`): `search_emails`, `read_email`, `send_email`, `draft_email`, `delete_email`, `modify_email`, `batch_modify_emails`, `batch_delete_emails`, `download_attachment`, `list_email_labels`, `create_label`, `update_label`, `delete_label`, `get_or_create_label`, `list_filters`, `get_filter`, `create_filter`, `create_filter_from_template`, `delete_filter`. + +**Why this pattern:** v2's invariant is that containers never receive raw API keys — OneCLI is the sole credential path (see CHANGELOG v2.0.0). The stub-file pattern satisfies this: the container sees `"onecli-managed"` placeholders, the gateway swaps them in flight. + +## Phase 1: Pre-flight + +### Verify OneCLI has Gmail connected + +```bash +onecli apps get --provider gmail +``` + +Expected: `"connection": { "status": "connected" }` with scopes including `gmail.readonly`, `gmail.modify`, `gmail.send`. + +If not connected, tell the user: + +> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Gmail, and click Connect. Sign in with the Google account you want the agent to act as. + +### Verify stub credentials exist + +```bash +ls -la ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json 2>&1 +``` + +If both exist and contain `"onecli-managed"`: + +```bash +grep -l onecli-managed ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json +``` + +...skip to Phase 2. + +If either file exists but does **not** contain `onecli-managed`, **STOP** and tell the user — these are real OAuth credentials from a previous non-OneCLI install. Back them up, then delete before proceeding. The OneCLI migration normally handles this; if it didn't, something is wrong. + +If both files are absent, write them now: + +```bash +mkdir -p ~/.gmail-mcp +cat > ~/.gmail-mcp/gcp-oauth.keys.json <<'EOF' +{ + "installed": { + "client_id": "onecli-managed.apps.googleusercontent.com", + "client_secret": "onecli-managed", + "redirect_uris": ["http://localhost:3000/oauth2callback"] + } +} +EOF +cat > ~/.gmail-mcp/credentials.json <<'EOF' +{ + "access_token": "onecli-managed", + "refresh_token": "onecli-managed", + "token_type": "Bearer", + "expiry_date": 99999999999999, + "scope": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send" +} +EOF +chmod 600 ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json +``` + +### Verify mount allowlist covers the path + +```bash +cat ~/.config/nanoclaw/mount-allowlist.json +``` + +`~/.gmail-mcp` must sit under an `allowedRoots` entry (e.g. `/home/`). If it doesn't, tell the user to run `/manage-mounts` first or add their home directory. + +### Check agent secret-mode + +For each target agent group, confirm OneCLI will inject Gmail secrets into its container. Find the OneCLI agent ID that matches the group's `agentGroupId`: + +```bash +onecli agents list +``` + +If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets: + +```bash +onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app) +onecli agents set-secrets --id --secret-ids +``` + +## Phase 2: Apply Code Changes + +### Check if already applied + +```bash +grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \ +grep -q "mcp__gmail__\*" container/agent-runner/src/providers/claude.ts && \ +echo "ALREADY APPLIED — skip to Phase 3" +``` + +### Add MCP server to Dockerfile + +Edit `container/Dockerfile`. Find the pinned-version ARG block: + +```dockerfile +ARG CLAUDE_CODE_VERSION=2.1.116 +ARG AGENT_BROWSER_VERSION=latest +ARG VERCEL_VERSION=latest +ARG BUN_VERSION=1.3.12 +``` + +Add a new line: + +```dockerfile +ARG GMAIL_MCP_VERSION=1.1.11 +``` + +Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block after it, before `# ---- Entrypoint`: + +```dockerfile +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g \ + "@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \ + "zod-to-json-schema@3.22.5" +``` + +Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates trunk installs, and CLAUDE.md requires a fixed ARG version for all Node CLIs installed into the image. + +**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`. + +### Add tools to allowlist + +Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it. + +### Rebuild the container image + +```bash +./container/build.sh +``` + +Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cached on rebuild). + +## Phase 3: Wire Per-Agent-Group + +For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), edit `groups//container.json` to add the mount and MCP server. + +Merge these into the group's `container.json`: + +```jsonc +{ + "mcpServers": { + "gmail": { + "command": "gmail-mcp", + "args": [], + "env": { + "GMAIL_OAUTH_PATH": "/workspace/extra/.gmail-mcp/gcp-oauth.keys.json", + "GMAIL_CREDENTIALS_PATH": "/workspace/extra/.gmail-mcp/credentials.json" + } + } + }, + "additionalMounts": [ + { + "hostPath": "/home//.gmail-mcp", + "containerPath": ".gmail-mcp", + "readonly": false + } + ] +} +``` + +Substitute `` with the host user's home (use `echo $HOME`, don't assume `~` will expand — `container-runner.ts` does expand `~` via `expandPath`, but an explicit absolute path is clearer and matches what `/manage-mounts` writes). + +**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container. + +## Phase 4: Build and Restart + +```bash +pnpm run build +systemctl --user restart nanoclaw # Linux +# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` + +## Phase 5: Verify + +### Test from the wired agent + +Tell the user: + +> In your `` chat, send: **"list my gmail labels"** or **"search my inbox for invoices from last month"**. +> +> The agent should use `mcp__gmail__list_labels` / `mcp__gmail__search`. The first call may take a second or two while the MCP server starts and OneCLI does the token exchange. + +### Check logs if the tool isn't working + +```bash +tail -100 logs/nanoclaw.log logs/nanoclaw.error.log | grep -iE 'gmail|mcp' +# Per-container logs — session-scoped: +ls data/v2-sessions/*/stderr.log | head +``` + +Common signals: +- `command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile). +- `ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`. +- `401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id `) and that the Gmail app is connected (`onecli apps get --provider gmail`). +- Agent says "I don't have Gmail tools" → `mcp__gmail__*` wasn't added to `TOOL_ALLOWLIST`, or the agent-runner wasn't rebuilt (image cache — run `./container/build.sh` again with `--no-cache` if suspicious). + +## Removal + +1. Delete the `"gmail"` entry from `mcpServers` and the `.gmail-mcp` entry from `additionalMounts` in each group's `container.json`. +2. Remove `'mcp__gmail__*'` from `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`. +3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`. +4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`. +5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs. +6. (Optional) Disconnect Gmail in OneCLI: `onecli apps disconnect --provider gmail`. + +## Notes + +- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes. +- **Scopes are set at OAuth connect time.** If the agent needs scopes beyond what's currently connected (e.g. the user later wants `calendar.readonly` for combined email/calendar workflows), disconnect and reconnect Gmail in the OneCLI web UI with the expanded scope set. +- **This is tool-only.** Inbound email as a channel (emails trigger the agent) is a separate piece of work — it needs a `src/channels/gmail.ts` adapter that polls the inbox and routes to a messaging group. The pre-v2 qwibitai skill had this; it has not been ported to v2's channel architecture as of v2.0.0. + +## Credits & references + +- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed. +- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`. +- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md). +- **Addresses:** [issue #1500](https://github.com/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side. +- **Related PRs:** [#1810](https://github.com/qwibitai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version. diff --git a/container/Dockerfile b/container/Dockerfile index 4b4cf22..8c296ea 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -23,6 +23,7 @@ ARG CLAUDE_CODE_VERSION=2.1.116 ARG AGENT_BROWSER_VERSION=latest ARG VERCEL_VERSION=latest ARG BUN_VERSION=1.3.12 +ARG GMAIL_MCP_VERSION=1.1.11 # ---- System dependencies ----------------------------------------------------- # tini: correct PID 1 / signal forwarding so outbound.db writes finalize on @@ -104,6 +105,11 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \ RUN --mount=type=cache,target=/root/.cache/pnpm \ pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g \ + "@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \ + "zod-to-json-schema@3.22.5" + # ---- Entrypoint -------------------------------------------------------------- COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index fbb077c..0ba0919 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -55,6 +55,7 @@ const TOOL_ALLOWLIST = [ 'Skill', 'NotebookEdit', 'mcp__nanoclaw__*', + 'mcp__gmail__*', ]; interface SDKUserMessage { From 81ef193e692dcf76a2fcd72a3995dc7edd017aef Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 24 Apr 2026 13:38:46 +1000 Subject: [PATCH 169/185] refactor(session-state): key continuations per provider to survive provider switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before, every provider stored its opaque continuation id under the single outbound.db key `sdk_session_id`. Flipping a session's agent_provider (e.g. Codex → Claude) meant the new provider read the old provider's id at wake, handed it to its own SDK, and got a "No conversation found" error that cost the user one sacrificed message before the stale-session recovery path cleared the id. This reshapes session_state so continuations are keyed `continuation:` instead. Consequences: - Per-provider continuations coexist. Flipping Claude → Codex → Claude resumes the Claude thread exactly where it left off, with the intervening Codex thread also still on file. - No provider ever reads another provider's id. Switching costs no sacrificed message and emits no transient error. - Legacy installs are migrated forward on first startup: migrateLegacyContinuation() adopts any pre-existing `sdk_session_id` row into the current provider's slot (best guess — it was whichever provider ran last), then deletes the legacy row unconditionally so it can't poison a future provider's read. runPollLoop now takes providerName alongside the provider instance, and threads it through processQuery to setContinuation on init. Tests: 9 new tests covering set/get isolation across providers, clear-specificity, legacy-adoption, legacy-always-deleted, prefer-existing-slot-over-legacy, and idempotency of a second migration call. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agent-runner/src/db/session-state.test.ts | 100 ++++++++++++++++++ .../agent-runner/src/db/session-state.ts | 62 ++++++++--- container/agent-runner/src/index.ts | 1 + .../agent-runner/src/integration.test.ts | 1 + container/agent-runner/src/poll-loop.ts | 28 +++-- 5 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 container/agent-runner/src/db/session-state.test.ts diff --git a/container/agent-runner/src/db/session-state.test.ts b/container/agent-runner/src/db/session-state.test.ts new file mode 100644 index 0000000..b5aa269 --- /dev/null +++ b/container/agent-runner/src/db/session-state.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; + +import { getOutboundDb, initTestSessionDb } from './connection.js'; +import { + clearContinuation, + getContinuation, + migrateLegacyContinuation, + setContinuation, +} from './session-state.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +function seedLegacy(value: string): void { + getOutboundDb() + .prepare('INSERT INTO session_state (key, value, updated_at) VALUES (?, ?, ?)') + .run('sdk_session_id', value, new Date().toISOString()); +} + +describe('session-state — per-provider continuations', () => { + test('set/get round-trip, case-insensitive provider key', () => { + setContinuation('claude', 'claude-conv-1'); + expect(getContinuation('claude')).toBe('claude-conv-1'); + expect(getContinuation('Claude')).toBe('claude-conv-1'); + expect(getContinuation('CLAUDE')).toBe('claude-conv-1'); + }); + + test('providers are isolated — switching reads the right slot', () => { + setContinuation('claude', 'claude-conv-1'); + setContinuation('codex', 'codex-thread-xyz'); + + expect(getContinuation('claude')).toBe('claude-conv-1'); + expect(getContinuation('codex')).toBe('codex-thread-xyz'); + }); + + test('clearContinuation only affects the specified provider', () => { + setContinuation('claude', 'keep-me'); + setContinuation('codex', 'drop-me'); + + clearContinuation('codex'); + + expect(getContinuation('claude')).toBe('keep-me'); + expect(getContinuation('codex')).toBeUndefined(); + }); + + test('unknown provider returns undefined', () => { + expect(getContinuation('never-used')).toBeUndefined(); + }); +}); + +describe('session-state — legacy migration', () => { + test('adopts legacy value into current provider when current is empty', () => { + seedLegacy('old-session-id'); + + const adopted = migrateLegacyContinuation('claude'); + + expect(adopted).toBe('old-session-id'); + expect(getContinuation('claude')).toBe('old-session-id'); + }); + + test('always deletes legacy row regardless of migration outcome', () => { + seedLegacy('old-session-id'); + setContinuation('claude', 'existing'); + + migrateLegacyContinuation('claude'); + + // After migration the legacy key must be gone, whether or not it was adopted. + // A subsequent migration for a different provider must not see it. + const resultAfterSecondCall = migrateLegacyContinuation('codex'); + expect(resultAfterSecondCall).toBeUndefined(); + }); + + test('prefers existing current-provider slot over legacy', () => { + seedLegacy('legacy-value'); + setContinuation('claude', 'claude-value'); + + const result = migrateLegacyContinuation('claude'); + + expect(result).toBe('claude-value'); + expect(getContinuation('claude')).toBe('claude-value'); + }); + + test('no legacy row — returns current provider value (possibly undefined)', () => { + expect(migrateLegacyContinuation('claude')).toBeUndefined(); + + setContinuation('codex', 'codex-value'); + expect(migrateLegacyContinuation('codex')).toBe('codex-value'); + }); + + test('migration is idempotent on a second call (legacy already gone)', () => { + seedLegacy('once'); + + const first = migrateLegacyContinuation('claude'); + expect(first).toBe('once'); + + const second = migrateLegacyContinuation('claude'); + expect(second).toBe('once'); + }); +}); diff --git a/container/agent-runner/src/db/session-state.ts b/container/agent-runner/src/db/session-state.ts index a199ae1..9e12309 100644 --- a/container/agent-runner/src/db/session-state.ts +++ b/container/agent-runner/src/db/session-state.ts @@ -2,12 +2,20 @@ * Persistent key/value state for the container. Lives in outbound.db * (container-owned, already scoped per channel/thread). * - * Primary use: remember the SDK session ID so the agent's conversation - * resumes across container restarts. Cleared by /clear. + * Primary use: remember each provider's opaque continuation id so the + * agent's conversation resumes across container restarts. Keyed per + * provider because continuations are provider-private — a Claude + * conversation id means nothing to Codex and vice versa. Switching + * providers is therefore lossless: each provider's last thread stays + * on file and resumes cleanly if the user flips back. */ import { getOutboundDb } from './connection.js'; -const SDK_SESSION_KEY = 'sdk_session_id'; +const LEGACY_KEY = 'sdk_session_id'; + +function continuationKey(providerName: string): string { + return `continuation:${providerName.toLowerCase()}`; +} function getValue(key: string): string | undefined { const row = getOutboundDb() @@ -18,9 +26,7 @@ function getValue(key: string): string | undefined { function setValue(key: string, value: string): void { getOutboundDb() - .prepare( - 'INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)', - ) + .prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)') .run(key, value, new Date().toISOString()); } @@ -28,14 +34,46 @@ function deleteValue(key: string): void { getOutboundDb().prepare('DELETE FROM session_state WHERE key = ?').run(key); } -export function getStoredSessionId(): string | undefined { - return getValue(SDK_SESSION_KEY); +/** + * One-time migration of the pre-per-provider continuation row. + * + * Before this was keyed per provider, continuations lived under the + * single key `sdk_session_id`. On container start, if that legacy row + * exists and the current provider has no continuation of its own, adopt + * the legacy value into the current provider's slot (best-guess — the + * legacy row was written by whatever provider ran last). The legacy row + * is always deleted so future provider flips never re-read a stale id + * through the wrong lens. + * + * Returns the continuation the caller should use at startup (either the + * current provider's existing value, the adopted legacy value, or + * undefined). + */ +export function migrateLegacyContinuation(providerName: string): string | undefined { + const legacy = getValue(LEGACY_KEY); + const currentKey = continuationKey(providerName); + const current = getValue(currentKey); + + if (legacy === undefined) return current; + + // Always drop the legacy row so no future provider reads it. + deleteValue(LEGACY_KEY); + + // Prefer the current provider's own slot if one already exists. + if (current !== undefined) return current; + + setValue(currentKey, legacy); + return legacy; } -export function setStoredSessionId(sessionId: string): void { - setValue(SDK_SESSION_KEY, sessionId); +export function getContinuation(providerName: string): string | undefined { + return getValue(continuationKey(providerName)); } -export function clearStoredSessionId(): void { - deleteValue(SDK_SESSION_KEY); +export function setContinuation(providerName: string, id: string): void { + setValue(continuationKey(providerName), id); +} + +export function clearContinuation(providerName: string): void { + deleteValue(continuationKey(providerName)); } diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 236be4c..90c690f 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -95,6 +95,7 @@ async function main(): Promise { await runPollLoop({ provider, + providerName, cwd: CWD, systemContext: { instructions }, }); diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index 4a8b091..3447c38 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -98,6 +98,7 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna return Promise.race([ runPollLoop({ provider, + providerName: 'mock', cwd: '/tmp', }), new Promise((_, reject) => { diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index d93bdd3..bd48db2 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -2,7 +2,11 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; import { writeMessageOut } from './db/messages-out.js'; import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; -import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js'; +import { + clearContinuation, + migrateLegacyContinuation, + setContinuation, +} from './db/session-state.js'; import { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js'; import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js'; @@ -19,6 +23,12 @@ function generateId(): string { export interface PollLoopConfig { provider: AgentProvider; + /** + * Name of the provider (e.g. "claude", "codex", "opencode"). Used to key + * the stored continuation per-provider so flipping providers doesn't + * resurrect a stale id from a different backend. + */ + providerName: string; cwd: string; systemContext?: { instructions?: string; @@ -39,8 +49,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise { // Resume the agent's prior session from a previous container run if one // was persisted. The continuation is opaque to the poll-loop — the // provider decides how to use it (Claude resumes a .jsonl transcript, - // other providers may reload a thread ID, etc.). - let continuation: string | undefined = getStoredSessionId(); + // other providers may reload a thread ID, etc.). Keyed per-provider so + // a Codex thread id never gets handed to Claude or vice versa. + let continuation: string | undefined = migrateLegacyContinuation(config.providerName); if (continuation) { log(`Resuming agent session ${continuation}`); @@ -94,7 +105,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) { log('Clearing session (resetting continuation)'); continuation = undefined; - clearStoredSessionId(); + clearContinuation(config.providerName); writeMessageOut({ id: generateId(), kind: 'chat', @@ -160,10 +171,10 @@ export async function runPollLoop(config: PollLoopConfig): Promise { const skippedSet = new Set(skipped); const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id)); try { - const result = await processQuery(query, routing, processingIds); + const result = await processQuery(query, routing, processingIds, config.providerName); if (result.continuation && result.continuation !== continuation) { continuation = result.continuation; - setStoredSessionId(continuation); + setContinuation(config.providerName, continuation); } } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); @@ -175,7 +186,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { if (continuation && config.provider.isSessionInvalid(err)) { log(`Stale session detected (${continuation}) — clearing for next retry`); continuation = undefined; - clearStoredSessionId(); + clearContinuation(config.providerName); } // Write error response so the user knows something went wrong @@ -238,6 +249,7 @@ async function processQuery( query: AgentQuery, routing: RoutingContext, initialBatchIds: string[], + providerName: string, ): Promise { let queryContinuation: string | undefined; let done = false; @@ -288,7 +300,7 @@ async function processQuery( // container died between `init` and `result`, the SDK session was // effectively orphaned and the next message started a blank // Claude session with no prior context. - setStoredSessionId(event.continuation); + setContinuation(providerName, event.continuation); } else if (event.type === 'result') { // A result — with or without text — means the turn is done. Mark // the initial batch completed now so the host sweep doesn't see From 672e228876aa84ecb5c1a7142ab3b991c5506cee Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 24 Apr 2026 15:15:33 +1000 Subject: [PATCH 170/185] fix(agent-route): forward file attachments between agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: `send_file(to='parent')` from a sub-agent wrote the bytes to the sub-agent's own session outbox, but agent-to-agent routing copied only the content JSON — the target's inbound message referenced `files: ['x.png']` but the bytes lived in a session directory the target couldn't mount. Parent agents orchestrating sub-agents (e.g. Design Team delegating illustration work to an Illustrator sub-agent on Codex) received file-reference messages with nothing to forward. Fix: on route, if the source's content has `files`, copy each referenced file from `/outbox//` to `/inbox//`, and emit `attachments` (the existing formatter convention — see formatter.ts:223) with `localPath` relative to `/workspace/`. The target formatter already renders these as `[file: — saved to /workspace/inbox//]`, so the target agent sees the path and can call `send_file(path=…, to=…)` to forward onward. Convention matches what session-manager.ts:256 already does for base64-encoded channel-inbound attachments — same inbox layout, same content shape. Nothing on the formatter/agent side needed to change. ## Scope - `forwardAttachedFiles(source, target)` — pure-ish helper that copies files and returns the attachments array. - `forwardFileAttachments(msg, …)` — wraps the helper for the route path: parses content, copies files if present, merges into any existing `attachments`, re-serialises. - `routeAgentMessage` — uses the rewritten content when writing the target's inbound row. - Log line now includes `forwardedFileCount` for observability. Missing source files are skipped with a warning rather than killing the route — a bad filename in a batch shouldn't drop the accompanying text. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/agent-to-agent/agent-route.ts | 144 +++++++++++++++++++++- 1 file changed, 138 insertions(+), 6 deletions(-) diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index 760356c..faec2b9 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -3,9 +3,13 @@ * * Outbound messages with `channel_type === 'agent'` target another agent * group rather than a channel. Permission is enforced via `agent_destinations` — - * the source agent must have a row for the target. Content is copied verbatim; - * the target's formatter looks up the source agent in its own local map to - * display a name. + * the source agent must have a row for the target. Content is copied into the + * target's inbound DB; if the source message had `files` (from `send_file`), + * the actual bytes are copied from the source's outbox into the target's + * `inbox//` directory and surfaced to the target agent as + * `attachments` (existing formatter convention — see formatter.ts:230). + * The target agent can then forward the file onward via its own `send_file` + * call using the absolute `/workspace/inbox//` path. * * Self-messages are always allowed (used for system notes injected back into * an agent's own session, e.g. post-approval follow-up prompts). @@ -14,14 +18,75 @@ * `channel_type === 'agent'` check. When the module is absent the check in * core throws with a "module not installed" message so retry → mark failed. */ +import fs from 'fs'; +import path from 'path'; + import { getAgentGroup } from '../../db/agent-groups.js'; import { getSession } from '../../db/sessions.js'; import { wakeContainer } from '../../container-runner.js'; import { log } from '../../log.js'; -import { resolveSession, writeSessionMessage } from '../../session-manager.js'; +import { resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js'; import type { Session } from '../../types.js'; import { hasDestination } from './db/agent-destinations.js'; +export interface ForwardedAttachment { + name: string; + filename: string; + type: 'file'; + localPath: string; +} + +/** + * Copy file attachments from the source agent's outbox into the target + * agent's inbox. Returns attachments using the formatter's existing + * `{name, type, localPath}` convention — target agent reads `localPath` + * as relative to `/workspace/`, matching how channel-inbound attachments + * are surfaced today. + * + * Missing source files are skipped with a warning rather than failing + * the whole route — a bad filename reference shouldn't kill the + * accompanying text. + */ +export function forwardAttachedFiles( + source: { agentGroupId: string; sessionId: string; messageId: string; filenames: string[] }, + target: { agentGroupId: string; sessionId: string; messageId: string }, +): ForwardedAttachment[] { + if (source.filenames.length === 0) return []; + + const sourceDir = path.join(sessionDir(source.agentGroupId, source.sessionId), 'outbox', source.messageId); + if (!fs.existsSync(sourceDir)) { + log.warn('agent-route: source outbox dir missing, no files forwarded', { + sourceMsgId: source.messageId, + sourceDir, + }); + return []; + } + + const targetInboxDir = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox', target.messageId); + fs.mkdirSync(targetInboxDir, { recursive: true }); + + const attachments: ForwardedAttachment[] = []; + for (const filename of source.filenames) { + const src = path.join(sourceDir, filename); + if (!fs.existsSync(src)) { + log.warn('agent-route: referenced file missing in source outbox, skipped', { + sourceMsgId: source.messageId, + filename, + }); + continue; + } + const dst = path.join(targetInboxDir, filename); + fs.copyFileSync(src, dst); + attachments.push({ + name: filename, + filename, + type: 'file', + localPath: `inbox/${target.messageId}/${filename}`, + }); + } + return attachments; +} + export interface RoutableAgentMessage { id: string; platform_id: string | null; @@ -45,20 +110,87 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`); } const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared'); + const a2aMsgId = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // If the source message references files (via `send_file`), forward the + // bytes from the source's outbox into the target's inbox so the target + // agent can actually see and re-send them. Without this, agent-to-agent + // file attachments look like they arrive but the target has no way to + // read the bytes — they live in a session dir it doesn't mount. + const forwardedContent = forwardFileAttachments(msg, a2aMsgId, session, targetAgentGroupId, targetSession.id); + writeSessionMessage(targetAgentGroupId, targetSession.id, { - id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + id: a2aMsgId, kind: 'chat', timestamp: new Date().toISOString(), platformId: session.agent_group_id, channelType: 'agent', threadId: null, - content: msg.content, + content: forwardedContent, }); log.info('Agent message routed', { from: session.agent_group_id, to: targetAgentGroupId, targetSession: targetSession.id, + a2aMsgId, + forwardedFileCount: countForwardedFiles(forwardedContent), }); const fresh = getSession(targetSession.id); if (fresh) await wakeContainer(fresh); } + +/** + * Parse source content, copy any referenced `files` from source outbox to + * target inbox, and return a JSON string with an `attachments` array added + * (formatter.ts:223 already knows how to render this shape). + * + * If the source content isn't JSON or has no files, returns the original + * content string unchanged — this is safe to call on every route. + */ +function forwardFileAttachments( + msg: RoutableAgentMessage, + a2aMsgId: string, + sourceSession: Session, + targetAgentGroupId: string, + targetSessionId: string, +): string { + let parsed: Record; + try { + parsed = JSON.parse(msg.content); + } catch { + return msg.content; + } + const files = parsed.files as unknown; + if (!Array.isArray(files) || files.length === 0) return msg.content; + const filenames = files.filter((f): f is string => typeof f === 'string'); + if (filenames.length === 0) return msg.content; + + const attachments = forwardAttachedFiles( + { + agentGroupId: sourceSession.agent_group_id, + sessionId: sourceSession.id, + messageId: msg.id, + filenames, + }, + { + agentGroupId: targetAgentGroupId, + sessionId: targetSessionId, + messageId: a2aMsgId, + }, + ); + + // Merge into any existing `attachments` (unlikely in a2a context but safe). + const existing = Array.isArray(parsed.attachments) ? (parsed.attachments as Record[]) : []; + parsed.attachments = [...existing, ...attachments]; + + return JSON.stringify(parsed); +} + +function countForwardedFiles(contentStr: string): number { + try { + const parsed = JSON.parse(contentStr); + return Array.isArray(parsed.attachments) ? parsed.attachments.length : 0; + } catch { + return 0; + } +} From fd03b893336397728a09fa850473d001432e4063 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 24 Apr 2026 15:44:19 +1000 Subject: [PATCH 171/185] fix(agent-route): reject unsafe attachment filenames to prevent path traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filenames in forwardAttachedFiles arrived from the source agent's messages_out content and were used directly in path.join on both source outbox read and target inbox write. A value like `../evil.sh` could escape `inbox//` on the target session (and similarly the source outbox on read), breaking session isolation — an adversarial or hallucinating sub-agent could overwrite files in a sibling session. Adds isSafeAttachmentName(name) — exported so it's unit-testable — which rejects empty, `.`, `..`, anything containing `/`, `\`, or NUL, and anything path.basename would strip. Guard runs before any I/O. Unsafe names are dropped with a warning log, same pattern as missing-source-file handling; a bad filename in one attachment doesn't kill the whole route's text delivery. Addresses Codex Review P1 on qwibitai/nanoclaw#1967. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agent-to-agent/agent-route.test.ts | 46 +++++++++++++++++++ src/modules/agent-to-agent/agent-route.ts | 33 +++++++++++-- 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 src/modules/agent-to-agent/agent-route.test.ts diff --git a/src/modules/agent-to-agent/agent-route.test.ts b/src/modules/agent-to-agent/agent-route.test.ts new file mode 100644 index 0000000..4d48f6f --- /dev/null +++ b/src/modules/agent-to-agent/agent-route.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { isSafeAttachmentName } from './agent-route.js'; + +/** + * `forwardAttachedFiles` has a filesystem side that's awkward to unit-test + * without mocking DATA_DIR. The guarantee worth pinning is that the + * filename validator rejects everything that could escape the inbox dir — + * `forwardAttachedFiles` runs this guard before any I/O, so traversal is + * impossible as long as this matrix holds. + */ +describe('isSafeAttachmentName', () => { + it('accepts plain filenames', () => { + expect(isSafeAttachmentName('baby-duck.png')).toBe(true); + expect(isSafeAttachmentName('file with spaces.pdf')).toBe(true); + expect(isSafeAttachmentName('report.v2.docx')).toBe(true); + expect(isSafeAttachmentName('.hidden')).toBe(true); // leading dot is fine, just not `.` / `..` + }); + + it('rejects empty / sentinel values', () => { + expect(isSafeAttachmentName('')).toBe(false); + expect(isSafeAttachmentName('.')).toBe(false); + expect(isSafeAttachmentName('..')).toBe(false); + }); + + it('rejects path separators', () => { + expect(isSafeAttachmentName('../evil.png')).toBe(false); + expect(isSafeAttachmentName('/etc/passwd')).toBe(false); + expect(isSafeAttachmentName('nested/file.txt')).toBe(false); + expect(isSafeAttachmentName('windows\\path.exe')).toBe(false); + }); + + it('rejects NUL bytes', () => { + expect(isSafeAttachmentName('clean\0.png')).toBe(false); + }); + + it('rejects anything path.basename would strip', () => { + expect(isSafeAttachmentName('a/b')).toBe(false); + expect(isSafeAttachmentName('./thing')).toBe(false); + }); + + it('rejects non-string input', () => { + expect(isSafeAttachmentName(null as unknown as string)).toBe(false); + expect(isSafeAttachmentName(undefined as unknown as string)).toBe(false); + }); +}); diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index faec2b9..812cb8e 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -36,6 +36,26 @@ export interface ForwardedAttachment { localPath: string; } +/** + * Is `name` safe to use as the last segment of a path inside the target + * agent's inbox directory? Filenames arrive in messages_out content from + * the source agent — under a multi-agent setup with heterogenous providers + * (or a compromised / hallucinating sub-agent) they can't be trusted. + * + * Rejects: + * - empty string + * - `.` / `..` (traversal sentinels that path.basename returns as-is) + * - anything containing a path separator (`/` or `\`) or NUL + * - any value where `path.basename(name) !== name`, catching OS-specific + * separators and covering drives/prefixes on Windows runtimes + */ +export function isSafeAttachmentName(name: string): boolean { + if (typeof name !== 'string' || name.length === 0) return false; + if (name === '.' || name === '..') return false; + if (/[\\/\0]/.test(name)) return false; + return path.basename(name) === name; +} + /** * Copy file attachments from the source agent's outbox into the target * agent's inbox. Returns attachments using the formatter's existing @@ -43,9 +63,9 @@ export interface ForwardedAttachment { * as relative to `/workspace/`, matching how channel-inbound attachments * are surfaced today. * - * Missing source files are skipped with a warning rather than failing - * the whole route — a bad filename reference shouldn't kill the - * accompanying text. + * Missing source files and unsafe (path-traversal) filenames are skipped + * with a warning rather than failing the whole route — a bad filename + * reference shouldn't kill the accompanying text. */ export function forwardAttachedFiles( source: { agentGroupId: string; sessionId: string; messageId: string; filenames: string[] }, @@ -67,6 +87,13 @@ export function forwardAttachedFiles( const attachments: ForwardedAttachment[] = []; for (const filename of source.filenames) { + if (!isSafeAttachmentName(filename)) { + log.warn('agent-route: rejecting unsafe attachment filename (path traversal attempt?)', { + sourceMsgId: source.messageId, + filename, + }); + continue; + } const src = path.join(sourceDir, filename); if (!fs.existsSync(src)) { log.warn('agent-route: referenced file missing in source outbox, skipped', { From f41c1620091185a65239e2169c1d402a731a916d Mon Sep 17 00:00:00 2001 From: Pankaj Garg Date: Fri, 24 Apr 2026 08:42:10 +0200 Subject: [PATCH 172/185] detect setup auth ping failures --- setup/lib/agent-ping.test.ts | 30 ++++++++++++++++++++++++++++++ setup/lib/agent-ping.ts | 24 ++++++++++++++++++++---- setup/verify.ts | 2 +- 3 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 setup/lib/agent-ping.test.ts diff --git a/setup/lib/agent-ping.test.ts b/setup/lib/agent-ping.test.ts new file mode 100644 index 0000000..5f2be2c --- /dev/null +++ b/setup/lib/agent-ping.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { classifyPingResult } from './agent-ping.js'; + +describe('classifyPingResult', () => { + it('treats a normal text reply as ok', () => { + expect(classifyPingResult(0, 'pong\n')).toBe('ok'); + }); + + it('detects Anthropic auth errors printed as a chat reply', () => { + expect( + classifyPingResult( + 0, + 'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token"}}', + ), + ).toBe('auth_error'); + }); + + it('detects auth errors on stderr too', () => { + expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error'); + }); + + it('preserves socket errors', () => { + expect(classifyPingResult(2, '')).toBe('socket_error'); + }); + + it('treats empty output as no reply', () => { + expect(classifyPingResult(0, '')).toBe('no_reply'); + }); +}); diff --git a/setup/lib/agent-ping.ts b/setup/lib/agent-ping.ts index 8c5127f..49c5fe2 100644 --- a/setup/lib/agent-ping.ts +++ b/setup/lib/agent-ping.ts @@ -13,7 +13,21 @@ */ import { spawn } from 'child_process'; -export type PingResult = 'ok' | 'no_reply' | 'socket_error'; +export type PingResult = 'ok' | 'no_reply' | 'socket_error' | 'auth_error'; + +export function classifyPingResult(exitCode: number | null, stdout: string, stderr = ''): PingResult { + const output = `${stdout}\n${stderr}`; + if ( + /Invalid bearer token/i.test(output) || + /authentication[_ ]error/i.test(output) || + /Failed to authenticate/i.test(output) + ) { + return 'auth_error'; + } + if (exitCode === 2) return 'socket_error'; + if (exitCode === 0 && stdout.trim().length > 0) return 'ok'; + return 'no_reply'; +} export function pingCliAgent(timeoutMs = 30_000): Promise { return new Promise((resolve) => { @@ -21,6 +35,7 @@ export function pingCliAgent(timeoutMs = 30_000): Promise { stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; + let stderr = ''; let settled = false; const timer = setTimeout(() => { if (settled) return; @@ -32,13 +47,14 @@ export function pingCliAgent(timeoutMs = 30_000): Promise { child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf-8'); }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf-8'); + }); child.on('close', (code) => { if (settled) return; settled = true; clearTimeout(timer); - if (code === 2) resolve('socket_error'); - else if (code === 0 && stdout.trim().length > 0) resolve('ok'); - else resolve('no_reply'); + resolve(classifyPingResult(code, stdout, stderr)); }); child.on('error', () => { if (settled) return; diff --git a/setup/verify.ts b/setup/verify.ts index 281b243..873af66 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -220,7 +220,7 @@ export async function run(_args: string[]): Promise { // 7. End-to-end: ping the CLI agent and confirm it replies. Only run if // everything upstream looks healthy, since a broken socket would just hang. - let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'skipped' = 'skipped'; + let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'auth_error' | 'skipped' = 'skipped'; if (service === 'running' && registeredGroups > 0) { log.info('Pinging CLI agent'); agentPing = await pingCliAgent(); From 1de5a0356bd5fddd6f36eb8470316883e297a238 Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 11:44:35 +0200 Subject: [PATCH 173/185] fix(setup): accept CLI-only verify success --- setup/verify.ts | 69 ++++++++++++++++++++++++------------------------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/setup/verify.ts b/setup/verify.ts index 281b243..4bfd3d0 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -14,14 +14,9 @@ import Database from 'better-sqlite3'; import { DATA_DIR } from '../src/config.js'; import { readEnvFile } from '../src/env.js'; import { log } from '../src/log.js'; -import { pingCliAgent } from './lib/agent-ping.js'; +import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; -import { - getPlatform, - getServiceManager, - hasSystemd, - isRoot, -} from './platform.js'; +import { getPlatform, getServiceManager, hasSystemd, isRoot } from './platform.js'; import { emitStatus } from './status.js'; export async function run(_args: string[]): Promise { @@ -38,11 +33,7 @@ export async function run(_args: string[]): Promise { // a sibling checkout (common for developers with multiple clones), this // repo's `data/cli.sock` won't exist — AGENT_PING would return a // misleading `socket_error`. Surface the mismatch directly instead. - let service: - | 'not_found' - | 'stopped' - | 'running' - | 'running_other_checkout' = 'not_found'; + let service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout' = 'not_found'; let runningFromPath: string | null = null; const mgr = getServiceManager(); @@ -74,10 +65,7 @@ export async function run(_args: string[]): Promise { execSync(`${prefix} is-active ${systemdUnit}`, { stdio: 'ignore' }); service = 'running'; try { - const pidStr = execSync( - `${prefix} show ${systemdUnit} -p MainPID --value`, - { encoding: 'utf-8' }, - ).trim(); + const pidStr = execSync(`${prefix} show ${systemdUnit} -p MainPID --value`, { encoding: 'utf-8' }).trim(); const pid = Number(pidStr); if (Number.isInteger(pid) && pid > 0) { runningFromPath = resolveBinaryScript(pid); @@ -115,11 +103,7 @@ export async function run(_args: string[]): Promise { } } - if ( - service === 'running' && - runningFromPath && - !isPathInside(runningFromPath, projectRoot) - ) { + if (service === 'running' && runningFromPath && !isPathInside(runningFromPath, projectRoot)) { service = 'running_other_checkout'; } @@ -210,11 +194,7 @@ export async function run(_args: string[]): Promise { // 6. Check mount allowlist let mountAllowlist = 'missing'; - if ( - fs.existsSync( - path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'), - ) - ) { + if (fs.existsSync(path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'))) { mountAllowlist = 'configured'; } @@ -227,15 +207,15 @@ export async function run(_args: string[]): Promise { log.info('Agent ping result', { agentPing }); } - // Determine overall status - const status = - service === 'running' && - credentials !== 'missing' && - anyChannelConfigured && - registeredGroups > 0 && - (agentPing === 'ok' || agentPing === 'skipped') - ? 'success' - : 'failed'; + // Determine overall status. A CLI-only install is valid when the local + // agent round-trip succeeds; messaging app credentials are optional. + const status = determineVerifyStatus({ + service, + credentials, + anyChannelConfigured, + registeredGroups, + agentPing, + }); log.info('Verification complete', { status, channelAuth }); @@ -255,6 +235,25 @@ export async function run(_args: string[]): Promise { if (status === 'failed') process.exit(1); } +export function determineVerifyStatus(input: { + service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout'; + credentials: string; + anyChannelConfigured: boolean; + registeredGroups: number; + agentPing: PingResult | 'skipped'; +}): 'success' | 'failed' { + const cliAgentResponds = input.agentPing === 'ok'; + const hasUsableChannel = input.anyChannelConfigured || cliAgentResponds; + + return input.service === 'running' && + input.credentials !== 'missing' && + hasUsableChannel && + input.registeredGroups > 0 && + (cliAgentResponds || input.agentPing === 'skipped') + ? 'success' + : 'failed'; +} + /** * Given a PID, resolve the script path the process is executing (i.e. the * first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any From 4fc2c4275cc41be6abf2d2d7ad51e7911dad4b08 Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 11:44:58 +0200 Subject: [PATCH 174/185] test(setup): cover CLI-only verify status --- setup/verify.test.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 setup/verify.test.ts diff --git a/setup/verify.test.ts b/setup/verify.test.ts new file mode 100644 index 0000000..1e09acd --- /dev/null +++ b/setup/verify.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { determineVerifyStatus } from './verify.js'; + +const healthyBase = { + service: 'running' as const, + credentials: 'configured', + anyChannelConfigured: false, + registeredGroups: 1, + agentPing: 'ok' as const, +}; + +describe('determineVerifyStatus', () => { + it('accepts a working CLI-only install', () => { + expect(determineVerifyStatus(healthyBase)).toBe('success'); + }); + + it('accepts a messaging-channel install when CLI ping is skipped', () => { + expect( + determineVerifyStatus({ + ...healthyBase, + anyChannelConfigured: true, + agentPing: 'skipped', + }), + ).toBe('success'); + }); + + it('fails when neither CLI nor messaging channels are usable', () => { + expect( + determineVerifyStatus({ + ...healthyBase, + agentPing: 'skipped', + }), + ).toBe('failed'); + }); + + it('fails when the CLI agent does not respond', () => { + expect( + determineVerifyStatus({ + ...healthyBase, + anyChannelConfigured: true, + agentPing: 'no_reply', + }), + ).toBe('failed'); + }); + + it('fails when no agent groups are registered', () => { + expect( + determineVerifyStatus({ + ...healthyBase, + registeredGroups: 0, + }), + ).toBe('failed'); + }); +}); From 9fd694c763d086253717567d1f624e68abc803c7 Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 11:49:04 +0200 Subject: [PATCH 175/185] chore(setup): minimize verify diff --- setup/verify.ts | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/setup/verify.ts b/setup/verify.ts index 4bfd3d0..dbd37e5 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -16,7 +16,12 @@ import { readEnvFile } from '../src/env.js'; import { log } from '../src/log.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; -import { getPlatform, getServiceManager, hasSystemd, isRoot } from './platform.js'; +import { + getPlatform, + getServiceManager, + hasSystemd, + isRoot, +} from './platform.js'; import { emitStatus } from './status.js'; export async function run(_args: string[]): Promise { @@ -33,7 +38,11 @@ export async function run(_args: string[]): Promise { // a sibling checkout (common for developers with multiple clones), this // repo's `data/cli.sock` won't exist — AGENT_PING would return a // misleading `socket_error`. Surface the mismatch directly instead. - let service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout' = 'not_found'; + let service: + | 'not_found' + | 'stopped' + | 'running' + | 'running_other_checkout' = 'not_found'; let runningFromPath: string | null = null; const mgr = getServiceManager(); @@ -65,7 +74,10 @@ export async function run(_args: string[]): Promise { execSync(`${prefix} is-active ${systemdUnit}`, { stdio: 'ignore' }); service = 'running'; try { - const pidStr = execSync(`${prefix} show ${systemdUnit} -p MainPID --value`, { encoding: 'utf-8' }).trim(); + const pidStr = execSync( + `${prefix} show ${systemdUnit} -p MainPID --value`, + { encoding: 'utf-8' }, + ).trim(); const pid = Number(pidStr); if (Number.isInteger(pid) && pid > 0) { runningFromPath = resolveBinaryScript(pid); @@ -103,7 +115,11 @@ export async function run(_args: string[]): Promise { } } - if (service === 'running' && runningFromPath && !isPathInside(runningFromPath, projectRoot)) { + if ( + service === 'running' && + runningFromPath && + !isPathInside(runningFromPath, projectRoot) + ) { service = 'running_other_checkout'; } @@ -194,7 +210,11 @@ export async function run(_args: string[]): Promise { // 6. Check mount allowlist let mountAllowlist = 'missing'; - if (fs.existsSync(path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'))) { + if ( + fs.existsSync( + path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'), + ) + ) { mountAllowlist = 'configured'; } From 3d6837c411133227a4de7a5ae4b347c275d5fbcd Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 12:12:05 +0200 Subject: [PATCH 176/185] chore(format): apply prettier to chat-sdk-bridge.ts Two long-line violations introduced in d121cd1 (isGroup plumbing) exceed the printWidth limit. CI format:check fails on every PR opened against main until this is fixed; the fix is isolated here so no behavior change is mixed in. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/chat-sdk-bridge.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index c8cf3cc..18ab2cb 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -125,7 +125,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter let setupConfig: ChannelSetup; let gatewayAbort: AbortController | null = null; - async function messageToInbound(message: ChatMessage, isMention: boolean, isGroup?: boolean): Promise { + async function messageToInbound( + message: ChatMessage, + isMention: boolean, + isGroup?: boolean, + ): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const serialized = message.toJSON() as Record; @@ -216,7 +220,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // wirings still fire on in-thread mentions. chat.onSubscribedMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true, true)); + await setupConfig.onInbound( + channelId, + thread.id, + await messageToInbound(message, message.isMention === true, true), + ); }); // @mention in an unsubscribed thread — SDK-confirmed bot mention. From 2b51a4e7076d154b389499afb1df011cbe1e8123 Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 12:50:25 +0200 Subject: [PATCH 177/185] fix(workflows): label PRs from forks that follow the contributing template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fork PR, GITHUB_TOKEN is demoted to read-only regardless of the workflow's permissions: block, so issues.addLabels() returns 403. The label workflow silently works for PRs that skip the template (no checkboxes ticked → no API call) and fails for PRs that actually follow it — a hostile incentive against contributors who do the right thing. pull_request_target runs in the context of the base branch with full declared permissions, which is the documented fix for this case. Safe here because the workflow is metadata-only: it reads context.payload.pull_request.body and calls addLabels. No checkout, no PR-supplied code executes. A SECURITY comment is added above the trigger to keep it that way. Refs: - https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_target - https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/label-pr.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index bec9d3e..ebfe3f3 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -1,7 +1,12 @@ name: Label PR +# SECURITY: this workflow runs with write access to the base repo on fork PRs, +# because `pull_request_target` executes in the context of the base branch. +# Keep it metadata-only — do NOT add actions/checkout or any step that +# executes PR-supplied content (install scripts, build commands, etc.). +# See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ on: - pull_request: + pull_request_target: types: [opened, edited] jobs: From 5cbfccec05ef4fd078a8a0188e2d67c485d76c6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 12:25:45 +0000 Subject: [PATCH 178/185] chore: bump version to 2.0.11 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 20afddb..5454aa4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.10", + "version": "2.0.11", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From f37e7753589b44dffe0aaf3f5d10e56f6cc091b3 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 24 Apr 2026 16:30:14 +0300 Subject: [PATCH 179/185] Revert src changes; skill applies them at install time Phase 2 of the SKILL.md already contains the Dockerfile + TOOL_ALLOWLIST edit instructions with an "ALREADY APPLIED" short-circuit. Keeping those edits out of trunk means users who never run /add-gmail-tool don't carry the Gmail MCP package in their image. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/Dockerfile | 6 ------ container/agent-runner/src/providers/claude.ts | 1 - 2 files changed, 7 deletions(-) diff --git a/container/Dockerfile b/container/Dockerfile index 8c296ea..4b4cf22 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -23,7 +23,6 @@ ARG CLAUDE_CODE_VERSION=2.1.116 ARG AGENT_BROWSER_VERSION=latest ARG VERCEL_VERSION=latest ARG BUN_VERSION=1.3.12 -ARG GMAIL_MCP_VERSION=1.1.11 # ---- System dependencies ----------------------------------------------------- # tini: correct PID 1 / signal forwarding so outbound.db writes finalize on @@ -105,11 +104,6 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \ RUN --mount=type=cache,target=/root/.cache/pnpm \ pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" -RUN --mount=type=cache,target=/root/.cache/pnpm \ - pnpm install -g \ - "@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \ - "zod-to-json-schema@3.22.5" - # ---- Entrypoint -------------------------------------------------------------- COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 0ba0919..fbb077c 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -55,7 +55,6 @@ const TOOL_ALLOWLIST = [ 'Skill', 'NotebookEdit', 'mcp__nanoclaw__*', - 'mcp__gmail__*', ]; interface SDKUserMessage { From 52f8661f0cb172c953b6c361dc963c68d4d8c417 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 24 Apr 2026 13:35:49 +0000 Subject: [PATCH 180/185] docs(providers): note that container.json provider is what the runner reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upstream precedence fix (5845a5a) made agent_groups.agent_provider and sessions.agent_provider authoritative for host-side provider contribution (per-session mount, env passthrough), but those DB values don't propagate into the group's container.json — and the in-container runner reads `provider` from container.json, not from the DB. That caused a confusing failure mode: flipping the DB column to 'codex', rebuilding, and restarting still spawned a Claude runner because container.json had no provider field. The old skill wording ("container receives AGENT_PROVIDER from the resolved value") overstated the integration. Update add-codex and add-opencode "Per group / per session" sections to say: set `"provider": ""` in the group's container.json — that's the source the runner reads. Keep the DB columns documented for the host-side contribution they actually drive, and spell out the session → group → container.json → 'claude' fallback so the precedence is still discoverable. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-codex/SKILL.md | 2 +- .claude/skills/add-opencode/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/add-codex/SKILL.md b/.claude/skills/add-codex/SKILL.md index 3411bae..14b3072 100644 --- a/.claude/skills/add-codex/SKILL.md +++ b/.claude/skills/add-codex/SKILL.md @@ -128,7 +128,7 @@ Codex also ships first-class local-runner flags — `codex --oss --local-provide ### Per group / per session -Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `codex` for groups or sessions that should use Codex. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group). +Set `"provider": "codex"` in the group's **`container.json`** (`groups//container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`. `CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group. diff --git a/.claude/skills/add-opencode/SKILL.md b/.claude/skills/add-opencode/SKILL.md index 08a558f..555f0fe 100644 --- a/.claude/skills/add-opencode/SKILL.md +++ b/.claude/skills/add-opencode/SKILL.md @@ -208,7 +208,7 @@ onecli secrets create --name "OpenCode Zen" --type generic \ ### Per group / per session -Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group). +Set `"provider": "opencode"` in the group's **`container.json`** (`groups//container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`. Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers. From 6d35c8512997e5639af0d1f1ac1d95313226caa4 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 24 Apr 2026 16:49:40 +0300 Subject: [PATCH 181/185] skill(add-gcal-tool): OneCLI-native Google Calendar MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /add-gcal-tool — a sibling of /add-gmail-tool that installs @cocal/google-calendar-mcp with the same OneCLI stub-file pattern. Skill applies the Dockerfile + TOOL_ALLOWLIST changes at install time; trunk stays clean so users who never run the skill don't carry the calendar MCP in their image. Dropped the Phase 5 dry-run section since it hardcoded a per-install image tag slug and duplicated Phase 4's live agent test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-gcal-tool/SKILL.md | 210 ++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 .claude/skills/add-gcal-tool/SKILL.md diff --git a/.claude/skills/add-gcal-tool/SKILL.md b/.claude/skills/add-gcal-tool/SKILL.md new file mode 100644 index 0000000..5751933 --- /dev/null +++ b/.claude/skills/add-gcal-tool/SKILL.md @@ -0,0 +1,210 @@ +--- +name: add-gcal-tool +description: Add Google Calendar as an MCP tool (list calendars, list/search/create events, free/busy queries) using OneCLI-managed OAuth. Multi-calendar and multi-account supported. Mirrors /add-gmail-tool's stub pattern — no raw credentials ever reach the container; OneCLI injects real tokens at request time. +--- + +# Add Google Calendar Tool (OneCLI-native) + +This skill wires [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `calendar.googleapis.com` / `oauth2.googleapis.com` and swaps the bearer for the real OAuth token from its vault. + +**Why this package (and not gongrzhe's):** `@gongrzhe/server-calendar-autoauth-mcp` only supports the `primary` calendar and exposes 5 tools (no `list_calendars`). `@cocal/google-calendar-mcp` explicitly supports multi-calendar and multi-account, and is actively maintained. + +Tools exposed (surfaced as `mcp__calendar__`, exact set depends on version — run `tools/list` against the MCP server to enumerate): `list-calendars`, `list-events`, `search-events`, `create-event`, `update-event`, `delete-event`, `get-event`, `list-colors`, `get-freebusy`, `get-current-time`, plus multi-account management tools. + +**Why this pattern:** v2's invariant is that containers never receive raw API keys (CHANGELOG 2.0.0). Same stub pattern `/add-gmail-tool` uses. This skill is deliberately a sibling, not a combined "Google Workspace" skill — installs independently and removes cleanly. + +## Phase 1: Pre-flight + +### Verify OneCLI has Google Calendar connected + +```bash +onecli apps get --provider google-calendar +``` + +Expected: `"connection": { "status": "connected" }` with scopes including `calendar.readonly` and `calendar.events`. + +If not connected, tell the user: + +> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Google Calendar, and click Connect. Sign in with the Google account the agent should act as. `calendar.readonly` + `calendar.events` are the minimum useful scopes. + +### Verify stub credentials exist + +The stub lives at `~/.calendar-mcp/` by convention (shared with `/add-gmail-tool`'s sibling). cocal doesn't default to this path (it uses `~/.config/google-calendar-mcp/tokens.json`) — we override via env vars below so it reads our stubs instead. + +```bash +ls -la ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json 2>&1 +``` + +If both exist with `onecli-managed`: + +```bash +grep -l onecli-managed ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json +``` + +...skip to Phase 2. If either file has real credentials (no `onecli-managed`), **STOP** — back up and delete before proceeding. + +If absent, write them: + +```bash +mkdir -p ~/.calendar-mcp +cat > ~/.calendar-mcp/gcp-oauth.keys.json <<'EOF' +{ + "installed": { + "client_id": "onecli-managed.apps.googleusercontent.com", + "client_secret": "onecli-managed", + "redirect_uris": ["http://localhost:3000/oauth2callback"] + } +} +EOF +cat > ~/.calendar-mcp/credentials.json <<'EOF' +{ + "access_token": "onecli-managed", + "refresh_token": "onecli-managed", + "token_type": "Bearer", + "expiry_date": 99999999999999, + "scope": "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events" +} +EOF +chmod 600 ~/.calendar-mcp/*.json +``` + +### Verify mount allowlist covers the path + +```bash +cat ~/.config/nanoclaw/mount-allowlist.json +``` + +`~/.calendar-mcp` must sit under an `allowedRoots` entry. + +### Check agent secret-mode + +For each target agent group, confirm OneCLI will inject the Google Calendar token: + +```bash +onecli agents list +``` + +`secretMode: all` is sufficient. If `selective`, explicitly assign the Calendar secret. + +## Phase 2: Apply Code Changes + +### Check if already applied + +```bash +grep -q 'CALENDAR_MCP_VERSION' container/Dockerfile && \ +grep -q "mcp__calendar__\*" container/agent-runner/src/providers/claude.ts && \ +echo "ALREADY APPLIED — skip to Phase 3" +``` + +### Add MCP server to Dockerfile + +Edit `container/Dockerfile`. Find the pinned-version ARG block and add: + +```dockerfile +ARG CALENDAR_MCP_VERSION=2.6.1 +``` + +If `/add-gmail-tool` has already been applied, the pnpm global-install block already exists with its `zod-to-json-schema@3.22.5` pin. Just append the calendar package — **the calendar-mcp uses `zod@4.x` and does NOT need that pin**, but it's harmless to share the block: + +```dockerfile +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g \ + "@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \ + "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}" \ + "zod-to-json-schema@3.22.5" +``` + +If `/add-gmail-tool` hasn't been applied, install Calendar standalone: + +```dockerfile +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}" +``` + +### Add tools to allowlist + +Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present). + +### Rebuild the container image + +```bash +./container/build.sh +``` + +## Phase 3: Wire Per-Agent-Group + +For each agent group, merge into `groups//container.json`: + +```jsonc +{ + "mcpServers": { + "calendar": { + "command": "google-calendar-mcp", + "args": [], + "env": { + "GOOGLE_OAUTH_CREDENTIALS": "/workspace/extra/.calendar-mcp/gcp-oauth.keys.json", + "GOOGLE_CALENDAR_MCP_TOKEN_PATH": "/workspace/extra/.calendar-mcp/credentials.json" + } + } + }, + "additionalMounts": [ + { + "hostPath": "/home//.calendar-mcp", + "containerPath": ".calendar-mcp", + "readonly": false + } + ] +} +``` + +Substitute `` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/`). + +**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, **merge, don't replace** — both entries coexist in `mcpServers` and `additionalMounts`. + +## Phase 4: Build and Restart + +```bash +pnpm run build +systemctl --user restart nanoclaw # Linux +# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` + +Kill any existing agent containers so they respawn with the new mcpServers config: + +```bash +docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill +``` + +## Phase 5: Verify + +### Test from a wired agent + +> Send: **"list my calendars"** or **"what's on my work calendar next Monday?"**. +> +> First call takes 2–3s while the MCP server starts and OneCLI does the token exchange. + +### Check logs if the tool isn't working + +```bash +tail -100 logs/nanoclaw.log | grep -iE 'calendar|mcp' +``` + +Common signals: +- `command not found: google-calendar-mcp` → image not rebuilt. +- `ENOENT ...credentials.json` → mount missing. Check the mount allowlist. +- `401 Unauthorized` from `*.googleapis.com` → OneCLI isn't injecting; verify agent's secret mode and that Google Calendar is connected. +- Agent says "I don't have calendar tools" → `mcp__calendar__*` missing from `TOOL_ALLOWLIST`, or image cache stale (`./container/build.sh` again). + +## Removal + +1. Delete `"calendar"` from `mcpServers` and the `.calendar-mcp` mount from `additionalMounts` in each group's `container.json`. +2. Remove `'mcp__calendar__*'` from `TOOL_ALLOWLIST`. +3. Remove `CALENDAR_MCP_VERSION` ARG and the calendar package from the Dockerfile install block. +4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`. +5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`. + +## Credits & references + +- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar. +- **Why not gongrzhe:** earlier versions of this skill used `@gongrzhe/server-calendar-autoauth-mcp@1.0.2` which only supports the primary calendar with 5 event-level tools. The cocal server supersedes it. +- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism. From fc375ca72b28ce2582e9ea5a0de492d1cef04a5a Mon Sep 17 00:00:00 2001 From: grtwrn Date: Thu, 23 Apr 2026 21:04:15 -0400 Subject: [PATCH 182/185] fix(register): wire channels with correct engage fields, skip prefix for native IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup/register.ts had two bugs that prevented new channels from being registered via `/manage-channels`: 1. createMessagingGroupAgent was called with the legacy field names `trigger_rules` and `response_scope`. The SQL INSERT expects `engage_mode` / `engage_pattern` / `sender_scope` / `ignored_message_policy` (migration 010). Every register call failed with `RangeError: Missing named parameter "engage_mode"` after the agent and messaging group were partially created — leaving an orphaned pair. Now mirrors scripts/init-first-agent.ts:wireIfMissing: - Groups (is_group=1) default to engage_mode='mention' (bot only responds when addressed). - DMs (is_group=0) default to engage_mode='pattern' with '.' (respond to every message). - An explicit --trigger overrides the pattern regex. 2. The "normalize platform_id" block unconditionally prefixed ":" even for native IDs like WhatsApp JIDs ("120363408974444974@g.us"), iMessage emails ("user@example.com"), or Signal phones ("+15551234567") / Signal groups ("group:abc"). But the router (src/router.ts:158) looks up messaging_groups by the raw event.platformId from the adapter, which for these native adapters never has a prefix. So the prefixed row was never matched — the message was silently dropped with no "Message routed" log. Extracted scripts/init-first-agent.ts:namespacedPlatformId into src/platform-id.ts so both setup paths use the same heuristic (skip the prefix for IDs containing '@', starting with '+', or starting with 'group:'). Prevents future drift between the two paths. Tested by: re-running `setup/index.ts --step register` for a WhatsApp group JID, confirming the row is created with correct engage fields and matching platform_id, then sending a test message and observing "Message routed" with the right agent group. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/init-first-agent.ts | 27 +-------------------------- setup/register.ts | 22 +++++++++++++--------- src/platform-id.ts | 23 +++++++++++++++++++++++ 3 files changed, 37 insertions(+), 35 deletions(-) create mode 100644 src/platform-id.ts diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index fc61b9c..61a17d6 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -48,6 +48,7 @@ import { addMember } from '../src/modules/permissions/db/agent-group-members.js' import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js'; import { upsertUser } from '../src/modules/permissions/db/users.js'; import { initGroupFilesystem } from '../src/group-init.js'; +import { namespacedPlatformId } from '../src/platform-id.js'; import type { AgentGroup, MessagingGroup } from '../src/types.js'; type Role = 'owner' | 'admin' | 'member'; @@ -137,32 +138,6 @@ function namespacedUserId(channel: string, raw: string): string { return raw.includes(':') ? raw : `${channel}:${raw}`; } -/** - * Determine whether a platform ID needs a channel-type prefix. - * - * Chat SDK adapters (Telegram, Discord, Slack, Teams, etc.) namespace their - * platform IDs with a channel prefix: "telegram:123456", "discord:guild:chan". - * The router stores `channel_type` and `platform_id` in separate columns, but - * Chat SDK adapters send the prefixed form as the platform_id, so this script - * must match that format. - * - * Native adapters (Signal, WhatsApp) use their own ID formats and send them - * as-is — no channel prefix. Signal sends raw phone numbers (+15551234567) - * for DMs and "group:" for group chats. WhatsApp sends JIDs containing - * '@' (@s.whatsapp.net, @g.us). Prefixing these would cause - * a mismatch between what the adapter sends and what the DB stores, breaking - * message routing. - */ -function namespacedPlatformId(channel: string, raw: string): string { - if (raw.startsWith(`${channel}:`)) return raw; - // Native WhatsApp JIDs contain '@' — no prefix needed. - if (raw.includes('@')) return raw; - // Native Signal IDs: phone numbers (+...) and group IDs (group:...). - if (raw.startsWith('+') || raw.startsWith('group:')) return raw; - // Chat SDK adapters — add the channel prefix. - return `${channel}:${raw}`; -} - function generateId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } diff --git a/setup/register.ts b/setup/register.ts index ff194fc..7bd5ae3 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -20,6 +20,7 @@ import { import { isValidGroupFolder } from '../src/group-folder.js'; import { initGroupFilesystem } from '../src/group-init.js'; import { log } from '../src/log.js'; +import { namespacedPlatformId } from '../src/platform-id.js'; import { resolveSession, writeSessionMessage } from '../src/session-manager.js'; import { emitStatus } from './status.js'; @@ -112,12 +113,10 @@ export async function run(args: string[]): Promise { process.exit(4); } - // Chat SDK adapters prefix platform IDs with the channel type - // (e.g. "telegram:123", "discord:guild:channel"). Normalize here so - // the stored ID always matches what the adapter sends at runtime. - if (!parsed.platformId.startsWith(`${parsed.channel}:`)) { - parsed.platformId = `${parsed.channel}:${parsed.platformId}`; - } + // Normalize platform_id to the same shape the adapter will emit at runtime, + // so the router's (channel_type, platform_id) lookup matches what we store. + // Chat SDK adapters prefix, native adapters (WhatsApp/iMessage/Signal) don't. + parsed.platformId = namespacedPlatformId(parsed.channel, parsed.platformId); log.info('Registering channel', parsed); @@ -167,8 +166,13 @@ export async function run(args: string[]): Promise { if (!existing) { newlyWired = true; const mgaId = generateId('mga'); - const engageMode = parsed.trigger || !parsed.requiresTrigger ? 'pattern' : 'mention'; - const engagePattern = parsed.trigger ? parsed.trigger : (!parsed.requiresTrigger ? '.' : null); + // Mirrors scripts/init-first-agent.ts:wireIfMissing so both setup paths + // create rows with the same shape. Groups default to 'mention' (bot only + // responds when addressed); DMs default to 'pattern'/'.' (respond to + // every message). An explicit --trigger overrides the pattern regex. + const isGroup = messagingGroup.is_group === 1; + const engageMode: 'pattern' | 'mention' = isGroup && !parsed.trigger ? 'mention' : 'pattern'; + const engagePattern: string | null = engageMode === 'pattern' ? parsed.trigger || '.' : null; createMessagingGroupAgent({ id: mgaId, messaging_group_id: messagingGroup.id, @@ -177,7 +181,7 @@ export async function run(args: string[]): Promise { engage_pattern: engagePattern, sender_scope: 'all', ignored_message_policy: 'drop', - session_mode: parsed.sessionMode, + session_mode: parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared', priority: 0, created_at: new Date().toISOString(), }); diff --git a/src/platform-id.ts b/src/platform-id.ts new file mode 100644 index 0000000..1c49325 --- /dev/null +++ b/src/platform-id.ts @@ -0,0 +1,23 @@ +/** + * Determine whether a platform ID needs a channel-type prefix. + * + * Chat SDK adapters (Telegram, Discord, Slack, Teams, etc.) namespace their + * platform IDs with a channel prefix: "telegram:123456", "discord:guild:chan". + * The router stores channel_type and platform_id in separate columns, but + * Chat SDK adapters send the prefixed form as the platform_id — so any code + * that writes messaging_groups rows must produce the same shape the adapter + * will later emit as event.platformId, or router lookups miss and messages + * get silently dropped. + * + * Native adapters (Signal, WhatsApp, iMessage) use their own ID formats and + * send them as-is — no channel prefix. WhatsApp/iMessage emit JIDs/emails + * containing '@'. Signal emits raw phone numbers ('+15551234567') for DMs + * and 'group:' for group chats. Prefixing any of these would cause a + * mismatch with what the adapter later emits. + */ +export function namespacedPlatformId(channel: string, raw: string): string { + if (raw.startsWith(`${channel}:`)) return raw; + if (raw.includes('@')) return raw; + if (raw.startsWith('+') || raw.startsWith('group:')) return raw; + return `${channel}:${raw}`; +} From 226fc9379595b97eb1746200bffc9ed396ca0ade Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 14:13:32 +0000 Subject: [PATCH 183/185] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?32k=20tokens=20=C2=B7=2066%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index fd8a436..0dfb9a2 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 130k tokens, 65% of context window + + 132k tokens, 66% of context window @@ -15,8 +15,8 @@ tokens - - 130k + + 132k From 15a6950b5b74f65afe3f86c85882323204369d8a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 14:13:34 +0000 Subject: [PATCH 184/185] chore: bump version to 2.0.12 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5454aa4..c3a3d85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.11", + "version": "2.0.12", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 8d8522202a0604d187f9da132c59f386e3c489a9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 14:20:58 +0000 Subject: [PATCH 185/185] chore: bump version to 2.0.13 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c3a3d85..6029e0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.12", + "version": "2.0.13", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0",