From 0320e3fe26051be6a75a505b3e456bbb4944974e Mon Sep 17 00:00:00 2001 From: ingyukoh Date: Thu, 26 Mar 2026 16:53:07 +0900 Subject: [PATCH 001/144] docs: add ingyukoh to contributors --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4038595..ab29b55 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -16,3 +16,4 @@ Thanks to everyone who has contributed to NanoClaw! - [flobo3](https://github.com/flobo3) — Flo - [edwinwzhe](https://github.com/edwinwzhe) — Edwin He - [scottgl9](https://github.com/scottgl9) — Scott Glover +- [ingyukoh](https://github.com/ingyukoh) — Ingyu Koh From 4743513018b4a568f87c5b2ace16404818164073 Mon Sep 17 00:00:00 2001 From: NanoClaw Date: Fri, 27 Mar 2026 15:01:38 +0000 Subject: [PATCH 002/144] docs: add PR hygiene check to CLAUDE.md and contributing guidelines Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 12 ++++++++++++ CONTRIBUTING.md | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c9c49ff..85418fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,18 @@ Four types of skills exist in NanoClaw. See [CONTRIBUTING.md](CONTRIBUTING.md) f Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, SKILL.md format rules, PR requirements, and the pre-submission checklist (searching for existing PRs/issues, testing, description format). +## PR Hygiene + +Before pushing or creating a PR, run these checks and show the output to the user for approval: + +```bash +git diff upstream/main --name-only HEAD +git diff upstream/main --stat HEAD +git log upstream/main..HEAD --oneline +``` + +If any personal files appear (CLAUDE.md, .claude/, personal configs, group data), remove them before pushing. Do not push until the user confirms the diff is clean. + ## Development Run commands directly—don't tell the user to run them. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a7816a..3c0e6d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,7 +123,8 @@ Test your contribution on a fresh clone before submitting. For skills, run the s 1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge. 2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone. -3. **Check the right box** in the PR template. Labels are auto-applied based on your selection: +3. **Check for personal files.** Before pushing, verify no personal files are in your diff (see PR Hygiene in CLAUDE.md). +4. **Check the right box** in the PR template. Labels are auto-applied based on your selection: | Checkbox | Label | |----------|-------| From 94689fcb36f0903c3e984662deeb0c6438ab7ab7 Mon Sep 17 00:00:00 2001 From: NanoClaw Date: Fri, 27 Mar 2026 22:04:29 +0000 Subject: [PATCH 003/144] docs: consolidate PR hygiene check from 3 commands to 2 Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 85418fb..7ae7555 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,6 @@ Before creating a PR, adding a skill, or preparing any contribution, you MUST re Before pushing or creating a PR, run these checks and show the output to the user for approval: ```bash -git diff upstream/main --name-only HEAD git diff upstream/main --stat HEAD git log upstream/main..HEAD --oneline ``` From ad507fa426ab1dcdda930a27f6def7b8055c482b Mon Sep 17 00:00:00 2001 From: NanoClaw Date: Fri, 27 Mar 2026 22:09:36 +0000 Subject: [PATCH 004/144] docs: clarify PR hygiene check wording Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7ae7555..e5b9b7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,14 +50,14 @@ Before creating a PR, adding a skill, or preparing any contribution, you MUST re ## PR Hygiene -Before pushing or creating a PR, run these checks and show the output to the user for approval: +Before pushing or creating a PR, run these checks: ```bash git diff upstream/main --stat HEAD git log upstream/main..HEAD --oneline ``` -If any personal files appear (CLAUDE.md, .claude/, personal configs, group data), remove them before pushing. Do not push until the user confirms the diff is clean. +Show the output and wait for approval before pushing. If any personal files appear (CLAUDE.md, .claude/, personal configs, group data), remove them first. ## Development From 5ed74c3a3fe55c3b5778c62fae678ee78899581e Mon Sep 17 00:00:00 2001 From: NanoClaw Date: Fri, 27 Mar 2026 22:52:05 +0000 Subject: [PATCH 005/144] docs: scope PR hygiene check to PR creation only, improve wording Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e5b9b7f..e662afd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,14 +50,14 @@ Before creating a PR, adding a skill, or preparing any contribution, you MUST re ## PR Hygiene -Before pushing or creating a PR, run these checks: +Before creating a PR, run these checks: ```bash git diff upstream/main --stat HEAD git log upstream/main..HEAD --oneline ``` -Show the output and wait for approval before pushing. If any personal files appear (CLAUDE.md, .claude/, personal configs, group data), remove them first. +Show the output and wait for approval. Installation-specific files (group files, .claude/settings.json, local configs) should not be included. ## Development From 0c420cffca123692d7d0d73934f2f410f1072c11 Mon Sep 17 00:00:00 2001 From: NanoClaw Date: Fri, 27 Mar 2026 23:56:06 +0000 Subject: [PATCH 006/144] docs: align contributing guidelines with updated PR hygiene wording Co-Authored-By: Claude Opus 4.6 (1M context) --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c0e6d5..413e542 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,7 +123,7 @@ Test your contribution on a fresh clone before submitting. For skills, run the s 1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge. 2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone. -3. **Check for personal files.** Before pushing, verify no personal files are in your diff (see PR Hygiene in CLAUDE.md). +3. **Check for installation-specific files.** Before creating a PR, verify no installation-specific files are in your diff (see PR Hygiene in CLAUDE.md). 4. **Check the right box** in the PR template. Labels are auto-applied based on your selection: | Checkbox | Label | From 06918f35e076435b6bcac795d5513724ee793b5b Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Mon, 20 Apr 2026 12:11:51 +0300 Subject: [PATCH 007/144] =?UTF-8?q?feat(channels):=20add=20Signal=20channe?= =?UTF-8?q?l=20adapter=20(v2)=20=E2=80=94=20skill=20and=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the /add-signal-v2 skill: a native Signal channel adapter wrapping signal-sdk (signal-cli under the hood). No bot API — NanoClaw registers as a full Signal account on a dedicated number or as a linked device. Features: text, group & DM routing, voice transcription via whisper.cpp, attachments, emoji reactions, @mention detection, quote-reply detection. Troubleshooting note updated: GroupV2 group ID lives at envelope.dataMessage.groupV2.id — not groupInfo.groupId (GroupV1/legacy). --- .claude/skills/add-signal-v2/SKILL.md | 338 ++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 .claude/skills/add-signal-v2/SKILL.md diff --git a/.claude/skills/add-signal-v2/SKILL.md b/.claude/skills/add-signal-v2/SKILL.md new file mode 100644 index 0000000..1cfd5da --- /dev/null +++ b/.claude/skills/add-signal-v2/SKILL.md @@ -0,0 +1,338 @@ +# Add Signal Channel (v2) + +Adds Signal messaging support to NanoClaw v2 using `signal-sdk` (a TypeScript +wrapper around `signal-cli`). Unlike Telegram/Discord, Signal has no bot API — +NanoClaw registers as a full Signal account on a dedicated phone number. + +**Two registration paths:** +- **New number (recommended):** Register a dedicated SIM or VoIP number as a + standalone Signal account. NanoClaw owns the number entirely. +- **Linked device:** Join an existing Signal account as a secondary device via + QR code. Simpler, but NanoClaw shares your personal number. + +Both paths are documented below. The new-number path is battle-tested. + +--- + +## Pre-flight + +Check if `src/channels/signal.ts` exists and the import is uncommented in +`src/channels/index.ts`. If both are in place, skip to Registration. + +## Install + +### 1. Check Java + +Java 17+ is required. Check: + +```bash +java -version +``` + +If missing: +- **RHEL/CentOS/Fedora:** `sudo dnf install -y java-17-openjdk` +- **Debian/Ubuntu:** `sudo apt-get install -y default-jre` +- **macOS:** `brew install --cask temurin@17` + +Java 17–25 all work. Java 25 (RHEL9 default) is confirmed working. + +### 2. Install signal-cli + +The `signal-sdk` npm package bundles signal-cli, but the bundled version is +often outdated. Install the latest standalone binary: + +```bash +SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])") +curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \ + | tar -xz -C ~/.local +ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli +signal-cli --version +``` + +> **Note:** The Linux native tarball extracts a single binary directly to +> `~/.local/signal-cli` (not into a subdirectory). The symlink above handles this. + +### 3. Install signal-sdk + +```bash +npm install signal-sdk +``` + +### 4. Enable the adapter + +Uncomment the Signal import in `src/channels/index.ts`: + +```typescript +import './signal.js'; +``` + +### 5. Build + +Always build with Node 22 (nvm): + +```bash +source ~/.nvm/nvm.sh && nvm use 22 && npm run build +``` + +--- + +## Path A: Register a new Signal number + +Use this if you have a dedicated SIM or VoIP number for NanoClaw. + +> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP +> providers are blocked even for voice calls. If registration fails with an auth +> error, try a different provider or a physical SIM. + +### Step 1: Request SMS verification + +Signal requires a CAPTCHA on first registration: + +1. Open `https://signalcaptchas.org/registration/generate.html` in a browser +2. Solve the captcha +3. Right-click the **"Open Signal"** button → **Copy Link** +4. The link starts with `signalcaptcha://...` + +```bash +SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ + signal-cli -u +YOURNUMBER register \ + --captcha "PASTE_CAPTCHA_TOKEN_HERE" +``` + +The captcha token is everything after `signalcaptcha://` in the copied link. + +### Step 2: Voice call fallback (VoIP numbers without SMS) + +If your number cannot receive SMS, wait ~60 seconds after the SMS request then +request a voice call: + +```bash +SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ + signal-cli -u +YOURNUMBER register --voice \ + --captcha "SAME_CAPTCHA_TOKEN" +``` + +Signal will call your number and read a 6-digit code. + +> The captcha token from Step 1 is reusable for the voice retry — no need to +> solve a new one. + +### Step 3: Verify + +```bash +SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ + signal-cli -u +YOURNUMBER verify CODE +``` + +No output = success. + +### Step 4: Set profile name (optional) + +> ⚠ signal-sdk holds an exclusive lock on `data/signal/` while nanoclaw is +> running. Stop the service before running signal-cli commands, then restart. + +```bash +systemctl --user stop nanoclaw +SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ + signal-cli -u +YOURNUMBER updateProfile --name "YourBotName" +systemctl --user start nanoclaw +``` + +To set an avatar too: +```bash +signal-cli -u +YOURNUMBER updateProfile --name "YourBotName" --avatar /path/to/avatar.jpg +``` + +--- + +## Path B: Link as secondary device + +Use this to join an existing Signal account as a secondary device. + +```bash +mkdir -p data/signal +export SIGNAL_CLI_CONFIG_PATH=$(pwd)/data/signal +npx signal-sdk link -n "NanoClaw" -a +YOURNUMBER +``` + +This prints a QR code. On your phone: **Settings → Linked Devices → Link New Device**. +Scan the code within ~30 seconds. + +--- + +## Configure environment + +Add to `.env`: + +```bash +SIGNAL_PHONE_NUMBER=+YOURNUMBER +``` + +--- + +## Wire to an agent + +### DMs + +After the service starts, **send any message** to the Signal number from your +personal Signal app. The router auto-creates a `messaging_groups` row. Then: + +```bash +sqlite3 data/v2.db \ + "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" +``` + +Pass the `id` and `platform_id` to `/init-first-agent` or wire manually. + +**Important:** DM `platform_id` is UUID-based, not phone-based: +- DM: `signal:3de71d7f-ffa3-437e-b4db-097534bccd46` (UUID of sender) +- Group: `signal:UwaIz6bc09Olg1pBr/XeBuQf6z3fyCZoNj/Y3Tpe3hI=` (base64 group ID) + +### Groups + +Add the Signal number to a group from your phone. Send any message — the router +auto-creates the group's `messaging_groups` row. Wire it: + +```bash +sqlite3 data/v2.db \ + "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" +``` + +Then insert the wiring (replace IDs): + +```bash +NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") +sqlite3 data/v2.db " +INSERT OR IGNORE INTO messaging_group_agents + (id, messaging_group_id, agent_group_id, session_mode, priority, created_at) +VALUES + ('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW'); +" +``` + +Use `session_mode='isolated'` for groups so each group has its own session. + +### Grant user access + +Users who message via Signal need to be granted membership. Without this, +messages are silently dropped with `not_member`. After first contact: + +```bash +NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") +sqlite3 data/v2.db " +INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) + VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW'); +INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at) + VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW'); +" +``` + +Find the UUID from `messaging_groups.platform_id` or the `users` table. + +--- + +## Voice Transcription (optional) + +Inbound voice messages are automatically transcribed using a local whisper.cpp +binary. The feature degrades gracefully — if whisper-cli or ffmpeg is missing, +voice messages are still delivered as attachments with no transcript. + +### Install whisper.cpp on Linux + +```bash +# Build from source (requires git, cmake, make, gcc) +git clone https://github.com/ggerganov/whisper.cpp +cd whisper.cpp +cmake -B build && cmake --build build --config Release -j$(nproc) +sudo cp build/bin/whisper-cli /usr/local/bin/whisper-cli +``` + +Or install ffmpeg + Python openai-whisper (slower but easier): +```bash +sudo dnf install -y ffmpeg # or: sudo apt install ffmpeg +pip3 install openai-whisper +# then set WHISPER_BIN=whisper and WHISPER_MODEL=base in .env +``` + +On macOS: `brew install whisper-cpp ffmpeg` + +### Download a model + +```bash +mkdir -p data/models +curl -L -o data/models/ggml-base.bin \ + "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin" +``` + +Larger models trade speed for accuracy: `ggml-small.bin` (466 MB), `ggml-medium.bin` (1.5 GB). + +### Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary | +| `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file | + +### Verify + +```bash +ffmpeg -version >/dev/null && echo "ffmpeg OK" || echo "ffmpeg missing" +whisper-cli --version 2>/dev/null && echo "whisper-cli OK" || echo "whisper-cli missing" +ls data/models/ggml-*.bin 2>/dev/null || echo "no model — download one" +``` + +--- + +## Channel Info + +- **type**: `signal` +- **terminology**: "Chats" (1:1) and "groups" (multi-member). No threads. +- **supports-threads**: no +- **inbound**: text, reactions (forwarded to agent with emoji + targetTimestamp), images, files, voice (transcribed via whisper-cli if installed) +- **outbound**: text, reactions (sendReaction), file attachments +- **platform-id-format**: + - DM: `signal:{UUID}` — sender's Signal UUID, **not** their phone number + - Group: `signal:{base64GroupId}` +- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` + as shown above. +- **config-lock**: signal-sdk holds an exclusive lock on `data/signal/` while + nanoclaw is running. Stop the service before running any `signal-cli` commands. + +--- + +## Troubleshooting + +**`Config file is in use by another instance`** — nanoclaw is running and +signal-sdk has the lock. Stop the service, run the command, restart. + +**Messages dropped with `not_member`** — the Signal user hasn't been granted +membership. See "Grant user access" above. This affects every new Signal user, +including the owner's Signal identity (which is separate from their Telegram +identity even if it's the same person). + +**Captcha required** — Signal requires captcha for new registrations. Go to +`https://signalcaptchas.org/registration/generate.html`, solve it, right-click +"Open Signal", copy the link, extract the token after `signalcaptcha://`. + +**`Invalid verification method: Before requesting voice verification…`** — +You must request SMS first, wait ~60 seconds, then request voice. Both can use +the same captcha token. + +**`The provided model identifier is invalid` (Bedrock)** — unrelated to Signal; +this is a LiteLLM model ID issue. + +**Group replies going to DM instead of group** — modern Signal groups use +GroupV2. The adapter must extract the group ID from +`envelope?.dataMessage?.groupV2?.id` (not just `groupInfo?.groupId`, which is +GroupV1/legacy). Check `src/channels/signal.ts` and confirm the groupId +extraction falls through to `groupV2.id`. + +**Java not found** — install Java 17+ (see Install step 1). + +**QR code expired (Path B)** — QR codes expire in ~30 seconds. Re-run the +link command to generate a new one. + +**signal-cli binary location** — The native Linux tarball extracts directly to +`~/.local/signal-cli` (a single file, not a directory). The system `aws` or +other tools named `signal-cli` won't be in PATH by default; check +`~/.local/bin/signal-cli`. From bc0b55946101b9b2050cd451ff0181872d3cda6b Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Mon, 20 Apr 2026 12:27:10 +0300 Subject: [PATCH 008/144] docs(add-signal-v2): document XDG attachment path behaviour and voice fix signal-sdk launches signal-cli without --config, so attachments land at ~/.local/share/signal-cli/attachments/ (XDG default) rather than data/signal/. Document this in the Channel Info section and add a troubleshooting entry explaining the symptom (voice messages silently skipped, no transcript), how to confirm (ps aux | grep signal-cli), and the automatic fallback the adapter uses. --- .claude/skills/add-signal-v2/SKILL.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.claude/skills/add-signal-v2/SKILL.md b/.claude/skills/add-signal-v2/SKILL.md index 1cfd5da..e6b8165 100644 --- a/.claude/skills/add-signal-v2/SKILL.md +++ b/.claude/skills/add-signal-v2/SKILL.md @@ -297,6 +297,11 @@ ls data/models/ggml-*.bin 2>/dev/null || echo "no model — download one" as shown above. - **config-lock**: signal-sdk holds an exclusive lock on `data/signal/` while nanoclaw is running. Stop the service before running any `signal-cli` commands. +- **attachment storage**: signal-sdk launches signal-cli **without** a `--config` + flag, so signal-cli stores attachments at the XDG default + (`~/.local/share/signal-cli/attachments/`), not under `data/signal/`. The + adapter checks both locations. Verify with: + `ps aux | grep signal-cli` — if there is no `-c` argument, XDG default is in use. --- @@ -327,6 +332,15 @@ GroupV2. The adapter must extract the group ID from GroupV1/legacy). Check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`. +**Voice messages / attachments silently skipped, no transcript** — signal-sdk +launches signal-cli without a `--config` flag, so attachments land at the XDG +default (`~/.local/share/signal-cli/attachments/`) rather than under +`data/signal/`. Confirm with `ps aux | grep signal-cli` — if there is no `-c` +argument in the process line, the XDG default is in use. The adapter falls back +to that location automatically. If you still see no "Signal attachment saved" +log lines, add a debug log around the `if (!storedPath) continue` guard in +`src/channels/signal.ts` to inspect `att.storedFilename` and `att.id`. + **Java not found** — install Java 17+ (see Install step 1). **QR code expired (Path B)** — QR codes expire in ~30 seconds. Re-run the From ad97829151449a2667c790fc513aef3c5ec534f2 Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Mon, 20 Apr 2026 13:17:57 +0300 Subject: [PATCH 009/144] docs(add-signal-v2): replace inline voice section with reference to add-voice-transcription-free-whisper Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/add-signal-v2/SKILL.md | 49 +++------------------------ 1 file changed, 4 insertions(+), 45 deletions(-) diff --git a/.claude/skills/add-signal-v2/SKILL.md b/.claude/skills/add-signal-v2/SKILL.md index e6b8165..06edd17 100644 --- a/.claude/skills/add-signal-v2/SKILL.md +++ b/.claude/skills/add-signal-v2/SKILL.md @@ -233,53 +233,12 @@ Find the UUID from `messaging_groups.platform_id` or the `users` table. ## Voice Transcription (optional) -Inbound voice messages are automatically transcribed using a local whisper.cpp -binary. The feature degrades gracefully — if whisper-cli or ffmpeg is missing, +Inbound voice messages are automatically transcribed using a local whisper +binary. The feature degrades gracefully — if whisper or ffmpeg is missing, voice messages are still delivered as attachments with no transcript. -### Install whisper.cpp on Linux - -```bash -# Build from source (requires git, cmake, make, gcc) -git clone https://github.com/ggerganov/whisper.cpp -cd whisper.cpp -cmake -B build && cmake --build build --config Release -j$(nproc) -sudo cp build/bin/whisper-cli /usr/local/bin/whisper-cli -``` - -Or install ffmpeg + Python openai-whisper (slower but easier): -```bash -sudo dnf install -y ffmpeg # or: sudo apt install ffmpeg -pip3 install openai-whisper -# then set WHISPER_BIN=whisper and WHISPER_MODEL=base in .env -``` - -On macOS: `brew install whisper-cpp ffmpeg` - -### Download a model - -```bash -mkdir -p data/models -curl -L -o data/models/ggml-base.bin \ - "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin" -``` - -Larger models trade speed for accuracy: `ggml-small.bin` (466 MB), `ggml-medium.bin` (1.5 GB). - -### Environment variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary | -| `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file | - -### Verify - -```bash -ffmpeg -version >/dev/null && echo "ffmpeg OK" || echo "ffmpeg missing" -whisper-cli --version 2>/dev/null && echo "whisper-cli OK" || echo "whisper-cli missing" -ls data/models/ggml-*.bin 2>/dev/null || echo "no model — download one" -``` +See `/add-voice-transcription-free-whisper` for full setup instructions +(ffmpeg, whisper backends, model download, environment variables, troubleshooting). --- From 3ee7d2147e570fc77a50f8e1d89a09844db2a56d Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Thu, 23 Apr 2026 12:05:34 +0000 Subject: [PATCH 010/144] =?UTF-8?q?feat:=20add=20v1=20=E2=86=92=20v2=20mig?= =?UTF-8?q?ration=20to=20setup=20flow=20(experimental)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bash nanoclaw.sh` detects a v1 install before channel pairing and does a best-effort automated port of operationally important state. Hands off to a new `/migrate-from-v1` skill for owner seeding and fork customizations. Between the timezone and channel steps, `setup/auto.ts` calls `runMigrateV1()` which orchestrates these registered sub-steps (each a separate entry in the progression log with its own raw log + status block — failures never abort the chain): - **migrate-detect** — scans siblings of the v2 checkout + common $HOME locations; `$NANOCLAW_V1_PATH` overrides authoritatively. Relaxed `package.json` check lets forks + partial installs still match; DB presence is the strongest signal. - **migrate-validate** — asserts v1 DB shape (tables + required columns); writes `schema-mismatch.json` on failure. Subsequent steps short-circuit their DB-dependent parts but still run. - **migrate-db** — seeds `agent_groups` + `messaging_groups` + `messaging_group_agents` from v1's `registered_groups`. JID decomposition (`dc:123` → `channel_type='discord'`, `platform_id='discord:123'`); `trigger_pattern` + `requires_trigger` → `engage_mode` + `engage_pattern` (mirrors migration 010 backfill). Users + user_roles are NOT seeded — the skill does that with an owner interview. Idempotent: existing rows reused, not duplicated. - **migrate-groups** — rsync group folders. v1 `CLAUDE.md` → v2 `CLAUDE.local.md` (v2 composes `CLAUDE.md` at container spawn); v1 `container_config` JSON → `.v1-container-config.json` sidecar for the skill to translate. Tight v1-pattern scan (`/workspace/ipc/tasks`, `store/messages.db`, `[PR_CONTEXT:`, etc.) flags files referencing v1-specific infrastructure — content is NOT modified, just flagged in the handoff. - **migrate-env** — merges v1 `.env` into v2 `.env`, never overwriting existing v2 keys. - **migrate-channel-auth** — per-channel registry tracks v1 env keys, v2 required keys (with source-of-key instructions — e.g. Discord needs `DISCORD_PUBLIC_KEY` which v1 never stored), and candidate on-disk auth state paths (Baileys keystore, matrix sync state, etc.). Missing required v2 keys surface as actionable followups and flip the step to `partial`. - **migrate-channels** — runs `setup/install-.sh` for each detected channel in non-interactive mode. Install-script output is captured to `logs/setup-migration/install-.log` sidecars (silent under the parent spinner). Channels with no v2 adapter get a `not_supported` followup but don't degrade status. - **migrate-tasks** — v1 `scheduled_tasks` → `messages_in` rows with `kind='task'` in each session's `inbound.db`. `schedule_type` mapping (cron / interval / once → v2 cron). Idempotent: skips v1 task ids already present. Inactive rows dumped to `inactive-tasks.json` for reference. Everything writes to `logs/setup-migration/handoff.json` — the source of truth the skill consumes. `.claude/skills/migrate-from-v1/SKILL.md`: - **Phase A** (always): owner seeding + v1 access policy flip (`unknown_sender_policy` public/strict) via `AskUserQuestion`. Pulls sender candidates from v1's `messages` table as hints. - **Phase B** (if followups exist): walks `handoff.followups` — translates `.v1-container-config.json` sidecars, handles `not_supported` channels, fills in missing required keys with instructions on where to get them. - **Phase C** (fork-aware): `git log ..HEAD` in v1. Empty → "no customizations to port." Non-empty → scope choice (mechanical / full interview / reference-only). Portable categories (`container/skills/*`, `.claude/skills/*`, docs) scan+copy with `scanForV1Patterns`. Non-portable (`src/*`, `container/agent-runner/src/*`) stash to `docs/v1-fork-reference/` — explicit "don't translate v1 infra to v2" warning because v1's IPC file queue / single DB don't exist in v2. Clearly marked in README, CLAUDE.md, SKILL.md header, and via a `p.warn` that fires once per run when v1 is detected. Users with no v1 install see a silent skip — no prompts, no noise. Verified end-to-end against a live v1 install (300 discord + 1 discord-supervisor groups, fork with ~15 commits of PR-factory work): - Detect → validate → db (301 rows seeded) → groups (301 CLAUDE.local.md + 178 other files + 1 container_config sidecar) → env (4 keys copied) → channel-auth (flagged missing `DISCORD_APPLICATION_ID` + `DISCORD_PUBLIC_KEY`) → channels (discord installed, discord-supervisor → not_supported) → tasks (0 rows, skipped) - Idempotent re-run: 0 rows created, 903 rows reused; tasks skip if id already present - Fresh-user case: silent skip, no prompts, straight to "You're ready!" - Schema-mismatch case: recorded to `schema-mismatch.json`, chain continues - Unit tests for the pure transforms (`parseJid`, `inferChannelType`, `triggerToEngage`, `scanForV1Patterns`, `looksLikeV1Install`) - Validate `requiredV2Keys` for telegram/slack/matrix/teams/webex/ resend/linear against the actual Chat SDK packages (Discord was verified from real error output) - Widen candidate auth file paths for WhatsApp/Matrix/iMessage based on real non-Discord v1 installs once we have some See docs/v1-to-v2-changes.md for the v1 → v2 architecture diff. --- .claude/skills/migrate-from-v1/SKILL.md | 120 +++++ CHANGELOG.md | 4 + CLAUDE.md | 2 + README.md | 2 + docs/v1-to-v2-changes.md | 172 +++++++ setup/auto.ts | 16 +- setup/index.ts | 8 + setup/migrate-v1.ts | 257 ++++++++++ setup/migrate-v1/channel-auth.ts | 213 ++++++++ setup/migrate-v1/channels.ts | 172 +++++++ setup/migrate-v1/db.ts | 296 +++++++++++ setup/migrate-v1/detect.ts | 107 ++++ setup/migrate-v1/env.ts | 135 +++++ setup/migrate-v1/groups.ts | 230 +++++++++ setup/migrate-v1/shared.ts | 639 ++++++++++++++++++++++++ setup/migrate-v1/tasks.ts | 307 ++++++++++++ setup/migrate-v1/validate.ts | 213 ++++++++ 17 files changed, 2891 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/migrate-from-v1/SKILL.md create mode 100644 docs/v1-to-v2-changes.md create mode 100644 setup/migrate-v1.ts create mode 100644 setup/migrate-v1/channel-auth.ts create mode 100644 setup/migrate-v1/channels.ts create mode 100644 setup/migrate-v1/db.ts create mode 100644 setup/migrate-v1/detect.ts create mode 100644 setup/migrate-v1/env.ts create mode 100644 setup/migrate-v1/groups.ts create mode 100644 setup/migrate-v1/shared.ts create mode 100644 setup/migrate-v1/tasks.ts create mode 100644 setup/migrate-v1/validate.ts diff --git a/.claude/skills/migrate-from-v1/SKILL.md b/.claude/skills/migrate-from-v1/SKILL.md new file mode 100644 index 0000000..810ae03 --- /dev/null +++ b/.claude/skills/migrate-from-v1/SKILL.md @@ -0,0 +1,120 @@ +--- +name: migrate-from-v1 +description: Finish migrating a NanoClaw v1 install into v2. Run this after `bash nanoclaw.sh` has completed its automated migration step. Seeds the owner user, applies v1 access defaults, fixes any migration sub-step that didn't finish, and interviews the user about custom v1 code to port forward. Triggers on "migrate from v1", "finish migration", "v1 migration", or automatically after setup when `logs/setup-migration/handoff.json` exists. +--- + +# Migrate from v1 to v2 + +> ⚠️ **Experimental.** This skill and the setup migration step are early. Remind the user to back up `data/v2.db` + `groups/` before making destructive changes, and prefer small, reversible edits. Not recommended yet for high-stakes production installs. + +The setup flow's `migration` step (in `setup/migrate-v1.ts`) already ran a best-effort automated pass. Your job is to finish what it couldn't do automatically, then interview the user about any custom code they had in v1 and help port it forward. + +Read [docs/v1-to-v2-changes.md](../../../docs/v1-to-v2-changes.md) before doing anything — it's the vocabulary for where v1 things moved to in v2. + +## What the automation did + +The setup flow ran these sub-steps (each as its own progression-log entry): + +| Sub-step | What it did | +|----------|-------------| +| `migrate-detect` | Found v1 install on disk (scanned `~/nanoclaw`, `~/.nanoclaw`, `~/Code/nanoclaw`, etc., or `$NANOCLAW_V1_PATH`). | +| `migrate-validate` | Checked v1 DB has expected tables + required columns. | +| `migrate-db` | Seeded `agent_groups` + `messaging_groups` + `messaging_group_agents` from `registered_groups`. Mapped `trigger_pattern`/`requires_trigger` → `engage_mode`/`engage_pattern`. Did NOT seed `users`/`user_roles`. | +| `migrate-groups` | Copied v1 `groups//` to v2. v1 `CLAUDE.md` → v2 `CLAUDE.local.md`. v1 `container_config` JSON → `.v1-container-config.json` sidecar (don't silent-map to v2's `container.json`). | +| `migrate-env` | Merged v1 `.env` keys into v2 `.env` (never overwrote existing keys). | +| `migrate-channel-auth` | Copied non-env auth state per channel (Baileys keystore, matrix state, etc.) based on `CHANNEL_AUTH_REGISTRY` in `setup/migrate-v1/shared.ts`. | +| `migrate-channels` | Ran `setup/install-.sh` for each channel detected in `registered_groups`. | +| `migrate-tasks` | Ported active v1 `scheduled_tasks` into each session's `inbound.db` as `kind='task'` rows. Inactive tasks dumped to `inactive-tasks.json` for reference. | + +## Artifacts to read first + +- `logs/setup-migration/handoff.json` — **start here.** Structured summary of every sub-step: `status`, `fields`, `notes`, plus detected channels, group selection, and a top-level `followups` list. The top-level `overall_status` tells you at a glance what kind of session this is. +- `logs/setup.log` — the progression log. Each `migrate-*` sub-step has one entry with status, duration, and a pointer to its raw log. +- `logs/setup-steps/NN-migrate-*.log` — raw per-sub-step stdout+stderr. Read these when a step failed or you need to understand why. +- `logs/setup-migration/schema-mismatch.json` — only exists if `migrate-validate` rejected the v1 DB shape. Describes what was missing. +- `logs/setup-migration/inactive-tasks.json` — v1 scheduled tasks we didn't migrate (completed, stopped, or unmappable schedule types). + +## Flow + +### Phase A — always run: owner seeding + access policy + +The automation deliberately did not seed `users`, `user_roles`, or flip `messaging_groups.unknown_sender_policy`. v1 has no ground truth for who the owner is, and no single source for the "anyone can message / only known users" setting. Ask the user. + +1. Read `handoff.json` → `detected_channels` to know which channel(s) to address the user on. +2. Use `AskUserQuestion` to ask "Which handle on `` is yours?" with options pulled from context if you have any hints (e.g. recent v1 message senders), plus "Let me type it" and "Use a different channel." Build the user id as `:`. +3. Insert into v2 central DB (`data/v2.db`): + - `users(id, kind, display_name, created_at)` — use the channel_type as `kind`. + - `user_roles(user_id, role='owner', agent_group_id=NULL, granted_by=NULL, granted_at=now)`. +4. Ask "In v1, could anyone message your assistant, or only known users?" via `AskUserQuestion`: + - "Anyone could message it" → update every row in `messaging_groups` (for migrated channel_types) to `unknown_sender_policy='public'`. + - "Only known users" → leave `unknown_sender_policy='strict'`; walk the user through seeding `agent_group_members` rows for each trusted handle they name. + +Use the DB helpers in `src/db/agent-groups.ts`, `src/db/messaging-groups.ts`, and `src/db/user-roles.ts` rather than hand-rolling SQL — they keep the companion `agent_destinations` and indexes correct. Always init the central DB first: + +```ts +import { initDb } from '../src/db/connection.js'; +import { runMigrations } from '../src/db/migrations/index.js'; +import { DATA_DIR } from '../src/config.js'; +import path from 'path'; +const db = initDb(path.join(DATA_DIR, 'v2.db')); +runMigrations(db); +``` + +### Phase B — branch on `handoff.json: overall_status` + +**If `overall_status === 'success'`** and `followups` is empty: go straight to Phase C (customization interview). + +**Otherwise (partial, failed, or non-empty followups)**: walk `handoff.steps` and `handoff.followups` top-to-bottom. For each entry: + +- Read the step's `fields` and `notes` and its raw log (`logs/setup-steps/NN-.log`). +- Explain the situation to the user in one sentence, then propose a fix. +- Do the fix yourself when it's mechanical (re-running an install script, seeding a missed `agent_destinations` row, re-copying a channel's auth files, manually translating an unsupported `schedule_type`). Use `AskUserQuestion` when a judgment call is needed (is this orphan channel worth keeping? is this v1 container_config still relevant?). + +Common cases: + +- **`migrate-validate` status=failed**: the v1 DB had an unexpected shape. Read `schema-mismatch.json`. If tables are missing, the user may have run a very old or customized v1 — ask before trying to salvage. If only columns are missing, you can often proceed by hand-writing the SELECT with the columns that exist. +- **`migrate-db` status=partial, SKIPPED>0**: some `registered_groups` rows didn't seed. The `notes` field of the step entry names each failed folder. Most commonly: a JID we couldn't parse. Ask the user whether to manually wire each. +- **`migrate-channels` status=partial, some entries `not_supported`**: v1 had channels v2 doesn't ship a skill for yet. Ask the user whether to keep the `messaging_groups` rows (they'll stay orphaned until v2 grows the adapter) or delete them. +- **`migrate-channel-auth` has `files_missing`**: for WhatsApp specifically, encryption sessions often can't survive the copy — tell the user a fresh pair may be needed via `/add-whatsapp`. +- **Per-folder `.v1-container-config.json` sidecars exist**: read each, discuss with the user, and translate to v2's `groups//container.json` format. + +### Phase C — customizations (fork-aware) + +NanoClaw recommends running on a fork, so most real v1 installs have at least some customizations. + +**Start with divergence detection.** In the v1 repo at `handoff.v1_path`: + +```bash +cd +git remote -v # identify the upstream remote +git log --oneline /main..HEAD # commits ahead of upstream +``` + +If the log is **empty**: stock v1. Tell the user "no customizations to port" and skip the rest of Phase C. + +If the log has commits, show them to the user and offer a scope via `AskUserQuestion`: + +1. **Mechanical** (recommended) — copy the portable categories (skills, docs), stash the rest as reference. +2. **Full interview** — walk each commit with me, decide one-by-one. Use `Explore` sub-agents for diffs > 10 files. +3. **Reference only** — stash everything to `docs/v1-fork-reference/`, copy nothing now. + +**Portability rules of thumb:** +- **Portable**: `container/skills/*`, `.claude/skills/*`, `docs/*`, top-level config. Scan each with `scanForV1Patterns` (in `setup/migrate-v1/shared.ts`) before copying — clean ones land as-is, dirty ones get a followup. +- **Not portable**: `src/*` (host) and `container/agent-runner/src/*` (agent-runner). v2's architecture is fundamentally different (Node host with split session DBs vs v1's single process + IPC file queue). Stash to `docs/v1-fork-reference/` with a README explaining the v1→v2 mapping — **don't translate**. Mechanical translation is a trap; let the user rebuild the feature on v2 primitives. +- **Already handled**: `groups/*` — `migrate-groups` copied these and flagged v1 patterns. Don't redo in Phase C. +- **Case by case**: `package.json` deps — check whether v2 already has each; never add to v2's lockfile without approval (supply-chain `minimumReleaseAge` applies). + +When stashing, write `docs/v1-fork-reference/README.md` with commits list, stashed source files, and the suggested porting plan. + +## Principles + +- **Never silently copy code.** Read, explain, propose, apply. Show diffs before applying when non-trivial. +- **Credentials are masked when displayed** (first 4 + `...` + last 4 characters). The handoff file doesn't contain values; keep it that way. +- **The v1 checkout is read-only.** We never delete or modify `~/nanoclaw`. If the user wants to retire it later, that's a separate conversation. +- **No migration re-runs.** The `migrate-*` sub-steps are idempotent, but re-running them from inside this skill is almost always the wrong move — finish by hand. Re-running is for when the user re-runs `bash nanoclaw.sh`. +- **`handoff.json` is source of truth across context compactions.** If the conversation gets compacted mid-work, re-read it and `git status` to recover state. Do not maintain a separate state file. + +## When you're done + +- Delete `logs/setup-migration/handoff.json` once every followup is cleared and the user confirms. The file is a working document, not a record — if the user wants a record, offer to move it to `docs/migration-.md` before deleting. +- Tell the user: if the service is running (check `launchctl list | grep nanoclaw` on macOS or `systemctl --user status nanoclaw*` on Linux), restart it so the seeded `users` / `user_roles` / any channel installs take effect. diff --git a/CHANGELOG.md b/CHANGELOG.md index ab2fd5a..cf6992b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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). +## [Unreleased] + +- **v1 → v2 migration (experimental).** `bash nanoclaw.sh` now detects a v1 install (`~/nanoclaw`, `~/.nanoclaw`, siblings of the v2 checkout, or `$NANOCLAW_V1_PATH`) and runs a best-effort port before channel pairing: seeds `agent_groups`/`messaging_groups`/wirings from v1's `registered_groups` (with trigger_pattern → engage_mode/engage_pattern), copies group folders, merges `.env`, installs v2 channel adapters, and ports `scheduled_tasks`. Hands off to `/migrate-from-v1` for owner seeding and fork customizations. Experimental — back up `data/v2.db` and `groups/` first; see [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md). + ## [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. diff --git a/CLAUDE.md b/CLAUDE.md index 7115c4c..b584c73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,7 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f | `container/skills/` | Container skills mounted into every agent session | | `groups//` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) | | `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) | +| `setup/migrate-v1.ts` + `setup/migrate-v1/` | v1→v2 migration (**experimental**); runs inside `setup/auto.ts` before channel pairing. Seeds `agent_groups` + `messaging_groups` + wirings from v1's `registered_groups`, copies group folders, merges `.env`, installs channel adapters, ports scheduled tasks. Writes `logs/setup-migration/handoff.json` for the `/migrate-from-v1` skill to pick up (owner seeding + fork customizations). | ## Channels and Providers (skill-installed) @@ -211,6 +212,7 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac | [docs/setup-wiring.md](docs/setup-wiring.md) | What's wired, what's open in the setup flow | | [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 | +| [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) | v1→v2 architecture diff — vocabulary for where v1 things moved. Referenced by the migration step and `/migrate-from-v1` skill | ## Container Build Cache diff --git a/README.md b/README.md index de61f6d..9228d40 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ 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. +**Coming from v1?** `nanoclaw.sh` detects your old install (scans siblings of the v2 checkout + common `$HOME` locations) and migrates automatically; `/migrate-from-v1` in Claude finishes owner setup and helps port custom fork work. If v1 is at a non-standard path, run with `NANOCLAW_V1_PATH=/path/to/nanoclaw bash nanoclaw.sh`. See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md). _Experimental — back up `data/v2.db` and `groups/` first; not recommended yet for high-stakes production installs._ + ## 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. diff --git a/docs/v1-to-v2-changes.md b/docs/v1-to-v2-changes.md new file mode 100644 index 0000000..f81fb58 --- /dev/null +++ b/docs/v1-to-v2-changes.md @@ -0,0 +1,172 @@ +# NanoClaw v1 → v2 — what changed + +Big-picture differences between NanoClaw v1 (the `~/nanoclaw` checkout you've been running) and v2 (this rewrite). Not a migration guide — that's what `bash nanoclaw.sh` and the `/migrate-from-v1` skill are for. This doc is the **vocabulary**: when something has moved or been renamed, find it here. + +Read this before touching the migration code or porting customizations forward. + +--- + +## One-line summary + +v1 was one Node process with one SQLite file and native channel adapters. v2 is a host that spawns per-session Docker containers, splits state across a central DB + per-session DB pair, routes through an explicit entity model, and installs channels as skills from a sibling branch. + +--- + +## Entity model — the biggest shift + +**v1:** one flat table `registered_groups(jid, name, folder, trigger_pattern, requires_trigger, is_main, channel_name)`. A group folder is the unit of agent identity. A chat (JID) is wired to exactly one folder, and `trigger_pattern` is an opaque regex the router applies to every incoming message. + +**v2:** three tables, with a deliberate many-to-many in the middle: + +``` +agent_groups ─┐ + ├─ messaging_group_agents ─┬─ messaging_groups + │ (engage_mode, │ (channel_type, + │ engage_pattern, │ platform_id, + │ sender_scope, │ unknown_sender_policy) + │ ignored_message_policy, + │ session_mode, priority) +``` + +Consequences: + +- **One agent can answer on many chats, and one chat can fan out to many agents.** v1 couldn't do either. +- **No `is_main` flag.** Privilege is now explicit via `user_roles` (owner/admin, global or scoped). See below. +- **No `trigger_pattern` regex.** Replaced with four orthogonal columns. Mapping rule used by the automated migration and by the `/migrate-from-v1` skill: + - v1 `trigger_pattern` non-empty → v2 `engage_mode='pattern'`, `engage_pattern = ` + - v1 `requires_trigger=0` or pattern was `.`/`.*` → v2 `engage_mode='pattern'`, `engage_pattern='.'` (the "always" flavor) + - no pattern and requires a trigger → v2 `engage_mode='mention'` + - `sender_scope` and `ignored_message_policy` are new; defaults `all` / `drop` +- **JID decomposition.** v1's `jid` column stored `dc:12345` / `tg:67890`. v2 splits this into `channel_type` + `platform_id`. Concretely: `dc:12345` becomes `channel_type='discord'`, `platform_id='discord:12345'`. Prefix aliases (`dc` → `discord`, `tg` → `telegram`, `wa` → `whatsapp`) are in `setup/migrate-v1/shared.ts`. +- **`channel_name` was unreliable in v1.** Many rows had it empty; the actual channel had to be guessed from the JID prefix. v2's `channel_type` is always explicit. + +--- + +## Central DB vs session DBs + +**v1:** one SQLite file at `store/messages.db`. Every chat, message, registered group, scheduled task, and session lived there. Host and any agent processes all opened the same file. + +**v2:** three DB shapes. + +1. `data/v2.db` — **central**. Everything that isn't per-session: users, roles, agent groups, messaging groups, wirings, pending approvals, user DMs, schema migrations. +2. `data/v2-sessions//inbound.db` — **host writes, container reads**. `messages_in`, routing, destinations, pending questions, processing_ack. This is where scheduled tasks live (see "Scheduling" below). +3. `data/v2-sessions//outbound.db` — **container writes, host reads**. `messages_out`, session_state. + +Exactly one writer per file. No cross-mount lock contention. Heartbeat is a file touch at `/workspace/.heartbeat`, not a DB update. Host uses even `seq` numbers, container uses odd. + +Message history (v1 `messages` table, v1 `chats` table) is **not migrated**. The migration copies operationally important state forward (agents, channels, wirings, scheduled tasks, group folders) and leaves chat logs behind. + +--- + +## Scheduling + +**v1:** dedicated `scheduled_tasks` table in `store/messages.db` with its own columns (`schedule_type`, `schedule_value`, `next_run`, `last_run`, `context_mode`, `script`, `status`). A separate cron-ish scheduler process read from it. + +**v2:** scheduled tasks are **`messages_in` rows with `kind='task'`** in a session's `inbound.db`. Relevant columns: +- `process_after` (ISO8601) — host sweep wakes the container when `datetime(process_after) <= datetime('now')` +- `recurrence` — cron string; `NULL` = one-shot +- `series_id` — groups recurring occurrences; set to the task id on first insert +- `status` — `pending` | `processing` | `completed` | `failed` | `paused` + +The public API is `insertTask()` in `src/modules/scheduling/db.ts`. Recurrence is computed in the user's TZ via `cron-parser` (see `src/modules/scheduling/recurrence.ts`). The migration maps v1's `schedule_type`+`schedule_value` pair into a single cron string before calling `insertTask()`. + +Tasks can exist before a session is awake — the host sweep creates/wakes the container on the first due tick. + +--- + +## Credentials + +**v1:** `.env` — plain environment variables. `DISCORD_BOT_TOKEN`, `ANTHROPIC_API_KEY`, etc. The host read them directly and passed them in to any code that needed them. + +**v2:** OneCLI Agent Vault. A separate local service at `http://127.0.0.1:10254` holds secrets. Agents are *scoped* to specific secrets and the vault injects them into approved API requests as they leave the container. The container never sees the raw secret value. + +Gotcha: auto-created agents default to `selective` secret mode — no secrets attached, even if matching secrets exist in the vault. See the "auto-created agents start in selective secret mode" section of the root CLAUDE.md for the fix (`onecli agents set-secret-mode --mode all`). + +**What the automated migration does:** copies every v1 `.env` key verbatim into v2 `.env`, never overwriting existing v2 keys. The OneCLI vault migration is a separate step owned by the `/init-onecli` skill, which knows how to pull from `.env`. + +--- + +## Channel adapters + +**v1:** native adapters (e.g. `discord.js` used directly) imported in `src/channels/`. Installing a channel meant editing code, adding a dependency, and setting env vars. + +**v2:** channel adapters live on a sibling `channels` branch. Each `/add-` skill: +1. `git fetch origin channels` +2. `git show channels:src/channels/.ts > src/channels/.ts` +3. Appends `import './.js';` to `src/channels/index.ts` +4. `pnpm install @chat-adapter/@` +5. `pnpm run build` + +Idempotent — re-running is a no-op. Pinned versions keep the supply chain honest. The automated migration detects which channels were wired in v1 (via distinct `channel_name` / JID prefix) and runs the matching `setup/install-.sh` for each. Channels in v1 that don't have a v2 skill (rare now, more common as v2 catches up) are recorded in the handoff file for the `/migrate-from-v1` skill to raise with the user. + +**Channel auth beyond `.env`.** Some channels store session state on disk (Baileys WhatsApp keystore, Matrix sync state, iMessage tokens). The `channel-auth` sub-step has a per-channel registry (`setup/migrate-v1/shared.ts: CHANNEL_AUTH_REGISTRY`) that knows which file globs to copy alongside env keys. + +--- + +## Privilege — from implicit to explicit + +**v1:** `registered_groups.is_main = 1` flagged one group as the privileged one. No `users` table. Permissions were conventions, not enforced. + +**v2:** explicit tables. +- `users(id = ":", kind, display_name)` — one row per messaging-platform identifier +- `user_roles(user_id, role ∈ {owner, admin}, agent_group_id nullable, granted_by, granted_at)` — owner is always global; admin can be global or scoped +- `agent_group_members(user_id, agent_group_id, ...)` — "known" membership for the `sender_scope='known'` gate + +Owner gets seeded during the `/migrate-from-v1` skill's interview phase ("Which handle is you?"). The automated migration doesn't guess — v1 has no source of truth for it. + +**Default access — "anyone can talk to the bot" vs "only known users".** v1 stored this implicitly (via trigger regex + `is_main`). v2 exposes it as `messaging_groups.unknown_sender_policy ∈ {'strict', 'request_approval', 'public'}`. The skill asks the user which mode v1 ran in and flips the migrated messaging groups accordingly. + +--- + +## Group folders on disk + +**v1:** `groups//CLAUDE.md` and optional `logs/`. `CLAUDE.md` was a plain instruction file, group-specific. + +**v2:** each group still lives at `groups//`, but the shape is richer: +- `CLAUDE.md` — **composed at container spawn** from `.claude-shared.md` (symlink to global) + `.claude-fragments/*.md` (module fragments) + `CLAUDE.local.md`. **Don't edit `CLAUDE.md` directly.** +- `CLAUDE.local.md` — per-group content. The migration writes v1's old `CLAUDE.md` here. +- `container.json` — optional per-group container config (apt deps, env, mounts). v1's `registered_groups.container_config` JSON is close but not identical — the migration stores the v1 payload at `groups//.v1-container-config.json` for the skill to reconcile, rather than silently mapping it. +- `.claude-fragments/` and `.claude-shared.md` are installed by `initGroupFilesystem()` the first time the host touches the group, so the migration only has to write `CLAUDE.local.md` and leave the scaffolding to the host. + +--- + +## Host process vs containers + +**v1:** single Node process. The "agent" was the same process as the router. + +**v2:** Node host at top, Bun-runtime Docker container per session. They communicate only via the two session DBs. No shared modules, no IPC, no stdin piping. If you wrote custom code that reached from the agent into host internals (or vice versa), that surface no longer exists — porting it is a `/migrate-from-v1` skill topic, not a mechanical copy. + +Lockfiles: host uses `pnpm-lock.yaml`, agent-runner uses `bun.lock`. `minimumReleaseAge: 4320` on the host side (3-day supply-chain wait); agent-runner has no release-age gate. + +--- + +## Self-modification and MCP tools + +**v1:** if you added MCP servers or self-modification plumbing, it was usually direct edits to the long-running process. + +**v2:** +- MCP servers register through `container/agent-runner/src/mcp-tools/*.ts` and load per-session. There's also `install_packages` and `add_mcp_server` self-mod tools that go through an admin-approval flow (`src/modules/self-mod/apply.ts`) before rebuilding the container image. +- Custom MCP tools you wrote in v1 map cleanly to the v2 tool registry, but the import paths, runtime (Bun vs Node), and SQL helper differences (`bun:sqlite` uses `$name`-prefixed params) may need adjustment. The skill walks through this. + +--- + +## Things that are gone or don't map + +- **`scheduled_tasks` as a separate table** — moved into session `inbound.db` under `kind='task'`. Migration ports active rows; inactive/completed are exported to `logs/setup-migration/inactive-tasks.json` for reference. +- **`messages` / `chats` tables (chat history)** — not migrated. Stay in the v1 checkout if you need them. +- **`router_state` (key/value)** — not migrated. v2 state lives in the explicit tables above. +- **`sessions` (v1 group→session_id)** — v1 sessions don't map; v2 sessions are keyed by `(agent_group_id, messaging_group_id, thread_id)` and are created on demand. +- **Raw access to the old `store/messages.db`** — the v1 DB is left in place and untouched. If migration goes wrong you can re-run it (the migration sub-steps are idempotent for agents/channels/wirings; folders use rsync semantics). + +--- + +## Migration surface — where the code lives + +- `setup/migrate-v1.ts` — orchestrator called from `setup/auto.ts` between the timezone and channel steps. +- `setup/migrate-v1/.ts` — registered in `setup/index.ts` STEPS; each runs under `runQuietStep`. +- `logs/setup.log` — progression log. Each sub-step appends one entry. +- `logs/setup-steps/NN-migrate-.log` — raw per-sub-step stdout/stderr. +- `logs/setup-migration/handoff.json` — summary of what was migrated, what failed, what was deferred. Read by the `/migrate-from-v1` skill. +- `logs/setup-migration/schema-mismatch.json` — written only if `migrate-validate` finds a v1 DB that doesn't match the expected shape. The skill uses this to decide what to hand back to the user. +- `logs/setup-migration/inactive-tasks.json` — completed or stopped v1 `scheduled_tasks`, exported for reference. +- `.claude/skills/migrate-from-v1/SKILL.md` — tells Claude how to finish anything the automation couldn't and then interview the user about custom code changes. diff --git a/setup/auto.ts b/setup/auto.ts index 4c20262..720ea0f 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -14,8 +14,10 @@ * "Terminal Agent". * NANOCLAW_SKIP comma-separated step names to skip * (environment|container|onecli|auth|mounts| - * service|cli-agent|timezone|channel|verify| - * first-chat) + * service|cli-agent|timezone|migration|channel| + * verify|first-chat) + * NANOCLAW_V1_PATH explicit path to a v1 install to migrate + * from (default: scan common locations) * * Timezone is auto-detected after the CLI agent step. UTC resolves are * confirmed with the user, and free-text replies fall through to a @@ -36,6 +38,7 @@ 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 { runMigrateV1 } from './migrate-v1.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { claudeCliAvailable, @@ -306,7 +309,16 @@ async function main(): Promise { await runTimezoneStep(); } + if (!skip.has('migration')) { + // Runs silently when there's no v1 install; otherwise orchestrates the + // detect → validate → db → groups → env → channel-auth → channels → + // tasks sub-steps and writes logs/setup-migration/handoff.json for the + // /migrate-from-v1 skill to pick up. + await runMigrateV1(); + } + let channelChoice: ChannelChoice = 'skip'; + if (!skip.has('channel')) { channelChoice = await askChannelChoice(); if (channelChoice === 'telegram') { diff --git a/setup/index.ts b/setup/index.ts index 25d1934..b327541 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -22,6 +22,14 @@ const STEPS: Record< onecli: () => import('./onecli.js'), auth: () => import('./auth.js'), 'cli-agent': () => import('./cli-agent.js'), + 'migrate-detect': () => import('./migrate-v1/detect.js'), + 'migrate-validate': () => import('./migrate-v1/validate.js'), + 'migrate-db': () => import('./migrate-v1/db.js'), + 'migrate-groups': () => import('./migrate-v1/groups.js'), + 'migrate-env': () => import('./migrate-v1/env.js'), + 'migrate-channel-auth': () => import('./migrate-v1/channel-auth.js'), + 'migrate-channels': () => import('./migrate-v1/channels.js'), + 'migrate-tasks': () => import('./migrate-v1/tasks.js'), }; async function main(): Promise { diff --git a/setup/migrate-v1.ts b/setup/migrate-v1.ts new file mode 100644 index 0000000..a3c1883 --- /dev/null +++ b/setup/migrate-v1.ts @@ -0,0 +1,257 @@ +/** + * v1 → v2 migration orchestrator. Called from setup/auto.ts after the + * timezone step and before the channel step. + * + * Silent happy path: if no v1 install is found, we emit one "skipped" step + * and return. Users on a fresh v2 install never see anything. + * + * When v1 IS found: detect → [confirm] → group-selection prompt → validate + * → db → groups → env → channel-auth → channels → tasks → handoff. + * Every sub-step is a separate entry in the progression log; failures never + * abort the chain (the handoff file records them for the skill to finish). + * + * After everything runs, a one-line note points the user at the + * `/migrate-from-v1` skill. + */ +import fs from 'fs'; +import path from 'path'; + +import * as p from '@clack/prompts'; +import Database from 'better-sqlite3'; +import k from 'kleur'; + +import { ensureAnswer, runQuietStep } from './lib/runner.js'; +import { wrapForGutter } from './lib/theme.js'; +import * as setupLog from './logs.js'; +import { + HANDOFF_PATH, + MIGRATION_DIR, + inferChannelType, + readHandoff, + v1PathsFor, + writeHandoff, +} from './migrate-v1/shared.js'; + +/** + * Count groups in v1's registered_groups, split by whether the channel_type + * can be inferred. Uses the same `inferChannelType` logic as migrate-db so + * the displayed count matches what will actually get seeded. Open-and-close + * because this runs in the orchestrator before migrate-db's child process. + */ +function countV1Groups(v1Root: string): { total: number; wired: number } { + const dbPath = v1PathsFor(v1Root).db; + try { + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + const rows = db + .prepare('SELECT jid, channel_name FROM registered_groups') + .all() as Array<{ jid: string; channel_name: string | null }>; + db.close(); + let wired = 0; + for (const r of rows) { + if (inferChannelType(r.jid, r.channel_name)) wired++; + } + return { total: rows.length, wired }; + } catch { + return { total: 0, wired: 0 }; + } +} + +async function askGroupSelection(counts: { total: number; wired: number }): Promise<'all' | 'wired-only' | 'cancel'> { + // Non-interactive escape hatch for CI / re-runs / scripted migrations. + // NANOCLAW_MIGRATE_SELECTION = 'all' | 'wired-only' | 'cancel'. + const envChoice = process.env.NANOCLAW_MIGRATE_SELECTION?.trim(); + if (envChoice === 'all' || envChoice === 'wired-only' || envChoice === 'cancel') { + setupLog.userInput('migrate_selection', `${envChoice} (from NANOCLAW_MIGRATE_SELECTION)`); + return envChoice; + } + // Most v1 installs accumulated many orphan folders. Default the user to + // wired-only (the ones we can actually route) — explicit opt-in for "all". + const choice = ensureAnswer( + await p.select({ + message: `Found ${counts.total} v1 group folders (${counts.wired} wired to a channel). Which to bring over?`, + options: [ + { + value: 'wired-only', + label: `Only the ${counts.wired} wired ones`, + hint: 'recommended — skips orphans', + }, + { + value: 'all', + label: `All ${counts.total} folders`, + hint: 'brings dead/orphan folders over too', + }, + { + value: 'cancel', + label: 'Skip migration', + hint: "I'll migrate later", + }, + ], + }), + ) as 'all' | 'wired-only' | 'cancel'; + setupLog.userInput('migrate_selection', choice); + return choice; +} + +/** + * Finalize the handoff record after every sub-step has run. Computes an + * overall status from per-step statuses: anything `failed` → partial; + * anything `partial` → partial; else success. + */ +function finalizeHandoff(): 'success' | 'partial' | 'failed' { + const h = readHandoff(); + const statuses = Object.values(h.steps).map((s) => s?.status); + const anyFailed = statuses.includes('failed'); + const anyPartial = statuses.includes('partial'); + const overall: 'success' | 'partial' | 'failed' = anyFailed + ? 'partial' // DB or files may have landed; the skill can pick up the rest + : anyPartial + ? 'partial' + : 'success'; + h.overall_status = overall; + writeHandoff(h); + return overall; +} + +function printHandoffNote(overall: 'success' | 'partial' | 'failed'): void { + const relHandoff = path.relative(process.cwd(), HANDOFF_PATH); + const lines: string[] = []; + if (overall === 'success') { + lines.push( + wrapForGutter( + 'Your v1 install has been migrated. Run `/migrate-from-v1` in Claude next — it will seed your owner account and help port any custom code you had.', + 4, + ), + ); + } else { + lines.push( + wrapForGutter( + 'Migration finished with some items for a human. Run `/migrate-from-v1` in Claude — it will read the handoff, finish the unfinished steps, and walk through custom code.', + 4, + ), + ); + } + lines.push(''); + lines.push(k.dim(` Handoff: ${relHandoff}`)); + lines.push(k.dim(` Full log: ${setupLog.progressLogPath}`)); + lines.push(k.dim(` Raw logs: ${setupLog.stepsDir}/`)); + p.note(lines.join('\n'), 'Migration handoff'); +} + +export async function runMigrateV1(): Promise<'proceeded' | 'skipped' | 'cancelled'> { + // 0. Ensure migration log dir exists before any sub-step writes to it. + fs.mkdirSync(MIGRATION_DIR, { recursive: true }); + + // 1. Detect. If nothing obvious, give the user one subtle chance to point + // us at a non-standard path — then accept silently. + const detect = await runQuietStep('migrate-detect', { + running: 'Checking for a previous NanoClaw install…', + done: 'Found a previous install.', + skipped: 'No previous install to migrate.', + }); + + const v1Found = detect.ok && detect.terminal?.fields.STATUS === 'success'; + + if (!v1Found) { + // Silent skip — the 99% case is a fresh install with no v1 anywhere. + // Prompting for a custom path on every fresh run is UX noise. Users + // with a v1 at a non-standard location use `NANOCLAW_V1_PATH= + // bash nanoclaw.sh` (documented in README + setup/auto.ts header). + return 'skipped'; + } + + // 2. Ask the user which groups to bring over. + const h = readHandoff(); + if (!h.v1_path) { + // Shouldn't happen — detect set it if v1Found. Guard anyway. + return 'skipped'; + } + + // Experimental warning — fires only when a v1 install is found, so stock + // v2 users (no v1 to migrate) never see it. Not a blocker; the user can + // still proceed. Skip when NANOCLAW_MIGRATE_SELECTION is set (scripted / + // CI runs have already accepted the risk by defining their selection). + if (!process.env.NANOCLAW_MIGRATE_SELECTION) { + p.log.warn( + wrapForGutter( + 'v1 → v2 migration is experimental. Back up your v2 state (data/v2.db, groups/) before continuing. Not recommended for high-stakes production installs — it does a best-effort port and a human still has to finish via /migrate-from-v1.', + 4, + ), + ); + } + + const counts = countV1Groups(h.v1_path); + const selection = await askGroupSelection(counts); + if (selection === 'cancel') { + // Mark the handoff so the skill can still see what would have happened. + const ho = readHandoff(); + ho.overall_status = 'skipped'; + writeHandoff(ho); + return 'cancelled'; + } + + // 3. Validate — if it fails, subsequent steps will short-circuit the + // DB-dependent parts. Groups + env still run. + await runQuietStep('migrate-validate', { + running: "Checking the v1 database's shape…", + done: 'v1 database looks good.', + failed: "v1 database didn't match what I expected.", + skipped: 'Skipped database validation.', + }); + + // 4. DB seeding — parameterized by the user's selection. + await runQuietStep( + 'migrate-db', + { + running: 'Seeding v2 agents and channels from v1…', + done: 'Seeded v2 database.', + skipped: 'Skipped database seeding.', + failed: "Couldn't seed the v2 database.", + }, + ['--selection', selection], + ); + + // 5. Group folders. + await runQuietStep('migrate-groups', { + running: 'Copying group folders…', + done: 'Group folders copied.', + skipped: 'Skipped group-folder copy.', + failed: "Couldn't copy some group folders.", + }); + + // 6. Env keys. + await runQuietStep('migrate-env', { + running: 'Merging v1 .env into v2 .env…', + done: 'Env keys migrated.', + skipped: 'No env keys to migrate.', + failed: "Couldn't merge .env.", + }); + + // 7. Non-env channel auth (Baileys keystore, matrix state, etc.). + await runQuietStep('migrate-channel-auth', { + running: 'Copying channel auth files…', + done: 'Channel auth copied.', + skipped: 'No channel auth to copy.', + failed: 'Some channel auth files need attention.', + }); + + // 8. Install v2 channel adapters for the detected channels. + await runQuietStep('migrate-channels', { + running: 'Installing v2 channel adapters…', + done: 'Channel adapters installed.', + skipped: 'No channels to install.', + failed: 'Some channel adapters need attention.', + }); + + // 9. Scheduled tasks. + await runQuietStep('migrate-tasks', { + running: 'Porting scheduled tasks…', + done: 'Scheduled tasks ported.', + skipped: 'No scheduled tasks to port.', + failed: 'Some scheduled tasks need attention.', + }); + + // 10. Finalize + hand off. + const overall = finalizeHandoff(); + printHandoffNote(overall); + return 'proceeded'; +} diff --git a/setup/migrate-v1/channel-auth.ts b/setup/migrate-v1/channel-auth.ts new file mode 100644 index 0000000..1ef2bc4 --- /dev/null +++ b/setup/migrate-v1/channel-auth.ts @@ -0,0 +1,213 @@ +/** + * Step: migrate-channel-auth + * + * For each channel detected in migrate-db, copy non-.env auth state from v1 + * to the matching v2 location. Env keys are handled by migrate-env (this + * step reads the registry to confirm they made it over, but doesn't rewrite + * them). Files are copied from the first matching candidate path in the + * registry — missing paths are recorded so the skill can prompt the user. + * + * Destination uses the same relative path on v2 (e.g. v1 has + * `data/sessions/baileys/` → v2 gets `data/sessions/baileys/`). If v2 already + * has a different file/dir at that path, we skip and flag it — never clobber. + */ +import fs from 'fs'; +import path from 'path'; + +import { emitStatus } from '../status.js'; +import { + CHANNEL_AUTH_REGISTRY, + readHandoff, + recordStep, + v1PathsFor, + writeHandoff, +} from './shared.js'; + +/** + * Copy file or directory tree from src to dst. `force: false` means existing + * files on the v2 side are never clobbered — important because we'd otherwise + * overwrite auth state the user may have set up on v2 directly. Returns a + * rough count of files copied (post-hoc walk of the destination). + */ +function copyRecursive(src: string, dst: string): number { + if (!fs.existsSync(src)) return 0; + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.cpSync(src, dst, { recursive: true, force: false, errorOnExist: false }); + return countFilesUnder(dst); +} + +function countFilesUnder(p: string): number { + if (!fs.existsSync(p)) return 0; + if (fs.statSync(p).isFile()) return 1; + let n = 0; + for (const entry of fs.readdirSync(p, { withFileTypes: true })) { + n += countFilesUnder(path.join(p, entry.name)); + } + return n; +} + +export async function run(_args: string[]): Promise { + const h = readHandoff(); + if (!h.v1_path) { + recordStep('migrate-channel-auth', { + status: 'skipped', + fields: { REASON: 'detect-not-run' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_CHANNEL_AUTH', { STATUS: 'skipped', REASON: 'no_v1_path' }); + return; + } + + const channels = h.detected_channels; + if (channels.length === 0) { + recordStep('migrate-channel-auth', { + status: 'skipped', + fields: { REASON: 'no-channels-detected' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_CHANNEL_AUTH', { STATUS: 'skipped', REASON: 'no_channels' }); + return; + } + + const v1Paths = v1PathsFor(h.v1_path); + const v1Env = fs.existsSync(v1Paths.env) ? fs.readFileSync(v1Paths.env, 'utf-8') : ''; + const v1EnvKeys = new Set( + v1Env + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + .map((line) => line.split('=')[0].trim()) + .filter(Boolean), + ); + + const results: typeof h.channel_auth = []; + const followups: string[] = []; + let anyMissingRequired = false; + + for (const ch of channels) { + const spec = CHANNEL_AUTH_REGISTRY[ch.channel_type]; + if (!spec) { + // Unknown channel — give the skill enough context to drive a useful + // interview instead of a generic "we don't know." Scan v1's .env for + // keys that look related (substring match on channel name + common + // suffixes) and list v1 state directories the user should check. + const haystack = ch.channel_type.toLowerCase(); + const candidateEnvKeys = [...v1EnvKeys].filter((k) => { + const lk = k.toLowerCase(); + return ( + lk.includes(haystack) || + (haystack.length >= 3 && lk.includes(haystack.slice(0, 3))) + ); + }); + const v1DataDirs = ['data', 'store', 'data/sessions'] + .map((d) => path.join(h.v1_path, d)) + .filter((p) => fs.existsSync(p)); + + results.push({ + channel_type: ch.channel_type, + env_keys_copied: [], + files_copied: [], + files_missing: [], + notes: `Unknown channel (not in CHANNEL_AUTH_REGISTRY). Inferred via ${ch.source}. Candidate v1 env keys: ${candidateEnvKeys.join(', ') || 'none found'}. Check v1 dirs: ${v1DataDirs.join(', ') || '(none)'}.`, + }); + followups.push( + `Channel "${ch.channel_type}" (${ch.group_count} group(s), inferred via ${ch.source}) is not in the auth registry. ` + + `Candidate v1 env keys that may belong to it: ${candidateEnvKeys.length > 0 ? candidateEnvKeys.join(', ') : '(none obvious)'}. ` + + `Check v1 for on-disk auth state under ${v1DataDirs.join(', ') || '(no standard dirs found)'}. ` + + `The skill should interview the user, then add a registry entry to setup/migrate-v1/shared.ts for future migrations.`, + ); + continue; + } + + const envKeysPresentInV1 = spec.v1EnvKeys.filter((key) => v1EnvKeys.has(key)); + + // Check v2's .env for required keys the v2 adapter needs to boot. v1 + // may not have had all of them (e.g. v1's Discord used discord.js + // directly and never stored DISCORD_PUBLIC_KEY which v2's Chat SDK + // requires). Surface missing ones as actionable followups. + const v2EnvPath = path.join(process.cwd(), '.env'); + const v2Env = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; + const v2EnvKeys = new Set( + v2Env + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('#')) + .map((l) => l.split('=')[0].trim()) + .filter(Boolean), + ); + const missingRequired = spec.requiredV2Keys.filter((r) => !v2EnvKeys.has(r.key)); + if (missingRequired.length > 0) { + anyMissingRequired = true; + followups.push( + `Channel "${ch.channel_type}" is missing required v2 keys in .env: ${missingRequired + .map((r) => `${r.key} (${r.where})`) + .join('; ')}. The v2 adapter won't boot until these are set.`, + ); + } + + const filesCopied: string[] = []; + const filesMissing: string[] = []; + + for (const relPath of spec.candidatePaths) { + const src = path.join(h.v1_path, relPath); + if (!fs.existsSync(src)) continue; + + const dst = path.join(process.cwd(), relPath); + if (fs.existsSync(dst)) { + followups.push( + `Channel "${ch.channel_type}": v2 already has ${relPath} — left untouched. Reconcile manually if needed.`, + ); + filesMissing.push(`${relPath} (already exists in v2)`); + continue; + } + + try { + const count = copyRecursive(src, dst); + filesCopied.push(`${relPath} (${count} files)`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + filesMissing.push(`${relPath} (copy failed: ${message})`); + followups.push(`Channel "${ch.channel_type}": failed to copy ${relPath} — ${message}`); + } + } + + if (spec.candidatePaths.length > 0 && filesCopied.length === 0) { + filesMissing.push(`(no candidate paths existed under ${h.v1_path})`); + } + + results.push({ + channel_type: ch.channel_type, + env_keys_copied: envKeysPresentInV1, + files_copied: filesCopied, + files_missing: filesMissing, + notes: spec.note ?? '', + }); + } + + const handoffAfter = readHandoff(); + handoffAfter.channel_auth = results; + handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; + writeHandoff(handoffAfter); + + const anyFileMissing = results.some((r) => r.files_missing.length > 0); + const anyPartial = anyFileMissing || anyMissingRequired; + recordStep('migrate-channel-auth', { + status: anyPartial ? 'partial' : 'success', + fields: { + CHANNELS: channels.map((c) => c.channel_type).join(','), + FILES_COPIED: results.reduce((sum, r) => sum + r.files_copied.length, 0), + FILES_MISSING: results.reduce((sum, r) => sum + r.files_missing.length, 0), + }, + notes: followups, + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_CHANNEL_AUTH', { + STATUS: anyPartial ? 'partial' : 'success', + CHANNELS: channels.map((c) => c.channel_type).join(','), + FILES_COPIED: String(results.reduce((sum, r) => sum + r.files_copied.length, 0)), + FILES_MISSING: String(results.reduce((sum, r) => sum + r.files_missing.length, 0)), + }); +} diff --git a/setup/migrate-v1/channels.ts b/setup/migrate-v1/channels.ts new file mode 100644 index 0000000..89df966 --- /dev/null +++ b/setup/migrate-v1/channels.ts @@ -0,0 +1,172 @@ +/** + * Step: migrate-channels + * + * For each channel detected in migrate-db, run the corresponding v2 + * `setup/install-.sh` script in non-interactive mode. The script + * copies the adapter from the `channels` branch, installs the pinned + * dependency, and rebuilds. Credentials in v2 `.env` (migrate-env already + * copied them) are picked up automatically on the next service restart. + * + * This step does NOT run the pairing flow for each channel (that needs + * interactive prompts). The user is guided through pairing by the normal + * channel-selection step in setup/auto.ts, which happens immediately after + * migration. Installing the adapter first means that step won't have to + * re-install. + * + * Channels not supported in v2 are recorded in the handoff as + * `not_supported` so the skill can raise them with the user. + */ +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { log } from '../../src/log.js'; +import { emitStatus } from '../status.js'; +import { + installScriptForChannel, + readHandoff, + recordStep, + writeHandoff, +} from './shared.js'; + +function runScript(script: string): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn('bash', [script], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, MIGRATION_NONINTERACTIVE: '1' }, + }); + // Capture both streams silently — the parent is under a clack spinner, + // and forwarding to stdout/stderr would break the spinner UI. The full + // transcript still lands in this step's raw log via the parent's tee + // (runner.ts: spawnStep writes this step's stdout/stderr to logs/setup- + // steps/NN-migrate-channels.log already). + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (c: Buffer) => { + stdout += c.toString('utf-8'); + }); + child.stderr.on('data', (c: Buffer) => { + stderr += c.toString('utf-8'); + }); + child.on('close', (code) => + resolve({ code: code ?? 1, stdout, stderr }), + ); + child.on('error', () => + resolve({ code: 1, stdout, stderr: stderr || 'spawn_error' }), + ); + }); +} + +export async function run(_args: string[]): Promise { + const h = readHandoff(); + if (!h.v1_path) { + recordStep('migrate-channels', { + status: 'skipped', + fields: { REASON: 'detect-not-run' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_CHANNELS', { STATUS: 'skipped', REASON: 'no_v1_path' }); + return; + } + + const channels = h.detected_channels; + if (channels.length === 0) { + recordStep('migrate-channels', { + status: 'skipped', + fields: { REASON: 'no-channels-detected' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_CHANNELS', { STATUS: 'skipped', REASON: 'no_channels' }); + return; + } + + const results: typeof h.channels_installed = []; + const followups: string[] = []; + + for (const ch of channels) { + const script = installScriptForChannel(ch.channel_type); + if (!script) { + results.push({ + channel_type: ch.channel_type, + status: 'not_supported', + }); + followups.push( + `Channel "${ch.channel_type}" has no v2 install script. The /migrate-from-v1 skill should ask the user whether to keep it as an orphan messaging_group or drop it.`, + ); + continue; + } + + const absoluteScript = path.join(process.cwd(), script); + if (!fs.existsSync(absoluteScript)) { + results.push({ + channel_type: ch.channel_type, + status: 'failed', + error: `install script missing at ${script}`, + }); + followups.push(`Install script for "${ch.channel_type}" missing at ${script} — this is a v2 repo issue, not a user issue.`); + continue; + } + + log.info('Running channel install script', { channel: ch.channel_type, script: absoluteScript }); + const { code, stdout, stderr } = await runScript(absoluteScript); + // Persist the install-script output to a sidecar so the skill can read it + // if diagnosis is needed. The parent's tee already captures our own + // stdout/stderr but the nested script's output is lost otherwise. + try { + const sidecar = path.join( + process.cwd(), + 'logs', + 'setup-migration', + `install-${ch.channel_type}.log`, + ); + fs.mkdirSync(path.dirname(sidecar), { recursive: true }); + fs.writeFileSync(sidecar, `# ${script}\n# exit ${code}\n\n=== stdout ===\n${stdout}\n=== stderr ===\n${stderr}\n`); + } catch { + // Sidecar is diagnostic-only — don't abort if the log dir is unwritable. + } + if (code === 0) { + results.push({ channel_type: ch.channel_type, status: 'success' }); + } else { + results.push({ + channel_type: ch.channel_type, + status: 'failed', + error: stderr.trim().slice(0, 400) || `exit ${code}`, + }); + followups.push( + `Installing "${ch.channel_type}" failed (exit ${code}). The /migrate-from-v1 skill should retry ${script} or walk the user through /add-${ch.channel_type}.`, + ); + } + } + + const handoffAfter = readHandoff(); + handoffAfter.channels_installed = results; + handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; + writeHandoff(handoffAfter); + + // `not_supported` is an expected/known outcome for channels whose v1 adapter + // has no v2 equivalent yet. It's a followup for the skill to raise — not a + // partial success. Only real install failures degrade status. + const anyFailed = results.some((r) => r.status === 'failed'); + const status: 'success' | 'partial' | 'failed' = anyFailed ? 'partial' : 'success'; + + recordStep('migrate-channels', { + status, + fields: { + INSTALLED: results.filter((r) => r.status === 'success').length, + FAILED: results.filter((r) => r.status === 'failed').length, + NOT_SUPPORTED: results.filter((r) => r.status === 'not_supported').length, + CHANNELS: results.map((r) => `${r.channel_type}=${r.status}`).join(','), + }, + notes: followups, + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_CHANNELS', { + STATUS: status, + INSTALLED: String(results.filter((r) => r.status === 'success').length), + FAILED: String(results.filter((r) => r.status === 'failed').length), + NOT_SUPPORTED: String(results.filter((r) => r.status === 'not_supported').length), + }); +} diff --git a/setup/migrate-v1/db.ts b/setup/migrate-v1/db.ts new file mode 100644 index 0000000..e455012 --- /dev/null +++ b/setup/migrate-v1/db.ts @@ -0,0 +1,296 @@ +/** + * Step: migrate-db + * + * Seed v2.db with the essentials derived from v1's `registered_groups`: + * - agent_groups: one per v1 folder the user selected + * - messaging_groups: one per distinct (channel_type, platform_id) pair + * - messaging_group_agents: the wiring between them, with engage fields + * backfilled from v1's trigger_pattern / requires_trigger + * + * Does NOT seed users, user_roles, or agent_group_members. v1 has no ground + * truth for them — the /migrate-from-v1 skill interviews the user for the + * owner and seeds those tables. + * + * Idempotent: re-running skips any (folder) agent_group, (channel, platform_id) + * messaging_group, and (mg, ag) wiring that already exist. Safe to re-run + * after a partial failure. + * + * Expects `--selection ` where mode is 'all' | 'wired-only'. The + * orchestrator asks the user via clack and passes the result. + */ +import fs from 'fs'; +import path from 'path'; + +import Database from 'better-sqlite3'; + +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 { log } from '../../src/log.js'; +import { emitStatus } from '../status.js'; +import { + generateId, + inferChannelType, + readHandoff, + recordStep, + triggerToEngage, + v1PathsFor, + v2PlatformId, + writeHandoff, +} from './shared.js'; + +interface V1Group { + jid: string; + name: string; + folder: string; + trigger_pattern: string | null; + requires_trigger: number | null; + is_main: number | null; + channel_name: string | null; +} + +interface DbArgs { + selection: 'all' | 'wired-only'; +} + +function parseArgs(args: string[]): DbArgs { + let selection: 'all' | 'wired-only' = 'wired-only'; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--selection') { + const v = args[++i]; + if (v === 'all' || v === 'wired-only') selection = v; + } + } + return { selection }; +} + +export async function run(args: string[]): Promise { + const parsed = parseArgs(args); + const h = readHandoff(); + + if (!h.v1_path) { + recordStep('migrate-db', { + status: 'skipped', + fields: { REASON: 'detect-not-run' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_DB', { STATUS: 'skipped', REASON: 'no_v1_path' }); + return; + } + + const validate = h.steps['migrate-validate']; + if (validate && validate.status === 'failed') { + recordStep('migrate-db', { + status: 'skipped', + fields: { REASON: 'validate-failed' }, + notes: ['DB shape did not validate; skipping DB migration.'], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_DB', { STATUS: 'skipped', REASON: 'validate_failed' }); + return; + } + + const paths = v1PathsFor(h.v1_path); + let v1Db: Database.Database; + try { + v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + recordStep('migrate-db', { + status: 'failed', + fields: { REASON: 'v1-db-open-failed' }, + notes: [message], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_DB', { STATUS: 'failed', REASON: 'v1_db_open_failed', ERROR: message }); + return; + } + + const v1Groups = v1Db + .prepare( + 'SELECT jid, name, folder, trigger_pattern, requires_trigger, is_main, channel_name FROM registered_groups', + ) + .all() as V1Group[]; + v1Db.close(); + + // Filter by selection mode. "wired-only" keeps rows where we can confidently + // say which channel they belong to — either `channel_name` is set, or the + // JID prefix resolves to a known channel type. + const selected: V1Group[] = []; + const detectedChannels = new Map(); + + for (const g of v1Groups) { + const channelType = inferChannelType(g.jid, g.channel_name); + const source: 'channel_name' | 'jid_prefix' = g.channel_name?.trim() ? 'channel_name' : 'jid_prefix'; + if (!channelType) { + // Can't infer — skip in both modes; the skill raises it with the user. + continue; + } + if (parsed.selection === 'wired-only' && source === 'jid_prefix' && !channelType) { + continue; + } + selected.push(g); + const entry = detectedChannels.get(channelType) ?? { source, count: 0 }; + entry.count += 1; + // Prefer explicit channel_name as the source if any row had it. + if (source === 'channel_name') entry.source = 'channel_name'; + detectedChannels.set(channelType, entry); + } + + h.group_selection = { + mode: parsed.selection, + selected_folders: selected.map((g) => g.folder), + total_v1_groups: v1Groups.length, + wired_v1_groups: selected.length, + }; + h.detected_channels = [...detectedChannels.entries()].map(([channel_type, info]) => ({ + channel_type, + source: info.source, + group_count: info.count, + })); + writeHandoff(h); + + // Initialize v2.db (creates schema if not present — runMigrations is no-op + // when the schema is already current, so this is safe on a live v2 install). + fs.mkdirSync(path.join(process.cwd(), 'data'), { recursive: true }); + const v2Path = path.join(DATA_DIR, 'v2.db'); + const v2Db = initDb(v2Path); + runMigrations(v2Db); + + let agentGroupsCreated = 0; + let agentGroupsReused = 0; + let messagingGroupsCreated = 0; + let messagingGroupsReused = 0; + let wiringsCreated = 0; + let wiringsReused = 0; + let skipped = 0; + const followups: string[] = []; + + for (const g of selected) { + const channelType = inferChannelType(g.jid, g.channel_name); + if (!channelType) { + skipped += 1; + continue; + } + + const platformId = v2PlatformId(channelType, g.jid); + const createdAt = new Date().toISOString(); + + try { + // agent_group — one per folder + let ag = getAgentGroupByFolder(g.folder); + if (!ag) { + createAgentGroup({ + id: generateId('ag'), + name: g.name || g.folder, + folder: g.folder, + agent_provider: null, + created_at: createdAt, + }); + ag = getAgentGroupByFolder(g.folder)!; + agentGroupsCreated += 1; + } else { + agentGroupsReused += 1; + } + + // messaging_group — one per (channel_type, platform_id) + let mg = getMessagingGroupByPlatform(channelType, platformId); + if (!mg) { + createMessagingGroup({ + id: generateId('mg'), + channel_type: channelType, + platform_id: platformId, + name: g.name || null, + is_group: 1, // v1 didn't distinguish; default to group (safe for routing) + unknown_sender_policy: 'strict', // skill's interview flips this if v1 was "public" + created_at: createdAt, + }); + mg = getMessagingGroupByPlatform(channelType, platformId)!; + messagingGroupsCreated += 1; + } else { + messagingGroupsReused += 1; + } + + // messaging_group_agents — wire them if not already wired + const existingWiring = getMessagingGroupAgentByPair(mg.id, ag.id); + if (!existingWiring) { + const engage = triggerToEngage({ + trigger_pattern: g.trigger_pattern, + requires_trigger: g.requires_trigger, + }); + createMessagingGroupAgent({ + id: generateId('mga'), + messaging_group_id: mg.id, + agent_group_id: ag.id, + engage_mode: engage.engage_mode, + engage_pattern: engage.engage_pattern, + sender_scope: 'all', + ignored_message_policy: 'drop', + session_mode: 'shared', + priority: 0, + created_at: createdAt, + }); + wiringsCreated += 1; + } else { + wiringsReused += 1; + } + + if (g.is_main === 1) { + followups.push( + `Folder "${g.folder}" was the v1 main group (is_main=1). v2 has no is_main flag — the /migrate-from-v1 skill should grant this folder's channel to the owner user when it runs.`, + ); + } + } catch (err) { + skipped += 1; + const message = err instanceof Error ? err.message : String(err); + log.error('Failed to seed v1 group', { folder: g.folder, err: message }); + followups.push(`Folder "${g.folder}" failed to seed: ${message}`); + } + } + + v2Db.close(); + + const partial = skipped > 0; + const handoffAfter = readHandoff(); + handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; + writeHandoff(handoffAfter); + + recordStep('migrate-db', { + status: partial ? 'partial' : 'success', + fields: { + SELECTION: parsed.selection, + V1_GROUPS_TOTAL: v1Groups.length, + SELECTED: selected.length, + AGENT_GROUPS_CREATED: agentGroupsCreated, + AGENT_GROUPS_REUSED: agentGroupsReused, + MESSAGING_GROUPS_CREATED: messagingGroupsCreated, + MESSAGING_GROUPS_REUSED: messagingGroupsReused, + WIRINGS_CREATED: wiringsCreated, + WIRINGS_REUSED: wiringsReused, + SKIPPED: skipped, + CHANNELS: [...detectedChannels.keys()].join(','), + }, + notes: followups, + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_DB', { + STATUS: partial ? 'partial' : 'success', + SELECTION: parsed.selection, + V1_GROUPS_TOTAL: String(v1Groups.length), + SELECTED: String(selected.length), + AGENT_GROUPS_CREATED: String(agentGroupsCreated), + MESSAGING_GROUPS_CREATED: String(messagingGroupsCreated), + WIRINGS_CREATED: String(wiringsCreated), + SKIPPED: String(skipped), + CHANNELS: [...detectedChannels.keys()].join(',') || 'none', + }); +} diff --git a/setup/migrate-v1/detect.ts b/setup/migrate-v1/detect.ts new file mode 100644 index 0000000..983531d --- /dev/null +++ b/setup/migrate-v1/detect.ts @@ -0,0 +1,107 @@ +/** + * Step: migrate-detect + * + * Find a v1 install on disk. Scans the standard candidate paths; if none + * matches, exits with a NOT_FOUND status (the orchestrator then offers a + * clack prompt so the user can point at a custom path). + * + * Never prompts — this step is pure discovery so it stays safe to run under + * NANOCLAW_SKIP= without blocking on stdin. + */ +import fs from 'fs'; +import path from 'path'; + +import { emitStatus } from '../status.js'; +import { + defaultV1Candidates, + looksLikeV1Install, + readHandoff, + recordStep, + v1PathsFor, + writeHandoff, +} from './shared.js'; + +interface DetectArgs { + /** Explicit path to check, skipping the default candidate list. */ + path?: string; +} + +function parseArgs(args: string[]): DetectArgs { + const out: DetectArgs = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--path') { + out.path = args[++i] || undefined; + } + } + return out; +} + +export async function run(args: string[]): Promise { + const parsed = parseArgs(args); + + // An explicit path — either from --path or $NANOCLAW_V1_PATH — is + // authoritative. If it doesn't validate, we don't fall through to + // the default candidate list. That keeps the user's explicit intent + // from being silently overridden. + const envOverride = process.env.NANOCLAW_V1_PATH?.trim(); + const explicit = parsed.path ?? envOverride ?? null; + const candidates = explicit ? [explicit] : defaultV1Candidates(); + + for (const candidate of candidates) { + const absolute = path.resolve(candidate); + // Don't self-match — if the candidate resolves to the v2 checkout we're + // running inside, skip it. Protects users who cloned v2 into `~/nanoclaw` + // after deleting v1. + if (absolute === path.resolve(process.cwd())) continue; + + const check = looksLikeV1Install(absolute); + if (!check.ok) continue; + + const paths = v1PathsFor(absolute); + let version = 'unknown'; + try { + const pkg = JSON.parse(fs.readFileSync(paths.packageJson, 'utf-8')) as { version?: string }; + version = pkg.version ?? 'unknown'; + } catch { + // Already sanity-checked by looksLikeV1Install — a failure here means + // the file changed under us between calls. Unlikely, not fatal. + } + + const h = readHandoff(); + h.v1_path = absolute; + h.v1_version = version; + writeHandoff(h); + + recordStep('migrate-detect', { + status: 'success', + fields: { V1_PATH: absolute, V1_VERSION: version }, + notes: [], + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_DETECT', { + STATUS: 'success', + V1_PATH: absolute, + V1_VERSION: version, + DB_PATH: paths.db, + ENV_PATH: paths.env, + GROUPS_PATH: paths.groups, + }); + return; + } + + // Nothing matched. Not an error — most v2 installs are fresh, not migrations. + const scanned = candidates.map((c) => path.resolve(c)).join(','); + recordStep('migrate-detect', { + status: 'skipped', + fields: { REASON: 'no-v1-install-found' }, + notes: [`Scanned: ${scanned}`], + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_DETECT', { + STATUS: 'skipped', + REASON: 'not_found', + CANDIDATES_SCANNED: String(candidates.length), + }); +} diff --git a/setup/migrate-v1/env.ts b/setup/migrate-v1/env.ts new file mode 100644 index 0000000..e2530e0 --- /dev/null +++ b/setup/migrate-v1/env.ts @@ -0,0 +1,135 @@ +/** + * Step: migrate-env + * + * Copy every key from v1 `.env` to v2 `.env`. Preserves v2 values that + * already exist (never overwrites). Skips lines that don't look like a + * `KEY=value` pair. + * + * Why copy everything, not a curated list? v1 installs accumulate + * project-specific keys (custom MCP creds, feature flags, webhook tokens) + * that the migration can't enumerate ahead of time. The user explicitly + * asked for everything. We log what we carried so the skill can review. + * + * Security note: we do NOT log values here — only keys. The raw log already + * contains the file contents; we don't echo them to stdout. + */ +import fs from 'fs'; +import path from 'path'; + +import { emitStatus } from '../status.js'; +import { readHandoff, recordStep, v1PathsFor } from './shared.js'; + +interface EnvLine { + key: string; + value: string; + raw: string; +} + +function parseEnv(text: string): EnvLine[] { + const out: EnvLine[] = []; + for (const raw of text.split('\n')) { + const line = raw.trimEnd(); + if (!line) continue; + if (line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue; + const value = line.slice(eq + 1); + out.push({ key, value, raw: line }); + } + return out; +} + +export async function run(_args: string[]): Promise { + const h = readHandoff(); + if (!h.v1_path) { + recordStep('migrate-env', { + status: 'skipped', + fields: { REASON: 'detect-not-run' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_ENV', { STATUS: 'skipped', REASON: 'no_v1_path' }); + return; + } + + const paths = v1PathsFor(h.v1_path); + if (!fs.existsSync(paths.env)) { + recordStep('migrate-env', { + status: 'skipped', + fields: { REASON: 'v1-env-missing' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_ENV', { STATUS: 'skipped', REASON: 'v1_env_missing' }); + return; + } + + const v2EnvPath = path.join(process.cwd(), '.env'); + const v1Text = fs.readFileSync(paths.env, 'utf-8'); + const v1Lines = parseEnv(v1Text); + + let v2Text = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; + const v2Lines = parseEnv(v2Text); + const v2Keys = new Set(v2Lines.map((l) => l.key)); + + const copied: string[] = []; + const skipped: string[] = []; + const appended: string[] = []; + + // Tag the appended block so a later re-run can find it and not double-append. + const BLOCK_START = '# ── migrated from v1 ──'; + const alreadyMigrated = v2Text.includes(BLOCK_START); + + for (const line of v1Lines) { + if (v2Keys.has(line.key)) { + skipped.push(line.key); + continue; + } + copied.push(line.key); + appended.push(line.raw); + } + + if (appended.length > 0) { + const suffix = [ + v2Text.endsWith('\n') || v2Text === '' ? '' : '\n', + alreadyMigrated ? '' : `\n${BLOCK_START}\n`, + appended.join('\n'), + '\n', + ].join(''); + v2Text = v2Text + suffix; + fs.writeFileSync(v2EnvPath, v2Text); + } + + // Container reads from data/env/env (host mounts it). Keep it in sync. + const containerEnvDir = path.join(process.cwd(), 'data', 'env'); + try { + fs.mkdirSync(containerEnvDir, { recursive: true }); + fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env')); + } catch { + // Non-fatal; the service restart (later step) will rehydrate if needed. + } + + recordStep('migrate-env', { + status: 'success', + fields: { + KEYS_COPIED: copied.length, + KEYS_SKIPPED_EXISTING: skipped.length, + V1_ENV: paths.env, + V2_ENV: v2EnvPath, + }, + notes: [ + copied.length > 0 ? `Copied: ${copied.join(', ')}` : '', + skipped.length > 0 ? `Skipped (already in v2 .env): ${skipped.join(', ')}` : '', + ].filter(Boolean), + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_ENV', { + STATUS: 'success', + KEYS_COPIED: String(copied.length), + KEYS_SKIPPED_EXISTING: String(skipped.length), + COPIED_KEYS: copied.join(',') || 'none', + }); +} diff --git a/setup/migrate-v1/groups.ts b/setup/migrate-v1/groups.ts new file mode 100644 index 0000000..206f441 --- /dev/null +++ b/setup/migrate-v1/groups.ts @@ -0,0 +1,230 @@ +/** + * Step: migrate-groups + * + * Copy v1 group folders into v2. For each folder selected in migrate-db: + * - Create groups// in v2 if missing + * - Copy v1's CLAUDE.md to v2 as CLAUDE.local.md (v2 composes CLAUDE.md at + * container spawn — don't write directly to CLAUDE.md) + * - If v1 had a container_config JSON, write it to .v1-container-config.json + * for the /migrate-from-v1 skill to reconcile (v2's container.json shape + * has drifted enough that a silent 1:1 copy would be wrong) + * - Preserve any other non-standard files from the v1 folder (e.g. SOUL.md, + * personality.md, custom subdirs) — rsync-style, skipping destination files + * that already exist. + * + * Does not overwrite files already present in v2 — re-running is safe. + */ +import fs from 'fs'; +import path from 'path'; + +import Database from 'better-sqlite3'; + +import { log } from '../../src/log.js'; +import { emitStatus } from '../status.js'; +import { + readHandoff, + recordStep, + safeJsonStringify, + scanForV1Patterns, + v1PathsFor, + writeHandoff, +} from './shared.js'; + +const SKIP_NAMES = new Set(['CLAUDE.md', 'logs', '.git', '.DS_Store', 'node_modules']); + +/** + * Copy everything in src except SKIP_NAMES. CLAUDE.md is handled separately. + * Returns the count of files actually written (skipped-existing not counted). + */ +function copyTree(src: string, dst: string): number { + let written = 0; + if (!fs.existsSync(src)) return 0; + fs.mkdirSync(dst, { recursive: true }); + + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (SKIP_NAMES.has(entry.name)) continue; + const s = path.join(src, entry.name); + const d = path.join(dst, entry.name); + + if (entry.isDirectory()) { + written += copyTree(s, d); + continue; + } + // Don't clobber files v2 already has (e.g. CLAUDE.local.md that the + // operator already wrote). Append-only semantics for this step. + if (fs.existsSync(d)) continue; + fs.copyFileSync(s, d); + written += 1; + } + return written; +} + +export async function run(_args: string[]): Promise { + const h = readHandoff(); + if (!h.v1_path) { + recordStep('migrate-groups', { + status: 'skipped', + fields: { REASON: 'detect-not-run' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_GROUPS', { STATUS: 'skipped', REASON: 'no_v1_path' }); + return; + } + + if (h.group_selection.selected_folders.length === 0) { + recordStep('migrate-groups', { + status: 'skipped', + fields: { REASON: 'no-folders-selected' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_GROUPS', { STATUS: 'skipped', REASON: 'no_selection' }); + return; + } + + const paths = v1PathsFor(h.v1_path); + const v2GroupsDir = path.join(process.cwd(), 'groups'); + fs.mkdirSync(v2GroupsDir, { recursive: true }); + + // Pull container_config for each selected folder up-front so we can write + // the .v1-container-config.json sidecar without holding the DB open per-folder. + const containerConfigs = new Map(); + try { + const v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); + const rows = v1Db + .prepare('SELECT folder, container_config FROM registered_groups WHERE folder IN (SELECT value FROM json_each(?))') + .all(JSON.stringify(h.group_selection.selected_folders)) as Array<{ folder: string; container_config: string | null }>; + for (const r of rows) containerConfigs.set(r.folder, r.container_config); + v1Db.close(); + } catch (err) { + // Older sqlite without json_each would break the query. Fall back to + // per-folder reads — slower but reliable. + log.info('Falling back to per-folder container_config lookup', { err }); + try { + const v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); + const stmt = v1Db.prepare('SELECT container_config FROM registered_groups WHERE folder = ?'); + for (const folder of h.group_selection.selected_folders) { + const row = stmt.get(folder) as { container_config: string | null } | undefined; + containerConfigs.set(folder, row?.container_config ?? null); + } + v1Db.close(); + } catch { + // Give up — we still migrate files; the skill handles missing config. + } + } + + let foldersProcessed = 0; + let foldersSkippedMissing = 0; + let claudeMdMigrated = 0; + let claudeLocalPreserved = 0; + let containerConfigsStashed = 0; + let otherFilesCopied = 0; + const followups: string[] = []; + + for (const folder of h.group_selection.selected_folders) { + const v1Folder = path.join(paths.groups, folder); + const v2Folder = path.join(v2GroupsDir, folder); + + if (!fs.existsSync(v1Folder)) { + foldersSkippedMissing += 1; + followups.push( + `Folder "${folder}" was in v1's registered_groups but not on disk at ${v1Folder} — DB entry was seeded, no files to migrate.`, + ); + continue; + } + + fs.mkdirSync(v2Folder, { recursive: true }); + + // CLAUDE.md → CLAUDE.local.md. Don't write CLAUDE.md directly — v2's + // group-init.ts composes that file from shared + fragments + local. + const v1Claude = path.join(v1Folder, 'CLAUDE.md'); + const v2Local = path.join(v2Folder, 'CLAUDE.local.md'); + let claudeContent: string | null = null; + if (fs.existsSync(v1Claude)) { + if (fs.existsSync(v2Local)) { + claudeLocalPreserved += 1; + try { + claudeContent = fs.readFileSync(v2Local, 'utf-8'); + } catch { + claudeContent = null; + } + } else { + try { + claudeContent = fs.readFileSync(v1Claude, 'utf-8'); + fs.writeFileSync(v2Local, claudeContent); + claudeMdMigrated += 1; + } catch (err) { + followups.push(`Failed to copy CLAUDE.md for "${folder}": ${err instanceof Error ? err.message : err}`); + } + } + } + + // Scan the copied content for v1-specific infrastructure patterns. If we + // find any, add a followup so the /migrate-from-v1 skill can triage the + // file with the user. We DON'T edit the file — v1 CLAUDE.md can be + // author-specific and heuristic translation is worse than a flag. + if (claudeContent) { + const matches = scanForV1Patterns(claudeContent); + if (matches.length > 0) { + const summary = matches + .map((m) => `${m.description} (lines ${m.lines.join(',')})`) + .join('; '); + followups.push( + `Folder "${folder}" CLAUDE.local.md references v1-specific infrastructure: ${summary}. The skill should read the file and translate patterns using docs/v1-to-v2-changes.md.`, + ); + } + } + + // Stash container_config JSON so the skill can reconcile it. + const config = containerConfigs.get(folder); + if (config) { + const sidecar = path.join(v2Folder, '.v1-container-config.json'); + try { + // Pretty-print so humans can read it during reconciliation. + const parsed = JSON.parse(config) as unknown; + fs.writeFileSync(sidecar, safeJsonStringify(parsed)); + containerConfigsStashed += 1; + followups.push( + `Folder "${folder}" has a v1 container_config — stashed at ${path.relative(process.cwd(), sidecar)}. The /migrate-from-v1 skill will map it to v2's container.json shape.`, + ); + } catch { + // Non-JSON container_config — write raw so the skill can still read it. + fs.writeFileSync(sidecar, config); + containerConfigsStashed += 1; + } + } + + otherFilesCopied += copyTree(v1Folder, v2Folder); + foldersProcessed += 1; + } + + // Merge followups. + const handoffAfter = readHandoff(); + handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; + writeHandoff(handoffAfter); + + const partial = foldersSkippedMissing > 0; + recordStep('migrate-groups', { + status: partial ? 'partial' : 'success', + fields: { + FOLDERS_PROCESSED: foldersProcessed, + FOLDERS_SKIPPED_MISSING: foldersSkippedMissing, + CLAUDE_MD_MIGRATED: claudeMdMigrated, + CLAUDE_LOCAL_PRESERVED: claudeLocalPreserved, + CONTAINER_CONFIGS_STASHED: containerConfigsStashed, + OTHER_FILES_COPIED: otherFilesCopied, + }, + notes: followups, + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_GROUPS', { + STATUS: partial ? 'partial' : 'success', + FOLDERS_PROCESSED: String(foldersProcessed), + FOLDERS_SKIPPED_MISSING: String(foldersSkippedMissing), + CLAUDE_MD_MIGRATED: String(claudeMdMigrated), + CONTAINER_CONFIGS_STASHED: String(containerConfigsStashed), + OTHER_FILES_COPIED: String(otherFilesCopied), + }); +} diff --git a/setup/migrate-v1/shared.ts b/setup/migrate-v1/shared.ts new file mode 100644 index 0000000..f9f03bc --- /dev/null +++ b/setup/migrate-v1/shared.ts @@ -0,0 +1,639 @@ +/** + * Shared types, constants, and helpers for the v1 → v2 migration. + * + * The migration is a sequence of small steps registered in setup/index.ts + * (migrate-detect, migrate-validate, migrate-db, …). Every step: + * - Reads state it needs from `logs/setup-migration/handoff.json` + * - Writes its own outcome back to that handoff file + * - Emits exactly one `=== NANOCLAW SETUP: MIGRATE_ ===` block on stdout + * + * No step aborts the chain on failure — the orchestrator in setup/migrate-v1.ts + * reads the handoff after each step to decide whether to continue, skip, or + * hand off to the Claude `/migrate-from-v1` skill. + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// ── Paths ────────────────────────────────────────────────────────────── + +export const MIGRATION_DIR = path.join('logs', 'setup-migration'); +export const HANDOFF_PATH = path.join(MIGRATION_DIR, 'handoff.json'); +export const SCHEMA_MISMATCH_PATH = path.join(MIGRATION_DIR, 'schema-mismatch.json'); +export const INACTIVE_TASKS_PATH = path.join(MIGRATION_DIR, 'inactive-tasks.json'); + +// ── V1 install discovery ─────────────────────────────────────────────── + +/** + * Default candidate paths to scan for a v1 install. Combines: + * - `$NANOCLAW_V1_PATH` (explicit override, takes priority) + * - Sibling directories of the v2 checkout whose name contains "nanoclaw" + * or "clawdbot" (most common layout — v1 lives next to v2) + * - Common checkout locations under $HOME + * - Common XDG-style state dirs (.nanoclaw, .clawdbot — v1's predecessor) + * + * Kept generic — don't bake specific usernames in. Deduped so a path that + * satisfies multiple rules only appears once. + */ +export function defaultV1Candidates(): string[] { + const home = os.homedir(); + const cwd = process.cwd(); + const cwdParent = path.dirname(cwd); + + const siblings: string[] = []; + try { + if (fs.existsSync(cwdParent)) { + for (const entry of fs.readdirSync(cwdParent, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const lower = entry.name.toLowerCase(); + // Match anything claw-ish next to v2: "nanoclaw", "nanoclaw-v1", + // "clawdbot", user's fork name like "nanoclaw-prod". Excludes the + // v2 checkout we're running from so we don't self-match. + if (!lower.includes('claw')) continue; + const full = path.join(cwdParent, entry.name); + if (path.resolve(full) === path.resolve(cwd)) continue; + siblings.push(full); + } + } + } catch { + // Can't list parent — fall through to the fixed list. + } + + const fixed = [ + path.join(home, 'nanoclaw'), + path.join(home, '.nanoclaw'), + path.join(home, 'clawdbot'), + path.join(home, '.clawdbot'), + path.join(home, 'Code', 'nanoclaw'), + path.join(home, 'code', 'nanoclaw'), + path.join(home, 'projects', 'nanoclaw'), + path.join(home, 'Projects', 'nanoclaw'), + path.join(home, 'src', 'nanoclaw'), + path.join(home, 'dev', 'nanoclaw'), + path.join(home, 'workspace', 'nanoclaw'), + path.join(home, 'Documents', 'nanoclaw'), + path.join(home, 'GitHub', 'nanoclaw'), + path.join(home, 'github', 'nanoclaw'), + path.join(home, 'repos', 'nanoclaw'), + ]; + + // NANOCLAW_V1_PATH is handled authoritatively by detect.ts — if it's set, + // detect doesn't call this function at all. So we only build the + // auto-discovery list here. + const all = [...siblings, ...fixed]; + + // Dedupe by resolved path. A sibling "nanoclaw" and a fixed "$HOME/nanoclaw" + // often resolve to the same thing on single-user machines. + const seen = new Set(); + const out: string[] = []; + for (const p of all) { + const resolved = path.resolve(p); + if (seen.has(resolved)) continue; + seen.add(resolved); + out.push(p); + } + return out; +} + +export interface V1Paths { + root: string; + db: string; + env: string; + groups: string; + packageJson: string; +} + +/** + * Build the expected v1 file layout relative to a root. All paths are returned + * even if they don't exist — callers check existence on the ones they care about. + */ +export function v1PathsFor(root: string): V1Paths { + return { + root, + db: path.join(root, 'store', 'messages.db'), + env: path.join(root, '.env'), + groups: path.join(root, 'groups'), + packageJson: path.join(root, 'package.json'), + }; +} + +/** + * Quick "does this path look like a v1 install?" check — used by detect. + * + * Strategy: the strongest signal is `store/messages.db`, so that's required. + * The package.json check is a weaker corroboration — forks may rename + * `"name"` or strip it, so we allow: + * - `name` missing or non-string + * - `name` containing "nanoclaw" or "clawdbot" (case-insensitive) + * We reject only if `name` looks like a completely unrelated project, OR + * the version is 2.x (the v2 rewrite itself). + * + * This keeps stock + forked v1 installs detectable while filtering out + * unrelated repos that happen to have a `store/messages.db`. + */ +export function looksLikeV1Install(root: string): { ok: boolean; reason?: string } { + if (!fs.existsSync(root)) return { ok: false, reason: 'root_missing' }; + const { db, packageJson } = v1PathsFor(root); + if (!fs.existsSync(db)) return { ok: false, reason: 'db_missing' }; + + // package.json is optional — a user may have stripped it, or be running + // from a state-only dir (.nanoclaw). The DB shape is checked separately + // by migrate-validate, which is authoritative for "is this schema v1?" + if (!fs.existsSync(packageJson)) return { ok: true }; + + try { + const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf-8')) as { name?: string; version?: string }; + const name = (pkg.name ?? '').toLowerCase(); + if (pkg.version && /^2\./.test(pkg.version)) return { ok: false, reason: 'already_v2' }; + if (name && !name.includes('nanoclaw') && !name.includes('clawdbot')) { + return { ok: false, reason: 'unrelated_project' }; + } + } catch { + // Broken package.json doesn't rule out v1 — DB presence is enough. + return { ok: true }; + } + return { ok: true }; +} + +// ── Handoff state (single source of truth across sub-steps) ──────────── + +/** + * Rich state shared between migration sub-steps. Each step reads the whole + * file, merges its section, and writes it back. Never hand-edit — it's + * consumed by the `/migrate-from-v1` skill too. + * + * All paths stored are ABSOLUTE, so subsequent steps don't need to guess + * about cwd. Relative paths would be a footgun once the skill reads this + * file later from a different cwd. + */ +export interface Handoff { + version: 1; + started_at: string; + v1_path: string | null; + v1_version: string | null; + + /** Overall status once migrate-handoff finalizes the run. */ + overall_status: 'pending' | 'success' | 'partial' | 'failed' | 'skipped'; + + steps: Partial>; + + /** Group folders the user chose to bring over (migrate-db populates). */ + group_selection: { + mode: 'all' | 'wired-only' | 'cancelled' | null; + selected_folders: string[]; + total_v1_groups: number; + wired_v1_groups: number; + }; + + /** Distinct channels inferred from v1 registered_groups. */ + detected_channels: Array<{ + channel_type: string; + source: 'channel_name' | 'jid_prefix'; + group_count: number; + }>; + + /** Per-channel auth copy results (migrate-channel-auth populates). */ + channel_auth: Array<{ + channel_type: string; + env_keys_copied: string[]; + files_copied: string[]; + files_missing: string[]; + notes: string; + }>; + + /** Result of each `setup/install-.sh` invocation. */ + channels_installed: Array<{ + channel_type: string; + status: 'success' | 'failed' | 'skipped' | 'not_supported'; + error?: string; + }>; + + /** Scheduled-task migration results (migrate-tasks populates). */ + tasks: { + v1_active: number; + v1_inactive: number; + migrated: number; + failed: number; + skipped: number; + }; + + /** Things the skill must finish manually. Always safe to append to. */ + followups: string[]; +} + +export type MigrateStep = + | 'migrate-detect' + | 'migrate-validate' + | 'migrate-db' + | 'migrate-groups' + | 'migrate-env' + | 'migrate-channel-auth' + | 'migrate-channels' + | 'migrate-tasks' + | 'migrate-handoff'; + +export interface StepOutcome { + status: 'success' | 'partial' | 'failed' | 'skipped'; + fields: Record; + notes: string[]; + at: string; +} + +function emptyHandoff(): Handoff { + return { + version: 1, + started_at: new Date().toISOString(), + v1_path: null, + v1_version: null, + overall_status: 'pending', + steps: {}, + group_selection: { + mode: null, + selected_folders: [], + total_v1_groups: 0, + wired_v1_groups: 0, + }, + detected_channels: [], + channel_auth: [], + channels_installed: [], + tasks: { v1_active: 0, v1_inactive: 0, migrated: 0, failed: 0, skipped: 0 }, + followups: [], + }; +} + +/** Read the handoff, creating an empty one if it doesn't exist yet. */ +export function readHandoff(): Handoff { + fs.mkdirSync(MIGRATION_DIR, { recursive: true }); + if (!fs.existsSync(HANDOFF_PATH)) return emptyHandoff(); + try { + const parsed = JSON.parse(fs.readFileSync(HANDOFF_PATH, 'utf-8')) as Handoff; + if (parsed.version !== 1) throw new Error(`unsupported handoff version ${parsed.version}`); + return parsed; + } catch { + // Broken handoff shouldn't wedge the migration — start fresh and let the + // step that called us re-record its outcome. + return emptyHandoff(); + } +} + +/** Persist a handoff mutation atomically (write-tmp + rename). */ +export function writeHandoff(h: Handoff): void { + fs.mkdirSync(MIGRATION_DIR, { recursive: true }); + const tmp = HANDOFF_PATH + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(h, null, 2)); + fs.renameSync(tmp, HANDOFF_PATH); +} + +/** Convenience: merge a step outcome into the handoff and persist. */ +export function recordStep(step: MigrateStep, outcome: StepOutcome): void { + const h = readHandoff(); + h.steps[step] = outcome; + writeHandoff(h); +} + +// ── JID parsing + channel inference ──────────────────────────────────── + +/** + * v1 stored chat identifiers as `:` in `registered_groups.jid`. + * The prefix was often a short code (`dc` for Discord, `tg` for Telegram) + * that doesn't match v2's `channel_type` names. This table normalizes them. + * + * Unknown prefixes fall through as-is (`channel_type = prefix`) so a channel + * we didn't anticipate still ends up with a distinct messaging_group per + * chat — the skill can reconcile it interactively. + */ +export const JID_PREFIX_TO_CHANNEL: Record = { + dc: 'discord', + discord: 'discord', + tg: 'telegram', + telegram: 'telegram', + wa: 'whatsapp', + whatsapp: 'whatsapp', + slack: 'slack', + matrix: 'matrix', + mx: 'matrix', + teams: 'teams', + imessage: 'imessage', + im: 'imessage', + email: 'email', + webex: 'webex', + gchat: 'gchat', + linear: 'linear', + github: 'github', +}; + +export interface ParsedJid { + raw: string; + prefix: string; + id: string; + channel_type: string; +} + +export function parseJid(raw: string): ParsedJid | null { + const colon = raw.indexOf(':'); + if (colon === -1) return null; + const prefix = raw.slice(0, colon).toLowerCase(); + const id = raw.slice(colon + 1); + if (!prefix || !id) return null; + return { + raw, + prefix, + id, + channel_type: JID_PREFIX_TO_CHANNEL[prefix] ?? prefix, + }; +} + +/** + * Prefer an explicit v1 `channel_name` when one is set; fall back to the JID + * prefix. v1 left `channel_name` empty on most rows (it was a late addition), + * so the JID prefix is often the only honest source. + */ +export function inferChannelType(jid: string, channelName: string | null): string | null { + if (channelName && channelName.trim()) return channelName.trim(); + const parsed = parseJid(jid); + return parsed?.channel_type ?? null; +} + +/** + * v2's messaging_groups.platform_id is always prefixed with the channel_type + * (see setup/register.ts:118-120). This helper normalizes v1's `jid` into + * that shape so router lookups at runtime find the right row. + */ +export function v2PlatformId(channelType: string, jid: string): string { + const parsed = parseJid(jid); + const id = parsed?.id ?? jid; + return id.startsWith(`${channelType}:`) ? id : `${channelType}:${id}`; +} + +// ── Trigger rules → engage mode (ports migration 010's backfill) ─────── + +/** + * Mirrors the backfill() logic in src/db/migrations/010-engage-modes.ts so + * rows written by the migration land in the same shape as rows written by + * setup/register.ts (which goes through migration 010 at boot). + */ +export function triggerToEngage(input: { + trigger_pattern: string | null; + requires_trigger: number | null; +}): { + engage_mode: 'pattern' | 'mention' | 'mention-sticky'; + engage_pattern: string | null; +} { + const pattern = input.trigger_pattern && input.trigger_pattern.trim().length > 0 ? input.trigger_pattern : null; + const requiresTrigger = input.requires_trigger !== 0; // NULL/1 → true; 0 → false + + if (pattern === '.' || pattern === '.*') { + return { engage_mode: 'pattern', engage_pattern: '.' }; + } + if (pattern) { + return { engage_mode: 'pattern', engage_pattern: pattern }; + } + if (!requiresTrigger) { + return { engage_mode: 'pattern', engage_pattern: '.' }; + } + return { engage_mode: 'mention', engage_pattern: null }; +} + +// ── Channel auth registry (non-.env state per channel) ───────────────── + +/** + * Describes the auth surface for a channel beyond `.env`. Each entry tells + * the channel-auth step: + * + * - `v1EnvKeys`: env keys we might find on the v1 side and carry over + * - `requiredV2Keys`: env keys v2's adapter REQUIRES to boot — if missing + * from v2's merged .env after migrate-env runs, a followup is emitted so + * the user knows exactly what to add (and where to get it). + * - `candidatePaths`: relative paths under the v1 root that may hold + * on-disk auth state (WhatsApp keystore, matrix sync state, etc.) + * - `note`: short human-readable hint surfaced to the user + * + * Unknown channels fall through as {v1EnvKeys:[], requiredV2Keys:[], + * candidatePaths:[]} — the skill asks the user how to proceed. + * + * Keep `requiredV2Keys` honest: list only what the v2 adapter actually + * refuses to boot without. False positives spam the followups; false + * negatives let the agent silently fail. Verify against the actual + * `@chat-adapter/` package when adding/updating entries. + */ +export interface ChannelAuthSpec { + v1EnvKeys: string[]; + requiredV2Keys: { key: string; where: string }[]; + candidatePaths: string[]; + note?: string; +} + +export const CHANNEL_AUTH_REGISTRY: Record = { + discord: { + v1EnvKeys: ['DISCORD_BOT_TOKEN', 'DISCORD_CLIENT_ID', 'DISCORD_GUILD_ID'], + // v1 used raw discord.js (bot token only). v2 uses Chat SDK which needs + // the interaction-verification public key + application id on top. + requiredV2Keys: [ + { key: 'DISCORD_BOT_TOKEN', where: 'Discord Developer Portal → Application → Bot → Token' }, + { key: 'DISCORD_APPLICATION_ID', where: 'Discord Developer Portal → Application → General → Application ID' }, + { key: 'DISCORD_PUBLIC_KEY', where: 'Discord Developer Portal → Application → General → Public Key' }, + ], + candidatePaths: [], + note: 'v1 used raw discord.js (bot token only). v2 uses Chat SDK and needs APPLICATION_ID + PUBLIC_KEY too.', + }, + 'discord-supervisor': { + v1EnvKeys: ['DISCORD_SUPERVISOR_BOT_TOKEN'], + requiredV2Keys: [], + candidatePaths: [], + note: 'v1-specific secondary bot. v2 does not have a native supervisor channel; the token is preserved in .env for the skill to reconcile.', + }, + telegram: { + v1EnvKeys: ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_API_ID', 'TELEGRAM_API_HASH'], + requiredV2Keys: [ + { key: 'TELEGRAM_BOT_TOKEN', where: 'BotFather on Telegram → /mybots → Bot → API Token' }, + ], + candidatePaths: ['data/sessions/telegram', 'store/telegram-session'], + }, + whatsapp: { + v1EnvKeys: ['WHATSAPP_PHONE', 'WHATSAPP_OWNER'], + requiredV2Keys: [], + candidatePaths: [ + 'data/sessions/baileys', + 'data/baileys_auth', + 'store/auth_info_baileys', + 'store/baileys', + 'auth_info_baileys', + ], + note: 'Baileys keystore — copying is best-effort. Encryption sessions may still need a fresh pair via /add-whatsapp.', + }, + matrix: { + v1EnvKeys: ['MATRIX_HOMESERVER', 'MATRIX_USER_ID', 'MATRIX_ACCESS_TOKEN'], + requiredV2Keys: [ + { key: 'MATRIX_HOMESERVER', where: 'your Matrix homeserver URL (e.g. https://matrix.org)' }, + { key: 'MATRIX_ACCESS_TOKEN', where: 'Element → Settings → Help & About → Access Token (keep secret)' }, + ], + candidatePaths: ['data/matrix-store', 'store/matrix', 'data/sessions/matrix'], + }, + slack: { + v1EnvKeys: ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'SLACK_SIGNING_SECRET'], + requiredV2Keys: [ + { key: 'SLACK_BOT_TOKEN', where: 'Slack app → OAuth & Permissions → Bot User OAuth Token (xoxb-…)' }, + { key: 'SLACK_SIGNING_SECRET', where: 'Slack app → Basic Information → Signing Secret' }, + ], + candidatePaths: [], + }, + teams: { + v1EnvKeys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_TENANT_ID'], + requiredV2Keys: [ + { key: 'TEAMS_APP_ID', where: 'Azure portal → App registration → Application (client) ID' }, + { key: 'TEAMS_APP_PASSWORD', where: 'Azure portal → App registration → Certificates & secrets' }, + ], + candidatePaths: [], + }, + imessage: { + v1EnvKeys: ['IMESSAGE_PHOTON_URL', 'IMESSAGE_PHOTON_TOKEN'], + requiredV2Keys: [], + candidatePaths: ['data/imessage', 'store/imessage'], + }, + webex: { + v1EnvKeys: ['WEBEX_BOT_TOKEN'], + requiredV2Keys: [{ key: 'WEBEX_BOT_TOKEN', where: 'Webex developer portal → Bot → Bot Access Token' }], + candidatePaths: [], + }, + gchat: { + v1EnvKeys: ['GCHAT_SERVICE_ACCOUNT', 'GCHAT_WEBHOOK_URL'], + requiredV2Keys: [], + candidatePaths: ['data/gchat-credentials.json', 'store/gchat-sa.json'], + }, + resend: { + v1EnvKeys: ['RESEND_API_KEY', 'RESEND_FROM'], + requiredV2Keys: [{ key: 'RESEND_API_KEY', where: 'resend.com → API Keys' }], + candidatePaths: [], + }, + github: { + v1EnvKeys: ['GITHUB_WEBHOOK_SECRET', 'GITHUB_APP_ID', 'GITHUB_PRIVATE_KEY_PATH'], + requiredV2Keys: [], + candidatePaths: [], + note: 'Webhook channel — secrets carry over, but GitHub webhook URLs are new per v2 install.', + }, + linear: { + v1EnvKeys: ['LINEAR_API_KEY', 'LINEAR_WEBHOOK_SECRET'], + requiredV2Keys: [{ key: 'LINEAR_API_KEY', where: 'Linear → Settings → API → Personal API keys' }], + candidatePaths: [], + }, +}; + +/** + * Map a v2 `channel_type` name to the corresponding `setup/install-.sh` + * script, if one exists. `null` means no v2 skill is available yet — the + * handoff lists the channel as "not supported" and the skill raises it with + * the user. + */ +export function installScriptForChannel(channelType: string): string | null { + const known = new Set([ + 'discord', + 'telegram', + 'whatsapp', + 'whatsapp-cloud', + 'teams', + 'slack', + 'matrix', + 'imessage', + 'webex', + 'gchat', + 'resend', + 'github', + 'linear', + ]); + if (!known.has(channelType)) return null; + return `setup/install-${channelType}.sh`; +} + +// ── Misc helpers ─────────────────────────────────────────────────────── + +export function generateId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +// ── v1-specific pattern scan (for migrate-groups) ────────────────────── + +/** + * Tight set of v1-only infrastructure patterns. When one of these shows up + * in a copied CLAUDE.md, the content referencing v1 plumbing that is genuinely + * gone in v2 (IPC file queue, single-DB paths, v1 pr-factory conventions). + * + * Deliberately excludes portable patterns — `mcp__nanoclaw__*` tool names, + * `agent-browser`, generic `/workspace/` paths — which v2 supports the same + * way. The list is scan-only; the migration does NOT modify file content. It + * just adds a followup so the /migrate-from-v1 skill can triage each file + * with the user. + * + * Keep this list conservative: false positives spam the skill with noise, + * false negatives leave the user with silently-broken agents. When adding, + * include a comment naming the specific v1 thing each pattern points at. + */ +export interface V1PatternMatch { + pattern: string; + description: string; + lines: number[]; +} + +const V1_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ + { + pattern: /\/workspace\/ipc\/tasks/, + description: "v1 IPC file queue (gone in v2 — agents talk to the host via session DBs, not JSON files)", + }, + { + pattern: /\/workspace\/extra\/project\/store\b/, + description: "v1-specific mount + store/ path (v2 mounts differ; state lives under data/)", + }, + { + pattern: /\bstore\/messages\.db\b/, + description: "v1 central DB path (v2 uses data/v2.db + data/v2-sessions//{inbound,outbound}.db)", + }, + { + pattern: /"clear_session"|"retrigger"/, + description: "v1 IPC task types (no v2 equivalent; use session lifecycle + the scheduling MCP tool instead)", + }, + { + pattern: /\[PR_CONTEXT:/, + description: "v1 pr-factory context-tag convention (specific to the supervisor group; needs reworking in v2)", + }, + { + pattern: /\brequires_trigger\b|\btrigger_pattern\b/, + description: "v1 column names on registered_groups (v2 uses engage_mode + engage_pattern on messaging_group_agents)", + }, + { + pattern: /\bchatJid\b(?!\s*[:=]\s*["']dc:)/, + description: "v1 routing key (v2 uses messaging_group_id or channel_type+platform_id)", + }, +]; + +/** Scan a CLAUDE.md-ish text blob for v1-specific infrastructure patterns. */ +export function scanForV1Patterns(text: string): V1PatternMatch[] { + const matches: V1PatternMatch[] = []; + const lines = text.split('\n'); + + for (const entry of V1_PATTERNS) { + const hitLines: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (entry.pattern.test(lines[i])) { + hitLines.push(i + 1); + } + } + if (hitLines.length > 0) { + matches.push({ + pattern: entry.pattern.source, + description: entry.description, + // Cap to first 5 line numbers — we're generating a followup summary, + // not a code index. Full context is in the file itself. + lines: hitLines.slice(0, 5), + }); + } + } + + return matches; +} + +export function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return '{"error":"unserializable"}'; + } +} diff --git a/setup/migrate-v1/tasks.ts b/setup/migrate-v1/tasks.ts new file mode 100644 index 0000000..836be7c --- /dev/null +++ b/setup/migrate-v1/tasks.ts @@ -0,0 +1,307 @@ +/** + * Step: migrate-tasks + * + * Port v1's `scheduled_tasks` into v2's session inbound DBs. v1 had a + * dedicated table with its own scheduling grammar; v2 treats tasks as + * `messages_in` rows with `kind='task'`, `process_after`, and `recurrence` + * (cron string). See docs/v1-to-v2-changes.md "Scheduling". + * + * Flow per v1 row: + * 1. Resolve (agent_group_id, messaging_group_id) from v1 (group_folder, chat_jid) + * 2. resolveSession() — creates the session on demand if absent + * 3. insertTask() into the session's inbound.db + * + * Active v1 rows (status='active') are migrated. Completed/stopped rows get + * exported to logs/setup-migration/inactive-tasks.json for reference. + * + * v1's schedule_type / schedule_value are mapped to cron here. Known types: + * 'cron' → schedule_value is already a cron string + * 'interval' → e.g. '5m'/'1h' → cron equivalent (best effort) + * 'once' → no recurrence, process_after = schedule_value if parseable + * Unknown types go to inactive-tasks.json with a note. + */ +import fs from 'fs'; +import path from 'path'; + +import Database from 'better-sqlite3'; + +import { DATA_DIR } from '../../src/config.js'; +import { initDb, closeDb } from '../../src/db/connection.js'; +import { getAgentGroupByFolder } from '../../src/db/agent-groups.js'; +import { getMessagingGroupByPlatform } from '../../src/db/messaging-groups.js'; +import { runMigrations } from '../../src/db/migrations/index.js'; +import { log } from '../../src/log.js'; +import { insertTask } from '../../src/modules/scheduling/db.js'; +import { openInboundDb, resolveSession } from '../../src/session-manager.js'; +import { emitStatus } from '../status.js'; +import { + INACTIVE_TASKS_PATH, + MIGRATION_DIR, + inferChannelType, + readHandoff, + recordStep, + safeJsonStringify, + v1PathsFor, + v2PlatformId, + writeHandoff, +} from './shared.js'; + +interface V1Task { + id: string; + group_folder: string; + chat_jid: string; + prompt: string; + schedule_type: string; + schedule_value: string; + next_run: string | null; + last_run: string | null; + status: string; + context_mode: string | null; + script: string | null; +} + +/** Convert v1 schedule_type + schedule_value into (processAfter, recurrence). */ +function toProcessAfterAndRecurrence(t: V1Task): { + processAfter: string; + recurrence: string | null; + note?: string; +} | null { + const now = new Date().toISOString(); + + if (t.schedule_type === 'cron') { + // Validate shape — 5 or 6 fields separated by whitespace. cron-parser is + // the runtime source of truth; here we just reject obvious garbage so + // we don't insert tasks that will explode on the first sweep tick. + const fields = t.schedule_value.trim().split(/\s+/).length; + if (fields < 5 || fields > 6) return null; + return { + processAfter: t.next_run || now, + recurrence: t.schedule_value.trim(), + }; + } + + if (t.schedule_type === 'interval') { + // '5m' → '*/5 * * * *'; '1h' → '0 * * * *'; '1d' → '0 0 * * *'. + // Best effort — any unit we don't recognize falls through to null. + const m = /^(\d+)([smhd])$/.exec(t.schedule_value.trim()); + if (!m) return null; + const n = parseInt(m[1], 10); + const unit = m[2]; + if (!n || n < 1) return null; + let cron: string | null = null; + if (unit === 'm' && n < 60) cron = `*/${n} * * * *`; + else if (unit === 'h' && n < 24) cron = `0 */${n} * * *`; + else if (unit === 'd' && n < 28) cron = `0 0 */${n} * *`; + if (!cron) return null; + return { processAfter: t.next_run || now, recurrence: cron }; + } + + if (t.schedule_type === 'once' || t.schedule_type === 'at') { + return { + processAfter: t.next_run || t.schedule_value || now, + recurrence: null, + }; + } + + return null; +} + +export async function run(_args: string[]): Promise { + const h = readHandoff(); + if (!h.v1_path) { + recordStep('migrate-tasks', { + status: 'skipped', + fields: { REASON: 'detect-not-run' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_TASKS', { STATUS: 'skipped', REASON: 'no_v1_path' }); + return; + } + + const validate = h.steps['migrate-validate']; + if (validate && validate.status === 'failed') { + recordStep('migrate-tasks', { + status: 'skipped', + fields: { REASON: 'validate-failed' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_TASKS', { STATUS: 'skipped', REASON: 'validate_failed' }); + return; + } + + const paths = v1PathsFor(h.v1_path); + + // Read v1 tasks into memory so we can close the v1 DB before we open v2's + // central DB via initDb() (which is a module singleton and doesn't love + // having two files open through it). + let activeTasks: V1Task[] = []; + let inactiveTasks: V1Task[] = []; + try { + const v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); + const all = v1Db.prepare('SELECT * FROM scheduled_tasks').all() as V1Task[]; + v1Db.close(); + activeTasks = all.filter((t) => t.status === 'active'); + inactiveTasks = all.filter((t) => t.status !== 'active'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + recordStep('migrate-tasks', { + status: 'failed', + fields: { REASON: 'v1-read-failed' }, + notes: [message], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_TASKS', { STATUS: 'failed', REASON: 'v1_read_failed', ERROR: message }); + return; + } + + if (activeTasks.length === 0 && inactiveTasks.length === 0) { + recordStep('migrate-tasks', { + status: 'skipped', + fields: { REASON: 'no-v1-tasks' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_TASKS', { STATUS: 'skipped', REASON: 'no_v1_tasks' }); + return; + } + + // Dump inactive tasks for reference — always, even if there are no active ones. + if (inactiveTasks.length > 0) { + fs.mkdirSync(MIGRATION_DIR, { recursive: true }); + fs.writeFileSync(INACTIVE_TASKS_PATH, safeJsonStringify({ tasks: inactiveTasks })); + } + + // Connect to v2 central DB to resolve (folder → ag) and (channel+pid → mg). + const v2Path = path.join(DATA_DIR, 'v2.db'); + fs.mkdirSync(path.dirname(v2Path), { recursive: true }); + const v2Db = initDb(v2Path); + runMigrations(v2Db); + + const followups: string[] = []; + let migrated = 0; + let failed = 0; + let skipped = 0; + + for (const t of activeTasks) { + try { + const ag = getAgentGroupByFolder(t.group_folder); + if (!ag) { + skipped += 1; + followups.push( + `Task "${t.id}" (folder "${t.group_folder}"): agent_group not seeded in v2 — run migrate-db first or deselect the task.`, + ); + continue; + } + + const channelType = inferChannelType(t.chat_jid, null); + if (!channelType) { + skipped += 1; + followups.push(`Task "${t.id}": could not infer channel from chat_jid "${t.chat_jid}".`); + continue; + } + const platformId = v2PlatformId(channelType, t.chat_jid); + const mg = getMessagingGroupByPlatform(channelType, platformId); + if (!mg) { + skipped += 1; + followups.push( + `Task "${t.id}": messaging_group for (${channelType}, ${platformId}) not seeded. Add the channel then re-run this step.`, + ); + continue; + } + + const scheduling = toProcessAfterAndRecurrence(t); + if (!scheduling) { + skipped += 1; + followups.push( + `Task "${t.id}": schedule_type "${t.schedule_type}" / value "${t.schedule_value}" did not map to a v2 cron — exported to inactive-tasks.json for manual review.`, + ); + inactiveTasks.push(t); + continue; + } + + // resolveSession creates (ag, mg) session if not present; 'shared' mode + // matches v1 which had one session per group_folder. + const { session } = resolveSession(ag.id, mg.id, null, 'shared'); + const inboxDb = openInboundDb(ag.id, session.id); + try { + // Idempotence: skip if we've already migrated this task id. We use the + // v1 task id verbatim as the v2 messages_in.id (stable — lets users + // re-run migration without duplicate-key errors or shadow tasks). + const existing = inboxDb + .prepare("SELECT id FROM messages_in WHERE id = ? AND kind = 'task'") + .get(t.id) as { id: string } | undefined; + if (existing) { + skipped += 1; + continue; + } + + insertTask(inboxDb, { + id: t.id, + processAfter: scheduling.processAfter, + recurrence: scheduling.recurrence, + platformId, + channelType, + threadId: null, + content: JSON.stringify({ + prompt: t.prompt, + script: t.script ?? null, + migrated_from_v1: { original_id: t.id, context_mode: t.context_mode ?? null }, + }), + }); + } finally { + inboxDb.close(); + } + + log.info('Migrated v1 scheduled task', { taskId: t.id, session: session.id, mg: mg.id }); + migrated += 1; + } catch (err) { + failed += 1; + const message = err instanceof Error ? err.message : String(err); + followups.push(`Task "${t.id}" failed to migrate: ${message}`); + } + } + + // Re-dump inactive tasks in case scheduling-translation pushed any in. + if (inactiveTasks.length > 0) { + fs.writeFileSync(INACTIVE_TASKS_PATH, safeJsonStringify({ tasks: inactiveTasks })); + } + + closeDb(); + + const handoffAfter = readHandoff(); + handoffAfter.tasks = { + v1_active: activeTasks.length, + v1_inactive: inactiveTasks.length, + migrated, + failed, + skipped, + }; + handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; + writeHandoff(handoffAfter); + + const partial = failed > 0 || skipped > 0; + recordStep('migrate-tasks', { + status: failed > 0 ? 'partial' : partial ? 'partial' : 'success', + fields: { + V1_ACTIVE: activeTasks.length, + V1_INACTIVE: inactiveTasks.length, + MIGRATED: migrated, + FAILED: failed, + SKIPPED: skipped, + INACTIVE_EXPORT: inactiveTasks.length > 0 ? INACTIVE_TASKS_PATH : '', + }, + notes: followups, + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_TASKS', { + STATUS: partial ? 'partial' : 'success', + V1_ACTIVE: String(activeTasks.length), + V1_INACTIVE: String(inactiveTasks.length), + MIGRATED: String(migrated), + FAILED: String(failed), + SKIPPED: String(skipped), + }); +} diff --git a/setup/migrate-v1/validate.ts b/setup/migrate-v1/validate.ts new file mode 100644 index 0000000..73cd377 --- /dev/null +++ b/setup/migrate-v1/validate.ts @@ -0,0 +1,213 @@ +/** + * Step: migrate-validate + * + * Before touching v1 data, assert the DB has the shape we expect. We know + * v1's schema (see docs/v1-to-v2-changes.md "Entity model") — different + * shapes happened over v1's development, but by v1.2.x the `registered_groups` + * columns and `scheduled_tasks` columns stabilized. If we see something else, + * we bail early so later steps don't write garbage to v2.db. + * + * Output: + * - `logs/setup-migration/schema-mismatch.json` on failure (read by the skill) + * - Status block MIGRATE_VALIDATE with OK/FAILED + * - Even on failure, subsequent steps still run — they'll short-circuit + * on their own if validate marked the DB unusable. This keeps env + group + * folder migration working when only the DB is broken. + */ +import fs from 'fs'; + +import Database from 'better-sqlite3'; + +import { emitStatus } from '../status.js'; +import { + SCHEMA_MISMATCH_PATH, + readHandoff, + recordStep, + safeJsonStringify, + v1PathsFor, +} from './shared.js'; + +const EXPECTED_TABLES = [ + 'registered_groups', + 'scheduled_tasks', + 'chats', + 'messages', + 'sessions', + 'router_state', +]; + +const REQUIRED_REGISTERED_GROUPS_COLUMNS = [ + 'jid', + 'name', + 'folder', + 'trigger_pattern', + 'added_at', + 'requires_trigger', +]; + +const REQUIRED_SCHEDULED_TASKS_COLUMNS = [ + 'id', + 'group_folder', + 'chat_jid', + 'prompt', + 'schedule_type', + 'schedule_value', + 'status', +]; + +interface TableInfo { + table: string; + columns: string[]; + missing_columns: string[]; +} + +export async function run(_args: string[]): Promise { + const h = readHandoff(); + if (!h.v1_path) { + recordStep('migrate-validate', { + status: 'skipped', + fields: { REASON: 'detect-not-run' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_VALIDATE', { STATUS: 'skipped', REASON: 'no_v1_path' }); + return; + } + + const paths = v1PathsFor(h.v1_path); + if (!fs.existsSync(paths.db)) { + recordStep('migrate-validate', { + status: 'failed', + fields: { REASON: 'db-missing', DB_PATH: paths.db }, + notes: ['v1 DB file does not exist at expected path'], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_VALIDATE', { + STATUS: 'failed', + REASON: 'db_missing', + DB_PATH: paths.db, + }); + return; + } + + let db: Database.Database; + try { + db = new Database(paths.db, { readonly: true, fileMustExist: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + recordStep('migrate-validate', { + status: 'failed', + fields: { REASON: 'db-open-failed' }, + notes: [message], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_VALIDATE', { + STATUS: 'failed', + REASON: 'db_open_failed', + ERROR: message, + }); + return; + } + + try { + const tableRows = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() as Array<{ name: string }>; + const tables = new Set(tableRows.map((r) => r.name)); + + const missingTables = EXPECTED_TABLES.filter((t) => !tables.has(t)); + const tableInfos: TableInfo[] = []; + + for (const t of EXPECTED_TABLES) { + if (!tables.has(t)) continue; + const cols = db.prepare(`PRAGMA table_info(${t})`).all() as Array<{ name: string }>; + const columnNames = cols.map((c) => c.name); + const missing = + t === 'registered_groups' + ? REQUIRED_REGISTERED_GROUPS_COLUMNS.filter((c) => !columnNames.includes(c)) + : t === 'scheduled_tasks' + ? REQUIRED_SCHEDULED_TASKS_COLUMNS.filter((c) => !columnNames.includes(c)) + : []; + tableInfos.push({ table: t, columns: columnNames, missing_columns: missing }); + } + + const columnMismatches = tableInfos.filter((t) => t.missing_columns.length > 0); + const groupCount = + tables.has('registered_groups') + ? ((db.prepare('SELECT COUNT(*) AS c FROM registered_groups').get() as { c: number }).c) + : 0; + const taskCount = + tables.has('scheduled_tasks') + ? ((db.prepare('SELECT COUNT(*) AS c FROM scheduled_tasks').get() as { c: number }).c) + : 0; + + db.close(); + + if (missingTables.length > 0 || columnMismatches.length > 0) { + const mismatch = { + v1_path: h.v1_path, + v1_version: h.v1_version, + present_tables: [...tables].sort(), + missing_tables: missingTables, + column_mismatches: columnMismatches, + scanned_at: new Date().toISOString(), + }; + fs.writeFileSync(SCHEMA_MISMATCH_PATH, safeJsonStringify(mismatch)); + + recordStep('migrate-validate', { + status: 'failed', + fields: { + MISSING_TABLES: missingTables.join(',') || 'none', + COLUMN_MISMATCHES: String(columnMismatches.length), + REPORT: SCHEMA_MISMATCH_PATH, + }, + notes: [ + missingTables.length > 0 ? `Missing tables: ${missingTables.join(', ')}` : '', + columnMismatches.length > 0 + ? `Column mismatches in: ${columnMismatches.map((c) => c.table).join(', ')}` + : '', + ].filter(Boolean), + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_VALIDATE', { + STATUS: 'failed', + REASON: 'schema_mismatch', + MISSING_TABLES: missingTables.join(',') || 'none', + COLUMN_MISMATCHES: String(columnMismatches.length), + REPORT: SCHEMA_MISMATCH_PATH, + }); + return; + } + + recordStep('migrate-validate', { + status: 'success', + fields: { + V1_GROUPS: groupCount, + V1_TASKS: taskCount, + }, + notes: [], + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_VALIDATE', { + STATUS: 'success', + V1_GROUPS: String(groupCount), + V1_TASKS: String(taskCount), + }); + } catch (err) { + db.close(); + const message = err instanceof Error ? err.message : String(err); + recordStep('migrate-validate', { + status: 'failed', + fields: { REASON: 'validate-error' }, + notes: [message], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_VALIDATE', { + STATUS: 'failed', + REASON: 'validate_error', + ERROR: message, + }); + } +} From e1c8876a728493a7c940ff63d7c0321340f60a90 Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Thu, 23 Apr 2026 12:30:27 +0000 Subject: [PATCH 011/144] feat(migrate-v1): auto-resolve missing v2 channel keys via adapter APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `migrate-channel-auth` now tries to derive v2-required keys that v1 never stored by calling the channel's API with the credential v1 did have. When the gap can be closed automatically, the keys land in v2 `.env` before the missing-required check, and the step reports `success` instead of `partial`. When it can't, the existing followup fires unchanged. ## Discord v1 used raw `discord.js` (bot token only). v2's Chat SDK needs `DISCORD_APPLICATION_ID` + `DISCORD_PUBLIC_KEY`. Both can be fetched with the bot token via: GET /oauth2/applications/@me Authorization: Bot → { id, verify_key, … } For a stock v1 Discord user, this means `bash nanoclaw.sh` now produces a fully working v2 Discord adapter with zero manual key-setting — just stop v1, and v2 takes over. ## Surface - `autoResolveV2Keys(channelType, lookup)` in `setup/migrate-v1/shared.ts` — pluggable per-channel resolver, returns a `{key: value}` map. Never throws; returns `{}` on any failure (network, auth, unexpected shape). Logs keys resolved, never values. - `migrate-channel-auth` wiring: build a lookup over v1 + v2 .env, call the resolver, append resolved keys to v2 .env (never overwriting), sync to `data/env/env`, then re-check `requiredV2Keys` to compute the real gap. Sidecar annotation `(auto-resolved)` on `env_keys_copied` in the handoff so the skill can tell which came from v1 vs derived. ## Extending to other channels Slack has `/auth.test` (bot token → team/app info), Telegram has `/getMe`, Matrix has `/whoami`. Most don't cover the full required-key set v2 needs (e.g. Slack's `SLACK_SIGNING_SECRET` lives only in app config and has no API equivalent). Add resolvers case-by-case when the API supports it; the registry's `requiredV2Keys` + followup fallback covers the rest. ## Testing - Stripped `DISCORD_APPLICATION_ID` + `DISCORD_PUBLIC_KEY` from v2 `.env` - Re-ran migration (wired-only, 301 groups): resolver populated both keys via the API; `migrate-channel-auth: success` (was `partial`); `overall_status: success` - Restarted v2: Discord adapter booted clean, Gateway connected, `GUILD_CREATE` received - v1 stopped, v2 handling Discord traffic --- setup/migrate-v1/channel-auth.ts | 53 ++++++++++++++++++++++++++++++-- setup/migrate-v1/shared.ts | 44 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/setup/migrate-v1/channel-auth.ts b/setup/migrate-v1/channel-auth.ts index 1ef2bc4..8c8a415 100644 --- a/setup/migrate-v1/channel-auth.ts +++ b/setup/migrate-v1/channel-auth.ts @@ -17,6 +17,7 @@ import path from 'path'; import { emitStatus } from '../status.js'; import { CHANNEL_AUTH_REGISTRY, + autoResolveV2Keys, readHandoff, recordStep, v1PathsFor, @@ -126,8 +127,56 @@ export async function run(_args: string[]): Promise { // Check v2's .env for required keys the v2 adapter needs to boot. v1 // may not have had all of them (e.g. v1's Discord used discord.js // directly and never stored DISCORD_PUBLIC_KEY which v2's Chat SDK - // requires). Surface missing ones as actionable followups. + // requires). Try to auto-resolve the gap by calling the channel's API + // with the v1 credential; fall through to a followup for anything we + // can't resolve. const v2EnvPath = path.join(process.cwd(), '.env'); + const v1EnvMap = new Map(); + for (const line of v1Env.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + v1EnvMap.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1)); + } + + // Also let the resolver reach into v2's .env (migrate-env already merged + // v1 keys into v2). Either source is fine for derivation inputs. + const v2EnvPre = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; + const v2EnvPreMap = new Map(); + for (const line of v2EnvPre.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + v2EnvPreMap.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1)); + } + + const resolved = await autoResolveV2Keys( + ch.channel_type, + (key) => v1EnvMap.get(key) ?? v2EnvPreMap.get(key), + ); + const resolvedKeys = Object.keys(resolved); + if (resolvedKeys.length > 0) { + // Append to v2 .env (never overwriting existing values) + sync the + // container-side copy. Log keys, never values. + let text = v2EnvPre; + if (text && !text.endsWith('\n')) text += '\n'; + for (const [key, value] of Object.entries(resolved)) { + if (v2EnvPreMap.has(key)) continue; + text += `${key}=${value}\n`; + } + fs.writeFileSync(v2EnvPath, text); + try { + const containerEnvDir = path.join(process.cwd(), 'data', 'env'); + fs.mkdirSync(containerEnvDir, { recursive: true }); + fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env')); + } catch { + // Best-effort; service restart rehydrates it if needed. + } + } + + // Re-read v2 .env after possible resolution to compute the real gap. const v2Env = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; const v2EnvKeys = new Set( v2Env @@ -179,7 +228,7 @@ export async function run(_args: string[]): Promise { results.push({ channel_type: ch.channel_type, - env_keys_copied: envKeysPresentInV1, + env_keys_copied: [...envKeysPresentInV1, ...resolvedKeys.map((k) => `${k} (auto-resolved)`)], files_copied: filesCopied, files_missing: filesMissing, notes: spec.note ?? '', diff --git a/setup/migrate-v1/shared.ts b/setup/migrate-v1/shared.ts index f9f03bc..bc5d3dd 100644 --- a/setup/migrate-v1/shared.ts +++ b/setup/migrate-v1/shared.ts @@ -518,6 +518,50 @@ export const CHANNEL_AUTH_REGISTRY: Record = { }, }; +/** + * For channels where v2's adapter needs keys v1 never stored (e.g. Discord's + * Chat SDK wants DISCORD_APPLICATION_ID + DISCORD_PUBLIC_KEY, but v1 used + * raw discord.js with just the bot token), try to derive the missing keys + * from the v1 creds we already have by calling the channel's API. + * + * Returns a map of key → value for what we successfully resolved. + * Never throws; returns `{}` on any failure (network, auth, unexpected + * shape). The caller writes the resolved keys to v2 .env, then re-checks + * `requiredV2Keys` so the step reports `success` instead of `partial` when + * auto-resolution covered the gap. + * + * Adding a new channel resolver: pull the needed values from an endpoint + * that accepts only the v1-side credential (bot token, API key). Don't + * prompt, don't log values. If the endpoint has rate limits, keep this + * best-effort and fail silently. + */ +export async function autoResolveV2Keys( + channelType: string, + v1EnvLookup: (key: string) => string | undefined, +): Promise> { + if (channelType === 'discord') { + const token = v1EnvLookup('DISCORD_BOT_TOKEN'); + if (!token) return {}; + try { + const resp = await fetch('https://discord.com/api/v10/oauth2/applications/@me', { + headers: { Authorization: `Bot ${token}` }, + }); + if (!resp.ok) return {}; + const data = (await resp.json()) as { id?: string; verify_key?: string }; + const out: Record = {}; + if (typeof data.id === 'string' && data.id) out.DISCORD_APPLICATION_ID = data.id; + if (typeof data.verify_key === 'string' && data.verify_key) { + out.DISCORD_PUBLIC_KEY = data.verify_key; + } + return out; + } catch { + return {}; + } + } + + return {}; +} + /** * Map a v2 `channel_type` name to the corresponding `setup/install-.sh` * script, if one exists. `null` means no v2 skill is available yet — the From 9faa8a9a2c6824e28c56d2e05a273354c9503b70 Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Thu, 23 Apr 2026 12:41:33 +0000 Subject: [PATCH 012/144] fix(migrate-v1): splice guild_id into Discord platform_id during seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2's Chat SDK Discord adapter emits `platform_id` as `discord::` at runtime, but v1 only stored `dc:` (no guild). Before this fix `migrate-db` wrote `discord:` into `messaging_groups.platform_id`, which didn't match what v2 saw on incoming messages — v2 treated every message as a new channel and fired its channel-registration approval flow instead of routing to the migrated agent_group. Now `migrate-db` fetches the bot's guilds once per channel_type via `GET /users/@me/guilds`. When the bot is in exactly one guild (the common case), the guild id is spliced into every Discord platform_id at seed time — matching v2's runtime format. Multi-guild bots fall back to the v1-format id; v2's channel-registration flow repairs on first message. Cost: one extra Discord API call per migration run (not per channel). No new failure modes — network/auth issues return null, fall through to the existing behavior. ## Surface - `v2PlatformId(channelType, jid, { guildId })` — new optional `extra` parameter. Back-compat with existing callers. - `fetchBotGuilds(channelType, lookup)` — new helper in `shared.ts`, same pattern as `autoResolveV2Keys`. Handles Discord today; extending to other channels is a case-by-case API check. - `migrate-db` pre-loop: builds `v1EnvMap`, fetches guilds per channel type, caches single-guild IDs for the row loop. ## Testing Verified on a 300-channel Discord v1 install: - Fresh run produced `discord::` platform_ids from the start - Incoming messages now route to the migrated agent_group instead of firing the unwire approval flow Rate-limit note: `/users/@me/guilds` is a single call. Per-channel `/guilds//channels` lookups for multi-guild bots would need proper rate-limit handling — deferred. --- setup/migrate-v1/db.ts | 27 +++++++++++++++++++- setup/migrate-v1/shared.ts | 50 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/setup/migrate-v1/db.ts b/setup/migrate-v1/db.ts index e455012..d338214 100644 --- a/setup/migrate-v1/db.ts +++ b/setup/migrate-v1/db.ts @@ -36,6 +36,7 @@ import { runMigrations } from '../../src/db/migrations/index.js'; import { log } from '../../src/log.js'; import { emitStatus } from '../status.js'; import { + fetchBotGuilds, generateId, inferChannelType, readHandoff, @@ -158,6 +159,29 @@ export async function run(args: string[]): Promise { })); writeHandoff(h); + // For channels where v2's platform_id includes a component v1 didn't record + // (Discord's guild id), fetch the bot's guilds up-front. If the bot is in + // a single guild we can splice that id into every platform_id; otherwise + // fall back to the v1-format id (v2's channel-registration flow will repair + // on first message). Done ONCE per channel_type, not per-row, so this is + // cheap regardless of group count. + const v1EnvText = fs.existsSync(paths.env) ? fs.readFileSync(paths.env, 'utf-8') : ''; + const v1EnvMap = new Map(); + for (const line of v1EnvText.split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('#')) continue; + const eq = t.indexOf('='); + if (eq <= 0) continue; + v1EnvMap.set(t.slice(0, eq).trim(), t.slice(eq + 1)); + } + const singleGuildByChannel = new Map(); + for (const channelType of detectedChannels.keys()) { + const info = await fetchBotGuilds(channelType, (k) => v1EnvMap.get(k)); + if (info && info.guildIds.length === 1) { + singleGuildByChannel.set(channelType, info.guildIds[0]); + } + } + // Initialize v2.db (creates schema if not present — runMigrations is no-op // when the schema is already current, so this is safe on a live v2 install). fs.mkdirSync(path.join(process.cwd(), 'data'), { recursive: true }); @@ -181,7 +205,8 @@ export async function run(args: string[]): Promise { continue; } - const platformId = v2PlatformId(channelType, g.jid); + const guildId = singleGuildByChannel.get(channelType); + const platformId = v2PlatformId(channelType, g.jid, { guildId }); const createdAt = new Date().toISOString(); try { diff --git a/setup/migrate-v1/shared.ts b/setup/migrate-v1/shared.ts index bc5d3dd..4597fcf 100644 --- a/setup/migrate-v1/shared.ts +++ b/setup/migrate-v1/shared.ts @@ -358,11 +358,57 @@ export function inferChannelType(jid: string, channelName: string | null): strin * v2's messaging_groups.platform_id is always prefixed with the channel_type * (see setup/register.ts:118-120). This helper normalizes v1's `jid` into * that shape so router lookups at runtime find the right row. + * + * Some channels need extra structure on the id itself. Discord's Chat SDK + * emits `discord::` at runtime but v1 only stored + * `dc:` (no guild). Callers that know the guild (e.g. bot with + * a single guild) can pass it via `extra`; otherwise the returned id will + * be the v1-format `discord:` and will be repaired on first + * message via v2's channel-registration approval flow. */ -export function v2PlatformId(channelType: string, jid: string): string { +export function v2PlatformId(channelType: string, jid: string, extra?: { guildId?: string }): string { const parsed = parseJid(jid); const id = parsed?.id ?? jid; - return id.startsWith(`${channelType}:`) ? id : `${channelType}:${id}`; + const prefixed = id.startsWith(`${channelType}:`) ? id : `${channelType}:${id}`; + // For Discord: splice the guild id in between when we know it and the id + // isn't already in `:` form. + if (channelType === 'discord' && extra?.guildId) { + const body = prefixed.slice(`discord:`.length); + if (!body.includes(':')) return `discord:${extra.guildId}:${body}`; + } + return prefixed; +} + +/** + * Fetch the bot's guild memberships for a channel_type so migrate-db can + * form platform_ids matching what the v2 adapter emits at runtime. Returns + * null on any failure (network, auth, rate limit, unsupported channel_type) + * — callers fall back to the v1-format platform_id, which works but may + * trigger v2's channel-registration flow on first message. + * + * Currently handles Discord. Extending to other channels: the function + * needs a "single-or-multi guild?" shape; for single-guild bots the caller + * can splice the guild id globally, for multi-guild a per-channel lookup + * is needed and the caller should probably bail (rate-limit risk). + */ +export async function fetchBotGuilds( + channelType: string, + v1EnvLookup: (key: string) => string | undefined, +): Promise<{ guildIds: string[] } | null> { + if (channelType !== 'discord') return null; + const token = v1EnvLookup('DISCORD_BOT_TOKEN'); + if (!token) return null; + try { + const resp = await fetch('https://discord.com/api/v10/users/@me/guilds', { + headers: { Authorization: `Bot ${token}` }, + }); + if (!resp.ok) return null; + const data = (await resp.json()) as Array<{ id?: string }>; + const guildIds = data.map((g) => g.id).filter((id): id is string => typeof id === 'string'); + return { guildIds }; + } catch { + return null; + } } // ── Trigger rules → engage mode (ports migration 010's backfill) ─────── From b6be3b9bf458e4710c0a8537002a60b6486447cb Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Sat, 25 Apr 2026 16:52:20 +0300 Subject: [PATCH 014/144] docs(skills): merge add-signal-v2 lessons into add-signal, drop v2 Absorbs battle-tested knowledge from the v2 skill into the upstream add-signal: registration paths (new number + linked device), CAPTCHA flow, VoIP SMS-first timing, Java prereq, config-lock warning, wiring SQL for groups, not_member silent-drop fix, GroupV2 groupId extraction note, and UUID-based platform ID format. Corrects a factual error in the upstream: DM platform IDs are signal:{UUID} (ACI), not phone numbers. Removes the now-redundant add-signal-v2 skill. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/add-signal-v2/SKILL.md | 311 -------------------------- .claude/skills/add-signal/SKILL.md | 198 ++++++++++++++-- 2 files changed, 184 insertions(+), 325 deletions(-) delete mode 100644 .claude/skills/add-signal-v2/SKILL.md diff --git a/.claude/skills/add-signal-v2/SKILL.md b/.claude/skills/add-signal-v2/SKILL.md deleted file mode 100644 index 06edd17..0000000 --- a/.claude/skills/add-signal-v2/SKILL.md +++ /dev/null @@ -1,311 +0,0 @@ -# Add Signal Channel (v2) - -Adds Signal messaging support to NanoClaw v2 using `signal-sdk` (a TypeScript -wrapper around `signal-cli`). Unlike Telegram/Discord, Signal has no bot API — -NanoClaw registers as a full Signal account on a dedicated phone number. - -**Two registration paths:** -- **New number (recommended):** Register a dedicated SIM or VoIP number as a - standalone Signal account. NanoClaw owns the number entirely. -- **Linked device:** Join an existing Signal account as a secondary device via - QR code. Simpler, but NanoClaw shares your personal number. - -Both paths are documented below. The new-number path is battle-tested. - ---- - -## Pre-flight - -Check if `src/channels/signal.ts` exists and the import is uncommented in -`src/channels/index.ts`. If both are in place, skip to Registration. - -## Install - -### 1. Check Java - -Java 17+ is required. Check: - -```bash -java -version -``` - -If missing: -- **RHEL/CentOS/Fedora:** `sudo dnf install -y java-17-openjdk` -- **Debian/Ubuntu:** `sudo apt-get install -y default-jre` -- **macOS:** `brew install --cask temurin@17` - -Java 17–25 all work. Java 25 (RHEL9 default) is confirmed working. - -### 2. Install signal-cli - -The `signal-sdk` npm package bundles signal-cli, but the bundled version is -often outdated. Install the latest standalone binary: - -```bash -SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])") -curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \ - | tar -xz -C ~/.local -ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli -signal-cli --version -``` - -> **Note:** The Linux native tarball extracts a single binary directly to -> `~/.local/signal-cli` (not into a subdirectory). The symlink above handles this. - -### 3. Install signal-sdk - -```bash -npm install signal-sdk -``` - -### 4. Enable the adapter - -Uncomment the Signal import in `src/channels/index.ts`: - -```typescript -import './signal.js'; -``` - -### 5. Build - -Always build with Node 22 (nvm): - -```bash -source ~/.nvm/nvm.sh && nvm use 22 && npm run build -``` - ---- - -## Path A: Register a new Signal number - -Use this if you have a dedicated SIM or VoIP number for NanoClaw. - -> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP -> providers are blocked even for voice calls. If registration fails with an auth -> error, try a different provider or a physical SIM. - -### Step 1: Request SMS verification - -Signal requires a CAPTCHA on first registration: - -1. Open `https://signalcaptchas.org/registration/generate.html` in a browser -2. Solve the captcha -3. Right-click the **"Open Signal"** button → **Copy Link** -4. The link starts with `signalcaptcha://...` - -```bash -SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ - signal-cli -u +YOURNUMBER register \ - --captcha "PASTE_CAPTCHA_TOKEN_HERE" -``` - -The captcha token is everything after `signalcaptcha://` in the copied link. - -### Step 2: Voice call fallback (VoIP numbers without SMS) - -If your number cannot receive SMS, wait ~60 seconds after the SMS request then -request a voice call: - -```bash -SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ - signal-cli -u +YOURNUMBER register --voice \ - --captcha "SAME_CAPTCHA_TOKEN" -``` - -Signal will call your number and read a 6-digit code. - -> The captcha token from Step 1 is reusable for the voice retry — no need to -> solve a new one. - -### Step 3: Verify - -```bash -SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ - signal-cli -u +YOURNUMBER verify CODE -``` - -No output = success. - -### Step 4: Set profile name (optional) - -> ⚠ signal-sdk holds an exclusive lock on `data/signal/` while nanoclaw is -> running. Stop the service before running signal-cli commands, then restart. - -```bash -systemctl --user stop nanoclaw -SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ - signal-cli -u +YOURNUMBER updateProfile --name "YourBotName" -systemctl --user start nanoclaw -``` - -To set an avatar too: -```bash -signal-cli -u +YOURNUMBER updateProfile --name "YourBotName" --avatar /path/to/avatar.jpg -``` - ---- - -## Path B: Link as secondary device - -Use this to join an existing Signal account as a secondary device. - -```bash -mkdir -p data/signal -export SIGNAL_CLI_CONFIG_PATH=$(pwd)/data/signal -npx signal-sdk link -n "NanoClaw" -a +YOURNUMBER -``` - -This prints a QR code. On your phone: **Settings → Linked Devices → Link New Device**. -Scan the code within ~30 seconds. - ---- - -## Configure environment - -Add to `.env`: - -```bash -SIGNAL_PHONE_NUMBER=+YOURNUMBER -``` - ---- - -## Wire to an agent - -### DMs - -After the service starts, **send any message** to the Signal number from your -personal Signal app. The router auto-creates a `messaging_groups` row. Then: - -```bash -sqlite3 data/v2.db \ - "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" -``` - -Pass the `id` and `platform_id` to `/init-first-agent` or wire manually. - -**Important:** DM `platform_id` is UUID-based, not phone-based: -- DM: `signal:3de71d7f-ffa3-437e-b4db-097534bccd46` (UUID of sender) -- Group: `signal:UwaIz6bc09Olg1pBr/XeBuQf6z3fyCZoNj/Y3Tpe3hI=` (base64 group ID) - -### Groups - -Add the Signal number to a group from your phone. Send any message — the router -auto-creates the group's `messaging_groups` row. Wire it: - -```bash -sqlite3 data/v2.db \ - "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" -``` - -Then insert the wiring (replace IDs): - -```bash -NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") -sqlite3 data/v2.db " -INSERT OR IGNORE INTO messaging_group_agents - (id, messaging_group_id, agent_group_id, session_mode, priority, created_at) -VALUES - ('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW'); -" -``` - -Use `session_mode='isolated'` for groups so each group has its own session. - -### Grant user access - -Users who message via Signal need to be granted membership. Without this, -messages are silently dropped with `not_member`. After first contact: - -```bash -NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") -sqlite3 data/v2.db " -INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) - VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW'); -INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at) - VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW'); -" -``` - -Find the UUID from `messaging_groups.platform_id` or the `users` table. - ---- - -## Voice Transcription (optional) - -Inbound voice messages are automatically transcribed using a local whisper -binary. The feature degrades gracefully — if whisper or ffmpeg is missing, -voice messages are still delivered as attachments with no transcript. - -See `/add-voice-transcription-free-whisper` for full setup instructions -(ffmpeg, whisper backends, model download, environment variables, troubleshooting). - ---- - -## Channel Info - -- **type**: `signal` -- **terminology**: "Chats" (1:1) and "groups" (multi-member). No threads. -- **supports-threads**: no -- **inbound**: text, reactions (forwarded to agent with emoji + targetTimestamp), images, files, voice (transcribed via whisper-cli if installed) -- **outbound**: text, reactions (sendReaction), file attachments -- **platform-id-format**: - - DM: `signal:{UUID}` — sender's Signal UUID, **not** their phone number - - Group: `signal:{base64GroupId}` -- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` - as shown above. -- **config-lock**: signal-sdk holds an exclusive lock on `data/signal/` while - nanoclaw is running. Stop the service before running any `signal-cli` commands. -- **attachment storage**: signal-sdk launches signal-cli **without** a `--config` - flag, so signal-cli stores attachments at the XDG default - (`~/.local/share/signal-cli/attachments/`), not under `data/signal/`. The - adapter checks both locations. Verify with: - `ps aux | grep signal-cli` — if there is no `-c` argument, XDG default is in use. - ---- - -## Troubleshooting - -**`Config file is in use by another instance`** — nanoclaw is running and -signal-sdk has the lock. Stop the service, run the command, restart. - -**Messages dropped with `not_member`** — the Signal user hasn't been granted -membership. See "Grant user access" above. This affects every new Signal user, -including the owner's Signal identity (which is separate from their Telegram -identity even if it's the same person). - -**Captcha required** — Signal requires captcha for new registrations. Go to -`https://signalcaptchas.org/registration/generate.html`, solve it, right-click -"Open Signal", copy the link, extract the token after `signalcaptcha://`. - -**`Invalid verification method: Before requesting voice verification…`** — -You must request SMS first, wait ~60 seconds, then request voice. Both can use -the same captcha token. - -**`The provided model identifier is invalid` (Bedrock)** — unrelated to Signal; -this is a LiteLLM model ID issue. - -**Group replies going to DM instead of group** — modern Signal groups use -GroupV2. The adapter must extract the group ID from -`envelope?.dataMessage?.groupV2?.id` (not just `groupInfo?.groupId`, which is -GroupV1/legacy). Check `src/channels/signal.ts` and confirm the groupId -extraction falls through to `groupV2.id`. - -**Voice messages / attachments silently skipped, no transcript** — signal-sdk -launches signal-cli without a `--config` flag, so attachments land at the XDG -default (`~/.local/share/signal-cli/attachments/`) rather than under -`data/signal/`. Confirm with `ps aux | grep signal-cli` — if there is no `-c` -argument in the process line, the XDG default is in use. The adapter falls back -to that location automatically. If you still see no "Signal attachment saved" -log lines, add a debug log around the `if (!storedPath) continue` guard in -`src/channels/signal.ts` to inspect `att.storedFilename` and `att.id`. - -**Java not found** — install Java 17+ (see Install step 1). - -**QR code expired (Path B)** — QR codes expire in ~30 seconds. Re-run the -link command to generate a new one. - -**signal-cli binary location** — The native Linux tarball extracts directly to -`~/.local/signal-cli` (a single file, not a directory). The system `aws` or -other tools named `signal-cli` won't be in PATH by default; check -`~/.local/bin/signal-cli`. diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md index e6d41aa..7dcc8ad 100644 --- a/.claude/skills/add-signal/SKILL.md +++ b/.claude/skills/add-signal/SKILL.md @@ -5,20 +5,116 @@ description: Add Signal channel integration via signal-cli TCP daemon. Native ad # Add Signal Channel -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. +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 — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`). + +Unlike Telegram or Discord, Signal has no bot API. NanoClaw registers as a full Signal account on a dedicated phone number (recommended) or links as a secondary device on your existing number. ## Prerequisites -`signal-cli` installed and a Signal account linked: +### Java -- 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 requires Java 17+: + +```bash +java -version +``` + +If missing: +- **macOS:** `brew install --cask temurin@17` +- **Debian/Ubuntu:** `sudo apt-get install -y default-jre` +- **RHEL/Fedora:** `sudo dnf install -y java-17-openjdk` + +Java 17–25 all work. + +### signal-cli + +- **macOS:** `brew install signal-cli` +- **Linux:** download the native binary from [GitHub releases](https://github.com/AsamK/signal-cli/releases): + +```bash +SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])") +curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \ + | tar -xz -C ~/.local +ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli +signal-cli --version +``` + +> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH. + +## Registration + +Two paths. The new-number path is recommended and battle-tested. + +### Path A: Register a new number (recommended) + +Use a dedicated SIM or VoIP number. NanoClaw owns it entirely. + +> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM. + +**Step 1: Solve the CAPTCHA** + +Signal requires a CAPTCHA on first registration: + +1. Open `https://signalcaptchas.org/registration/generate.html` in a browser +2. Solve the captcha +3. Right-click the **"Open Signal"** button → **Copy Link** +4. The link starts with `signalcaptcha://` — the token is everything after that prefix + +**Step 2: Request SMS verification** + +```bash +signal-cli -a +1YOURNUMBER register --captcha "PASTE_TOKEN_HERE" +``` + +**Step 3: Voice call fallback (if your number can't receive SMS)** + +Wait ~60 seconds after the SMS request, then: + +```bash +signal-cli -a +1YOURNUMBER register --voice --captcha "SAME_TOKEN" +``` + +Signal calls your number and reads a 6-digit code. The same captcha token is reusable — no need to solve a new one. + +> You must request SMS first. Requesting voice immediately fails with `Invalid verification method: Before requesting voice verification…` + +**Step 4: Verify** + +```bash +signal-cli -a +1YOURNUMBER verify CODE +``` + +No output = success. + +**Step 5: Set profile name (optional)** + +> ⚠ Stop NanoClaw before running signal-cli commands — the daemon holds an exclusive lock on its data directory while running. + +```bash +# macOS +launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist +signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName" +# optionally: --avatar /path/to/avatar.jpg +launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist + +# Linux +systemctl --user stop nanoclaw +signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName" +systemctl --user start nanoclaw +``` + +### Path B: Link as secondary device + +Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number. + +```bash +signal-cli -a +1YOURNUMBER link --name "NanoClaw" +``` + +This prints a `tsdevice:` URI. Scan it as a QR code on your phone: **Settings → Linked Devices → Link New Device**. QR codes expire in ~30 seconds — re-run if it expires. ## 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: @@ -55,7 +151,7 @@ import './signal.js'; pnpm run build ``` -No npm packages to install — the adapter uses only Node.js builtins (`node:net`, `node:child_process`, `node:fs`). +No npm packages to install — the adapter uses only Node.js builtins. ## Credentials @@ -97,27 +193,73 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw systemctl --user restart nanoclaw ``` +## Wiring + +### DMs + +After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then: + +```bash +sqlite3 data/v2.db \ + "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" +``` + +Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group. + +### Groups + +Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions: + +```bash +NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") +sqlite3 data/v2.db " +INSERT OR IGNORE INTO messaging_group_agents + (id, messaging_group_id, agent_group_id, session_mode, priority, created_at) +VALUES + ('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW'); +" +``` + +### Grant user access + +New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`: + +```bash +NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") +sqlite3 data/v2.db " +INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) + VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW'); +INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at) + VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW'); +" +``` + +Find the UUID from `messaging_groups.platform_id` or the `users` table. + ## Next Steps If you're in the middle of `/setup`, return to the setup flow now. -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. +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. ## Channel Info - **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`. +- **terminology**: Signal has "chats" (1:1 DMs) and "groups" - **supports-threads**: no +- **platform-id-format**: + - DM: `signal:{UUID}` — sender's Signal UUID (ACI), **not** their phone number + - Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID +- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above - **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. +- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode ### Features - 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 +- Echo suppression — outbound messages 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 @@ -145,4 +287,32 @@ If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DA ### 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. +If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped the connection. Restart the service to re-establish. + +### Messages dropped with `not_member` + +The Signal user hasn't been granted membership. See "Grant user access" above. This affects every new Signal user, including the owner's Signal identity — which is a separate user record from their identity on other channels even if it's the same person. + +### Captcha required + +Signal requires a captcha for new registrations. Go to `https://signalcaptchas.org/registration/generate.html`, solve it, right-click "Open Signal", copy the link, extract the token after `signalcaptcha://`. + +### `Invalid verification method: Before requesting voice verification…` + +You must request SMS first, wait ~60 seconds, then request voice. Both steps can use the same captcha token. + +### Config file in use / daemon lock + +signal-cli holds an exclusive lock on its data directory while the daemon is running. Stop NanoClaw before running any `signal-cli` commands directly, then restart afterward. + +### Group replies going to DM instead of group + +Modern Signal groups use GroupV2. The adapter must extract the group ID from `envelope?.dataMessage?.groupV2?.id` — not `groupInfo?.groupId`, which is GroupV1/legacy. If group messages are routing as DMs, check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`. + +### Java not found + +Install Java 17+ — see the Prerequisites section above. + +### QR code expired (Path B) + +QR codes expire in ~30 seconds. Re-run the link command to generate a new one. From 6b431c195d62f54d299903acb0a9ebfdd7f1a221 Mon Sep 17 00:00:00 2001 From: Emmanuel Venisse Date: Sun, 26 Apr 2026 18:33:19 +0200 Subject: [PATCH 015/144] feat(setup): add remote OneCLI option in setup flow Allow connecting to an OneCLI gateway running on another host instead of installing one locally. Adds a third choice ('Connect to a remote OneCLI') alongside reuse/fresh in the setup wizard, prompts for the remote URL, validates reachability before proceeding, and passes --remote-url to the onecli step. In onecli.ts: extracts installOnecliCliOnly() for the remote path (installs the CLI binary but skips the gateway), exports pollHealth for use by auto.ts, and handles --remote-url to configure api-host and write ONECLI_URL to .env without running the full gateway install. --- setup/auto.ts | 208 ++++++++++++++++++++++++++---------------------- setup/onecli.ts | 59 +++++++++++--- 2 files changed, 162 insertions(+), 105 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index cff2f63..88ea84e 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -38,10 +38,8 @@ import { brightSelect } from './lib/bright-select.js'; import { offerClaudeAssist } from './lib/claude-assist.js'; import { runWindowedStep } from './lib/windowed-runner.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; -import { - claudeCliAvailable, - resolveTimezoneViaClaude, -} from './lib/tz-from-claude.js'; +import { pollHealth } from './onecli.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 { emit as phEmit } from './lib/diagnostics.js'; @@ -51,15 +49,7 @@ import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); -type ChannelChoice = - | 'telegram' - | 'discord' - | 'whatsapp' - | 'signal' - | 'teams' - | 'slack' - | 'imessage' - | 'skip'; +type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip'; async function main(): Promise { printIntro(); @@ -88,12 +78,7 @@ async function main(): Promise { } if (!skip.has('container')) { - p.log.message( - dimWrap( - 'Your assistant lives in its own sandbox. It can only see what you explicitly share.', - 4, - ), - ); + p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)); 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.', @@ -138,45 +123,96 @@ async function main(): Promise { ), ); - // 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. + type OnecliChoice = 'reuse' | 'fresh' | 'remote'; + const existing = detectExistingOnecli(); - let reuse = false; - if (existing) { - const choice = ensureAnswer( - await brightSelect({ - message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`, - options: [ + const onecliOptions: { value: OnecliChoice; label: string; hint?: string }[] = [ + ...(existing + ? [ { - value: 'reuse', - label: 'Use the existing instance', + value: 'reuse' as OnecliChoice, + label: 'Use the existing instance on the same host', 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', + ] + : []), + { + value: 'fresh', + label: 'Install a fresh instance for NanoClaw', + hint: existing ? 'reinstalls onecli; other apps may need to reconnect' : 'recommended', + }, + { + value: 'remote', + label: 'Connect to an OneCLI on another host', + hint: 'point to a remote URL', + }, + ]; + + const onecliChoice = ensureAnswer( + await brightSelect({ + message: existing + ? `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?` + : 'How would you like to set up OneCLI?', + options: onecliOptions, + }), + ) as OnecliChoice; + setupLog.userInput('onecli_choice', onecliChoice); + + let remoteUrl: string | undefined; + if (onecliChoice === 'remote') { + while (true) { + const answer = ensureAnswer( + await p.text({ + message: 'OneCLI URL on the remote machine', + placeholder: 'http://192.168.1.10:10254', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'Required'; + if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://'; + return undefined; }, - ], - }), - ) as 'reuse' | 'fresh'; - setupLog.userInput('onecli_choice', choice); - reuse = choice === 'reuse'; + }), + ); + remoteUrl = (answer as string).trim(); + setupLog.userInput('onecli_remote_url', remoteUrl); + + const s = p.spinner(); + s.start('Checking remote OneCLI…'); + const healthy = await pollHealth(remoteUrl, 5000); + if (healthy) { + s.stop('Remote OneCLI is reachable.'); + break; + } + s.stop(`Couldn't reach OneCLI at ${remoteUrl}.`, 1); + p.log.warn(wrapForGutter('Make sure OneCLI is running and accessible from this machine, then try again.', 4)); + } } + const stepArgs = + onecliChoice === 'reuse' ? ['--reuse'] : onecliChoice === 'remote' ? ['--remote-url', remoteUrl!] : []; + const res = await runQuietStep( 'onecli', { - running: reuse - ? 'Hooking up to your existing OneCLI…' - : "Setting up OneCLI, your agent's vault…", + running: + onecliChoice === 'reuse' + ? 'Hooking up to your existing OneCLI…' + : onecliChoice === 'remote' + ? `Connecting to remote OneCLI at ${remoteUrl}…` + : "Setting up OneCLI, your agent's vault…", done: 'OneCLI vault ready.', }, - reuse ? ['--reuse'] : [], + stepArgs, ); if (!res.ok) { const err = res.terminal?.fields.ERROR; + if (onecliChoice === 'remote') { + await fail( + 'onecli', + `Couldn't connect to remote OneCLI (${err ?? 'unknown error'}).`, + 'Check the URL and that OneCLI is running on the remote machine, then retry.', + ); + } if (err === 'onecli_not_on_path_after_install') { await fail( 'onecli', @@ -217,19 +253,12 @@ async function main(): Promise { done: 'NanoClaw is running.', }); if (!res.ok) { - await fail( - 'service', - "Couldn't start NanoClaw.", - 'See logs/nanoclaw.error.log for details.', - ); + await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.'); } if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') { - p.log.warn( - "NanoClaw's permissions need a tweak before it can reach Docker.", - ); + p.log.warn("NanoClaw's permissions need a tweak before it can reach Docker."); p.log.message( - ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + - ` systemctl --user restart ${getSystemdUnit()}`, + ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`, ); } } @@ -294,7 +323,7 @@ async function main(): Promise { msg: ping === 'socket_error' ? "NanoClaw service isn't listening on its CLI socket." - : "No reply from the assistant within 30 seconds.", + : 'No reply from the assistant within 30 seconds.', hint: ping === 'socket_error' ? 'Socket at data/cli.sock did not accept a connection.' @@ -344,7 +373,7 @@ async function main(): Promise { if (!res.ok) { const notes: string[] = []; if (res.terminal?.fields.CREDENTIALS !== 'configured') { - notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.'); + notes.push("• Your Claude account isn't connected. Re-run setup and try again."); } const service = res.terminal?.fields.SERVICE; if (service === 'running_other_checkout') { @@ -370,7 +399,9 @@ async function main(): Promise { } } 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`.'); + 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"); @@ -404,9 +435,7 @@ async function main(): Promise { ['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'); + const nextSteps = rows.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 @@ -428,10 +457,7 @@ async function main(): Promise { // 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.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`.")); @@ -491,9 +517,7 @@ async function confirmAssistantResponds(): Promise { 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."; + result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time."; s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1); } return result; @@ -549,9 +573,7 @@ async function runFirstChat(): Promise { 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', + placeholder: first ? 'e.g. "hi, what can you do?"' : 'press Enter to continue', }), ); first = false; @@ -567,11 +589,9 @@ function sendChatMessage(message: string): Promise { // 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'] }, - ); + const child = spawn('pnpm', ['--silent', 'run', 'chat', ...message.split(/\s+/)], { + stdio: ['ignore', 'inherit', 'inherit'], + }); child.on('close', () => resolve()); child.on('error', () => resolve()); }); @@ -619,15 +639,11 @@ async function runAuthStep(): Promise { } 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)'), - ); + 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 code = await runInheritScript('bash', ['setup/register-claude-token.sh']); const durationMs = Date.now() - start; console.log(); if (code !== 0) { @@ -667,11 +683,16 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise { 'auth', 'onecli', [ - 'secrets', 'create', - '--name', 'Anthropic', - '--type', 'anthropic', - '--value', token, - '--host-pattern', 'api.anthropic.com', + 'secrets', + 'create', + '--name', + 'Anthropic', + '--type', + 'anthropic', + '--value', + token, + '--host-pattern', + 'api.anthropic.com', ], { running: `Saving your ${label} to your OneCLI vault…`, @@ -710,10 +731,7 @@ async function runTimezoneStep(): Promise { 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'; + const isUtc = resolvedTz === 'UTC' || resolvedTz === 'Etc/UTC' || resolvedTz === 'Universal'; // Three branches: // - no TZ detected: ask where they are (or leave as UTC) @@ -735,8 +753,8 @@ async function runTimezoneStep(): Promise { const message = needsInput ? "Your system didn't expose a timezone. Which one are you in?" : !isUtc - ? "Where are you, then?" - : "Your system reports UTC as the timezone. Is that right, or are you somewhere else?"; + ? 'Where are you, then?' + : 'Your system reports UTC as the timezone. Is that right, or are you somewhere else?'; // 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". @@ -763,7 +781,7 @@ async function runTimezoneStep(): Promise { const answer = ensureAnswer( await p.text({ - message: "Where are you? (city, region, or IANA zone)", + message: 'Where are you? (city, region, or IANA zone)', placeholder: 'e.g. New York, London, Asia/Tokyo', validate: (v) => (v && v.trim() ? undefined : 'Required'), }), @@ -959,9 +977,7 @@ function printIntro(): void { const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; if (isReexec) { - p.intro( - `${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`, - ); + p.intro(`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`); return; } diff --git a/setup/onecli.ts b/setup/onecli.ts index 3ceb1e8..3f46c88 100644 --- a/setup/onecli.ts +++ b/setup/onecli.ts @@ -103,6 +103,13 @@ function writeEnvOnecliUrl(url: string): void { const ONECLI_CLI_FALLBACK_VERSION = '1.3.0'; const ONECLI_CLI_REPO = 'onecli/onecli-cli'; +function installOnecliCliOnly(): { stdout: string; ok: boolean } { + const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh'); + if (upstream.ok) return { stdout: upstream.stdout, ok: true }; + const fallback = installOnecliCliDirect(); + return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok }; +} + function installOnecli(): { stdout: string; ok: boolean } { let stdout = ''; @@ -163,14 +170,12 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } { lines.push(s); }; - const osName = - process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null; + 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; + 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 }; @@ -201,10 +206,9 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } { try { append(`Downloading ${url}`); - execSync( - `curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`, - { stdio: ['ignore', 'pipe', 'pipe'] }, - ); + 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'], }); @@ -231,7 +235,7 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } { } } -async function pollHealth(url: string, timeoutMs: number): Promise { +export async function pollHealth(url: string, timeoutMs: number): Promise { // `/api/health` matches the path probe.sh uses — keep them aligned. const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -248,8 +252,45 @@ async function pollHealth(url: string, timeoutMs: number): Promise { export async function run(args: string[]): Promise { const reuse = args.includes('--reuse'); + const remoteUrlIdx = args.indexOf('--remote-url'); + const remoteUrl = remoteUrlIdx !== -1 ? args[remoteUrlIdx + 1] : null; ensureShellProfilePath(); + if (remoteUrl) { + log.info('Installing OneCLI CLI for remote gateway', { remoteUrl }); + const res = installOnecliCliOnly(); + if (!res.ok || !onecliVersion()) { + emitStatus('ONECLI', { + INSTALLED: false, + STATUS: 'failed', + ERROR: 'cli_install_failed', + HINT: 'CLI binary install failed. Make sure curl is installed and ~/.local/bin is writable.', + LOG: 'logs/setup.log', + }); + process.exit(1); + } + try { + execFileSync('onecli', ['config', 'set', 'api-host', remoteUrl], { + stdio: 'ignore', + env: childEnv(), + }); + } catch (err) { + log.warn('onecli config set api-host failed', { err }); + } + writeEnvOnecliUrl(remoteUrl); + log.info('Wrote ONECLI_URL to .env', { url: remoteUrl }); + const healthy = await pollHealth(remoteUrl, 5000); + emitStatus('ONECLI', { + INSTALLED: true, + REMOTE: true, + ONECLI_URL: remoteUrl, + HEALTHY: healthy, + STATUS: 'success', + LOG: 'logs/setup.log', + }); + return; + } + 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. From efdd05a7efefa2fa720c6ad436674a5dffa2c5d9 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 26 Apr 2026 23:39:12 +0300 Subject: [PATCH 016/144] feat(setup): advanced settings registry with remote OneCLI support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a single config registry that drives both CLI flags and an opt-in advanced-settings screen, so power users can override defaults like remote OneCLI host/token or alt Anthropic endpoints without burdening the standard linear flow with extra prompts. Why: advanced configurations didn't fit cleanly into the existing sequenced setup. PR #2030 took the "add another prompt step" route for remote OneCLI; this approach instead routes those overrides through a single source of truth so adding the next knob (alt endpoint, custom host pattern, …) doesn't mean another prompt-or-skip decision. setup/lib/setup-config.ts — schema (typed entry list with surface 'flag' | 'flag+ui'), name derivation (camelCase → NANOCLAW_UPPER_SNAKE + --kebab-case), seeded with --onecli-api-host, --onecli-api-token, --anthropic-base-url, plus existing NANOCLAW_SKIP / NANOCLAW_DISPLAY_NAME as flag-only entries. setup/lib/setup-config-parse.ts — argv parser (--key value, --key=value, --no-bool, -- terminator), env reader, applyToEnv() bridge that writes resolved values back to process.env so existing step code keeps reading env vars unchanged. Also --help printer. setup/lib/setup-config-screen.ts — interactive menu loop. Entries render with current value as hint; selecting one opens the right prompt type (text / password for secrets / confirm / brightSelect for enums); "Done" returns to the main flow. setup/auto.ts — parses argv first (--help short-circuits before any render), folds env+flags into process.env, then offers a welcome menu: "Standard setup" (default) vs "Advanced". The onecli step branches on NANOCLAW_ONECLI_API_HOST: if set, skips the local-vs-fresh prompt entirely, runs pollHealth pre-flight, then calls runQuietStep with --remote-url. Token, when provided, writes through to ONECLI_API_KEY in .env. Welcome copy tightened (drops the duplicate wordmark/tagline) so the bash → clack handoff reads as one flow. setup/onecli.ts — cherries the --remote-url implementation from PR run()) and generalizes writeEnvOnecliUrl into a writeEnvVar helper so ONECLI_API_KEY follows the same upsert path. nanoclaw.sh — forwards "$@" to setup:auto so flags reach the parser; trims the redundant "Setting up your personal AI assistant" subtitle and the bootstrap teach line so the pre-clack section isn't competing with the clack intro for the same role. Token plumbing only fires in --remote-url mode; local installs are unauthenticated against localhost and don't need it. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 15 ++- setup/auto.ts | 216 ++++++++++++++++++------------- setup/lib/setup-config-parse.ts | 161 +++++++++++++++++++++++ setup/lib/setup-config-screen.ts | 127 ++++++++++++++++++ setup/lib/setup-config.ts | 130 +++++++++++++++++++ setup/onecli.ts | 20 ++- 6 files changed, 565 insertions(+), 104 deletions(-) create mode 100644 setup/lib/setup-config-parse.ts create mode 100644 setup/lib/setup-config-screen.ts create mode 100644 setup/lib/setup-config.ts diff --git a/nanoclaw.sh b/nanoclaw.sh index f8b58e7..058dbbf 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -129,10 +129,10 @@ rm -f "$PROGRESS_LOG" mkdir -p "$STEPS_DIR" "$LOGS_DIR" write_header -# 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')" +# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing, +# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and +# skips re-printing the wordmark, keeping the flow visually continuous. +printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" # ─── pre-flight: Homebrew on macOS ───────────────────────────────────── # setup/install-node.sh and setup/install-docker.sh both require `brew` on @@ -190,7 +190,7 @@ 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.")" + "$(dim "Small. Runs on your machine. Yours to modify.")" spinner_start "$BOOTSTRAP_LABEL" # Run in the background so we can tick elapsed time. Capture exit code via @@ -222,7 +222,7 @@ rm -f "$BOOTSTRAP_EXIT_FILE" BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START )) if [ "$BOOTSTRAP_RC" -eq 0 ]; then - spinner_success "Basics installed" "$BOOTSTRAP_DUR" + spinner_success "Basics ready" "$BOOTSTRAP_DUR" write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" else spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR" @@ -259,4 +259,5 @@ 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. -exec pnpm --silent run setup:auto +# `-- "$@"` forwards any flags (e.g. --onecli-api-host) to setup:auto. +exec pnpm --silent run setup:auto -- "$@" diff --git a/setup/auto.ts b/setup/auto.ts index 88ea84e..91f9bc1 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -36,7 +36,15 @@ 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 { + applyToEnv, + parseFlags, + printHelp, + readFromEnv, +} from './lib/setup-config-parse.js'; +import { runAdvancedScreen } from './lib/setup-config-screen.js'; import { runWindowedStep } from './lib/windowed-runner.js'; +import { pollHealth } from './onecli.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { pollHealth } from './onecli.js'; import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js'; @@ -52,10 +60,45 @@ const RUN_START = Date.now(); type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip'; async function main(): Promise { + // Parse CLI flags first — `--help` short-circuits before we render anything, + // and flag values get folded into process.env so existing step code reading + // NANOCLAW_* sees them unchanged. + const flagResult = parseFlags(process.argv.slice(2)); + if (flagResult.help) { + printHelp(); + process.exit(0); + } + if (flagResult.errors.length > 0) { + for (const err of flagResult.errors) console.error(`error: ${err}`); + console.error(''); + console.error('Run with --help for the full list of supported flags.'); + process.exit(1); + } + let configValues = { ...readFromEnv(), ...flagResult.values }; + applyToEnv(configValues); + printIntro(); initProgressionLog(); phEmit('auto_started'); + // Welcome menu — default path or open advanced overrides before any setup + // work begins. Default lands on standard so Enter is the happy path. + const startChoice = ensureAnswer( + await brightSelect<'default' | 'advanced'>({ + message: 'How would you like to begin?', + options: [ + { value: 'default', label: 'Standard setup' }, + { value: 'advanced', label: 'Advanced', hint: 'override defaults' }, + ], + initialValue: 'default', + }), + ) as 'default' | 'advanced'; + setupLog.userInput('start_choice', startChoice); + if (startChoice === 'advanced') { + configValues = await runAdvancedScreen(configValues); + applyToEnv(configValues); + } + const skip = new Set( (process.env.NANOCLAW_SKIP ?? '') .split(',') @@ -123,108 +166,95 @@ async function main(): Promise { ), ); - type OnecliChoice = 'reuse' | 'fresh' | 'remote'; + const remoteHost = process.env.NANOCLAW_ONECLI_API_HOST?.trim(); - const existing = detectExistingOnecli(); - const onecliOptions: { value: OnecliChoice; label: string; hint?: string }[] = [ - ...(existing - ? [ - { - value: 'reuse' as OnecliChoice, - label: 'Use the existing instance on the same host', - hint: 'recommended — keeps other apps bound to this vault working', - }, - ] - : []), - { - value: 'fresh', - label: 'Install a fresh instance for NanoClaw', - hint: existing ? 'reinstalls onecli; other apps may need to reconnect' : 'recommended', - }, - { - value: 'remote', - label: 'Connect to an OneCLI on another host', - hint: 'point to a remote URL', - }, - ]; - - const onecliChoice = ensureAnswer( - await brightSelect({ - message: existing - ? `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?` - : 'How would you like to set up OneCLI?', - options: onecliOptions, - }), - ) as OnecliChoice; - setupLog.userInput('onecli_choice', onecliChoice); - - let remoteUrl: string | undefined; - if (onecliChoice === 'remote') { - while (true) { - const answer = ensureAnswer( - await p.text({ - message: 'OneCLI URL on the remote machine', - placeholder: 'http://192.168.1.10:10254', - validate: (v) => { - const t = (v ?? '').trim(); - if (!t) return 'Required'; - if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://'; - return undefined; - }, - }), + if (remoteHost) { + // Advanced-settings override: user has already named a remote vault, + // so skip the local-vs-fresh prompt entirely. Health-check it here + // rather than letting the step fail silently — a typo in the URL is a + // common mistake and the answer is human-fixable. + const s = p.spinner(); + s.start(`Checking remote OneCLI at ${remoteHost}…`); + const healthy = await pollHealth(remoteHost, 5000); + if (!healthy) { + s.stop(`Couldn't reach OneCLI at ${remoteHost}.`, 1); + await fail( + 'onecli', + `Couldn't reach OneCLI at ${remoteHost}.`, + 'Check the URL and that OneCLI is running on the remote machine, then retry.', ); - remoteUrl = (answer as string).trim(); - setupLog.userInput('onecli_remote_url', remoteUrl); - - const s = p.spinner(); - s.start('Checking remote OneCLI…'); - const healthy = await pollHealth(remoteUrl, 5000); - if (healthy) { - s.stop('Remote OneCLI is reachable.'); - break; - } - s.stop(`Couldn't reach OneCLI at ${remoteUrl}.`, 1); - p.log.warn(wrapForGutter('Make sure OneCLI is running and accessible from this machine, then try again.', 4)); } - } + s.stop('Remote OneCLI is reachable.'); - const stepArgs = - onecliChoice === 'reuse' ? ['--reuse'] : onecliChoice === 'remote' ? ['--remote-url', remoteUrl!] : []; - - const res = await runQuietStep( - 'onecli', - { - running: - onecliChoice === 'reuse' - ? 'Hooking up to your existing OneCLI…' - : onecliChoice === 'remote' - ? `Connecting to remote OneCLI at ${remoteUrl}…` - : "Setting up OneCLI, your agent's vault…", - done: 'OneCLI vault ready.', - }, - stepArgs, - ); - if (!res.ok) { - const err = res.terminal?.fields.ERROR; - if (onecliChoice === 'remote') { + const res = await runQuietStep( + 'onecli', + { + running: `Connecting to remote OneCLI at ${remoteHost}…`, + done: 'OneCLI vault ready.', + }, + ['--remote-url', remoteHost], + ); + if (!res.ok) { + const err = res.terminal?.fields.ERROR; await fail( 'onecli', `Couldn't connect to remote OneCLI (${err ?? 'unknown error'}).`, 'Check the URL and that OneCLI is running on the remote machine, then retry.', ); } - if (err === 'onecli_not_on_path_after_install') { + } else { + // 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 brightSelect({ + 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') { + 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.', + ); + } 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.', + `Couldn't set up OneCLI (${err ?? 'unknown error'}).`, + 'Make sure curl is installed and ~/.local/bin is writable, then retry.', ); } - await fail( - 'onecli', - `Couldn't set up OneCLI (${err ?? 'unknown error'}).`, - 'Make sure curl is installed and ~/.local/bin is writable, then retry.', - ); } } @@ -981,11 +1011,11 @@ function printIntro(): void { return; } - // 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.")}`); + // bash already printed the wordmark above us; the clack intro carries the + // welcome framing alone so the two don't double up. Standalone runs of + // setup:auto still see this as the first line — fine without the wordmark + // since the line itself signals the start of the flow. + p.intro("Let's get you set up."); } /** diff --git a/setup/lib/setup-config-parse.ts b/setup/lib/setup-config-parse.ts new file mode 100644 index 0000000..85e3572 --- /dev/null +++ b/setup/lib/setup-config-parse.ts @@ -0,0 +1,161 @@ +/** + * Parser/reader/writer for the advanced-config registry (setup-config.ts). + * + * readFromEnv() → values found in process.env + * parseFlags() → values from argv, plus --help and any pass-through args + * applyToEnv() → write resolved values back to process.env so existing + * step code keeps reading env vars unchanged + * printHelp() → render --help from the registry + * + * Flag parsing supports: + * --key value space form + * --key=value equals form + * --key booleans only (sets true) + * --no-key booleans only (sets false) + */ +import { + CONFIG, + envVarFor, + flagFor, + findByFlag, + type Entry, +} from './setup-config.js'; + +export type ConfigValues = Record; + +function coerce(e: Entry, raw: string): string | number | boolean | undefined { + switch (e.type) { + case 'boolean': { + const v = raw.toLowerCase(); + if (['true', '1', 'yes'].includes(v)) return true; + if (['false', '0', 'no'].includes(v)) return false; + return undefined; + } + case 'integer': { + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; + } + default: + return raw; + } +} + +export function readFromEnv(env: NodeJS.ProcessEnv = process.env): ConfigValues { + const out: ConfigValues = {}; + for (const e of CONFIG) { + const raw = env[envVarFor(e)]; + if (raw === undefined || raw === '') continue; + const v = coerce(e, raw); + if (v !== undefined) out[e.key] = v; + } + return out; +} + +export type FlagParseResult = { + values: ConfigValues; + rest: string[]; + help: boolean; + errors: string[]; +}; + +export function parseFlags(argv: string[]): FlagParseResult { + const values: ConfigValues = {}; + const rest: string[] = []; + const errors: string[] = []; + let help = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (arg === '--help' || arg === '-h') { + help = true; + continue; + } + // POSIX end-of-options. pnpm passes a bare `--` through when invoked as + // `pnpm run script --` with nothing after it; treat the rest as + // pass-through positional args. + if (arg === '--') { + rest.push(...argv.slice(i + 1)); + break; + } + if (!arg.startsWith('--')) { + rest.push(arg); + continue; + } + + const eq = arg.indexOf('='); + let name = eq === -1 ? arg : arg.slice(0, eq); + const inline: string | undefined = eq === -1 ? undefined : arg.slice(eq + 1); + + let negated = false; + if (name.startsWith('--no-')) { + negated = true; + name = `--${name.slice(5)}`; + } + + const entry = findByFlag(name); + if (!entry) { + errors.push(`Unknown flag: ${arg}`); + continue; + } + + if (entry.type === 'boolean') { + if (negated) values[entry.key] = false; + else if (inline !== undefined) { + const v = coerce(entry, inline); + if (v === undefined) errors.push(`Invalid boolean for ${name}: ${inline}`); + else values[entry.key] = v; + } else values[entry.key] = true; + continue; + } + + const raw = inline !== undefined ? inline : argv[++i]; + if (raw === undefined) { + errors.push(`Missing value for ${name}`); + continue; + } + const v = coerce(entry, raw); + if (v === undefined) { + errors.push(`Invalid ${entry.type} for ${name}: ${raw}`); + continue; + } + if (entry.type === 'string' || entry.type === 'url') { + const err = entry.validate?.(raw); + if (err) { + errors.push(`${name}: ${err}`); + continue; + } + } + values[entry.key] = v; + } + + return { values, rest, help, errors }; +} + +export function applyToEnv( + values: ConfigValues, + env: NodeJS.ProcessEnv = process.env, +): void { + for (const e of CONFIG) { + if (!(e.key in values)) continue; + const v = values[e.key]; + env[envVarFor(e)] = + typeof v === 'boolean' ? (v ? 'true' : 'false') : String(v); + } +} + +export function printHelp(stream: NodeJS.WritableStream = process.stdout): void { + const lines: string[] = []; + lines.push('Usage: bash nanoclaw.sh [flags...]'); + lines.push(''); + lines.push('Flags:'); + const width = Math.max(...CONFIG.map((e) => flagFor(e).length)); + for (const e of CONFIG) { + const flag = flagFor(e).padEnd(width + 2); + lines.push(` ${flag}${e.help}`); + } + lines.push(''); + lines.push('Each flag also reads from its corresponding NANOCLAW_ env var.'); + lines.push('Run without flags for the default interactive flow.'); + stream.write(lines.join('\n') + '\n'); +} diff --git a/setup/lib/setup-config-screen.ts b/setup/lib/setup-config-screen.ts new file mode 100644 index 0000000..ad8ae62 --- /dev/null +++ b/setup/lib/setup-config-screen.ts @@ -0,0 +1,127 @@ +/** + * Advanced-settings screen — menu of UI-visible entries from the config + * registry. The user picks one entry, edits it, returns to the menu, and + * exits via "Done". Returns a fresh values object; the caller passes it to + * applyToEnv() so downstream step code reads them via env vars. + * + * Per-entry edit contract: + * - Blank input on text/password/integer = leave current value unchanged. + * - Enums get a synthetic "leave unchanged" first option. + * - Booleans use confirm with the current value as initialValue. + * - Secret entries mask the current value as bullets in hints/labels. + */ +import * as p from '@clack/prompts'; + +import { brightSelect } from './bright-select.js'; +import { ensureAnswer } from './runner.js'; +import { CONFIG, type Entry } from './setup-config.js'; +import type { ConfigValues } from './setup-config-parse.js'; + +const SKIP_SENTINEL = '__leave_unchanged__'; +const DONE_SENTINEL = '__done__'; +const MASK = '••••••••'; + +export async function runAdvancedScreen( + initial: ConfigValues, +): Promise { + const result: ConfigValues = { ...initial }; + const visible = CONFIG.filter((e) => e.surface === 'flag+ui'); + + while (true) { + const options = [ + ...visible.map((e) => ({ + value: e.key, + label: e.label, + hint: hintFor(e, result), + })), + { value: DONE_SENTINEL, label: 'Done — continue with setup' }, + ]; + + const choice = ensureAnswer( + await brightSelect({ + message: 'Pick a setting to override', + options, + initialValue: DONE_SENTINEL, + }), + ) as string; + + if (choice === DONE_SENTINEL) return result; + const entry = visible.find((e) => e.key === choice); + if (entry) await promptOne(entry, result); + } +} + +function hintFor(e: Entry, values: ConfigValues): string { + const v = values[e.key]; + if (v === undefined) return 'not set'; + if (e.secret) return MASK; + return String(v); +} + +async function promptOne(e: Entry, values: ConfigValues): Promise { + if (e.type === 'boolean') { + const init = + typeof values[e.key] === 'boolean' + ? (values[e.key] as boolean) + : (e.default ?? false); + const ans = ensureAnswer( + await p.confirm({ message: e.label, initialValue: init }), + ); + values[e.key] = ans as boolean; + return; + } + + if (e.type === 'enum') { + const ans = ensureAnswer( + await brightSelect({ + message: e.label, + options: [ + { value: SKIP_SENTINEL, label: 'Leave unchanged' }, + ...e.options, + ], + initialValue: SKIP_SENTINEL, + }), + ); + if (ans !== SKIP_SENTINEL) values[e.key] = ans as string; + return; + } + + if (e.type === 'integer') { + const ans = ensureAnswer( + await p.text({ + message: e.label, + placeholder: e.default !== undefined ? String(e.default) : undefined, + validate: (v) => { + const s = (v ?? '').trim(); + if (!s) return undefined; + const n = Number(s); + if (!Number.isFinite(n)) return 'Must be a number'; + if (e.min !== undefined && n < e.min) return `Must be ≥ ${e.min}`; + if (e.max !== undefined && n > e.max) return `Must be ≤ ${e.max}`; + return undefined; + }, + }), + ); + const trimmed = ((ans as string) ?? '').trim(); + if (trimmed) values[e.key] = Number(trimmed); + return; + } + + // string | url + const validate = (v: string | undefined): string | undefined => { + const s = (v ?? '').trim(); + if (!s) return undefined; + return e.validate?.(s); + }; + const ans = ensureAnswer( + e.secret + ? await p.password({ message: e.label, validate }) + : await p.text({ + message: e.label, + placeholder: e.placeholder ?? e.default, + validate, + }), + ); + const trimmed = ((ans as string) ?? '').trim(); + if (trimmed) values[e.key] = trimmed; +} diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts new file mode 100644 index 0000000..ad45086 --- /dev/null +++ b/setup/lib/setup-config.ts @@ -0,0 +1,130 @@ +/** + * Setup-time advanced-config registry. + * + * One source of truth for: CLI flags, env-var names, the advanced-settings + * screen, and `--help` output. The flag parser, env reader, and UI screen + * all consume this list and write resolved values back to `process.env` so + * existing step code keeps reading env vars unchanged. + * + * Default name conventions (overridable per entry): + * key 'fooBar' → envVar 'NANOCLAW_FOO_BAR' → flag '--foo-bar' + * + * Surface levels: + * 'flag' — CLI flag + env var only (debug/internal knobs) + * 'flag+ui' — also shown in the advanced-settings screen + */ + +export type EntrySurface = 'flag' | 'flag+ui'; + +interface BaseEntry { + /** Canonical camelCase key. */ + key: string; + /** Override of the auto-derived NANOCLAW_ env var. */ + envVar?: string; + /** Override of the auto-derived --kebab-case flag. */ + flag?: string; + label: string; + help: string; + surface: EntrySurface; + /** UI section header. Entries without a group land in 'Other'. */ + group?: string; + /** Mask in UI, redact in logs. */ + secret?: boolean; +} + +interface StringEntry extends BaseEntry { + type: 'string' | 'url'; + default?: string; + placeholder?: string; + validate?: (v: string) => string | undefined; +} + +interface EnumEntry extends BaseEntry { + type: 'enum'; + options: { value: string; label: string; hint?: string }[]; + default?: string; +} + +interface BoolEntry extends BaseEntry { + type: 'boolean'; + default?: boolean; +} + +interface IntEntry extends BaseEntry { + type: 'integer'; + default?: number; + min?: number; + max?: number; +} + +export type Entry = StringEntry | EnumEntry | BoolEntry | IntEntry; + +const httpUrl = (v: string): string | undefined => + /^https?:\/\/\S+/.test(v) ? undefined : 'Must be http(s)://…'; + +export const CONFIG: Entry[] = [ + { + key: 'onecliApiHost', + label: 'OneCLI vault URL', + help: 'Use a remote OneCLI vault instead of installing one locally.', + surface: 'flag+ui', + group: 'OneCLI', + type: 'url', + placeholder: 'https://vault.example.internal', + validate: httpUrl, + }, + { + key: 'onecliApiToken', + label: 'OneCLI access token', + help: 'Bearer token for the remote vault. Required if --onecli-api-host is set.', + surface: 'flag+ui', + group: 'OneCLI', + type: 'string', + secret: true, + placeholder: 'oat_…', + }, + { + key: 'anthropicBaseUrl', + label: 'Anthropic API base URL', + help: 'Use a proxy or alternative endpoint instead of api.anthropic.com.', + surface: 'flag+ui', + group: 'Anthropic', + type: 'url', + placeholder: 'https://api.anthropic.com', + validate: httpUrl, + }, + + // Existing env-var knobs — flag-only so they don't clutter the UI screen. + { + key: 'skip', + envVar: 'NANOCLAW_SKIP', + label: 'Skip steps', + help: 'Comma-separated step names to skip (debugging only).', + surface: 'flag', + type: 'string', + }, + { + key: 'displayName', + envVar: 'NANOCLAW_DISPLAY_NAME', + label: 'Display name', + help: 'Skip the "what should your assistant call you?" prompt.', + surface: 'flag', + type: 'string', + }, +]; + +// ─── name derivation ─────────────────────────────────────────────────── + +export function envVarFor(e: Entry): string { + if (e.envVar) return e.envVar; + return `NANOCLAW_${e.key.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`; +} + +export function flagFor(e: Entry): string { + if (e.flag) return e.flag; + return `--${e.key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`; +} + +export function findByFlag(flag: string): Entry | null { + return CONFIG.find((e) => flagFor(e) === flag) ?? null; +} diff --git a/setup/onecli.ts b/setup/onecli.ts index 3f46c88..d6dda38 100644 --- a/setup/onecli.ts +++ b/setup/onecli.ts @@ -86,17 +86,22 @@ function ensureShellProfilePath(): void { } } -function writeEnvOnecliUrl(url: string): void { +function writeEnvVar(name: string, value: string): void { const envFile = path.join(process.cwd(), '.env'); let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : ''; - if (/^ONECLI_URL=/m.test(content)) { - content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`); + const re = new RegExp(`^${name}=.*$`, 'm'); + if (re.test(content)) { + content = content.replace(re, `${name}=${value}`); } else { - content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`; + content = content.trimEnd() + (content ? '\n' : '') + `${name}=${value}\n`; } fs.writeFileSync(envFile, content); } +function writeEnvOnecliUrl(url: string): void { + writeEnvVar('ONECLI_URL', url); +} + // 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. @@ -257,6 +262,8 @@ export async function run(args: string[]): Promise { ensureShellProfilePath(); if (remoteUrl) { + // Remote-mode: install only the CLI, point it at the remote gateway, and + // record the URL in .env. No local gateway is started. log.info('Installing OneCLI CLI for remote gateway', { remoteUrl }); const res = installOnecliCliOnly(); if (!res.ok || !onecliVersion()) { @@ -279,6 +286,11 @@ export async function run(args: string[]): Promise { } writeEnvOnecliUrl(remoteUrl); log.info('Wrote ONECLI_URL to .env', { url: remoteUrl }); + const remoteToken = process.env.NANOCLAW_ONECLI_API_TOKEN?.trim(); + if (remoteToken) { + writeEnvVar('ONECLI_API_KEY', remoteToken); + log.info('Wrote ONECLI_API_KEY to .env'); + } const healthy = await pollHealth(remoteUrl, 5000); emitStatus('ONECLI', { INSTALLED: true, From f048447ec551ced6606e6ac59089df7741211230 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 27 Apr 2026 00:00:59 +0300 Subject: [PATCH 017/144] feat(setup): authenticate onecli CLI for remote vault setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without `onecli auth login`, setup-time CLI calls (e.g. `secrets list` inside anthropicSecretExists, `secrets create` in runPasteAuth) hit a secured remote vault unauthenticated and fail silently — the auth step sees no existing Anthropic credential and prompts the user to add one even when it's already in the remote vault. Two auth surfaces matter here: the CLI's persistent store via `onecli auth login --api-key`, and ONECLI_API_KEY in .env that the runtime SDK reads at request time. We need both. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/onecli.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/setup/onecli.ts b/setup/onecli.ts index d6dda38..fbf76a9 100644 --- a/setup/onecli.ts +++ b/setup/onecli.ts @@ -288,6 +288,18 @@ export async function run(args: string[]): Promise { log.info('Wrote ONECLI_URL to .env', { url: remoteUrl }); const remoteToken = process.env.NANOCLAW_ONECLI_API_TOKEN?.trim(); if (remoteToken) { + // Two auth surfaces: `onecli auth login` persists the key for CLI + // calls during setup itself (e.g. detecting an existing Anthropic + // secret via `onecli secrets list`), and ONECLI_API_KEY in .env is + // read by the runtime SDK at request time. Both are needed. + try { + execFileSync('onecli', ['auth', 'login', '--api-key', remoteToken], { + stdio: 'ignore', + env: childEnv(), + }); + } catch (err) { + log.warn('onecli auth login failed', { err }); + } writeEnvVar('ONECLI_API_KEY', remoteToken); log.info('Wrote ONECLI_API_KEY to .env'); } From e706dcac000e26c33eec4b1508ec495df8a2a9ae Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 27 Apr 2026 00:04:42 +0300 Subject: [PATCH 018/144] feat(setup): default OneCLI remote URL to https://app.onecli.sh Replaces the example.internal placeholder with the hosted gateway URL so the advanced screen and --help suggest the canonical destination out of the box. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/lib/setup-config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts index ad45086..7e2873e 100644 --- a/setup/lib/setup-config.ts +++ b/setup/lib/setup-config.ts @@ -70,7 +70,8 @@ export const CONFIG: Entry[] = [ surface: 'flag+ui', group: 'OneCLI', type: 'url', - placeholder: 'https://vault.example.internal', + default: 'https://app.onecli.sh', + placeholder: 'https://app.onecli.sh', validate: httpUrl, }, { From 7693a209708b6eca03813989ab44f60135edb7bc Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 27 Apr 2026 00:05:45 +0300 Subject: [PATCH 019/144] feat(setup): validate onecli api token starts with oc_ Matches the OneCLI CLI's own format expectation ("oc_... format" per `onecli auth login --help`) so a malformed token gets caught at setup time rather than at first vault call. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/lib/setup-config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts index 7e2873e..0a59731 100644 --- a/setup/lib/setup-config.ts +++ b/setup/lib/setup-config.ts @@ -82,7 +82,8 @@ export const CONFIG: Entry[] = [ group: 'OneCLI', type: 'string', secret: true, - placeholder: 'oat_…', + placeholder: 'oc_…', + validate: (v) => (v.startsWith('oc_') ? undefined : 'Must start with oc_'), }, { key: 'anthropicBaseUrl', From 26fc3ff3228c956473911a2ae62404c9d3870aab Mon Sep 17 00:00:00 2001 From: KeXin95 Date: Sat, 25 Apr 2026 22:12:09 -0700 Subject: [PATCH 020/144] feat: pass ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN into agent containers Users with a custom Anthropic-compatible endpoint (ANTHROPIC_BASE_URL) were getting 401s because the OneCLI proxy injects ANTHROPIC_API_KEY=placeholder and forwards to api.anthropic.com, overriding the custom endpoint and key. Add a claude provider host config that reads ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, and CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC from .env and passes them into the container. Also sets NO_PROXY for the custom host so the OneCLI proxy doesn't intercept those requests. Co-Authored-By: Claude Sonnet 4.6 --- src/providers/claude.ts | 16 ++++++++++++++++ src/providers/index.ts | 1 + 2 files changed, 17 insertions(+) create mode 100644 src/providers/claude.ts diff --git a/src/providers/claude.ts b/src/providers/claude.ts new file mode 100644 index 0000000..7252da8 --- /dev/null +++ b/src/providers/claude.ts @@ -0,0 +1,16 @@ +import { readEnvFile } from '../env.js'; +import { registerProviderContainerConfig } from './provider-container-registry.js'; + +registerProviderContainerConfig('claude', () => { + const dotenv = readEnvFile(['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC']); + const env: Record = {}; + if (dotenv.ANTHROPIC_BASE_URL) { + env.ANTHROPIC_BASE_URL = dotenv.ANTHROPIC_BASE_URL; + const host = new URL(dotenv.ANTHROPIC_BASE_URL).hostname; + env.NO_PROXY = host; + env.no_proxy = host; + } + if (dotenv.ANTHROPIC_AUTH_TOKEN) env.ANTHROPIC_AUTH_TOKEN = dotenv.ANTHROPIC_AUTH_TOKEN; + if (dotenv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC) env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = dotenv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; + return { env }; +}); diff --git a/src/providers/index.ts b/src/providers/index.ts index 3ec9512..1a3a638 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -4,3 +4,4 @@ // needs (claude, mock) don't appear here. // // Skills add a new provider by appending one import line below. +import './claude.js'; From 6591062fbb9e2bff5fefb57fbfe3ab4df48ae150 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 27 Apr 2026 00:34:31 +0300 Subject: [PATCH 021/144] refactor: route custom Anthropic endpoint through OneCLI vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original approach passed ANTHROPIC_AUTH_TOKEN into the container as an env var and disabled the proxy for the custom host (NO_PROXY) — which works, but bypasses OneCLI entirely for that credential. The container holds the raw secret, the gateway loses audit/rotation, and we lose the rest of the vault's protections for this cohort. OneCLI-native version: store the token as a generic secret with header injection (--header-name Authorization --value-format 'Bearer {value}' + host-pattern matching the base URL hostname). The container only needs ANTHROPIC_BASE_URL plus a placeholder ANTHROPIC_AUTH_TOKEN — the proxy rewrites the Authorization header on the wire. setup/lib/setup-config.ts — adds --anthropic-auth-token alongside the existing --anthropic-base-url. setup/auto.ts — runAuthStep short-circuits the auth-method prompt when both NANOCLAW_ANTHROPIC_BASE_URL and NANOCLAW_ANTHROPIC_AUTH_TOKEN are set: creates the OneCLI generic secret, writes ANTHROPIC_BASE_URL to .env (so the runtime reads it), and appends `import './claude.js';` to src/providers/index.ts (so the provider only registers when the user has configured a custom endpoint — no branching for everyone else). src/providers/claude.ts — drops ANTHROPIC_AUTH_TOKEN/NO_PROXY passthrough. Reads ANTHROPIC_BASE_URL from .env, sets a placeholder ANTHROPIC_AUTH_TOKEN in container env so the SDK includes an Authorization header for OneCLI to overwrite. src/providers/index.ts — removes the unconditional import; setup appends it on demand. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 98 +++++++++++++++++++++++++++++++++++++++ setup/lib/setup-config.ts | 10 ++++ src/providers/claude.ts | 24 +++++++--- src/providers/index.ts | 1 - 4 files changed, 126 insertions(+), 7 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 91f9bc1..8c78d64 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -22,6 +22,8 @@ * headless `claude -p` call for IANA-zone resolution. */ import { spawn, spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; @@ -636,6 +638,16 @@ async function runAuthStep(): Promise { return; } + // Custom Anthropic-compatible endpoint flow. Both URL and token must be set; + // OneCLI stores the token as a generic Bearer secret keyed to the URL host, + // so the container only ever sees ANTHROPIC_BASE_URL + a placeholder. + const customBaseUrl = process.env.NANOCLAW_ANTHROPIC_BASE_URL?.trim(); + const customAuthToken = process.env.NANOCLAW_ANTHROPIC_AUTH_TOKEN?.trim(); + if (customBaseUrl && customAuthToken) { + await runCustomEndpointAuth(customBaseUrl, customAuthToken); + return; + } + const method = ensureAnswer( await brightSelect({ message: 'How would you like to connect to Claude?', @@ -741,6 +753,92 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise { } } +/** + * Set up Anthropic auth for a custom endpoint. The token is stored as a + * OneCLI generic secret with header injection so the proxy rewrites the + * Authorization header on the wire — the container only ever sees + * ANTHROPIC_BASE_URL + a placeholder bearer. + */ +async function runCustomEndpointAuth( + baseUrl: string, + token: string, +): Promise { + let host: string; + try { + host = new URL(baseUrl).hostname; + } catch { + await fail( + 'auth', + `Invalid Anthropic base URL: ${baseUrl}`, + 'Check --anthropic-base-url and retry.', + ); + return; + } + + const res = await runQuietChild( + 'auth', + 'onecli', + [ + 'secrets', + 'create', + '--name', + 'Anthropic', + '--type', + 'generic', + '--value', + token, + '--host-pattern', + host, + '--header-name', + 'Authorization', + '--value-format', + 'Bearer {value}', + ], + { + running: `Saving your Anthropic auth token to your OneCLI vault…`, + done: 'Claude account connected.', + }, + { extraFields: { METHOD: 'custom-endpoint', HOST: host } }, + ); + if (!res.ok) { + await fail( + 'auth', + `Couldn't save your Anthropic auth token to the vault.`, + 'Make sure OneCLI is running (`onecli version`), then retry.', + ); + } + + // ANTHROPIC_BASE_URL has to be in .env so the runtime provider config + // reads it when building container env. The token is *not* written — + // OneCLI holds it. + writeEnvLine('ANTHROPIC_BASE_URL', baseUrl); + + // Register the claude provider so the runtime passes ANTHROPIC_BASE_URL + // and the placeholder bearer into the container. Only appended when the + // user has configured a custom endpoint; standard installs don't load + // the file at all. + appendProviderImport('./claude.js'); +} + +function writeEnvLine(key: string, value: string): void { + const envFile = path.join(process.cwd(), '.env'); + const content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : ''; + const re = new RegExp(`^${key}=.*$`, 'm'); + const next = re.test(content) + ? content.replace(re, `${key}=${value}`) + : content.trimEnd() + (content ? '\n' : '') + `${key}=${value}\n`; + fs.writeFileSync(envFile, next); +} + +function appendProviderImport(modulePath: string): void { + const file = path.join(process.cwd(), 'src', 'providers', 'index.ts'); + const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : ''; + const line = `import '${modulePath}';`; + if (content.includes(line)) return; + const sep = content && !content.endsWith('\n') ? '\n' : ''; + fs.writeFileSync(file, content + sep + line + '\n'); +} + // ─── timezone step ───────────────────────────────────────────────────── /** diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts index 0a59731..1fa6ad4 100644 --- a/setup/lib/setup-config.ts +++ b/setup/lib/setup-config.ts @@ -95,6 +95,16 @@ export const CONFIG: Entry[] = [ placeholder: 'https://api.anthropic.com', validate: httpUrl, }, + { + key: 'anthropicAuthToken', + label: 'Anthropic auth token', + help: 'Bearer token for the custom Anthropic endpoint. Used together with --anthropic-base-url.', + surface: 'flag+ui', + group: 'Anthropic', + type: 'string', + secret: true, + validate: (v) => (v.trim() ? undefined : 'Required'), + }, // Existing env-var knobs — flag-only so they don't clutter the UI screen. { diff --git a/src/providers/claude.ts b/src/providers/claude.ts index 7252da8..e61d721 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -1,16 +1,28 @@ +/** + * Claude provider container config — only registered when the user has + * configured a custom Anthropic-compatible endpoint via setup. Setup + * appends `import './claude.js'` to providers/index.ts at that point; + * standard installs hitting api.anthropic.com don't need this file + * loaded. + * + * The real auth token never enters the container. Setup creates an + * OneCLI generic secret (host-pattern = base URL hostname, header-name + * = Authorization, value-format = "Bearer {value}") so the proxy + * rewrites the Authorization header on the wire. The container only + * needs: + * - ANTHROPIC_BASE_URL — so the SDK knows where to call + * - ANTHROPIC_AUTH_TOKEN=placeholder — so the SDK adds an + * Authorization: Bearer header for OneCLI to overwrite + */ import { readEnvFile } from '../env.js'; import { registerProviderContainerConfig } from './provider-container-registry.js'; registerProviderContainerConfig('claude', () => { - const dotenv = readEnvFile(['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC']); + const dotenv = readEnvFile(['ANTHROPIC_BASE_URL']); const env: Record = {}; if (dotenv.ANTHROPIC_BASE_URL) { env.ANTHROPIC_BASE_URL = dotenv.ANTHROPIC_BASE_URL; - const host = new URL(dotenv.ANTHROPIC_BASE_URL).hostname; - env.NO_PROXY = host; - env.no_proxy = host; + env.ANTHROPIC_AUTH_TOKEN = 'placeholder'; } - if (dotenv.ANTHROPIC_AUTH_TOKEN) env.ANTHROPIC_AUTH_TOKEN = dotenv.ANTHROPIC_AUTH_TOKEN; - if (dotenv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC) env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = dotenv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; return { env }; }); diff --git a/src/providers/index.ts b/src/providers/index.ts index 1a3a638..3ec9512 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -4,4 +4,3 @@ // needs (claude, mock) don't appear here. // // Skills add a new provider by appending one import line below. -import './claude.js'; From be86bd3c2df113b2f408b08d63e87c75d2b623c0 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 27 Apr 2026 00:35:55 +0300 Subject: [PATCH 022/144] fix(setup): remove duplicate pollHealth import in auto.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slipped through during the #2035 rebase resolution — both #2030's import and ours landed in the merge. TypeScript dedups by symbol so it didn't fail the typecheck, but it's noise and would've eventually tripped a linter rule. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/setup/auto.ts b/setup/auto.ts index 91f9bc1..75afe52 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -46,7 +46,6 @@ import { runAdvancedScreen } from './lib/setup-config-screen.js'; import { runWindowedStep } from './lib/windowed-runner.js'; import { pollHealth } from './onecli.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; -import { pollHealth } from './onecli.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'; From b808ab4fd29e48db5ca06650d70d9bd287c252d7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 26 Apr 2026 21:39:18 +0000 Subject: [PATCH 023/144] chore: bump version to 2.0.14 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6029e0b..ee88d92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.13", + "version": "2.0.14", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From f8c3d023483c3775309d97b89638d96cded618df Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 26 Apr 2026 21:39:24 +0000 Subject: [PATCH 024/144] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?33k=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 0dfb9a2..f41b3e5 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 132k tokens, 66% of context window + + 133k tokens, 66% of context window @@ -15,8 +15,8 @@ tokens - - 132k + + 133k From a80f095174ecf2fbc0b2678d91c03dd767394287 Mon Sep 17 00:00:00 2001 From: dooha333 Date: Mon, 27 Apr 2026 00:56:29 +0000 Subject: [PATCH 025/144] fix(setup): inject ~/.local/bin into PATH so post-install onecli is reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup/auto.ts spawned register-claude-token.sh via runInheritScript, which inherits the parent Node process's PATH. When OneCLI was installed earlier in the same setup run, its installer wrote the binary to ~/.local/bin and appended a PATH line to the user's shell rc — but rc updates do not reach an already-running process. The script's first guard, `command -v onecli`, failed instantly (~3ms), and the auth step reported "Couldn't complete the Claude sign-in" even though the real blocker was OneCLI not on PATH. Patch process.env.PATH at the top of main() so every subsequent shell-out sees ~/.local/bin. Idempotent — no-op if already present. Also drops a duplicate `pollHealth` import that was lurking in the import block. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/setup/auto.ts b/setup/auto.ts index 5ce2712..5429a0d 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -23,6 +23,7 @@ */ import { spawn, spawnSync } from 'child_process'; import fs from 'fs'; +import * as os from 'os'; import path from 'path'; import * as p from '@clack/prompts'; @@ -61,6 +62,13 @@ const RUN_START = Date.now(); type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip'; async function main(): Promise { + // Make sure ~/.local/bin is on PATH for every child process we spawn. + // Installers we run mid-setup (OneCLI, claude) drop binaries there and + // append a PATH line to the user's shell rc, but rc updates don't reach + // an already-running Node process — so without this patch a freshly + // installed `onecli` is invisible to a subsequent `runInheritScript`. + ensureLocalBinOnPath(); + // Parse CLI flags first — `--help` short-circuits before we render anything, // and flag values get folded into process.env so existing step code reading // NANOCLAW_* sees them unchanged. @@ -1013,6 +1021,14 @@ async function askChannelChoice(): Promise { // ─── interactive / env helpers ───────────────────────────────────────── +function ensureLocalBinOnPath(): void { + const localBin = path.join(os.homedir(), '.local', 'bin'); + const current = process.env.PATH ?? ''; + const segments = current.split(path.delimiter).filter(Boolean); + if (segments.includes(localBin)) return; + process.env.PATH = current ? `${localBin}${path.delimiter}${current}` : localBin; +} + function anthropicSecretExists(): boolean { try { const res = spawnSync('onecli', ['secrets', 'list'], { From 7e37b13aabd0d7ed8ebdedfa96cecad8e1e89796 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 28 Apr 2026 13:26:44 +0300 Subject: [PATCH 026/144] Fix path traversal in attachment handling on channel-inbound path --- src/attachment-safety.ts | 23 ++++++++++++++ src/host-core.test.ts | 37 +++++++++++++++++++++++ src/modules/agent-to-agent/agent-route.ts | 23 ++------------ src/router.ts | 9 +++++- src/session-manager.ts | 18 ++++++++++- 5 files changed, 88 insertions(+), 22 deletions(-) create mode 100644 src/attachment-safety.ts diff --git a/src/attachment-safety.ts b/src/attachment-safety.ts new file mode 100644 index 0000000..85467f9 --- /dev/null +++ b/src/attachment-safety.ts @@ -0,0 +1,23 @@ +import path from 'path'; + +/** + * Is `name` safe to use as the last segment of a path inside an + * attachment-staging directory? Filenames originate from untrusted sources — + * channel messages from any chat participant, agent-to-agent forwards from + * a possibly-compromised peer agent — and land in `path.join(dir, name)` + * sinks on the host. Without this guard, a `..`-laden name escapes the + * inbox and writes anywhere the host process has filesystem permission. + * + * Rejects: + * - non-string / empty + * - `.` / `..` (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; +} diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 9906c4b..2bb72d4 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -173,6 +173,43 @@ describe('session manager', () => { expect(getSession(session.id)!.last_active).not.toBeNull(); }); + + it('should refuse path-traversal in attachment filenames', () => { + // Regression: attachment.name comes from untrusted senders (E2EE-protected + // chat platforms can't sanitize it server-side). Without the guard, a + // `../../../tmp/pwned` filename escapes the inbox dir and writes anywhere + // the host process can reach. + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + const inboxBase = path.join(sessionDir('ag-1', session.id), 'inbox'); + const escapeTarget = path.join('/tmp', 'nanoclaw-traversal-canary'); + if (fs.existsSync(escapeTarget)) fs.rmSync(escapeTarget); + + writeSessionMessage('ag-1', session.id, { + id: 'msg-attack', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'pwn', + attachments: [ + { + type: 'document', + name: '../../../../../../../../tmp/nanoclaw-traversal-canary', + data: Buffer.from('owned').toString('base64'), + }, + ], + }), + }); + + expect(fs.existsSync(escapeTarget)).toBe(false); + // The bytes should still land — under a synthesized safe name inside the + // inbox — so the agent doesn't lose data on a malicious filename. + const inboxDir = path.join(inboxBase, 'msg-attack'); + expect(fs.existsSync(inboxDir)).toBe(true); + const written = fs.readdirSync(inboxDir); + expect(written).toHaveLength(1); + expect(written[0]).not.toContain('/'); + expect(written[0]).not.toContain('..'); + }); }); describe('router', () => { diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index 812cb8e..613a1ed 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -21,6 +21,7 @@ import fs from 'fs'; import path from 'path'; +import { isSafeAttachmentName } from '../../attachment-safety.js'; import { getAgentGroup } from '../../db/agent-groups.js'; import { getSession } from '../../db/sessions.js'; import { wakeContainer } from '../../container-runner.js'; @@ -29,6 +30,8 @@ import { resolveSession, sessionDir, writeSessionMessage } from '../../session-m import type { Session } from '../../types.js'; import { hasDestination } from './db/agent-destinations.js'; +export { isSafeAttachmentName }; + export interface ForwardedAttachment { name: string; filename: string; @@ -36,26 +39,6 @@ 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 diff --git a/src/router.ts b/src/router.ts index 3cf0192..995496d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -289,7 +289,14 @@ export async function routeInbound(event: InboundEvent): Promise { log.warn('adapter.subscribe failed', { channelType: event.channelType, threadId: event.threadId, err }); }); } - } else if (agent.ignored_message_policy === 'accumulate') { + } else if (agent.ignored_message_policy === 'accumulate' && !(engages && (!accessOk || !scopeOk))) { + // Accumulate stores the message as silent context. We allow it when + // engagement simply didn't fire, but NOT when engagement fired and + // the access/scope gate refused — those refusals are security + // decisions about an untrusted sender, and silently storing their + // message (which also stages their attachments to disk via + // writeSessionMessage → extractAttachmentFiles) is exactly what the + // gate is meant to prevent. await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false); accumulatedCount++; } else { diff --git a/src/session-manager.ts b/src/session-manager.ts index 38eaa0d..996a750 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -14,6 +14,7 @@ import type Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; +import { isSafeAttachmentName } from './attachment-safety.js'; import type { OutboundFile } from './channels/adapter.js'; import { DATA_DIR } from './config.js'; import { getMessagingGroup } from './db/messaging-groups.js'; @@ -252,11 +253,26 @@ function extractAttachmentFiles( let changed = false; for (const att of attachments) { if (typeof att.data === 'string') { + // The name field is attacker-controlled: chat platforms with E2E + // attachment encryption (WhatsApp, Matrix) cannot sanitize filename + // server-side, and other adapters pass att.name through raw. Without + // this guard, `path.join(inboxDir, '../../...')` writes anywhere the + // host process has fs permission — see Signal Desktop's Nov 2025 + // attachment-fileName advisory for the same archetype. + const rawName = (att.name as string | undefined) ?? `attachment-${Date.now()}`; + const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`; + if (filename !== rawName) { + log.warn('Refused unsafe attachment filename — would escape inbox', { + messageId, + rawName, + replacement: filename, + }); + } const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId); fs.mkdirSync(inboxDir, { recursive: true }); - const filename = (att.name as string) || `attachment-${Date.now()}`; const filePath = path.join(inboxDir, filename); fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64')); + att.name = filename; att.localPath = `inbox/${messageId}/${filename}`; delete att.data; changed = true; From 45d3016bcec5afa8b7e6fc21ee952165400348ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 28 Apr 2026 10:27:34 +0000 Subject: [PATCH 027/144] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?33k=20tokens=20=C2=B7=2067%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index f41b3e5..5a0fe82 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 133k tokens, 66% of context window + + 133k tokens, 67% of context window From c36f0c6b36436dcb7367724dc441cb1be6cced21 Mon Sep 17 00:00:00 2001 From: Gabi Simons <263580637+gabi-simons@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:35:51 +0000 Subject: [PATCH 028/144] fix(setup): wire Slack agent during setup like Discord/Telegram Slack setup previously stopped after installing the adapter, leaving users to manually discover /init-first-agent. When they DM'd the bot, the channel-approval flow silently failed because no owner existed. Now the Slack setup flow matches Discord/Telegram: - Collects the operator's Slack member ID - Opens a DM channel via conversations.open (requires im:write scope) - Runs init-first-agent to establish ownership, wiring, and welcome DM - Updates post-install note to focus on webhook URL (the only remaining step) The welcome DM is delivered via chat.postMessage (outbound), which works before Event Subscriptions are configured. The user sees the greeting immediately; inbound replies require webhooks. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-slack/SKILL.md | 2 +- setup/auto.ts | 5 +- setup/channels/slack.ts | 191 ++++++++++++++++++++++++++---- 3 files changed, 168 insertions(+), 30 deletions(-) diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index 318de7b..addbd67 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -60,7 +60,7 @@ pnpm run build 1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch** 2. Name it (e.g., "NanoClaw") and select your workspace 3. Go to **OAuth & Permissions** and add Bot Token Scopes: - - `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write` + - `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write` 4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`) 5. Go to **Basic Information** and copy the **Signing Secret** diff --git a/setup/auto.ts b/setup/auto.ts index 5ce2712..4dee7c8 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -510,10 +510,7 @@ function channelDmLabel(choice: ChannelChoice): string | null { 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; + return 'Slack DMs'; default: return null; } diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index f66c29a..ac31cca 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -1,24 +1,23 @@ /** * 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: + * `runSlackChannel(displayName)` owns the full branch from creating a + * Slack app through the welcome DM: * * 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. + * 5. Ask for the operator's Slack user ID + * 6. conversations.open to get the DM channel ID + * 7. Ask for the messaging-agent name (defaulting to "Nano") + * 8. Wire the agent via scripts/init-first-agent.ts * - * 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. + * The welcome DM is sent via outbound delivery (chat.postMessage), which + * works without Event Subscriptions being configured. The user sees the + * greeting in Slack immediately; inbound replies require webhooks, so the + * post-install note covers that. * * All output obeys the three-level contract. See docs/setup-flow.md. */ @@ -27,11 +26,13 @@ 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'; import { wrapForGutter } from '../lib/theme.js'; const SLACK_API = 'https://slack.com/api'; const SLACK_APPS_URL = 'https://api.slack.com/apps'; +const DEFAULT_AGENT_NAME = 'Nano'; interface WorkspaceInfo { teamName: string; @@ -40,10 +41,7 @@ interface WorkspaceInfo { 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 { +export async function runSlackChannel(displayName: string): Promise { await walkThroughAppCreation(); const token = await collectBotToken(); @@ -78,6 +76,47 @@ export async function runSlackChannel(_displayName: string): Promise { ); } + const ownerUserId = await collectSlackUserId(); + const dmChannelId = await openDmChannel(token, ownerUserId); + const platformId = `slack:${dmChannelId}`; + + const role = await askOperatorRole('Slack'); + setupLog.userInput('slack_role', role); + + const agentName = await resolveAgentName(); + + const init = await runQuietChild( + 'init-first-agent', + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'slack', + '--user-id', `slack:${ownerUserId}`, + '--platform-id', platformId, + '--display-name', displayName, + '--agent-name', agentName, + '--role', role, + ], + { + running: `Wiring ${agentName} to your Slack DMs…`, + done: 'Agent wired.', + }, + { + extraFields: { + CHANNEL: 'slack', + AGENT_NAME: agentName, + PLATFORM_ID: platformId, + }, + }, + ); + if (!init.ok) { + await fail( + 'init-first-agent', + `Couldn't finish connecting ${agentName}.`, + 'You can retry later with `/init-first-agent` in Claude Code.', + ); + } + showPostInstallChecklist(info); } @@ -89,8 +128,9 @@ async function walkThroughAppCreation(): Promise { '', ' 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', + ' chat:write, im: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"', @@ -221,15 +261,120 @@ async function validateSlackToken(token: string): Promise { } } +async function collectSlackUserId(): Promise { + p.note( + [ + "To get your Slack member ID:", + '', + ' 1. In Slack, click your profile picture (top right)', + ' 2. Click "Profile"', + ' 3. Click the three dots (⋯) → "Copy member ID"', + ].join('\n'), + 'Find your Slack user ID', + ); + const answer = ensureAnswer( + await p.text({ + message: 'Paste your Slack member ID', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'Member ID is required'; + if (!/^U[A-Z0-9]{8,}$/.test(t)) { + return "That doesn't look like a Slack member ID (starts with U)"; + } + return undefined; + }, + }), + ); + const id = (answer as string).trim(); + setupLog.userInput('slack_user_id', id); + return id; +} + +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(`${SLACK_API}/conversations.open`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ users: userId }), + }); + const data = (await res.json()) as { + ok?: boolean; + channel?: { id?: string }; + error?: string; + }; + const elapsedS = Math.round((Date.now() - start) / 1000); + if (data.ok && data.channel?.id) { + s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`); + setupLog.step('slack-open-dm', 'success', Date.now() - start, { + DM_CHANNEL_ID: data.channel.id, + }); + return data.channel.id; + } + const reason = data.error ?? `HTTP ${res.status}`; + s.stop(`Couldn't open a DM channel: ${reason}`, 1); + setupLog.step('slack-open-dm', 'failed', Date.now() - start, { + ERROR: reason, + }); + if (reason === 'missing_scope') { + await fail( + 'slack-open-dm', + "Your Slack app is missing the im:write scope.", + 'Go to OAuth & Permissions in your Slack app settings, add the im:write scope, reinstall the app, then retry setup.', + ); + } + await fail( + 'slack-open-dm', + "Couldn't open a DM channel with you.", + `Slack said "${reason}". Check the member ID and app permissions, 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-open-dm', 'failed', Date.now() - start, { + ERROR: message, + }); + await fail( + 'slack-open-dm', + "Couldn't reach Slack.", + '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; +} + 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:`, + `Your agent is wired to Slack and a welcome DM is on its way.`, + `To receive replies, Slack needs a public URL for delivering events:`, '', - ' 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.', + ' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,', + ' Cloudflare Tunnel, or a reverse proxy on a VPS.', '', ' 2. In your Slack app → Event Subscriptions:', ' • Toggle "Enable Events" on', @@ -237,10 +382,6 @@ function showPostInstallChecklist(info: WorkspaceInfo): void { ' • 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, ), From c5d02434178023724c1ff6839481b9f0fd246286 Mon Sep 17 00:00:00 2001 From: Gabi Simons <263580637+gabi-simons@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:19:44 +0000 Subject: [PATCH 029/144] fix(setup): add Interactivity & Shortcuts step to Slack setup Slack interactive buttons (channel approval cards) require Interactivity to be enabled in the app settings. Without it, button clicks silently fail to reach the host. Added the step to both the setup wizard post-install checklist and the add-slack SKILL.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-slack/SKILL.md | 8 +++++++- setup/channels/slack.ts | 10 +++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index addbd67..d09db61 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -76,7 +76,13 @@ pnpm run build 10. Under **Subscribe to bot events**, add: - `message.channels`, `message.groups`, `message.im`, `app_mention` 11. Click **Save Changes** -12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions + +### Interactivity + +12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on +13. Set the **Request URL** to the same `https://your-domain/webhook/slack` +14. Click **Save Changes** +15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings ### Configure environment diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index ac31cca..6d1ff56 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -381,7 +381,15 @@ function showPostInstallChecklist(info: WorkspaceInfo): void { ` • 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', + ' • Save Changes', + '', + ' 3. In your Slack app → Interactivity & Shortcuts:', + ' • Toggle "Interactivity" on', + ` • Request URL: https:///webhook/slack`, + ' • Save Changes', + '', + ' 4. Slack will prompt you to reinstall the app — do it to apply', + ' the new settings', ].join('\n'), 6, ), From 2bf296b04a3802b25edd08e39692d042a3d7868f Mon Sep 17 00:00:00 2001 From: Daniel Milliner Date: Tue, 28 Apr 2026 14:01:32 +0000 Subject: [PATCH 030/144] add startup circuit breaker and troubleshooting docs Backs off on rapid restarts to avoid exhausting Discord gateway identify limits and triggering Cloudflare IP bans. Resets on clean shutdown so only crashes accumulate the counter. Also adds a troubleshooting section to CLAUDE.md with the most useful diagnostic locations. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 12 ++++++- src/circuit-breaker.ts | 79 ++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 5 +++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 src/circuit-breaker.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7115c4c..6565e8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,7 +186,17 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart systemctl --user start|stop|restart nanoclaw ``` -Host logs: `logs/nanoclaw.log` (normal) and `logs/nanoclaw.error.log` (errors only — some delivery/approval failures only show up here). +## Troubleshooting + +Check these first when something goes wrong: + +| What | Where | +|------|-------| +| Host logs | `logs/nanoclaw.error.log` first (delivery failures, crash-loop backoff, warnings), then `logs/nanoclaw.log` for the full routing chain | +| Setup logs | `logs/setup.log` (overall), `logs/setup-steps/*.log` (per-step: bootstrap, environment, container, onecli, mounts, service, etc.) | +| Session DBs | `data/v2-sessions///` — `inbound.db` (`messages_in`: did the message reach the container?), `outbound.db` (`messages_out`: did the agent produce a response?) | + +Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect. ## Supply Chain Security (pnpm) diff --git a/src/circuit-breaker.ts b/src/circuit-breaker.ts new file mode 100644 index 0000000..4288eb4 --- /dev/null +++ b/src/circuit-breaker.ts @@ -0,0 +1,79 @@ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from './config.js'; +import { log } from './log.js'; + +const CB_PATH = path.join(DATA_DIR, 'circuit-breaker.json'); +const RESET_WINDOW_MS = 60 * 60 * 1000; // 1 hour +const BACKOFF_SCHEDULE_S = [0, 0, 10, 30, 120, 300, 900]; // index = attempt number, 6+ capped at 15min + +interface CircuitBreakerState { + attempt: number; + timestamp: string; +} + +function read(): CircuitBreakerState | null { + try { + const raw = fs.readFileSync(CB_PATH, 'utf-8'); + return JSON.parse(raw) as CircuitBreakerState; + } catch { + return null; + } +} + +function write(state: CircuitBreakerState): void { + fs.writeFileSync(CB_PATH, JSON.stringify(state, null, 2) + '\n'); +} + +function getDelay(attempt: number): number { + const idx = Math.min(attempt, BACKOFF_SCHEDULE_S.length - 1); + return BACKOFF_SCHEDULE_S[idx]; +} + +export function resetCircuitBreaker(): void { + try { + fs.unlinkSync(CB_PATH); + log.info('Circuit breaker reset on clean shutdown'); + } catch {} +} + +export async function enforceStartupBackoff(): Promise { + const now = new Date(); + const prev = read(); + + let attempt: number; + if (!prev) { + attempt = 1; + } else { + const elapsedMs = now.getTime() - new Date(prev.timestamp).getTime(); + if (elapsedMs < RESET_WINDOW_MS) { + attempt = prev.attempt + 1; + log.warn('Previous startup was not a clean shutdown', { + previousAttempt: prev.attempt, + previousTimestamp: prev.timestamp, + elapsedSec: Math.round(elapsedMs / 1000), + }); + } else { + attempt = 1; + log.info('Circuit breaker reset — last startup was over 1h ago', { + previousAttempt: prev.attempt, + previousTimestamp: prev.timestamp, + }); + } + } + + write({ attempt, timestamp: now.toISOString() }); + + const delaySec = getDelay(attempt); + if (delaySec > 0) { + const resumeAt = new Date(now.getTime() + delaySec * 1000).toISOString(); + log.warn('Circuit breaker: delaying startup due to repeated crashes', { + attempt, + delaySec, + resumeAt, + }); + await new Promise((resolve) => setTimeout(resolve, delaySec * 1000)); + log.info('Circuit breaker: backoff complete, resuming startup', { attempt }); + } +} diff --git a/src/index.ts b/src/index.ts index ea9fba6..6235525 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import path from 'path'; import { DATA_DIR } from './config.js'; +import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js'; import { migrateGroupsToClaudeLocal } from './claude-md-compose.js'; import { initDb } from './db/connection.js'; import { runMigrations } from './db/migrations/index.js'; @@ -58,6 +59,9 @@ import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from async function main(): Promise { log.info('NanoClaw starting'); + // 0. Circuit breaker — backoff on rapid restarts + await enforceStartupBackoff(); + // 1. Init central DB const dbPath = path.join(DATA_DIR, 'v2.db'); const db = initDb(dbPath); @@ -175,6 +179,7 @@ async function shutdown(signal: string): Promise { stopDeliveryPolls(); stopHostSweep(); await teardownChannelAdapters(); + resetCircuitBreaker(); process.exit(0); } From 336e01d2a1f1014d87da4f1d00a0f67e1e811cad Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 28 Apr 2026 22:51:11 +0300 Subject: [PATCH 031/144] fix circuit-breaker off-by-one, ENOENT, and reset-on-throw + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getDelay indexed by attempt (1-based) into a 0-indexed array, so the leading 0 was unreachable and every "after a crash" delay was shifted up one slot. Use attempt - 1 so the documented schedule (0s → 0s → 10s → 30s → 2min → 5min → 15min cap) actually holds. - enforceStartupBackoff runs before initDb (which creates DATA_DIR), so on a fresh checkout fs.writeFileSync hit ENOENT. write() now mkdirSync's DATA_DIR first. - shutdown() didn't run resetCircuitBreaker if teardownChannelAdapters threw, so a graceful exit with a teardown error would be counted as a crash on the next start. Wrap teardown in try/finally. - Adds src/circuit-breaker.test.ts: state transitions, full schedule (parameterized), reset-window expiry, malformed file, and the fresh-install path. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/circuit-breaker.test.ts | 197 ++++++++++++++++++++++++++++++++++++ src/circuit-breaker.ts | 9 +- src/index.ts | 12 ++- 3 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 src/circuit-breaker.test.ts diff --git a/src/circuit-breaker.test.ts b/src/circuit-breaker.test.ts new file mode 100644 index 0000000..d8c996c --- /dev/null +++ b/src/circuit-breaker.test.ts @@ -0,0 +1,197 @@ +/** + * Unit tests for the startup circuit breaker. + * + * Covers state transitions, the documented backoff schedule, and the + * fresh-install case where DATA_DIR doesn't exist yet (the breaker runs + * before initDb, so it has to create the dir itself). + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// vi.mock factories are hoisted above imports, so they can't close over local +// consts. vi.hoisted is hoisted alongside the mock and runs before any +// `import` — so it can only use globals (no path/os modules). Use require() +// inside the callback to compute the test dir. +const { TEST_DIR } = vi.hoisted(() => { + const nodePath = require('path') as typeof import('path'); + const nodeOs = require('os') as typeof import('os'); + return { TEST_DIR: nodePath.join(nodeOs.tmpdir(), 'nanoclaw-cb-test') }; +}); +const CB_PATH = path.join(TEST_DIR, 'circuit-breaker.json'); + +vi.mock('./config.js', async () => { + const actual = await vi.importActual('./config.js'); + return { ...actual, DATA_DIR: TEST_DIR }; +}); + +vi.mock('./log.js', () => ({ + log: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, +})); + +import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js'; + +function readState(): { attempt: number; timestamp: string } { + return JSON.parse(fs.readFileSync(CB_PATH, 'utf-8')); +} + +function seedState(attempt: number, timestamp = new Date().toISOString()): void { + fs.writeFileSync(CB_PATH, JSON.stringify({ attempt, timestamp })); +} + +beforeEach(() => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); +}); + +afterEach(() => { + vi.useRealTimers(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); +}); + +describe('resetCircuitBreaker', () => { + it('deletes the state file', () => { + seedState(3); + expect(fs.existsSync(CB_PATH)).toBe(true); + resetCircuitBreaker(); + expect(fs.existsSync(CB_PATH)).toBe(false); + }); + + it('is a no-op when the file does not exist', () => { + expect(fs.existsSync(CB_PATH)).toBe(false); + expect(() => resetCircuitBreaker()).not.toThrow(); + }); +}); + +describe('enforceStartupBackoff — state transitions', () => { + it('first run writes attempt=1 and does not delay', async () => { + vi.useFakeTimers(); + const start = Date.now(); + await enforceStartupBackoff(); + // No timers should have been queued — clean first start is 0s. + expect(Date.now() - start).toBe(0); + expect(readState().attempt).toBe(1); + }); + + it('within reset window, attempt is incremented', async () => { + seedState(1); + vi.useFakeTimers(); + const promise = enforceStartupBackoff(); + await vi.runAllTimersAsync(); + await promise; + expect(readState().attempt).toBe(2); + }); + + it('outside reset window (>1h), attempt resets to 1', async () => { + const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + seedState(5, longAgo); + await enforceStartupBackoff(); + expect(readState().attempt).toBe(1); + }); + + it('exactly at the reset window boundary still counts as "within"', async () => { + // RESET_WINDOW_MS = 60min. Use 59min59s to stay inside even if the test + // takes a few ms to execute. + const justInside = new Date(Date.now() - (60 * 60 * 1000 - 1000)).toISOString(); + seedState(2, justInside); + vi.useFakeTimers(); + const promise = enforceStartupBackoff(); + await vi.runAllTimersAsync(); + await promise; + expect(readState().attempt).toBe(3); + }); + + it('treats a malformed state file as no prior state', async () => { + fs.writeFileSync(CB_PATH, '{ this is not json'); + await enforceStartupBackoff(); + expect(readState().attempt).toBe(1); + }); + + it('resetCircuitBreaker after a startup actually clears the counter for the next startup', async () => { + // Simulate: crash, restart (attempt=2), graceful shutdown, restart again. + seedState(1); + vi.useFakeTimers(); + const p1 = enforceStartupBackoff(); + await vi.runAllTimersAsync(); + await p1; + expect(readState().attempt).toBe(2); + + resetCircuitBreaker(); + expect(fs.existsSync(CB_PATH)).toBe(false); + + await enforceStartupBackoff(); + expect(readState().attempt).toBe(1); + }); +}); + +describe('enforceStartupBackoff — backoff schedule', () => { + /** + * Documented schedule: + * + * clean start → 1 crash → 2 crash → 3 crash → 4 crash → 5 crash → 6+ crash + * 0s → 0s → 10s → 30s → 2min → 5min → 15min cap + * + * Each row is [priorAttempt seeded in the file, expected delay this run + * produces in seconds]. priorAttempt=null = no file = very first start. + * + * To assert the *requested* delay (not just observed elapsed real time), + * we spy on global.setTimeout and look at the longest call. runAllTimersAsync + * lets the function complete so we can move on. + */ + const cases: Array<{ label: string; priorAttempt: number | null; expectedDelaySec: number }> = [ + { label: 'clean first start (no file)', priorAttempt: null, expectedDelaySec: 0 }, + { label: 'first crash (attempt=2)', priorAttempt: 1, expectedDelaySec: 0 }, + { label: 'second crash (attempt=3)', priorAttempt: 2, expectedDelaySec: 10 }, + { label: 'third crash (attempt=4)', priorAttempt: 3, expectedDelaySec: 30 }, + { label: 'fourth crash (attempt=5)', priorAttempt: 4, expectedDelaySec: 120 }, + { label: 'fifth crash (attempt=6)', priorAttempt: 5, expectedDelaySec: 300 }, + { label: 'sixth crash (attempt=7) — cap', priorAttempt: 6, expectedDelaySec: 900 }, + { label: 'far past cap (attempt=20)', priorAttempt: 19, expectedDelaySec: 900 }, + ]; + + for (const { label, priorAttempt, expectedDelaySec } of cases) { + it(`${label}: delays ${expectedDelaySec}s`, async () => { + if (priorAttempt !== null) seedState(priorAttempt); + + vi.useFakeTimers(); + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + + const promise = enforceStartupBackoff(); + await vi.runAllTimersAsync(); + await promise; + + // enforceStartupBackoff only calls setTimeout when delaySec > 0. Pick + // the longest delay it requested (vitest may queue small internal + // timers we don't care about). + const requestedDelays = setTimeoutSpy.mock.calls.map((c) => c[1] ?? 0); + const maxDelayMs = requestedDelays.length ? Math.max(...requestedDelays) : 0; + + expect(maxDelayMs).toBe(expectedDelaySec * 1000); + }); + } +}); + +describe('enforceStartupBackoff — fresh install (DATA_DIR missing)', () => { + /** + * The breaker runs before initDb (which is what creates DATA_DIR). On a + * fresh checkout the dir doesn't exist yet, so write() must create it + * before writing the state file — otherwise the host crashes on its very + * first start. + */ + it('creates DATA_DIR on demand and does not throw', async () => { + fs.rmSync(TEST_DIR, { recursive: true }); + expect(fs.existsSync(TEST_DIR)).toBe(false); + + await expect(enforceStartupBackoff()).resolves.toBeUndefined(); + expect(fs.existsSync(TEST_DIR)).toBe(true); + expect(fs.existsSync(CB_PATH)).toBe(true); + expect(readState().attempt).toBe(1); + }); +}); diff --git a/src/circuit-breaker.ts b/src/circuit-breaker.ts index 4288eb4..20211f0 100644 --- a/src/circuit-breaker.ts +++ b/src/circuit-breaker.ts @@ -6,7 +6,9 @@ import { log } from './log.js'; const CB_PATH = path.join(DATA_DIR, 'circuit-breaker.json'); const RESET_WINDOW_MS = 60 * 60 * 1000; // 1 hour -const BACKOFF_SCHEDULE_S = [0, 0, 10, 30, 120, 300, 900]; // index = attempt number, 6+ capped at 15min +// Index = number of consecutive crashes (0 = clean start, attempt 1). +// 6+ crashes capped at 15min. +const BACKOFF_SCHEDULE_S = [0, 0, 10, 30, 120, 300, 900]; interface CircuitBreakerState { attempt: number; @@ -23,11 +25,14 @@ function read(): CircuitBreakerState | null { } function write(state: CircuitBreakerState): void { + // The breaker runs before initDb (which is what creates DATA_DIR), so on a + // fresh checkout the dir may not exist yet. + fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(CB_PATH, JSON.stringify(state, null, 2) + '\n'); } function getDelay(attempt: number): number { - const idx = Math.min(attempt, BACKOFF_SCHEDULE_S.length - 1); + const idx = Math.min(attempt - 1, BACKOFF_SCHEDULE_S.length - 1); return BACKOFF_SCHEDULE_S[idx]; } diff --git a/src/index.ts b/src/index.ts index 6235525..9ded3d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -178,9 +178,15 @@ async function shutdown(signal: string): Promise { } stopDeliveryPolls(); stopHostSweep(); - await teardownChannelAdapters(); - resetCircuitBreaker(); - process.exit(0); + try { + await teardownChannelAdapters(); + } finally { + // Always reset on graceful shutdown — even if teardown threw, we got here + // via SIGTERM/SIGINT, not a crash, so the next start shouldn't be counted + // as one. + resetCircuitBreaker(); + process.exit(0); + } } process.on('SIGTERM', () => shutdown('SIGTERM')); From ede6c01da8784c1adf5cddea5443e937761ce297 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 28 Apr 2026 19:53:23 +0000 Subject: [PATCH 032/144] chore: bump version to 2.0.15 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ee88d92..9b3b6fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.14", + "version": "2.0.15", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 89738917aed2c82f66c89203c05fda27a734e929 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 08:18:29 +0000 Subject: [PATCH 033/144] offer to install and authenticate Claude CLI before diagnosis When setup fails and claude-assist kicks in, instead of silently skipping when the CLI is missing or unauthenticated, interactively offer to install it (via install-claude.sh) and sign in (via claude setup-token) so the user can get diagnostic help immediately. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/lib/claude-assist.ts | 73 +++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 1651a9c..9cc3e5d 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -2,8 +2,10 @@ * 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. + * 1. Check `claude` is on PATH — if not, offer to install it via + * setup/install-claude.sh. Then check auth via `claude auth status` + * — if not signed in, offer to run `claude setup-token` (browser + * OAuth). If either is declined or fails, silently skip. * 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* @@ -16,7 +18,7 @@ * * Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs. */ -import { execSync, spawn } from 'child_process'; +import { execSync, spawn, spawnSync } from 'child_process'; import fs from 'fs'; import path from 'path'; @@ -90,7 +92,7 @@ export async function offerClaudeAssist( projectRoot: string = process.cwd(), ): Promise { if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false; - if (!isClaudeUsable()) return false; + if (!(await ensureClaudeReady(projectRoot))) return false; const want = ensureAnswer( await p.confirm({ @@ -128,15 +130,70 @@ export async function offerClaudeAssist( return true; } -function isClaudeUsable(): boolean { +function isClaudeInstalled(): boolean { try { execSync('command -v claude', { stdio: 'ignore' }); + return true; } 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. +} + +function isClaudeAuthenticated(): boolean { + try { + execSync('claude auth status', { stdio: 'ignore', timeout: 5_000 }); + return true; + } catch { + return false; + } +} + +async function ensureClaudeReady(projectRoot: string): Promise { + if (!isClaudeInstalled()) { + const install = ensureAnswer( + await p.confirm({ + message: + 'Claude CLI is needed to diagnose this. Install it now?', + initialValue: true, + }), + ); + if (!install) return false; + + const code = spawnSync('bash', ['setup/install-claude.sh'], { + cwd: projectRoot, + stdio: 'inherit', + }).status; + if (code !== 0 || !isClaudeInstalled()) { + p.log.error("Couldn't install the Claude CLI."); + return false; + } + p.log.success('Claude CLI installed.'); + } + + if (!isClaudeAuthenticated()) { + const auth = ensureAnswer( + await p.confirm({ + message: + "Claude CLI isn't signed in. Sign in now? (a browser will open)", + initialValue: true, + }), + ); + if (!auth) return false; + + const code = await new Promise((resolve) => { + const child = spawn('claude', ['setup-token'], { + stdio: 'inherit', + }); + child.on('close', (c) => resolve(c ?? 1)); + child.on('error', () => resolve(1)); + }); + if (code !== 0 || !isClaudeAuthenticated()) { + p.log.error("Couldn't complete Claude sign-in."); + return false; + } + p.log.success('Claude CLI signed in.'); + } + return true; } From 93be2d15f0d1e78797f085733fba65026cdae19e Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 10:18:38 +0000 Subject: [PATCH 034/144] fix claude setup-token flow for headless/remote systems Use script(1) to capture PTY output and extract OAuth token when browser-based auth isn't available, with fallback code-paste flow. Co-Authored-By: Claude Opus 4.6 --- setup/lib/claude-assist.ts | 47 ++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 9cc3e5d..dbc5082 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -5,7 +5,8 @@ * 1. Check `claude` is on PATH — if not, offer to install it via * setup/install-claude.sh. Then check auth via `claude auth status` * — if not signed in, offer to run `claude setup-token` (browser - * OAuth). If either is declined or fails, silently skip. + * OAuth with code-paste fallback for headless/remote systems). + * If either is declined or fails, silently skip. * 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* @@ -20,6 +21,7 @@ */ import { execSync, spawn, spawnSync } from 'child_process'; import fs from 'fs'; +import os from 'os'; import path from 'path'; import * as p from '@clack/prompts'; @@ -180,14 +182,45 @@ async function ensureClaudeReady(projectRoot: string): Promise { ); if (!auth) return false; - const code = await new Promise((resolve) => { - const child = spawn('claude', ['setup-token'], { + // setup-token has an interactive TUI; reset terminal to cooked mode + // so its prompts render correctly after clack's raw-mode prompts. + spawnSync('stty', ['sane'], { stdio: 'inherit' }); + + // Run under script(1) to capture the OAuth token from PTY output + // while preserving interactive TTY for the browser OAuth flow. + // Same approach as register-claude-token.sh, but we set the env var + // instead of writing to OneCLI. + const tmpfile = path.join(os.tmpdir(), `claude-setup-token-${process.pid}`); + try { + const isUtilLinux = (() => { + try { + return execSync('script --version 2>&1', { encoding: 'utf-8' }).includes('util-linux'); + } catch { return false; } + })(); + const scriptArgs = isUtilLinux + ? ['-q', '-c', 'claude setup-token', tmpfile] + : ['-q', tmpfile, 'claude', 'setup-token']; + + spawnSync('script', scriptArgs, { + cwd: projectRoot, stdio: 'inherit', }); - child.on('close', (c) => resolve(c ?? 1)); - child.on('error', () => resolve(1)); - }); - if (code !== 0 || !isClaudeAuthenticated()) { + + if (!isClaudeAuthenticated() && fs.existsSync(tmpfile)) { + const raw = fs.readFileSync(tmpfile, 'utf-8'); + const stripped = raw + .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + .replace(/[\n\r]/g, ''); + const matches = stripped.match(/(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g); + if (matches) { + process.env.CLAUDE_CODE_OAUTH_TOKEN = matches[matches.length - 1]; + } + } + } finally { + try { fs.unlinkSync(tmpfile); } catch {} + } + + if (!isClaudeAuthenticated()) { p.log.error("Couldn't complete Claude sign-in."); return false; } From 9c8f680ca87d31fe3c273c246cd80eaec019e04e Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 10:20:10 +0000 Subject: [PATCH 035/144] fix: stop dimming setup card bodies Clack's `p.note` defaults to `format: e => styleText("dim", e)`, which fades note bodies regardless of the project's stated readability stance (see comment on `dimWrap` in setup/lib/theme.ts: "prose renders at the terminal's regular weight"). The dim styling makes body copy hard to read on dark terminals and visibly washes out brand-colored segments embedded in cards (e.g. the chip + bold heading rows). Add a `note()` helper in setup/lib/theme.ts that wraps `p.note` with a pass-through formatter, and route every setup-flow `p.note` call site through it: setup/auto.ts, every setup/channels/*.ts adapter, and the two setup/lib/claude-* helpers. Pre-styled segments (brandBold, brandChip, formatPairingCard, formatCodeCard) now render at full strength instead of being faded alongside surrounding prose. --- setup/auto.ts | 14 +++++++------- setup/channels/discord.ts | 11 ++++++----- setup/channels/imessage.ts | 8 ++++---- setup/channels/signal.ts | 5 +++-- setup/channels/slack.ts | 8 ++++---- setup/channels/teams.ts | 21 +++++++++++---------- setup/channels/telegram.ts | 8 ++++---- setup/channels/whatsapp.ts | 8 ++++---- setup/lib/claude-assist.ts | 4 ++-- setup/lib/claude-handoff.ts | 4 +++- setup/lib/theme.ts | 14 ++++++++++++++ 11 files changed, 62 insertions(+), 43 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 4dee7c8..ee5c369 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -52,7 +52,7 @@ import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-clau import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { emit as phEmit } from './lib/diagnostics.js'; -import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js'; +import { brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; @@ -435,7 +435,7 @@ async function main(): Promise { ); } if (notes.length > 0) { - p.note(notes.join('\n'), "What's left"); + 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. @@ -467,11 +467,11 @@ async function main(): Promise { ]; 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'); + 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( + 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, @@ -488,7 +488,7 @@ async function main(): Promise { // 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'); + 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`.")); @@ -567,7 +567,7 @@ function renderPingFailureNote(result: PingResult): void { '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'); + note(body, 'Skipping the first chat'); } /** @@ -582,7 +582,7 @@ function renderPingFailureNote(result: PingResult): void { * clearly optional. */ async function runFirstChat(): Promise { - p.note( + note( wrapForGutter( [ 'Your assistant runs in a sandbox on this machine.', diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 3668686..671d920 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -31,6 +31,7 @@ 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'; +import { note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const DISCORD_API = 'https://discord.com/api/v10'; @@ -155,7 +156,7 @@ async function askHasBotToken(): Promise { async function walkThroughBotCreation(): Promise { const url = 'https://discord.com/developers/applications'; - p.note( + note( [ "You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.", '', @@ -184,7 +185,7 @@ function showTokenLocationReminder(hasExistingBot: boolean): void { // 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( + note( [ "Where to find your bot token:", '', @@ -216,7 +217,7 @@ async function walkThroughServerCreation(): Promise { // 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( + note( [ "A Discord server is just a private space for you and the bot. Free and takes 30 seconds.", '', @@ -392,7 +393,7 @@ async function resolveOwnerUserId( } async function promptForUserIdWithDevMode(): Promise { - p.note( + note( [ "To get your Discord user ID:", '', @@ -430,7 +431,7 @@ async function promptInviteBot( `&scope=bot` + `&permissions=${INVITE_PERMISSIONS}`; - p.note( + note( [ `@${botUsername} needs to share a server with you before it can DM you.`, '', diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index d8b129f..387f6b2 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -36,7 +36,7 @@ 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'; +import { note, wrapForGutter } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -189,7 +189,7 @@ async function walkThroughFullDiskAccess(): Promise { } const nodeDir = path.dirname(nodePath); - p.note( + note( wrapForGutter( [ `iMessage needs Full Disk Access granted to the Node binary:`, @@ -222,7 +222,7 @@ async function walkThroughFullDiskAccess(): Promise { } async function collectRemoteCreds(): Promise { - p.note( + note( [ "Photon is a separate service that owns an iMessage account and", "exposes it over HTTP. NanoClaw will talk to it via its API.", @@ -264,7 +264,7 @@ async function collectRemoteCreds(): Promise { } async function askOperatorHandle(): Promise { - p.note( + note( [ "What phone number or email do you iMessage with?", "That's where your assistant will send its welcome message.", diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 9e54cb9..4e1cbfb 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -44,6 +44,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; +import { note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -139,7 +140,7 @@ async function ensureSignalCli(): Promise { if (!probe.error && probe.status === 0) return; if (process.platform === 'darwin') { - p.note( + note( [ "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", '', @@ -152,7 +153,7 @@ async function ensureSignalCli(): Promise { 'signal-cli not found', ); } else { - p.note( + note( [ "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", '', diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 6d1ff56..4ee5973 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -28,7 +28,7 @@ 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'; -import { wrapForGutter } from '../lib/theme.js'; +import { note, wrapForGutter } from '../lib/theme.js'; const SLACK_API = 'https://slack.com/api'; const SLACK_APPS_URL = 'https://api.slack.com/apps'; @@ -121,7 +121,7 @@ export async function runSlackChannel(displayName: string): Promise { } async function walkThroughAppCreation(): Promise { - p.note( + note( [ "You'll create a Slack app that the assistant talks through.", "Free and stays inside the workspaces you pick.", @@ -262,7 +262,7 @@ async function validateSlackToken(token: string): Promise { } async function collectSlackUserId(): Promise { - p.note( + note( [ "To get your Slack member ID:", '', @@ -367,7 +367,7 @@ async function resolveAgentName(): Promise { } function showPostInstallChecklist(info: WorkspaceInfo): void { - p.note( + note( wrapForGutter( [ `Your agent is wired to Slack and a welcome DM is on its way.`, diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index fb4d878..e412086 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -40,6 +40,7 @@ import { } from '../lib/claude-handoff.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { buildTeamsAppPackage } from '../lib/teams-manifest.js'; +import { note } from '../lib/theme.js'; import * as setupLog from '../logs.js'; const CHANNEL = 'teams'; @@ -79,7 +80,7 @@ export async function runTeamsChannel(_displayName: string): Promise { // ─── step: intro / prereqs ────────────────────────────────────────────── function printIntro(): void { - p.note( + note( [ 'Setting up Teams is more involved than the other channels — about', '7 steps across the Azure portal and Teams admin.', @@ -93,7 +94,7 @@ function printIntro(): void { } async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise { - p.note( + note( [ 'Before we start, confirm you have:', '', @@ -119,7 +120,7 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[] // ─── step: public URL ────────────────────────────────────────────────── async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise { - p.note( + note( [ "Azure Bot Service delivers messages to an HTTPS endpoint you", "control. The endpoint needs to reach this machine's webhook", @@ -175,7 +176,7 @@ async function stepAppRegistration(args: { collected: Collected; completed: string[]; }): Promise { - p.note( + note( [ `1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`, '2. Name it (e.g. "NanoClaw")', @@ -259,7 +260,7 @@ async function stepClientSecret(args: { collected: Collected; completed: string[]; }): Promise { - p.note( + note( [ `1. In your app registration, open "Certificates & secrets"`, '2. Click "New client secret"', @@ -328,7 +329,7 @@ async function stepAzureBot(args: { ` --appid ${args.collected.appId} \\\n` + ` ${tenantFlag}--endpoint "${endpoint}"`; - p.note( + note( [ `In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`, '', @@ -365,7 +366,7 @@ async function stepEnableTeamsChannel(args: { collected: Collected; completed: string[]; }): Promise { - p.note( + note( [ '1. Open your Azure Bot resource → Channels', '2. Click Microsoft Teams → Accept terms → Apply', @@ -435,7 +436,7 @@ async function stepSideload(args: { completed: string[]; zipPath: string; }): Promise { - p.note( + note( [ '1. Open Microsoft Teams', '2. Go to Apps → Manage your apps → Upload an app', @@ -501,7 +502,7 @@ async function finishWithHandoff( collected: Collected, completed: string[], ): Promise { - p.note( + note( [ 'The Teams adapter is live and the service is running.', '', @@ -530,7 +531,7 @@ async function finishWithHandoff( ); if (choice === 'self') { - p.note( + note( [ ' 1. Find your bot in Teams (search by name, or via the sideloaded', ' app) and send it a message ("hi" is fine)', diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index df97fcf..3a86a5f 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -33,7 +33,7 @@ import { spawnStep, writeStepEntry, } from '../lib/runner.js'; -import { brandBold } from '../lib/theme.js'; +import { brandBold, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -47,7 +47,7 @@ export async function runTelegramChannel(displayName: string): Promise { // 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( + note( [ `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, '', @@ -132,7 +132,7 @@ export async function runTelegramChannel(displayName: string): Promise { } async function collectTelegramToken(): Promise { - p.note( + note( [ "Your assistant talks to you through a Telegram bot you create.", "Here's how:", @@ -240,7 +240,7 @@ async function runPairTelegram(): Promise< } else { stopSpinner("Old code expired. Here's a fresh one."); } - p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); + 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') { diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 85c9866..eb487cb 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -46,7 +46,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { brandBold } from '../lib/theme.js'; +import { brandBold, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); @@ -171,7 +171,7 @@ async function askAuthMethod(): Promise { } async function askPhoneNumber(): Promise { - p.note( + note( [ "Enter your phone number the way WhatsApp expects it:", '', @@ -249,7 +249,7 @@ async function runWhatsAppAuth( } 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'); + note(formatPairingCard(code), 'Pairing code'); s.start('Waiting for you to enter the code…'); spinnerActive = true; } else if (block.type === 'WHATSAPP_AUTH') { @@ -395,7 +395,7 @@ async function restartService(): Promise { } async function askChatPhone(authedPhone: string): Promise { - p.note( + note( [ `Authenticated with ${k.cyan('+' + authedPhone)}.`, '', diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 1651a9c..48c760e 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -24,7 +24,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { ensureAnswer } from './runner.js'; -import { fitToWidth } from './theme.js'; +import { fitToWidth, note } from './theme.js'; export interface AssistContext { stepName: string; @@ -111,7 +111,7 @@ export async function offerClaudeAssist( return false; } - p.note( + note( `${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`, "Claude's suggestion", ); diff --git a/setup/lib/claude-handoff.ts b/setup/lib/claude-handoff.ts index 9c931f2..3a0c219 100644 --- a/setup/lib/claude-handoff.ts +++ b/setup/lib/claude-handoff.ts @@ -27,6 +27,8 @@ import { execSync, spawn } from 'child_process'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { note } from './theme.js'; + export interface HandoffContext { /** Channel this handoff is happening in (e.g., 'teams'). */ channel: string; @@ -69,7 +71,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise const systemPrompt = buildSystemPrompt(ctx); - p.note( + note( [ "I'm handing you off to Claude in interactive mode.", "It has the context of where you are in setup.", diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index 35b5ca3..f30ebe6 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -11,6 +11,7 @@ * - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan) * - Otherwise → kleur's 16-color cyan (closest fallback) */ +import * as p from '@clack/prompts'; import k from 'kleur'; const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; @@ -68,6 +69,19 @@ export function dimWrap(text: string, gutter: number): string { return wrapForGutter(text, gutter); } +/** + * Wrap clack's `p.note` with the dim formatter disabled. By default + * clack renders note bodies through `styleText("dim", …)`, which the + * project's prose-readability stance (see `dimWrap` above) explicitly + * rejects. Pass-through formatter keeps body text at the terminal's + * regular weight; pre-styled segments (chips, bold, brand color) come + * through unfaded. + */ +const passthroughFormat = (s: string): string => s; +export function note(message: string, title?: string): void { + p.note(message, title, { format: passthroughFormat }); +} + const ANSI_RE = /\x1b\[[0-9;]*m/g; function visibleLength(s: string): number { From aa390b3fd0466af30e5cc19113bb9e52944d3684 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 29 Apr 2026 10:20:54 +0000 Subject: [PATCH 036/144] detect existing .env and credentials on setup re-run When re-running setup on a machine that already has a .env with channel tokens or OneCLI config, detect them early and offer to reuse instead of prompting the user to paste everything again. - Add detectExistingEnv() to parse .env and group known keys - Add detectExistingDisplayName() to read display name from v2.db - Defer display name prompt until actually needed (cli-agent or channel) - Skip cli-agent and first-chat when groups are already wired - Add token reuse checks to Telegram, Discord, Slack, Teams, iMessage Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/auto.ts | 98 ++++++++++++++++++++++++++++++++++++-- setup/channels/discord.ts | 12 +++++ setup/channels/imessage.ts | 13 +++++ setup/channels/slack.ts | 24 ++++++++++ setup/channels/teams.ts | 22 +++++++++ setup/channels/telegram.ts | 12 +++++ setup/environment.ts | 18 +++++++ 7 files changed, 195 insertions(+), 4 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 4dee7c8..01d7f3a 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -46,6 +46,7 @@ import { } from './lib/setup-config-parse.js'; import { runAdvancedScreen } from './lib/setup-config-screen.js'; import { runWindowedStep } from './lib/windowed-runner.js'; +import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js'; import { pollHealth } from './onecli.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js'; @@ -121,6 +122,39 @@ async function main(): Promise { } } + // Detect existing .env and offer to reuse it so the user doesn't have to + // paste credentials again on a re-run. + const existingEnv = detectExistingEnv(); + if (existingEnv) { + const lines = Object.values(existingEnv.groups).map( + (g) => ` ${k.green('✓')} ${g.label}`, + ); + p.note(lines.join('\n'), 'Found existing configuration'); + + const reuseChoice = ensureAnswer( + await brightSelect({ + message: 'Use this existing environment?', + options: [ + { value: 'reuse', label: 'Yes, use what I already have', hint: 'recommended' }, + { value: 'fresh', label: 'No, start fresh' }, + ], + initialValue: 'reuse', + }), + ) as 'reuse' | 'fresh'; + setupLog.userInput('existing_env_choice', reuseChoice); + + if (reuseChoice === 'reuse') { + for (const [key, value] of Object.entries(existingEnv.raw)) { + if (!process.env[key]) process.env[key] = value; + } + if (existingEnv.groups.onecli) skip.add('onecli'); + if (detectRegisteredGroups(process.cwd())) { + skip.add('cli-agent'); + skip.add('first-chat'); + } + } + } + if (!skip.has('container')) { p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)); p.log.message( @@ -295,14 +329,17 @@ async function main(): Promise { } let displayName: string | undefined; - const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel'); - if (needsDisplayName) { - const fallback = process.env.USER?.trim() || 'Operator'; + async function resolveDisplayName(): Promise { + if (displayName) return displayName; const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim(); - displayName = preset || (await askDisplayName(fallback)); + const existing = detectExistingDisplayName(process.cwd()); + const fallback = process.env.USER?.trim() || 'Operator'; + displayName = preset || existing || (await askDisplayName(fallback)); + return displayName; } if (!skip.has('cli-agent')) { + await resolveDisplayName(); const res = await runQuietStep( 'cli-agent', { @@ -371,6 +408,9 @@ async function main(): Promise { let channelChoice: ChannelChoice = 'skip'; if (!skip.has('channel')) { channelChoice = await askChannelChoice(); + if (channelChoice !== 'skip') { + await resolveDisplayName(); + } if (channelChoice === 'telegram') { await runTelegramChannel(displayName!); } else if (channelChoice === 'discord') { @@ -1010,6 +1050,56 @@ async function askChannelChoice(): Promise { // ─── interactive / env helpers ───────────────────────────────────────── +interface ExistingEnvGroup { + label: string; + keys: string[]; +} + +const ENV_KEY_GROUPS: Record = { + onecli: { label: 'OneCLI', keys: ['ONECLI_URL'] }, + telegram: { label: 'Telegram', keys: ['TELEGRAM_BOT_TOKEN'] }, + discord: { label: 'Discord', keys: ['DISCORD_BOT_TOKEN', 'DISCORD_APPLICATION_ID', 'DISCORD_PUBLIC_KEY'] }, + slack: { label: 'Slack', keys: ['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET'] }, + signal: { label: 'Signal', keys: ['SIGNAL_ACCOUNT'] }, + teams: { label: 'Teams', keys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE'] }, + whatsapp: { label: 'WhatsApp', keys: ['ASSISTANT_HAS_OWN_NUMBER'] }, + imessage: { label: 'iMessage', keys: ['IMESSAGE_LOCAL', 'IMESSAGE_ENABLED', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY'] }, +}; + +function detectExistingEnv(): { groups: Record; raw: Record } | null { + const envPath = path.join(process.cwd(), '.env'); + if (!fs.existsSync(envPath)) return null; + + let content: string; + try { + content = fs.readFileSync(envPath, 'utf-8'); + } catch { + return null; + } + + const raw: Record = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 1) continue; + raw[trimmed.slice(0, eq)] = trimmed.slice(eq + 1); + } + + if (Object.keys(raw).length === 0) return null; + + const groups: Record = {}; + for (const [id, def] of Object.entries(ENV_KEY_GROUPS)) { + const found = def.keys.filter((key) => raw[key] !== undefined); + if (found.length > 0) { + groups[id] = { label: def.label, keys: found }; + } + } + + if (Object.keys(groups).length === 0) return null; + return { groups, raw }; +} + function anthropicSecretExists(): boolean { try { const res = spawnSync('onecli', ['secrets', 'list'], { diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 3668686..dd17bc2 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -239,6 +239,18 @@ async function walkThroughServerCreation(): Promise { } async function collectDiscordToken(): Promise { + const existing = process.env.DISCORD_BOT_TOKEN?.trim(); + if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('discord_token', 'reused-existing'); + return existing; + } + } + const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index d8b129f..89d2efe 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -222,6 +222,19 @@ async function walkThroughFullDiskAccess(): Promise { } async function collectRemoteCreds(): Promise { + const existingUrl = process.env.IMESSAGE_SERVER_URL?.trim(); + const existingKey = process.env.IMESSAGE_API_KEY?.trim(); + if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found existing Photon credentials (${existingUrl}). Use them?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('imessage_remote_creds', 'reused-existing'); + return { serverUrl: existingUrl, apiKey: existingKey }; + } + } + p.note( [ "Photon is a separate service that owns an iMessage account and", diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 6d1ff56..cfbd988 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -151,6 +151,18 @@ async function walkThroughAppCreation(): Promise { } async function collectBotToken(): Promise { + const existing = process.env.SLACK_BOT_TOKEN?.trim(); + if (existing && existing.startsWith('xoxb-') && existing.length >= 24) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('slack_bot_token', 'reused-existing'); + return existing; + } + } + const answer = ensureAnswer( await p.password({ message: 'Paste your Slack bot token', @@ -172,6 +184,18 @@ async function collectBotToken(): Promise { } async function collectSigningSecret(): Promise { + const existing = process.env.SLACK_SIGNING_SECRET?.trim(); + if (existing && /^[a-f0-9]{16,}$/i.test(existing)) { + const reuse = ensureAnswer(await p.confirm({ + message: 'Found an existing Slack signing secret. Use it?', + initialValue: true, + })); + if (reuse) { + setupLog.userInput('slack_signing_secret', 'reused-existing'); + return existing; + } + } + const answer = ensureAnswer( await p.password({ message: 'Paste your Slack signing secret', diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index fb4d878..91a91d9 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -59,6 +59,28 @@ export async function runTeamsChannel(_displayName: string): Promise { const collected: Collected = {}; const completed: string[] = []; + const existingAppId = process.env.TEAMS_APP_ID?.trim(); + const existingPassword = process.env.TEAMS_APP_PASSWORD?.trim(); + if (existingAppId && existingPassword) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`, + initialValue: true, + })); + if (reuse) { + collected.appId = existingAppId; + collected.appPassword = existingPassword; + collected.appType = (process.env.TEAMS_APP_TYPE?.trim() as 'SingleTenant' | 'MultiTenant') || 'MultiTenant'; + if (collected.appType === 'SingleTenant') { + collected.tenantId = process.env.TEAMS_APP_TENANT_ID?.trim(); + } + setupLog.userInput('teams_credentials', 'reused-existing'); + await installAdapter(collected); + completed.push('Adapter installed and service restarted (reused existing credentials).'); + await finishWithHandoff(collected, completed); + return; + } + } + printIntro(); await confirmPrereqs({ collected, completed }); diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index df97fcf..4659bd6 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -132,6 +132,18 @@ export async function runTelegramChannel(displayName: string): Promise { } async function collectTelegramToken(): Promise { + const existing = process.env.TELEGRAM_BOT_TOKEN?.trim(); + if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('telegram_token', 'reused-existing'); + return existing; + } + } + p.note( [ "Your assistant talks to you through a Telegram bot you create.", diff --git a/setup/environment.ts b/setup/environment.ts index 6986396..c351023 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -11,6 +11,24 @@ import { log } from '../src/log.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { emitStatus } from './status.js'; +export function detectExistingDisplayName(projectRoot: string): string | null { + const dbPath = path.join(projectRoot, 'data', 'v2.db'); + if (!fs.existsSync(dbPath)) return null; + + let db: Database.Database | null = null; + try { + db = new Database(dbPath, { readonly: true }); + const row = db + .prepare(`SELECT display_name FROM users WHERE id = 'cli:local'`) + .get() as { display_name: string } | undefined; + return row?.display_name?.trim() || null; + } catch { + return null; + } finally { + db?.close(); + } +} + export function detectRegisteredGroups(projectRoot: string): boolean { if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { return true; From a014a675561a9d8f893a02b50ee374675b3ed602 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 29 Apr 2026 10:34:58 +0000 Subject: [PATCH 037/144] fix password fields not clearing after validation error When pasting an invalid token, the old value stayed in the input field. Pasting a new token appended to the old one instead of replacing it, causing repeated validation failures. Add clearOnError: true to all 8 password prompts across setup. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/auto.ts | 1 + setup/channels/discord.ts | 1 + setup/channels/imessage.ts | 1 + setup/channels/slack.ts | 2 ++ setup/channels/teams.ts | 1 + setup/channels/telegram.ts | 1 + setup/lib/setup-config-screen.ts | 2 +- 7 files changed, 8 insertions(+), 1 deletion(-) diff --git a/setup/auto.ts b/setup/auto.ts index 4dee7c8..2f333a3 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -706,6 +706,7 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise { const answer = ensureAnswer( await p.password({ message: `Paste your ${label}`, + clearOnError: true, validate: (v) => { if (!v || !v.trim()) return 'Required'; if (!v.trim().startsWith(prefix)) { diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 3668686..74bc9af 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -242,6 +242,7 @@ async function collectDiscordToken(): Promise { const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', + clearOnError: true, validate: (v) => { const t = (v ?? '').trim(); if (!t) return 'Token is required'; diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index d8b129f..fae9fe4 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -250,6 +250,7 @@ async function collectRemoteCreds(): Promise { const keyAnswer = ensureAnswer( await p.password({ message: 'Photon API key', + clearOnError: true, validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'), }), ); diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 6d1ff56..9ae86ae 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -154,6 +154,7 @@ async function collectBotToken(): Promise { const answer = ensureAnswer( await p.password({ message: 'Paste your Slack bot token', + clearOnError: true, validate: (v) => { const t = (v ?? '').trim(); if (!t) return 'Token is required'; @@ -175,6 +176,7 @@ async function collectSigningSecret(): Promise { const answer = ensureAnswer( await p.password({ message: 'Paste your Slack signing secret', + clearOnError: true, validate: (v) => { const t = (v ?? '').trim(); if (!t) return 'Signing secret is required'; diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index fb4d878..2b892bf 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -276,6 +276,7 @@ async function stepClientSecret(args: { const answer = ensureAnswer( await p.password({ message: 'Paste the client secret Value', + clearOnError: true, validate: validateWithHelpEscape((v) => { const t = (v ?? '').trim(); if (!t) return 'Required'; diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index df97fcf..3c670e6 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -150,6 +150,7 @@ async function collectTelegramToken(): Promise { const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', + clearOnError: true, validate: (v) => { if (!v || !v.trim()) return "Token is required"; if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { diff --git a/setup/lib/setup-config-screen.ts b/setup/lib/setup-config-screen.ts index ad8ae62..88b10d5 100644 --- a/setup/lib/setup-config-screen.ts +++ b/setup/lib/setup-config-screen.ts @@ -115,7 +115,7 @@ async function promptOne(e: Entry, values: ConfigValues): Promise { }; const ans = ensureAnswer( e.secret - ? await p.password({ message: e.label, validate }) + ? await p.password({ message: e.label, clearOnError: true, validate }) : await p.text({ message: e.label, placeholder: e.placeholder ?? e.default, From ab2d5096711833c2f0cea53f5fdfc0ed8ab14ed5 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 11:43:30 +0000 Subject: [PATCH 038/144] feat(setup): paint card and log bodies in brand cyan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `brandBody` helper in setup/lib/theme.ts that wraps prose in brand cyan (#2BB7CE), with the same TTY/NO_COLOR/truecolor gating used by `brand`/`brandBold`/`brandChip`. The helper splits multi-line input and colors each line independently so the SGR sequence doesn't bleed across clack's gutter prefix. Routing: - `note()` (the un-dim card wrapper from #2095) now passes `brandBody` as its `format` callback, so card bodies render cyan line-by-line. - Every prose `p.log.{message,info,success,step,warn}` call in the setup flow wraps its body argument in `brandBody`. Calls whose body is explicitly `k.dim(...)` (failure transcript tails, log paths, claude-assist response previews) are left alone — those are the "preview/debug" cases the dim-policy comment in theme.ts already carves out. - Spinner-finish lines in windowed-runner / claude-assist color only the message portion; the `(5s)` elapsed suffix stays dim. Brand cyan accents (chips, wordmark, inline emphasis) are unchanged. This PR only adds the body color. A follow-up will add OSC 11 dark/light detection so light-mode terminals get a brand blue (#2b6fdc) variant — opt-in upgrade with no regression for the dark-mode default. --- setup/auto.ts | 58 ++++++++++++++++++++++-------------- setup/channels/discord.ts | 4 +-- setup/channels/whatsapp.ts | 4 +-- setup/lib/claude-assist.ts | 6 ++-- setup/lib/claude-handoff.ts | 6 ++-- setup/lib/runner.ts | 4 +-- setup/lib/theme.ts | 36 +++++++++++++++++----- setup/lib/windowed-runner.ts | 6 ++-- 8 files changed, 78 insertions(+), 46 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index ee5c369..c0b5add 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -52,7 +52,7 @@ import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-clau import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { emit as phEmit } from './lib/diagnostics.js'; -import { brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; +import { brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; @@ -122,11 +122,13 @@ async function main(): Promise { } if (!skip.has('container')) { - p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)); + p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4))); 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, + brandBody( + 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', { @@ -161,9 +163,11 @@ async function main(): Promise { if (!skip.has('onecli')) { p.log.message( - dimWrap( - 'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.', - 4, + brandBody( + dimWrap( + 'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.', + 4, + ), ), ); @@ -287,9 +291,11 @@ async function main(): Promise { await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.'); } if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') { - p.log.warn("NanoClaw's permissions need a tweak before it can reach Docker."); + p.log.warn(brandBody("NanoClaw's permissions need a tweak before it can reach Docker.")); p.log.message( - ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`, + brandBody( + ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`, + ), ); } } @@ -320,9 +326,11 @@ 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, + brandBody( + 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(); @@ -387,9 +395,11 @@ async function main(): Promise { await runIMessageChannel(displayName!); } else { p.log.info( - wrapForGutter( - 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', - 4, + brandBody( + wrapForGutter( + 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', + 4, + ), ), ); } @@ -629,7 +639,7 @@ function sendChatMessage(message: string): Promise { async function runAuthStep(): Promise { if (anthropicSecretExists()) { - p.log.success('Your Claude account is already connected.'); + p.log.success(brandBody('Your Claude account is already connected.')); setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); return; } @@ -677,7 +687,7 @@ async function runAuthStep(): Promise { } async function runSubscriptionAuth(): Promise { - p.log.step('Opening the Claude sign-in flow…'); + p.log.step(brandBody('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(); @@ -696,7 +706,7 @@ async function runSubscriptionAuth(): Promise { ); } setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' }); - p.log.success('Claude account connected.'); + p.log.success(brandBody('Claude account connected.')); } async function runPasteAuth(method: 'oauth' | 'api'): Promise { @@ -919,9 +929,11 @@ async function runTimezoneStep(): Promise { 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, + brandBody( + 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, + ), ), ); } @@ -1086,7 +1098,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(brandBody('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' }, diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 671d920..20024fe 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -31,7 +31,7 @@ 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'; -import { note } from '../lib/theme.js'; +import { brandBody, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const DISCORD_API = 'https://discord.com/api/v10'; @@ -386,7 +386,7 @@ async function resolveOwnerUserId( } } else { p.log.info( - "Your bot is owned by a Developer Team, so we need your Discord user ID directly.", + brandBody("Your bot is owned by a Developer Team, so we need your Discord user ID directly."), ); } return await promptForUserIdWithDevMode(); diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index eb487cb..fe4211b 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -46,7 +46,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { brandBold, note } from '../lib/theme.js'; +import { brandBody, brandBold, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); @@ -267,7 +267,7 @@ async function runWhatsAppAuth( if (spinnerActive) { stopSpinner('WhatsApp linked.'); } else { - p.log.success('WhatsApp linked.'); + p.log.success(brandBody('WhatsApp linked.')); } } else if (status === 'failed') { if (qrLinesPrinted > 0) { diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 48c760e..03d3e04 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -24,7 +24,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { ensureAnswer } from './runner.js'; -import { fitToWidth, note } from './theme.js'; +import { brandBody, fitToWidth, note } from './theme.js'; export interface AssistContext { stepName: string; @@ -106,7 +106,7 @@ export async function offerClaudeAssist( const parsed = parseResponse(response); if (!parsed) { - p.log.warn("Claude responded but I couldn't parse a command out of it."); + p.log.warn(brandBody("Claude responded but I couldn't parse a command out of it.")); p.log.message(k.dim(response.trim().slice(0, 500))); return false; } @@ -268,7 +268,7 @@ async function queryClaudeUnderSpinner( 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)}`); + p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`); resolve(payload); } else { p.log.error( diff --git a/setup/lib/claude-handoff.ts b/setup/lib/claude-handoff.ts index 3a0c219..87023ef 100644 --- a/setup/lib/claude-handoff.ts +++ b/setup/lib/claude-handoff.ts @@ -27,7 +27,7 @@ import { execSync, spawn } from 'child_process'; import * as p from '@clack/prompts'; import k from 'kleur'; -import { note } from './theme.js'; +import { brandBody, note } from './theme.js'; export interface HandoffContext { /** Channel this handoff is happening in (e.g., 'teams'). */ @@ -64,7 +64,7 @@ export interface HandoffContext { 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.", + brandBody("Claude isn't installed yet — can't hand you off here. Finish setup first, then retry."), ); return false; } @@ -93,7 +93,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise { stdio: 'inherit' }, ); child.on('close', () => { - p.log.success("Back from Claude. Let's continue."); + p.log.success(brandBody("Back from Claude. Let's continue.")); resolve(true); }); child.on('error', () => { diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index c1599e4..cf7a86d 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -20,7 +20,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'; +import { brandBody, fitToWidth } from './theme.js'; export type Fields = Record; export type Block = { type: string; fields: Fields }; @@ -390,7 +390,7 @@ export async function fail( const skipList = [ ...new Set([...existingSkip, ...setupLog.completedStepNames()]), ].join(','); - p.log.step(`Retrying from ${stepName}…`); + p.log.step(brandBody(`Retrying from ${stepName}…`)); const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], { stdio: 'inherit', env: { ...process.env, NANOCLAW_SKIP: skipList }, diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index f30ebe6..d313014 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -39,6 +39,29 @@ export function brandChip(s: string): string { return k.bgCyan(k.black(k.bold(s))); } +/** + * Brand body color for setup-flow prose. Used for card bodies (via the + * `note()` formatter) and `p.log.*` body arguments — anywhere the + * previous "dim" treatment was making prose hard to read or washing + * out embedded brand emphasis. + * + * Multi-line input is colored line-by-line so embedded line breaks + * don't bleed the SGR sequence across clack's gutter prefix. + */ +export function brandBody(s: string): string { + if (!USE_ANSI) return s; + if (TRUECOLOR) { + return s + .split('\n') + .map((line) => (line.length > 0 ? `\x1b[38;2;43;183;206m${line}\x1b[39m` : line)) + .join('\n'); + } + return s + .split('\n') + .map((line) => (line.length > 0 ? k.cyan(line) : line)) + .join('\n'); +} + /** * 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 @@ -70,16 +93,13 @@ export function dimWrap(text: string, gutter: number): string { } /** - * Wrap clack's `p.note` with the dim formatter disabled. By default - * clack renders note bodies through `styleText("dim", …)`, which the - * project's prose-readability stance (see `dimWrap` above) explicitly - * rejects. Pass-through formatter keeps body text at the terminal's - * regular weight; pre-styled segments (chips, bold, brand color) come - * through unfaded. + * Wrap clack's `p.note` so card bodies render in the brand body color + * (#2b6fdc) instead of clack's default dim. Clack runs the formatter + * on each line individually, so `brandBody` colors each line cleanly + * without bleeding across the gutter prefix. */ -const passthroughFormat = (s: string): string => s; export function note(message: string, title?: string): void { - p.note(message, title, { format: passthroughFormat }); + p.note(message, title, { format: brandBody }); } const ANSI_RE = /\x1b\[[0-9;]*m/g; diff --git a/setup/lib/windowed-runner.ts b/setup/lib/windowed-runner.ts index 875aba6..6f165a4 100644 --- a/setup/lib/windowed-runner.ts +++ b/setup/lib/windowed-runner.ts @@ -23,7 +23,7 @@ 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'; +import { brandBody, fitToWidth } from './theme.js'; const WINDOW_SIZE = 3; const SPINNER_FRAMES = ['◒', '◐', '◓', '◑']; @@ -169,7 +169,7 @@ async function runUnderWindow( 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)}`); + p.log.success(`${brandBody(fitToWidth(msg, suffix))}${k.dim(suffix)}`); } else { const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed'); p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`); @@ -185,7 +185,7 @@ async function handleStall( ): Promise { render.pauseRender(); p.log.warn( - `This looks stuck — no output from the ${stepName} step for the last 60 seconds.`, + brandBody(`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`), ); phEmit('step_stalled', { step: stepName }); From 4c791a41b2406454ba70726ee763fdfbeb8eba22 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 12:01:35 +0000 Subject: [PATCH 039/144] feat(setup): cyan highlight on active and submitted choices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customize `brightSelect`'s render function so the focused option's label paints in brand cyan during selection and the submitted answer paints in dim cyan after the user moves on. Inactive options keep their default rendering — only the cursor and submitted state pick up the color, matching the body-text emphasis added in #2101. Also migrate the one remaining `p.select` call site (the "What next?" prompt after the first chat) to `brightSelect` so every menu in the setup flow goes through the same render path. The shape of the call matches what `brightSelect` already supports — message + options with value/label/hint — so no feature is lost in the swap. Reuses `brandBody` from #2101 for the cyan, so the prompt highlight and the body prose share one definition of the brand body color. --- setup/auto.ts | 2 +- setup/lib/bright-select.ts | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index c0b5add..024da9f 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -337,7 +337,7 @@ async function main(): Promise { if (ping === 'ok') { phEmit('first_chat_ready'); const next = ensureAnswer( - await p.select({ + await brightSelect<'continue' | 'chat'>({ message: 'What next?', options: [ { diff --git a/setup/lib/bright-select.ts b/setup/lib/bright-select.ts index 94c4838..96c5de4 100644 --- a/setup/lib/bright-select.ts +++ b/setup/lib/bright-select.ts @@ -18,6 +18,8 @@ import { SelectPrompt } from '@clack/core'; import { isCancel } from '@clack/prompts'; import { styleText } from 'node:util'; +import { brandBody } from './theme.js'; + const BULLET_ACTIVE = '●'; const BULLET_INACTIVE = '○'; const BAR = '│'; @@ -95,7 +97,7 @@ export function brightSelect( const shown = st === 'cancel' ? styleText(['strikethrough', 'dim'], selected) - : styleText('dim', selected); + : styleText('dim', brandBody(selected)); lines.push(`${grayBar} ${shown}`); return lines.join('\n'); } @@ -104,11 +106,12 @@ export function brightSelect( 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}`); + const isActive = idx === cursor; + const marker = isActive + ? styleText('green', BULLET_ACTIVE) + : styleText('dim', BULLET_INACTIVE); + const shownLabel = isActive ? brandBody(label) : label; + lines.push(`${bar} ${marker} ${shownLabel}${hint}`); }); lines.push(styleText(color, CAP_BOT)); return lines.join('\n'); From 26594d2c5416fc878f0c51ffe79672fd4674a5df Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 12:16:15 +0000 Subject: [PATCH 040/144] feat(setup): paint "you" green in the display-name prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `accentGreen` helper (#3fba50) with the same TTY/NO_COLOR/ truecolor gating as the rest of the palette, then wraps the word "you" in the "What should your assistant call you?" prompt so the operator parses at a glance who the question is about — the user, not the assistant. The mirror prompt that asks for the assistant's name ("What should your assistant be called?") is left for a follow-up. --- setup/auto.ts | 4 ++-- setup/lib/theme.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 024da9f..2011f34 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -52,7 +52,7 @@ import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-clau import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { emit as phEmit } from './lib/diagnostics.js'; -import { brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; +import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; @@ -976,7 +976,7 @@ async function runTimezoneStep(): Promise { async function askDisplayName(fallback: string): Promise { const answer = ensureAnswer( await p.text({ - message: 'What should your assistant call you?', + message: `What should your assistant call ${accentGreen('you')}?`, placeholder: fallback, defaultValue: fallback, }), diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index d313014..0dfa53f 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -39,6 +39,18 @@ export function brandChip(s: string): string { return k.bgCyan(k.black(k.bold(s))); } +/** + * Accent green (#3fba50) for emphasizing a single word inside prompt + * messages — currently the "you" in "What should your assistant call + * you?" so the operator parses at a glance who the question is about. + * Same TTY/NO_COLOR/truecolor gating as the rest of the palette. + */ +export function accentGreen(s: string): string { + if (!USE_ANSI) return s; + if (TRUECOLOR) return `\x1b[38;2;63;186;80m${s}\x1b[39m`; + return k.green(s); +} + /** * Brand body color for setup-flow prose. Used for card bodies (via the * `note()` formatter) and `p.log.*` body arguments — anywhere the From 46088369534b323d9c921a9e52e7a1dae7d0e788 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 12:32:25 +0000 Subject: [PATCH 041/144] feat(setup): paint "assistant" green in the agent-name prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the word "assistant" in `accentGreen` (#3fba50, added in #2103) across the six channel adapters that ask "What should your assistant be called?" — Discord, iMessage, Signal, Slack, Telegram, WhatsApp. Mirrors the green emphasis on "you" in the display-name prompt: the green word names the subject of the question (assistant vs operator) so the operator parses it at a glance. --- setup/channels/discord.ts | 4 ++-- setup/channels/imessage.ts | 4 ++-- setup/channels/signal.ts | 4 ++-- setup/channels/slack.ts | 4 ++-- setup/channels/telegram.ts | 4 ++-- setup/channels/whatsapp.ts | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 20024fe..336fc72 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -31,7 +31,7 @@ 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'; -import { brandBody, note } from '../lib/theme.js'; +import { accentGreen, brandBody, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const DISCORD_API = 'https://discord.com/api/v10'; @@ -507,7 +507,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index 387f6b2..1096618 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -36,7 +36,7 @@ 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 { note, wrapForGutter } from '../lib/theme.js'; +import { accentGreen, note, wrapForGutter } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -303,7 +303,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 4e1cbfb..0c5718e 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -44,7 +44,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { note } from '../lib/theme.js'; +import { accentGreen, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -347,7 +347,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 4ee5973..32c124b 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -28,7 +28,7 @@ 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'; -import { note, wrapForGutter } from '../lib/theme.js'; +import { accentGreen, note, wrapForGutter } from '../lib/theme.js'; const SLACK_API = 'https://slack.com/api'; const SLACK_APPS_URL = 'https://api.slack.com/apps'; @@ -356,7 +356,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 3a86a5f..bc45d9e 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -33,7 +33,7 @@ import { spawnStep, writeStepEntry, } from '../lib/runner.js'; -import { brandBold, note } from '../lib/theme.js'; +import { accentGreen, brandBold, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -291,7 +291,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index fe4211b..96d23d5 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -46,7 +46,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { brandBody, brandBold, note } from '../lib/theme.js'; +import { accentGreen, brandBody, brandBold, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); @@ -462,7 +462,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), From db1983774076490ffe81b2d3f643d795d0655161 Mon Sep 17 00:00:00 2001 From: Gabi Simons <263580637+gabi-simons@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:17:35 +0000 Subject: [PATCH 042/144] feat(permissions): richer channel-approval flow with agent selection and free-text naming Replace the hardcoded Approve/Ignore card with a multi-step flow: - Single agent: "Connect to [name]" / "Connect new agent" / "Reject" - Multiple agents: "Choose existing agent" (follow-up list) / "Connect new agent" / "Reject" - "Connect new agent" prompts for a free-text name via DM, creates immediately on reply - Add setMessageInterceptor router hook for capturing free-text replies - Add resolveChannelName optional method to ChannelAdapter interface Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/adapter.ts | 1 + .../permissions/channel-approval.test.ts | 14 +- src/modules/permissions/channel-approval.ts | 217 +++++++++---- .../db/pending-channel-approvals.ts | 6 + src/modules/permissions/index.ts | 300 +++++++++++++++--- src/router.ts | 18 ++ 6 files changed, 458 insertions(+), 98 deletions(-) diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 82247a1..a2a7069 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -135,6 +135,7 @@ export interface ChannelAdapter { // Optional setTyping?(platformId: string, threadId: string | null): Promise; syncConversations?(): Promise; + resolveChannelName?(platformId: string): Promise; /** * Subscribe the bot to a thread so follow-up messages route via the diff --git a/src/modules/permissions/channel-approval.test.ts b/src/modules/permissions/channel-approval.test.ts index da992d2..a2e6690 100644 --- a/src/modules/permissions/channel-approval.test.ts +++ b/src/modules/permissions/channel-approval.test.ts @@ -153,8 +153,10 @@ describe('unknown-channel registration flow', () => { 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'); + // Single-agent card offers a direct "Connect to " button. + const connectOption = payload.options.find((o: { value: string }) => o.value.startsWith('connect:')); + expect(connectOption).toBeDefined(); + expect(connectOption.label).toContain('Andy'); const { getDb } = await import('../../db/connection.js'); const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{ @@ -202,11 +204,11 @@ describe('unknown-channel registration flow', () => { }; expect(pending).toBeDefined(); - // Owner clicks approve. + // Owner clicks "Connect to Andy" (single-agent card). for (const handler of getResponseHandlers()) { const claimed = await handler({ questionId: pending.messaging_group_id, - value: 'approve', + value: 'connect:ag-1', userId: 'owner', // raw platform id — handler namespaces it channelType: 'telegram', platformId: 'dm-owner', @@ -215,7 +217,7 @@ describe('unknown-channel registration flow', () => { if (claimed) break; } - // Wiring created with MVP defaults. + // Wiring created with defaults. const mga = getDb() .prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?') .get(pending.messaging_group_id) as { @@ -261,7 +263,7 @@ describe('unknown-channel registration flow', () => { for (const handler of getResponseHandlers()) { const claimed = await handler({ questionId: pending.messaging_group_id, - value: 'approve', + value: 'connect:ag-1', userId: 'owner', channelType: 'telegram', platformId: 'dm-owner', diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index 8ab41bc..6127cea 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -5,24 +5,32 @@ * 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. + * 1. Gather all existing agent groups. * 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. + * 3. Deliver a card with three action families: + * a. Connect to [agent] — one button per existing agent group. + * Single-agent installs get a one-click connect. + * b. Connect new agent — prompts for a free-text name, creates + * the agent immediately on reply. + * c. Reject — deny the channel. * 4. Record a `pending_channel_approvals` row holding the original event - * so it can be re-routed on approve. + * so it can be re-routed on connect/create. * - * On approve (handler in index.ts): - * - Create `messaging_group_agents` with MVP defaults + * On connect (handler in index.ts): + * - Create `messaging_group_agents` with 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: + * On connect new agent (handler in index.ts): + * - Prompt for a free-text agent name via DM + * - On reply: create the agent group + filesystem, then wire + * and replay as above + * + * On reject: * - Set `messaging_groups.denied_at = now()` so the router stops * escalating on this channel until an admin explicitly re-wires * - Delete the pending row @@ -36,19 +44,81 @@ * - 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 { normalizeOptions, type NormalizedOption, type RawOption } from '../../channels/ask-question.js'; +import { createAgentGroup, getAgentGroup, getAgentGroupByFolder, getAllAgentGroups } from '../../db/agent-groups.js'; +import { getChannelAdapter } from '../../channels/channel-registry.js'; +import { getMessagingGroup, updateMessagingGroup } from '../../db/messaging-groups.js'; import { getDeliveryAdapter } from '../../delivery.js'; +import { initGroupFilesystem } from '../../group-init.js'; import { log } from '../../log.js'; import type { InboundEvent } from '../../channels/adapter.js'; +import type { AgentGroup } from '../../types.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' }, -]; +// ── Value constants (response handler in index.ts parses these) ── + +export const CONNECT_PREFIX = 'connect:'; +export const NEW_AGENT_VALUE = 'new_agent'; +export const CHOOSE_EXISTING_VALUE = 'choose_existing'; +export const REJECT_VALUE = 'reject'; + +// ── Utilities ── + +function toFolder(name: string): string { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'unnamed' + ); +} + +// ── Card builders ── + +function buildApprovalOptions(agentGroups: AgentGroup[]): RawOption[] { + const options: RawOption[] = []; + if (agentGroups.length === 1) { + options.push({ + label: `Connect to ${agentGroups[0].name}`, + selectedLabel: `✅ Connected to ${agentGroups[0].name}`, + value: `${CONNECT_PREFIX}${agentGroups[0].id}`, + }); + } else { + options.push({ + label: 'Choose existing agent', + selectedLabel: '📋 Choosing…', + value: CHOOSE_EXISTING_VALUE, + }); + } + options.push({ + label: 'Connect new agent', + selectedLabel: '🆕 Connecting new agent…', + value: NEW_AGENT_VALUE, + }); + options.push({ + label: 'Reject', + selectedLabel: '🙅 Rejected', + value: REJECT_VALUE, + }); + return options; +} + +function buildQuestionText( + isGroup: boolean, + senderName: string | undefined, + channelName: string | null, + channelType: string, +): string { + const who = senderName ?? 'Someone'; + if (isGroup) { + const where = channelName ? `${channelName} on ${channelType}` : `a ${channelType} channel`; + return `${who} mentioned your bot in ${where}. How would you like to handle this channel?`; + } + return `${who} sent your bot a DM on ${channelType}. How would you like to handle it?`; +} + +// ── Main flow ── export interface RequestChannelApprovalInput { messagingGroupId: string; @@ -58,17 +128,11 @@ export interface RequestChannelApprovalInput { 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, - }); + 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.', { @@ -76,55 +140,65 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) }); return; } - const target = agentGroups[0]; + // Use first agent group for approver resolution — owners and global admins + // are returned regardless of which group we pass. + const referenceGroup = 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); + const approvers = pickApprover(referenceGroup.id); if (approvers.length === 0) { log.warn('Channel registration skipped — no owner or admin configured', { messagingGroupId, - targetAgentGroupId: target.id, + targetAgentGroupId: referenceGroup.id, }); return; } const originMg = getMessagingGroup(messagingGroupId); const originChannelType = originMg?.channel_type ?? ''; + + // Resolve channel name if not yet persisted. + if (originMg && !originMg.name) { + const channelAdapter = getChannelAdapter(originChannelType); + if (channelAdapter?.resolveChannelName) { + try { + const name = await channelAdapter.resolveChannelName(originMg.platform_id); + if (name) { + updateMessagingGroup(originMg.id, { name }); + originMg.name = name; + } + } catch { + /* non-critical */ + } + } + } + const delivery = await pickApprovalDelivery(approvers, originChannelType); if (!delivery) { log.warn('Channel registration skipped — no DM channel for any approver', { messagingGroupId, - targetAgentGroupId: target.id, + targetAgentGroupId: referenceGroup.id, }); return; } 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 + // non-critical } - const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message'; - const question = isGroup - ? 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?`; - const options = normalizeOptions(APPROVAL_OPTIONS); + const channelName = originMg?.name ?? null; + const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message'; + const question = buildQuestionText(isGroup, senderName, channelName, originChannelType); + const options = normalizeOptions(buildApprovalOptions(agentGroups)); createPendingChannelApproval({ messaging_group_id: messagingGroupId, - agent_group_id: target.id, + agent_group_id: referenceGroup.id, original_message: JSON.stringify(event), approver_user_id: delivery.userId, created_at: new Date().toISOString(), @@ -134,9 +208,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) const adapter = getDeliveryAdapter(); if (!adapter) { - log.error('Channel registration row created but no delivery adapter is wired', { - messagingGroupId, - }); + log.error('Channel registration row created but no delivery adapter is wired', { messagingGroupId }); return; } @@ -148,9 +220,6 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) '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, @@ -159,16 +228,56 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) ); log.info('Channel registration card delivered', { messagingGroupId, - targetAgentGroupId: target.id, + agentGroupCount: agentGroups.length, approver: delivery.userId, }); } catch (err) { - log.error('Channel registration card delivery failed', { - messagingGroupId, - err, - }); + log.error('Channel registration card delivery failed', { messagingGroupId, err }); } } -export const APPROVE_VALUE = 'approve'; -export const REJECT_VALUE = 'reject'; +// ── Helpers for the response handler (index.ts) ── + +/** + * Build normalized options for the agent-selection follow-up card. + */ +export function buildAgentSelectionOptions(agentGroups: AgentGroup[]): NormalizedOption[] { + const options: RawOption[] = agentGroups.map((ag) => ({ + label: ag.name, + selectedLabel: `✅ Connected to ${ag.name}`, + value: `${CONNECT_PREFIX}${ag.id}`, + })); + options.push({ + label: 'Cancel', + selectedLabel: '🙅 Cancelled', + value: REJECT_VALUE, + }); + return normalizeOptions(options); +} + +/** + * Create a new agent group and initialize its filesystem. Handles + * folder-name collisions with numeric suffixes. + */ +export function createNewAgentGroup(name: string): AgentGroup { + let folder = toFolder(name); + const baseFolder = folder; + let suffix = 2; + while (getAgentGroupByFolder(folder)) { + folder = `${baseFolder}-${suffix}`; + suffix++; + } + + const agId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + createAgentGroup({ + id: agId, + name, + folder, + agent_provider: null, + created_at: new Date().toISOString(), + }); + + const ag = getAgentGroup(agId)!; + initGroupFilesystem(ag); + return ag; +} diff --git a/src/modules/permissions/db/pending-channel-approvals.ts b/src/modules/permissions/db/pending-channel-approvals.ts index d402074..24f7209 100644 --- a/src/modules/permissions/db/pending-channel-approvals.ts +++ b/src/modules/permissions/db/pending-channel-approvals.ts @@ -51,6 +51,12 @@ export function hasInFlightChannelApproval(messagingGroupId: string): boolean { return row !== undefined; } +export function updatePendingChannelApprovalCard(messagingGroupId: string, title: string, optionsJson: string): void { + getDb() + .prepare('UPDATE pending_channel_approvals SET title = ?, options_json = ? WHERE messaging_group_id = ?') + .run(title, optionsJson, messagingGroupId); +} + 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 83390d8..98a9463 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -16,27 +16,53 @@ * access gate is not registered and core defaults to allow-all. */ import { recordDroppedMessage } from '../../db/dropped-messages.js'; +import { getAgentGroup, getAllAgentGroups } from '../../db/agent-groups.js'; import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js'; import { routeInbound, setAccessGate, setChannelRequestGate, + setMessageInterceptor, setSenderResolver, setSenderScopeGate, type AccessGateResult, } from '../../router.js'; import type { InboundEvent } from '../../channels/adapter.js'; import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; +import { getDeliveryAdapter } from '../../delivery.js'; import { log } from '../../log.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import { canAccessAgentGroup } from './access.js'; -import { requestChannelApproval } from './channel-approval.js'; +import { + buildAgentSelectionOptions, + CHOOSE_EXISTING_VALUE, + CONNECT_PREFIX, + createNewAgentGroup, + NEW_AGENT_VALUE, + REJECT_VALUE, + 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, + updatePendingChannelApprovalCard, +} 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'; import { requestSenderApproval } from './sender-approval.js'; +import { ensureUserDm } from './user-dm.js'; + +// ── Free-text name input state ── +// Tracks approvers waiting for a text reply with the agent name. Keyed by +// namespaced userId (e.g. "slack:U0ABC"). Cleared on receipt or restart. +interface PendingNameInput { + channelMgId: string; + dmChannelType: string; + dmPlatformId: string; +} +const awaitingNameInput = new Map(); function extractAndUpsertUser(event: InboundEvent): string | null { let content: Record; @@ -271,22 +297,17 @@ setChannelRequestGate(async (mg, event) => { * 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. + * Value dispatch: + * connect: — wire to an existing agent group, replay the message + * choose_existing — send a follow-up card listing all agents + * new_agent — prompt for a free-text agent name (interceptor + * captures the reply and creates immediately) + * reject — set denied_at, delete pending row */ 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)); @@ -296,25 +317,129 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< clickerId, expectedApprover: row.approver_user_id, }); - return true; // claim but take no action + return true; } const approverId = clickerId; - const approved = payload.value === 'approve'; - if (!approved) { + // ── Reject / Cancel ── + if (payload.value === REJECT_VALUE) { 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). + // ── Choose existing agent — send agent-selection follow-up card ── + if (payload.value === CHOOSE_EXISTING_VALUE) { + const approverDm = await ensureUserDm(row.approver_user_id); + if (!approverDm) { + log.error('Channel registration: no DM channel for approver', { + messagingGroupId: row.messaging_group_id, + approverUserId: row.approver_user_id, + }); + return true; + } + + const adapter = getDeliveryAdapter(); + if (!adapter) return true; + + const agentGroups = getAllAgentGroups(); + const options = buildAgentSelectionOptions(agentGroups); + const title = '📋 Choose an agent'; + updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options)); + + try { + await adapter.deliver( + approverDm.channel_type, + approverDm.platform_id, + null, + 'chat-sdk', + JSON.stringify({ + type: 'ask_question', + questionId: row.messaging_group_id, + title, + question: 'Which agent should handle this channel?', + options, + }), + ); + } catch (err) { + log.error('Channel registration: agent-selection card delivery failed', { + messagingGroupId: row.messaging_group_id, + err, + }); + } + return true; + } + + // ── Create new agent — prompt for free-text name ── + if (payload.value === NEW_AGENT_VALUE) { + const approverDm = await ensureUserDm(row.approver_user_id); + if (!approverDm) { + log.error('Channel registration: no DM channel for approver', { + messagingGroupId: row.messaging_group_id, + approverUserId: row.approver_user_id, + }); + return true; + } + + const adapter = getDeliveryAdapter(); + if (!adapter) { + log.error('Channel registration: no delivery adapter for name prompt', { + messagingGroupId: row.messaging_group_id, + }); + return true; + } + + awaitingNameInput.set(row.approver_user_id, { + channelMgId: row.messaging_group_id, + dmChannelType: approverDm.channel_type, + dmPlatformId: approverDm.platform_id, + }); + + try { + await adapter.deliver( + approverDm.channel_type, + approverDm.platform_id, + null, + 'chat-sdk', + JSON.stringify({ text: 'Reply with the name for your new agent:' }), + ); + } catch (err) { + log.error('Channel registration: name prompt delivery failed', { + messagingGroupId: row.messaging_group_id, + err, + }); + awaitingNameInput.delete(row.approver_user_id); + } + return true; + } + + // ── Resolve target agent group (connect to existing or create new) ── + let targetAgentGroupId: string; + + if (payload.value.startsWith(CONNECT_PREFIX)) { + targetAgentGroupId = payload.value.slice(CONNECT_PREFIX.length); + const ag = getAgentGroup(targetAgentGroupId); + if (!ag) { + log.error('Channel registration: target agent group no longer exists', { + messagingGroupId: row.messaging_group_id, + targetAgentGroupId, + }); + deletePendingChannelApproval(row.messaging_group_id); + return true; + } + } else { + log.warn('Channel registration: unknown response value', { + messagingGroupId: row.messaging_group_id, + value: payload.value, + }); + return true; + } + + // ── Wire + replay (shared path for connect and create) ── let event: InboundEvent; try { event = JSON.parse(row.original_message) as InboundEvent; @@ -327,15 +452,6 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< 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 : '.'; @@ -344,7 +460,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< createMessagingGroupAgent({ id: mgaId, messaging_group_id: row.messaging_group_id, - agent_group_id: row.agent_group_id, + agent_group_id: targetAgentGroupId, engage_mode: engageMode, engage_pattern: engagePattern, sender_scope: 'known', @@ -355,28 +471,22 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< }); log.info('Channel registration approved — wiring created', { messagingGroupId: row.messaging_group_id, - agentGroupId: row.agent_group_id, + agentGroupId: targetAgentGroupId, 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, + agent_group_id: targetAgentGroupId, 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 { @@ -391,3 +501,117 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< } registerResponseHandler(handleChannelApprovalResponse); + +// ── Free-text name interceptor ── +// Captures the next DM from an approver who clicked "Create new agent", +// creates the agent immediately, wires the channel, and replays. + +setMessageInterceptor(async (event: InboundEvent): Promise => { + const userId = extractAndUpsertUser(event); + if (!userId) return false; + + const pending = awaitingNameInput.get(userId); + if (!pending) return false; + if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return false; + + awaitingNameInput.delete(userId); + + let text: string | undefined; + try { + const parsed = JSON.parse(event.message.content) as Record; + text = (typeof parsed.text === 'string' ? parsed.text : undefined)?.trim(); + } catch { + /* fall through */ + } + + if (!text) { + log.warn('Channel registration: empty name reply, ignoring', { userId }); + return true; + } + + const row = getPendingChannelApproval(pending.channelMgId); + if (!row) return true; + + const ag = createNewAgentGroup(text); + log.info('Channel registration: new agent group created', { + messagingGroupId: row.messaging_group_id, + agentGroupId: ag.id, + agentName: ag.name, + folder: ag.folder, + }); + + let originalEvent: InboundEvent; + try { + originalEvent = 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; + } + + const isGroup = originalEvent.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: ag.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: ag.id, + mgaId, + engageMode, + approverId: userId, + }); + + const senderUserId = extractAndUpsertUser(originalEvent); + if (senderUserId) { + addMember({ + user_id: senderUserId, + agent_group_id: ag.id, + added_by: userId, + added_at: new Date().toISOString(), + }); + } + + deletePendingChannelApproval(row.messaging_group_id); + + try { + await routeInbound(originalEvent); + } catch (err) { + log.error('Failed to replay message after channel approval', { + messagingGroupId: row.messaging_group_id, + err, + }); + } + + const adapter = getDeliveryAdapter(); + if (adapter) { + const dm = await ensureUserDm(row.approver_user_id); + if (dm) { + adapter + .deliver( + dm.channel_type, + dm.platform_id, + null, + 'chat-sdk', + JSON.stringify({ text: `✅ Agent "${ag.name}" created and connected.` }), + ) + .catch(() => {}); + } + } + return true; +}); diff --git a/src/router.ts b/src/router.ts index 995496d..844041e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -108,6 +108,20 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void { senderScopeGate = fn; } +/** + * Message-interceptor hook. Runs at the very top of routeInbound, before + * messaging-group resolution. When the interceptor returns true the message + * is consumed and routing stops. Used by the permissions module to capture + * free-text replies during multi-step approval flows (e.g. agent naming). + */ +export type MessageInterceptorFn = (event: InboundEvent) => Promise; + +let messageInterceptor: MessageInterceptorFn | null = null; + +export function setMessageInterceptor(fn: MessageInterceptorFn): void { + messageInterceptor = 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 @@ -142,6 +156,10 @@ function safeParseContent(raw: string): { text?: string; sender?: string; sender * Creates messaging group + session if they don't exist yet. */ export async function routeInbound(event: InboundEvent): Promise { + // Pre-route interceptor — lets modules consume messages before any routing + // (e.g. free-text replies during multi-step approval flows). + if (messageInterceptor && (await messageInterceptor(event))) return; + // 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram, // WhatsApp, iMessage, email) collapse threads to the channel. const adapter = getChannelAdapter(event.channelType); From 5f34e262403867dcbc21e6942f09f38e34b8bd76 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 17:02:15 +0300 Subject: [PATCH 043/144] fix(credentials): translate auth errors and require OneCLI for spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for the case where credentials aren't usable: 1. Replace Claude Code's "Not logged in / Invalid API key · Please run /login" output with a host-aware message. The user can't run /login from chat, so the raw text is unhelpful. Provider gains an optional isAuthRequired() classifier; the poll-loop substitutes the message on both result-text and error paths. 2. Treat OneCLI gateway failure as a transient hard error instead of spawning a credential-less container. The catch in container-runner now propagates; router and host-sweep wrap wakeContainer to log and leave the inbound row pending so the next 60s sweep tick retries. Router also stops the typing indicator on failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 44 ++++++++++++++----- .../agent-runner/src/providers/claude.ts | 12 +++++ container/agent-runner/src/providers/types.ts | 8 ++++ src/container-runner.ts | 24 +++++----- src/host-sweep.ts | 9 +++- src/router.ts | 12 ++++- 6 files changed, 82 insertions(+), 27 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index bd48db2..2846337 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -21,6 +21,20 @@ function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +const AUTH_REQUIRED_USER_TEXT = + "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; + +function writeAuthRequiredMessage(routing: RoutingContext): void { + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: AUTH_REQUIRED_USER_TEXT }), + }); +} + export interface PollLoopConfig { provider: AgentProvider; /** @@ -171,7 +185,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, processingIds, config.providerName); + const result = await processQuery(query, routing, processingIds, config.provider, config.providerName); if (result.continuation && result.continuation !== continuation) { continuation = result.continuation; setContinuation(config.providerName, continuation); @@ -189,15 +203,18 @@ export async function runPollLoop(config: PollLoopConfig): Promise { clearContinuation(config.providerName); } - // Write error response so the user knows something went wrong - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: `Error: ${errMsg}` }), - }); + if (config.provider.isAuthRequired?.(errMsg)) { + writeAuthRequiredMessage(routing); + } else { + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: `Error: ${errMsg}` }), + }); + } } // Ensure completed even if processQuery ended without a result event @@ -249,6 +266,7 @@ async function processQuery( query: AgentQuery, routing: RoutingContext, initialBatchIds: string[], + provider: AgentProvider, providerName: string, ): Promise { let queryContinuation: string | undefined; @@ -310,7 +328,11 @@ async function processQuery( // at all — either way the turn is finished. markCompleted(initialBatchIds); if (event.text) { - dispatchResultText(event.text, routing); + if (provider.isAuthRequired?.(event.text)) { + writeAuthRequiredMessage(routing); + } else { + dispatchResultText(event.text, routing); + } } } } diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index fbb077c..6dcdb5a 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -236,6 +236,14 @@ const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; */ const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; +/** + * Auth-required detection. Matches Claude Code's output when no usable + * credential is available — "Not logged in · Please run /login" or + * "Invalid API key · Please run /login". The user can't run /login from + * chat, so the poll-loop substitutes a host-aware message. + */ +const AUTH_REQUIRED_RE = /(Not logged in|Invalid API key)[\s\S]*?Please run \/login/i; + export class ClaudeProvider implements AgentProvider { readonly supportsNativeSlashCommands = true; @@ -259,6 +267,10 @@ export class ClaudeProvider implements AgentProvider { return STALE_SESSION_RE.test(msg); } + isAuthRequired(text: string): boolean { + return AUTH_REQUIRED_RE.test(text); + } + query(input: QueryInput): AgentQuery { const stream = new MessageStream(); stream.push(input.prompt); diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 55ab919..99833a7 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -14,6 +14,14 @@ export interface AgentProvider { * (missing transcript, unknown session, etc.) and should be cleared. */ isSessionInvalid(err: unknown): boolean; + + /** + * True if the given text/error indicates the underlying SDK or CLI has no + * usable Anthropic auth (e.g. Claude Code's "Not logged in · Please run + * /login"). The poll-loop swaps the raw output for a host-aware message + * since the user can't run /login from chat. + */ + isAuthRequired?(text: string): boolean; } /** diff --git a/src/container-runner.ts b/src/container-runner.ts index 029b5fe..dc71248 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -435,20 +435,18 @@ async function buildContainerArgs( } // OneCLI gateway — injects HTTPS_PROXY + certs so container API calls - // are routed through the agent vault for credential injection. - try { - if (agentIdentifier) { - await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); - } - const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); - if (onecliApplied) { - log.info('OneCLI gateway applied', { containerName }); - } else { - log.warn('OneCLI gateway not applied — container will have no credentials', { containerName }); - } - } catch (err) { - log.warn('OneCLI gateway error — container will have no credentials', { containerName, err }); + // are routed through the agent vault for credential injection. Treated as + // a transient hard failure: if we can't wire the gateway, we don't spawn. + // The caller (router or host-sweep) catches the throw, leaves the inbound + // message pending, and the next sweep tick retries. + if (agentIdentifier) { + await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); } + const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); + if (!onecliApplied) { + throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials'); + } + log.info('OneCLI gateway applied', { containerName }); // Host gateway args.push(...hostGatewayArgs()); diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 4dc2fb7..ff88fb0 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -168,7 +168,14 @@ async function sweepSession(session: Session): Promise { 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); + try { + await wakeContainer(session); + } catch (err) { + // Transient spawn failure (e.g. OneCLI gateway down). Leave messages + // pending so the next sweep tick retries; don't abort the rest of + // the sweep cycle for other sessions. + log.warn('wakeContainer failed — will retry on next sweep', { sessionId: session.id, err }); + } } const alive = isContainerRunning(session.id); diff --git a/src/router.ts b/src/router.ts index 3cf0192..e429977 100644 --- a/src/router.ts +++ b/src/router.ts @@ -27,7 +27,7 @@ import { getMessagingGroupWithAgentCount, } from './db/messaging-groups.js'; import { findSessionForAgent } from './db/sessions.js'; -import { startTypingRefresh } from './modules/typing/index.js'; +import { startTypingRefresh, stopTypingRefresh } from './modules/typing/index.js'; import { log } from './log.js'; import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js'; import { wakeContainer } from './container-runner.js'; @@ -450,7 +450,15 @@ async function deliverToAgent( startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId); const freshSession = getSession(session.id); if (freshSession) { - await wakeContainer(freshSession); + try { + await wakeContainer(freshSession); + } catch (err) { + // Transient spawn failure (e.g. OneCLI gateway down). The inbound + // row is already persisted — host-sweep will retry the wake on its + // next tick. Don't bubble out of the channel adapter. + log.warn('wakeContainer failed — host-sweep will retry', { sessionId: freshSession.id, err }); + stopTypingRefresh(freshSession.id); + } } } } From d86051805b48dd29a64cabd4187f1bb663d4a796 Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Wed, 29 Apr 2026 14:09:47 +0000 Subject: [PATCH 044/144] feat(setup): delete scratch agent after ping-pong, simplify flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Terminal Agent" created for the connection test is now silently deleted after a successful ping. If the user chooses to chat, a new agent is auto-created as "{name}'s Terminal" — no name prompt needed. Condensed the three-line ping section into a single "Connection verified." status line. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/delete-cli-agent.ts | 74 +++++++++++++++++++++++++++++++++++++ setup/auto.ts | 31 +++++++++++++--- 2 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 scripts/delete-cli-agent.ts diff --git a/scripts/delete-cli-agent.ts b/scripts/delete-cli-agent.ts new file mode 100644 index 0000000..8e947cb --- /dev/null +++ b/scripts/delete-cli-agent.ts @@ -0,0 +1,74 @@ +/** + * Delete the scratch CLI agent created during setup's ping-pong test. + * + * Removes the agent group, its messaging_group_agents wiring, any + * agent_destinations rows, and the groups// directory. Leaves the + * CLI messaging group intact so it can be reused for a new agent. + * + * Usage: + * pnpm exec tsx scripts/delete-cli-agent.ts --folder + */ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from '../src/config.js'; +import { getAgentGroupByFolder, deleteAgentGroup } from '../src/db/agent-groups.js'; +import { initDb } from '../src/db/connection.js'; +import { runMigrations } from '../src/db/migrations/index.js'; + +interface Args { + folder: string; +} + +function parseArgs(): Args { + const argv = process.argv.slice(2); + let folder = ''; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--folder' && argv[i + 1]) folder = argv[++i]; + } + if (!folder) { + console.error('usage: pnpm exec tsx scripts/delete-cli-agent.ts --folder '); + process.exit(1); + } + return { folder }; +} + +const args = parseArgs(); + +const db = initDb(path.join(DATA_DIR, 'v2.db')); +runMigrations(db); + +const ag = getAgentGroupByFolder(args.folder); +if (!ag) { + console.log(`No agent group with folder "${args.folder}" — nothing to delete.`); + process.exit(0); +} + +// Delete all rows referencing this agent group, in dependency order. +const fkTables = [ + 'messaging_group_agents', + 'agent_destinations', + 'agent_group_members', + 'pending_sender_approvals', + 'channel_registrations', + 'user_roles', + 'sessions', +]; +for (const table of fkTables) { + const exists = db + .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?") + .get(table); + if (exists) { + db.prepare(`DELETE FROM ${table} WHERE agent_group_id = ?`).run(ag.id); + } +} + +deleteAgentGroup(ag.id); + +// Remove the groups// directory. +const groupDir = path.join(process.cwd(), 'groups', args.folder); +if (fs.existsSync(groupDir)) { + fs.rmSync(groupDir, { recursive: true }); +} + +console.log(`Deleted agent group ${ag.id} (${args.folder}).`); diff --git a/setup/auto.ts b/setup/auto.ts index 392bc13..e46a639 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -55,6 +55,7 @@ import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js import { emit as phEmit } from './lib/diagnostics.js'; import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; +import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.js'; const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); @@ -349,8 +350,8 @@ async function main(): Promise { const res = await runQuietStep( 'cli-agent', { - running: 'Bringing your assistant online…', - done: 'Assistant wired up.', + running: 'Preparing connection test…', + done: 'Ready to test.', }, ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME], ); @@ -365,7 +366,7 @@ async function main(): Promise { p.log.message( brandBody( 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.", + 'Checking your assistant can respond — first startup takes 30–60 seconds.', 4, ), ), @@ -373,6 +374,10 @@ async function main(): Promise { const ping = await confirmAssistantResponds(); if (ping === 'ok') { phEmit('first_chat_ready'); + const scratchFolder = `cli-with-${normalizeName(displayName!)}`; + spawnSync('pnpm', ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', scratchFolder], { + stdio: 'ignore', + }); const next = ensureAnswer( await brightSelect<'continue' | 'chat'>({ message: 'What next?', @@ -390,7 +395,23 @@ async function main(): Promise { }), ) as 'continue' | 'chat'; setupLog.userInput('first_chat_choice', next); - if (next === 'chat') await runFirstChat(); + if (next === 'chat') { + const terminalAgentName = `${displayName!}'s Terminal`; + const createRes = await runQuietChild( + 'create-terminal-agent', + 'pnpm', + ['exec', 'tsx', 'scripts/init-cli-agent.ts', '--display-name', displayName!, '--agent-name', terminalAgentName], + { running: `Creating ${terminalAgentName}…`, done: `${terminalAgentName} is ready.` }, + ); + if (!createRes.ok) { + await fail( + 'create-terminal-agent', + `Couldn't create ${terminalAgentName}.`, + 'You can retry later with `pnpm exec tsx scripts/init-cli-agent.ts`.', + ); + } + await runFirstChat(); + } } else { phEmit('first_chat_failed', { reason: ping }); renderPingFailureNote(ping); @@ -592,7 +613,7 @@ async function confirmAssistantResponds(): Promise { const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${elapsed}s)`; if (result === 'ok') { - s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`); + s.stop(`${k.bold(fitToWidth('Connection verified.', suffix))}${k.dim(suffix)}`); } else { const msg = result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time."; From 8c5d67cc78174d5a6f96cb692a3de4ba876625af Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Wed, 29 Apr 2026 14:27:03 +0000 Subject: [PATCH 045/144] fix(setup): dynamic FK cleanup, remove normalizeName coupling - delete-cli-agent.ts discovers tables with agent_group_id dynamically instead of hardcoding a list - cli-agent step emits FOLDER in its status block so setup/auto.ts reads it from the step result instead of re-deriving via normalizeName Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/delete-cli-agent.ts | 29 ++++++++++++----------------- setup/auto.ts | 4 +--- setup/cli-agent.ts | 5 ++++- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/scripts/delete-cli-agent.ts b/scripts/delete-cli-agent.ts index 8e947cb..be3d959 100644 --- a/scripts/delete-cli-agent.ts +++ b/scripts/delete-cli-agent.ts @@ -44,23 +44,18 @@ if (!ag) { process.exit(0); } -// Delete all rows referencing this agent group, in dependency order. -const fkTables = [ - 'messaging_group_agents', - 'agent_destinations', - 'agent_group_members', - 'pending_sender_approvals', - 'channel_registrations', - 'user_roles', - 'sessions', -]; -for (const table of fkTables) { - const exists = db - .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?") - .get(table); - if (exists) { - db.prepare(`DELETE FROM ${table} WHERE agent_group_id = ?`).run(ag.id); - } +// Dynamically find every table with an agent_group_id column and delete +// matching rows. This is self-maintaining — new FK tables are picked up +// automatically without updating a hardcoded list. +const tables = db + .prepare( + `SELECT DISTINCT m.name FROM sqlite_master m + JOIN pragma_table_info(m.name) p ON p.name = 'agent_group_id' + WHERE m.type = 'table' AND m.name != 'agent_groups'`, + ) + .all() as { name: string }[]; +for (const { name } of tables) { + db.prepare(`DELETE FROM ${name} WHERE agent_group_id = ?`).run(ag.id); } deleteAgentGroup(ag.id); diff --git a/setup/auto.ts b/setup/auto.ts index e46a639..6ebf486 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -55,8 +55,6 @@ import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js import { emit as phEmit } from './lib/diagnostics.js'; import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; -import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.js'; - const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); @@ -374,7 +372,7 @@ async function main(): Promise { const ping = await confirmAssistantResponds(); if (ping === 'ok') { phEmit('first_chat_ready'); - const scratchFolder = `cli-with-${normalizeName(displayName!)}`; + const scratchFolder = res.terminal?.fields.FOLDER ?? ''; spawnSync('pnpm', ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', scratchFolder], { stdio: 'ignore', }); diff --git a/setup/cli-agent.ts b/setup/cli-agent.ts index d9a90c5..18b8e97 100644 --- a/setup/cli-agent.ts +++ b/setup/cli-agent.ts @@ -60,8 +60,9 @@ export async function run(args: string[]): Promise { log.info('Invoking init-cli-agent', { displayName, agentName }); + let stdout = ''; try { - execFileSync('pnpm', scriptArgs, { + stdout = execFileSync('pnpm', scriptArgs, { cwd: projectRoot, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', @@ -82,9 +83,11 @@ export async function run(args: string[]): Promise { process.exit(1); } + const folderMatch = stdout.match(/@ groups\/(\S+)/); emitStatus('CLI_AGENT', { DISPLAY_NAME: displayName, AGENT_NAME: agentName || displayName, + FOLDER: folderMatch?.[1] ?? '', CHANNEL: 'cli/local', STATUS: 'success', LOG: 'logs/setup.log', From 8542c484f6433db6b347c2cb12ad68fe85536ca2 Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Wed, 29 Apr 2026 14:45:42 +0000 Subject: [PATCH 046/144] fix(setup): isolate scratch agent with hardcoded _ping-test folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scratch agent uses fixed folder `_ping-test` so it can never collide with a real agent on re-runs - Added --folder flag to init-cli-agent.ts and cli-agent step wrapper - Delete always targets `_ping-test` exactly — no re-derivation needed - Removed normalizeName coupling and FOLDER status field (no longer needed) Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/delete-cli-agent.ts | 7 ++++--- scripts/init-cli-agent.ts | 8 +++++++- setup/auto.ts | 5 ++--- setup/cli-agent.ts | 17 +++++++++++------ 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/scripts/delete-cli-agent.ts b/scripts/delete-cli-agent.ts index be3d959..01a9e33 100644 --- a/scripts/delete-cli-agent.ts +++ b/scripts/delete-cli-agent.ts @@ -1,9 +1,10 @@ /** * Delete the scratch CLI agent created during setup's ping-pong test. * - * Removes the agent group, its messaging_group_agents wiring, any - * agent_destinations rows, and the groups// directory. Leaves the - * CLI messaging group intact so it can be reused for a new agent. + * Dynamically finds and removes all rows referencing the agent group + * (any table with an agent_group_id column), deletes the agent group + * itself, and removes the groups// directory. Leaves the CLI + * messaging group intact so it can be reused for a new agent. * * Usage: * pnpm exec tsx scripts/delete-cli-agent.ts --folder diff --git a/scripts/init-cli-agent.ts b/scripts/init-cli-agent.ts index 4a56827..73fb9d1 100644 --- a/scripts/init-cli-agent.ts +++ b/scripts/init-cli-agent.ts @@ -41,11 +41,13 @@ const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`; interface Args { displayName: string; agentName: string; + folder?: string; } function parseArgs(argv: string[]): Args { let displayName: string | undefined; let agentName: string | undefined; + let folder: string | undefined; for (let i = 0; i < argv.length; i++) { const key = argv[i]; const val = argv[i + 1]; @@ -55,6 +57,9 @@ function parseArgs(argv: string[]): Args { } else if (key === '--agent-name') { agentName = val; i++; + } else if (key === '--folder') { + folder = val; + i++; } } @@ -67,6 +72,7 @@ function parseArgs(argv: string[]): Args { return { displayName, agentName: agentName?.trim() || displayName, + folder, }; } @@ -95,7 +101,7 @@ async function main(): Promise { const promotedToOwner = false; // 2. Agent group + filesystem. - const folder = `cli-with-${normalizeName(args.displayName)}`; + const folder = args.folder || `cli-with-${normalizeName(args.displayName)}`; let ag: AgentGroup | undefined = getAgentGroupByFolder(folder); if (!ag) { const agId = generateId('ag'); diff --git a/setup/auto.ts b/setup/auto.ts index 6ebf486..0b2cfa1 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -351,7 +351,7 @@ async function main(): Promise { running: 'Preparing connection test…', done: 'Ready to test.', }, - ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME], + ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME, '--folder', '_ping-test'], ); if (!res.ok) { await fail( @@ -372,8 +372,7 @@ async function main(): Promise { const ping = await confirmAssistantResponds(); if (ping === 'ok') { phEmit('first_chat_ready'); - const scratchFolder = res.terminal?.fields.FOLDER ?? ''; - spawnSync('pnpm', ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', scratchFolder], { + spawnSync('pnpm', ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', '_ping-test'], { stdio: 'ignore', }); const next = ensureAnswer( diff --git a/setup/cli-agent.ts b/setup/cli-agent.ts index 18b8e97..73b8557 100644 --- a/setup/cli-agent.ts +++ b/setup/cli-agent.ts @@ -8,6 +8,7 @@ * Args: * --display-name (required) operator's display name * --agent-name (optional) agent persona name, defaults to display-name + * --folder (optional) explicit folder name, defaults to cli-with- */ import { execFileSync } from 'child_process'; import path from 'path'; @@ -18,9 +19,11 @@ import { emitStatus } from './status.js'; function parseArgs(args: string[]): { displayName: string; agentName?: string; + folder?: string; } { let displayName: string | undefined; let agentName: string | undefined; + let folder: string | undefined; for (let i = 0; i < args.length; i++) { const key = args[i]; @@ -34,6 +37,10 @@ function parseArgs(args: string[]): { agentName = val; i++; break; + case '--folder': + folder = val; + i++; + break; } } @@ -46,23 +53,23 @@ function parseArgs(args: string[]): { process.exit(2); } - return { displayName, agentName }; + return { displayName, agentName, folder }; } export async function run(args: string[]): Promise { - const { displayName, agentName } = parseArgs(args); + const { displayName, agentName, folder } = parseArgs(args); const projectRoot = process.cwd(); const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts'); const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName]; if (agentName) scriptArgs.push('--agent-name', agentName); + if (folder) scriptArgs.push('--folder', folder); log.info('Invoking init-cli-agent', { displayName, agentName }); - let stdout = ''; try { - stdout = execFileSync('pnpm', scriptArgs, { + execFileSync('pnpm', scriptArgs, { cwd: projectRoot, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', @@ -83,11 +90,9 @@ export async function run(args: string[]): Promise { process.exit(1); } - const folderMatch = stdout.match(/@ groups\/(\S+)/); emitStatus('CLI_AGENT', { DISPLAY_NAME: displayName, AGENT_NAME: agentName || displayName, - FOLDER: folderMatch?.[1] ?? '', CHANNEL: 'cli/local', STATUS: 'success', LOG: 'logs/setup.log', From d5b48e474278a8dc6067944c63049a64fcd950f1 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 17:51:32 +0300 Subject: [PATCH 047/144] fix(credentials): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wakeContainer now never throws — returns Promise, catches internally. Closes the regression risk for the 5 awaited callers in agent-to-agent, interactive, and approvals/response-handler that the previous version left unwrapped. Router uses the boolean to stop the typing indicator on transient failure; host-sweep just awaits. - Tighten AUTH_REQUIRED_RE: anchor to start-of-string with the specific `·` (U+00B7) separator the CLI uses, so an agent that quotes the banner mid-sentence in a normal reply doesn't trip the classifier. - Log a one-line note from writeAuthRequiredMessage so substitutions are visible when debugging "user got the credentials message but I don't see why." - Add unit tests for ClaudeProvider.isAuthRequired covering both banner variants, trailing content, mid-sentence quoting, leading-prose quoting, alternate separators, and unrelated text. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 1 + .../agent-runner/src/providers/claude.test.ts | 37 +++++++++++++++++++ .../agent-runner/src/providers/claude.ts | 8 +++- src/container-runner.ts | 24 +++++++++--- src/host-sweep.ts | 11 ++---- src/router.ts | 14 +++---- 6 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 container/agent-runner/src/providers/claude.test.ts diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 2846337..43c9cf1 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -25,6 +25,7 @@ const AUTH_REQUIRED_USER_TEXT = "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; function writeAuthRequiredMessage(routing: RoutingContext): void { + log('Auth-required detected — substituting host-aware message for the user'); writeMessageOut({ id: generateId(), kind: 'chat', diff --git a/container/agent-runner/src/providers/claude.test.ts b/container/agent-runner/src/providers/claude.test.ts new file mode 100644 index 0000000..d906280 --- /dev/null +++ b/container/agent-runner/src/providers/claude.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'bun:test'; + +import { ClaudeProvider } from './claude.js'; + +describe('ClaudeProvider.isAuthRequired', () => { + const provider = new ClaudeProvider(); + + it('matches the "Not logged in" banner', () => { + expect(provider.isAuthRequired('Not logged in · Please run /login')).toBe(true); + }); + + it('matches the "Invalid API key" banner', () => { + expect(provider.isAuthRequired('Invalid API key · Please run /login')).toBe(true); + }); + + it('matches with trailing content after the banner', () => { + expect(provider.isAuthRequired('Not logged in · Please run /login\n\nstack trace …')).toBe(true); + }); + + it('does not match when the agent quotes the phrase mid-sentence', () => { + const quoted = "The error 'Invalid API key · Please run /login' means your auth has expired."; + expect(provider.isAuthRequired(quoted)).toBe(false); + }); + + it('does not match when the agent leads its reply with the phrase in prose', () => { + const prose = '"Not logged in · Please run /login" is a Claude Code error.'; + expect(provider.isAuthRequired(prose)).toBe(false); + }); + + it('does not match a different separator (defensive against typos in agent output)', () => { + expect(provider.isAuthRequired('Not logged in - Please run /login')).toBe(false); + }); + + it('does not match unrelated text', () => { + expect(provider.isAuthRequired('Tool execution failed: timeout')).toBe(false); + }); +}); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 6dcdb5a..11ea4b0 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -237,12 +237,16 @@ const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; /** - * Auth-required detection. Matches Claude Code's output when no usable + * Auth-required detection. Matches Claude Code's banner when no usable * credential is available — "Not logged in · Please run /login" or * "Invalid API key · Please run /login". The user can't run /login from * chat, so the poll-loop substitutes a host-aware message. + * + * Anchored to start-of-string with the specific `·` separator (U+00B7) + * the CLI uses, so an agent that quotes the phrase verbatim mid-sentence + * in a normal reply doesn't trip the classifier. */ -const AUTH_REQUIRED_RE = /(Not logged in|Invalid API key)[\s\S]*?Please run \/login/i; +const AUTH_REQUIRED_RE = /^(Not logged in|Invalid API key)\s*·\s*Please run \/login/; export class ClaudeProvider implements AgentProvider { readonly supportsNativeSlashCommands = true; diff --git a/src/container-runner.ts b/src/container-runner.ts index dc71248..27b0f5c 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -58,7 +58,7 @@ const activeContainers = new Map>(); +const wakePromises = new Map>(); export function getActiveContainerCount(): number { return activeContainers.size; @@ -73,20 +73,32 @@ export function isContainerRunning(sessionId: string): boolean { * (the in-flight wake promise is reused). * * The container runs the v2 agent-runner which polls the session DB. + * + * Contract: never throws. Returns `true` on successful spawn, `false` on + * transient spawn failure (e.g. OneCLI gateway unreachable). Callers don't + * need to wrap — the inbound row stays pending and host-sweep retries on + * its next tick. Callers that care (e.g. the router's typing indicator) + * can branch on the boolean. */ -export function wakeContainer(session: Session): Promise { +export function wakeContainer(session: Session): Promise { if (activeContainers.has(session.id)) { log.debug('Container already running', { sessionId: session.id }); - return Promise.resolve(); + return Promise.resolve(true); } const existing = wakePromises.get(session.id); if (existing) { log.debug('Container wake already in-flight — joining existing promise', { sessionId: session.id }); return existing; } - const promise = spawnContainer(session).finally(() => { - wakePromises.delete(session.id); - }); + const promise = spawnContainer(session) + .then(() => true) + .catch((err) => { + log.warn('wakeContainer failed — host-sweep will retry', { sessionId: session.id, err }); + return false; + }) + .finally(() => { + wakePromises.delete(session.id); + }); wakePromises.set(session.id, promise); return promise; } diff --git a/src/host-sweep.ts b/src/host-sweep.ts index ff88fb0..69a4d61 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -168,14 +168,9 @@ async function sweepSession(session: Session): Promise { const dueCount = countDueMessages(inDb); if (dueCount > 0 && !isContainerRunning(session.id)) { log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); - try { - await wakeContainer(session); - } catch (err) { - // Transient spawn failure (e.g. OneCLI gateway down). Leave messages - // pending so the next sweep tick retries; don't abort the rest of - // the sweep cycle for other sessions. - log.warn('wakeContainer failed — will retry on next sweep', { sessionId: session.id, err }); - } + // wakeContainer never throws — transient spawn failures (OneCLI down, + // etc.) return false and leave messages pending for the next tick. + await wakeContainer(session); } const alive = isContainerRunning(session.id); diff --git a/src/router.ts b/src/router.ts index e429977..69d7313 100644 --- a/src/router.ts +++ b/src/router.ts @@ -450,15 +450,11 @@ async function deliverToAgent( startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId); const freshSession = getSession(session.id); if (freshSession) { - try { - await wakeContainer(freshSession); - } catch (err) { - // Transient spawn failure (e.g. OneCLI gateway down). The inbound - // row is already persisted — host-sweep will retry the wake on its - // next tick. Don't bubble out of the channel adapter. - log.warn('wakeContainer failed — host-sweep will retry', { sessionId: freshSession.id, err }); - stopTypingRefresh(freshSession.id); - } + const woke = await wakeContainer(freshSession); + // wakeContainer never throws — it returns false on transient spawn + // failure (host-sweep retries). Stop the typing indicator we just + // started so it doesn't leak; the inbound row stays pending. + if (!woke) stopTypingRefresh(freshSession.id); } } } From ef8e3aa1b8ab1091abc1e3890fa79ccc1eb19170 Mon Sep 17 00:00:00 2001 From: robbyczgw-cla Date: Wed, 29 Apr 2026 14:55:47 +0000 Subject: [PATCH 048/144] fix(poll-loop): apply pre-task scripts to follow-up injections too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks arriving during an active query were pushed into the stream as follow-ups without running their `script` gate — so a wakeAgent=false pre-script that was supposed to suppress the tick silently leaked through and woke the agent every time. Evidence: monitoring cron firing every 10 min with [task-script] log lines never showing. Run applyPreTaskScripts on the follow-up batch too: wakeAgent=false tasks get marked completed and dropped; wakeAgent=true tasks have scriptOutput enriched exactly like the initial-batch path. Added a pollInFlight guard to serialize async runs and avoid overlapping script executions when the interval fires while one is still going. Wrapped in a MODULE-HOOK:scheduling-pre-task-followup marker block to match the existing initial-batch hook convention. --- container/agent-runner/src/poll-loop.ts | 69 +++++++++++++++++-------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index bd48db2..589b80b 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -260,31 +260,58 @@ async function processQuery( // 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. + let pollInFlight = false; const pollHandle = setInterval(() => { - if (done) return; + if (done || pollInFlight) return; + pollInFlight = true; - // Skip system messages (MCP tool responses) and /clear (needs fresh query). - // 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; - return true; - }); - if (newMessages.length > 0) { - const newIds = newMessages.map((m) => m.id); - markProcessing(newIds); + void (async () => { + try { + // Skip system messages (MCP tool responses) and /clear (needs fresh query). + // 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; + return true; + }); + if (newMessages.length === 0) return; - const prompt = formatMessages(newMessages); - log(`Pushing ${newMessages.length} follow-up message(s) into active query`); - query.push(prompt); + const newIds = newMessages.map((m) => m.id); + markProcessing(newIds); - markCompleted(newIds); - } + // Run pre-task scripts on follow-ups too — without this, a task that + // arrives during an active query (e.g. a */10 monitoring cron) bypasses + // its script gate and always wakes the agent, defeating the gate. + // Mirrors the initial-batch hook above. + let keep = newMessages; + let skipped: string[] = []; + // MODULE-HOOK:scheduling-pre-task-followup:start + const { applyPreTaskScripts } = await import('./scheduling/task-script.js'); + const preTask = await applyPreTaskScripts(newMessages); + keep = preTask.keep; + skipped = preTask.skipped; + if (skipped.length > 0) { + markCompleted(skipped); + log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`); + } + // MODULE-HOOK:scheduling-pre-task-followup:end + + if (keep.length === 0) return; + + const keptIds = keep.map((m) => m.id); + const prompt = formatMessages(keep); + log(`Pushing ${keep.length} follow-up message(s) into active query`); + query.push(prompt); + markCompleted(keptIds); + } finally { + pollInFlight = false; + } + })(); }, ACTIVE_POLL_INTERVAL_MS); try { From b9d302524e8b40be7e194e866cf2dd33d853fdc7 Mon Sep 17 00:00:00 2001 From: robbyczgw-cla Date: Wed, 29 Apr 2026 15:01:09 +0000 Subject: [PATCH 049/144] fix(session-manager): derive attachment extension from mimeType and att.type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a channel bridge passes an attachment without an explicit `name`, extractAttachmentFiles fell back to `attachment-` with no extension. Agents could not tell whether the file was a JPEG, PDF, or audio clip, and tools keyed on extension (image viewers, exiftool, etc.) misbehaved. Two cases are now covered: 1. Channels that set `mimeType` but no `name` (Discord/Slack documents, Telegram document uploads). A small MIME-to-extension table covers the common content types — image/*, audio/*, video/*, pdf, zip, txt, json. Unknown MIMEs fall back to the unsuffixed name. 2. Channels that set `att.type` but no `mimeType` (Telegram photos, stickers, voice, animations). The chat-sdk bridge sets a coarse media-class (`photo` / `sticker` / `voice` / `video` / `animation`) which is reliable enough to derive a canonical extension. Telegram GIFs are MP4 under the hood. The existing isSafeAttachmentName security guard is preserved — the derived name still passes through it before disk I/O. The new lookup tables emit static values from internal maps and cannot construct a path-traversal payload; attacker-controlled att.name continues to flow through the same validator. --- src/session-manager.ts | 56 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/session-manager.ts b/src/session-manager.ts index 996a750..342c155 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -230,6 +230,60 @@ export function writeSessionMessage( updateSession(sessionId, { last_active: new Date().toISOString() }); } +// Map common MIME types to canonical file extensions. Used to derive a +// usable suffix when the channel bridge passes an attachment without an +// explicit `name`. Without an extension, agents (and humans) can't tell +// what kind of file landed in the inbox. +const MIME_TO_EXT: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', + 'image/heic': 'heic', + 'audio/ogg': 'ogg', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/mp4': 'm4a', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'video/quicktime': 'mov', + 'application/pdf': 'pdf', + 'text/plain': 'txt', + 'application/json': 'json', + 'application/zip': 'zip', +}; + +// Fallback when `mimeType` is missing — Telegram photos and stickers arrive +// without an explicit MIME on the attachment object. The channel bridge sets +// `att.type` to a coarse media-class (`photo` / `sticker` / `voice` / etc.) +// which is reliable enough to derive a canonical extension. Telegram's GIFs +// are actually MP4, hence `animation: 'mp4'`. +const TYPE_TO_EXT: Record = { + image: 'jpg', + photo: 'jpg', + sticker: 'webp', + voice: 'ogg', + audio: 'mp3', + video: 'mp4', + animation: 'mp4', +}; + +function extForMime(mime: string | undefined): string { + if (!mime) return ''; + const clean = mime.split(';')[0].trim().toLowerCase(); + return MIME_TO_EXT[clean] ?? ''; +} + +function deriveAttachmentName(att: Record): string { + const explicit = att.name as string | undefined; + if (explicit) return explicit; + let ext = extForMime(att.mimeType as string | undefined); + if (!ext && typeof att.type === 'string') { + ext = TYPE_TO_EXT[att.type.toLowerCase()] ?? ''; + } + return ext ? `attachment-${Date.now()}.${ext}` : `attachment-${Date.now()}`; +} + /** * If message content has attachments with base64 `data`, save them to * the session's inbox directory and replace with `localPath`. @@ -259,7 +313,7 @@ function extractAttachmentFiles( // this guard, `path.join(inboxDir, '../../...')` writes anywhere the // host process has fs permission — see Signal Desktop's Nov 2025 // attachment-fileName advisory for the same archetype. - const rawName = (att.name as string | undefined) ?? `attachment-${Date.now()}`; + const rawName = deriveAttachmentName(att); const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`; if (filename !== rawName) { log.warn('Refused unsafe attachment filename — would escape inbox', { From beb5e049eda84b178bc02f10c4346e6ef99d1279 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 18:03:25 +0300 Subject: [PATCH 050/144] fix(credentials): move auth-required remediation message into provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a paired `authRequiredMessage()` method to AgentProvider so per-provider auth-failure remediation can differ. Claude returns the Anthropic/`claude` instruction; future providers (Codex, OpenCode, …) can return their own remediation text. The poll-loop calls `provider.authRequiredMessage?.()` and falls back to a generic message if a provider implements `isAuthRequired` without supplying its own remediation. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 17 +++++++++++------ .../agent-runner/src/providers/claude.test.ts | 10 ++++++++++ container/agent-runner/src/providers/claude.ts | 4 ++++ container/agent-runner/src/providers/types.ts | 17 +++++++++++++++-- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 43c9cf1..fb54378 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -21,10 +21,15 @@ function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -const AUTH_REQUIRED_USER_TEXT = - "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; +// Generic fallback for providers that classify auth failures via +// `isAuthRequired` but don't supply their own remediation text. Concrete +// providers (Claude, Codex, …) override this with a provider-specific +// message via `authRequiredMessage()`. +const GENERIC_AUTH_REQUIRED_MESSAGE = + "I can't reach my credentials right now. The operator running NanoClaw needs to re-authenticate on the host machine."; -function writeAuthRequiredMessage(routing: RoutingContext): void { +function writeAuthRequiredMessage(provider: AgentProvider, routing: RoutingContext): void { + const text = provider.authRequiredMessage?.() ?? GENERIC_AUTH_REQUIRED_MESSAGE; log('Auth-required detected — substituting host-aware message for the user'); writeMessageOut({ id: generateId(), @@ -32,7 +37,7 @@ function writeAuthRequiredMessage(routing: RoutingContext): void { platform_id: routing.platformId, channel_type: routing.channelType, thread_id: routing.threadId, - content: JSON.stringify({ text: AUTH_REQUIRED_USER_TEXT }), + content: JSON.stringify({ text }), }); } @@ -205,7 +210,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { } if (config.provider.isAuthRequired?.(errMsg)) { - writeAuthRequiredMessage(routing); + writeAuthRequiredMessage(config.provider, routing); } else { writeMessageOut({ id: generateId(), @@ -330,7 +335,7 @@ async function processQuery( markCompleted(initialBatchIds); if (event.text) { if (provider.isAuthRequired?.(event.text)) { - writeAuthRequiredMessage(routing); + writeAuthRequiredMessage(provider, routing); } else { dispatchResultText(event.text, routing); } diff --git a/container/agent-runner/src/providers/claude.test.ts b/container/agent-runner/src/providers/claude.test.ts index d906280..91836e4 100644 --- a/container/agent-runner/src/providers/claude.test.ts +++ b/container/agent-runner/src/providers/claude.test.ts @@ -35,3 +35,13 @@ describe('ClaudeProvider.isAuthRequired', () => { expect(provider.isAuthRequired('Tool execution failed: timeout')).toBe(false); }); }); + +describe('ClaudeProvider.authRequiredMessage', () => { + const provider = new ClaudeProvider(); + + it('returns the Anthropic-specific remediation', () => { + const msg = provider.authRequiredMessage(); + expect(msg).toContain('Anthropic credentials'); + expect(msg).toContain('claude'); + }); +}); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 11ea4b0..89dd5cd 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -275,6 +275,10 @@ export class ClaudeProvider implements AgentProvider { return AUTH_REQUIRED_RE.test(text); } + authRequiredMessage(): string { + return "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; + } + query(input: QueryInput): AgentQuery { const stream = new MessageStream(); stream.push(input.prompt); diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 99833a7..3124c07 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -17,11 +17,24 @@ export interface AgentProvider { /** * True if the given text/error indicates the underlying SDK or CLI has no - * usable Anthropic auth (e.g. Claude Code's "Not logged in · Please run + * usable credentials (e.g. Claude Code's "Not logged in · Please run * /login"). The poll-loop swaps the raw output for a host-aware message - * since the user can't run /login from chat. + * since the user can't authenticate from chat. + * + * Paired with `authRequiredMessage()` — providers that implement one + * should implement both. The matcher is provider-specific because each + * SDK/CLI has its own auth-failure banner format. */ isAuthRequired?(text: string): boolean; + + /** + * User-facing remediation message returned when `isAuthRequired` matches. + * Provider-specific because the actionable instruction differs across + * providers (e.g. Claude vs Codex vs OpenCode each direct the operator + * to a different auth flow). Falls back to a generic message in the + * poll-loop if a provider implements `isAuthRequired` but not this. + */ + authRequiredMessage?(): string; } /** From 9889848932d5b27b6f53c3e6d3b717c41d8aabea Mon Sep 17 00:00:00 2001 From: robbyczgw-cla Date: Wed, 29 Apr 2026 15:07:26 +0000 Subject: [PATCH 051/144] fix(claude-provider): respect operator-set CLAUDE_CODE_AUTO_COMPACT_WINDOW Closes #1820. The container agent-runner sets CLAUDE_CODE_AUTO_COMPACT_WINDOW unconditionally on the container process env, with no way to override it per-deployment without editing source. Read process.env first and fall back to the existing 165000 literal when unset. Default behavior is unchanged for installs that do not set the env var. Operators running 1M-context models or emergency-tuning a live deployment can now raise or lower the threshold from the host env. --- container/agent-runner/src/providers/claude.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index fbb077c..c9478b8 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -226,8 +226,12 @@ function createPreCompactHook(assistantName?: string): HookCallback { /** * Claude Code auto-compacts context at this window (tokens). Kept here so * the generic bootstrap doesn't need to know about Claude-specific env vars. + * + * Operator override: set CLAUDE_CODE_AUTO_COMPACT_WINDOW in the host env to + * raise or lower the threshold without editing source — useful when running + * with a 1M-context model variant or when emergency-tuning a deployment. */ -const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; +const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000'; /** * Stale-session detection. Matches Claude Code's error text when a From 70cb35f58b221a8236302cb409df6cc37b1c6cb8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:13:37 +0000 Subject: [PATCH 052/144] chore: bump version to 2.0.16 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9b3b6fb..419e4b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.15", + "version": "2.0.16", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From ee165d09c2959032c789309f5c6994767fce1fd4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:13:40 +0000 Subject: [PATCH 053/144] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?34k=20tokens=20=C2=B7=2067%=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 5a0fe82..cccf8c7 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 133k tokens, 67% of context window + + 134k tokens, 67% of context window @@ -15,8 +15,8 @@ tokens - - 133k + + 134k From e31a6c7e3493e00371a616eacb94d570e0174ca5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 18:26:04 +0300 Subject: [PATCH 054/144] revert(credentials): drop auth-required login-message handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the "Not logged in · Please run /login" detection and substitution from this PR — narrowing scope to just the OneCLI gateway transient-retry change. The login-message handling will be addressed separately. Reverts: - AgentProvider.isAuthRequired / authRequiredMessage - ClaudeProvider auth-required regex, classifier, and remediation text - poll-loop writeAuthRequiredMessage helper + call sites - claude.test.ts (auth-only test file) OneCLI/wakeContainer changes (the remaining content of the PR) are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 50 ++++--------------- .../agent-runner/src/providers/claude.test.ts | 47 ----------------- .../agent-runner/src/providers/claude.ts | 20 -------- container/agent-runner/src/providers/types.ts | 21 -------- 4 files changed, 11 insertions(+), 127 deletions(-) delete mode 100644 container/agent-runner/src/providers/claude.test.ts diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index fb54378..bd48db2 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -21,26 +21,6 @@ function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -// Generic fallback for providers that classify auth failures via -// `isAuthRequired` but don't supply their own remediation text. Concrete -// providers (Claude, Codex, …) override this with a provider-specific -// message via `authRequiredMessage()`. -const GENERIC_AUTH_REQUIRED_MESSAGE = - "I can't reach my credentials right now. The operator running NanoClaw needs to re-authenticate on the host machine."; - -function writeAuthRequiredMessage(provider: AgentProvider, routing: RoutingContext): void { - const text = provider.authRequiredMessage?.() ?? GENERIC_AUTH_REQUIRED_MESSAGE; - log('Auth-required detected — substituting host-aware message for the user'); - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text }), - }); -} - export interface PollLoopConfig { provider: AgentProvider; /** @@ -191,7 +171,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, processingIds, config.provider, config.providerName); + const result = await processQuery(query, routing, processingIds, config.providerName); if (result.continuation && result.continuation !== continuation) { continuation = result.continuation; setContinuation(config.providerName, continuation); @@ -209,18 +189,15 @@ export async function runPollLoop(config: PollLoopConfig): Promise { clearContinuation(config.providerName); } - if (config.provider.isAuthRequired?.(errMsg)) { - writeAuthRequiredMessage(config.provider, routing); - } else { - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: `Error: ${errMsg}` }), - }); - } + // Write error response so the user knows something went wrong + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: `Error: ${errMsg}` }), + }); } // Ensure completed even if processQuery ended without a result event @@ -272,7 +249,6 @@ async function processQuery( query: AgentQuery, routing: RoutingContext, initialBatchIds: string[], - provider: AgentProvider, providerName: string, ): Promise { let queryContinuation: string | undefined; @@ -334,11 +310,7 @@ async function processQuery( // at all — either way the turn is finished. markCompleted(initialBatchIds); if (event.text) { - if (provider.isAuthRequired?.(event.text)) { - writeAuthRequiredMessage(provider, routing); - } else { - dispatchResultText(event.text, routing); - } + dispatchResultText(event.text, routing); } } } diff --git a/container/agent-runner/src/providers/claude.test.ts b/container/agent-runner/src/providers/claude.test.ts deleted file mode 100644 index 91836e4..0000000 --- a/container/agent-runner/src/providers/claude.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect } from 'bun:test'; - -import { ClaudeProvider } from './claude.js'; - -describe('ClaudeProvider.isAuthRequired', () => { - const provider = new ClaudeProvider(); - - it('matches the "Not logged in" banner', () => { - expect(provider.isAuthRequired('Not logged in · Please run /login')).toBe(true); - }); - - it('matches the "Invalid API key" banner', () => { - expect(provider.isAuthRequired('Invalid API key · Please run /login')).toBe(true); - }); - - it('matches with trailing content after the banner', () => { - expect(provider.isAuthRequired('Not logged in · Please run /login\n\nstack trace …')).toBe(true); - }); - - it('does not match when the agent quotes the phrase mid-sentence', () => { - const quoted = "The error 'Invalid API key · Please run /login' means your auth has expired."; - expect(provider.isAuthRequired(quoted)).toBe(false); - }); - - it('does not match when the agent leads its reply with the phrase in prose', () => { - const prose = '"Not logged in · Please run /login" is a Claude Code error.'; - expect(provider.isAuthRequired(prose)).toBe(false); - }); - - it('does not match a different separator (defensive against typos in agent output)', () => { - expect(provider.isAuthRequired('Not logged in - Please run /login')).toBe(false); - }); - - it('does not match unrelated text', () => { - expect(provider.isAuthRequired('Tool execution failed: timeout')).toBe(false); - }); -}); - -describe('ClaudeProvider.authRequiredMessage', () => { - const provider = new ClaudeProvider(); - - it('returns the Anthropic-specific remediation', () => { - const msg = provider.authRequiredMessage(); - expect(msg).toContain('Anthropic credentials'); - expect(msg).toContain('claude'); - }); -}); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 89dd5cd..fbb077c 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -236,18 +236,6 @@ const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; */ const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; -/** - * Auth-required detection. Matches Claude Code's banner when no usable - * credential is available — "Not logged in · Please run /login" or - * "Invalid API key · Please run /login". The user can't run /login from - * chat, so the poll-loop substitutes a host-aware message. - * - * Anchored to start-of-string with the specific `·` separator (U+00B7) - * the CLI uses, so an agent that quotes the phrase verbatim mid-sentence - * in a normal reply doesn't trip the classifier. - */ -const AUTH_REQUIRED_RE = /^(Not logged in|Invalid API key)\s*·\s*Please run \/login/; - export class ClaudeProvider implements AgentProvider { readonly supportsNativeSlashCommands = true; @@ -271,14 +259,6 @@ export class ClaudeProvider implements AgentProvider { return STALE_SESSION_RE.test(msg); } - isAuthRequired(text: string): boolean { - return AUTH_REQUIRED_RE.test(text); - } - - authRequiredMessage(): string { - return "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; - } - query(input: QueryInput): AgentQuery { const stream = new MessageStream(); stream.push(input.prompt); diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 3124c07..55ab919 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -14,27 +14,6 @@ export interface AgentProvider { * (missing transcript, unknown session, etc.) and should be cleared. */ isSessionInvalid(err: unknown): boolean; - - /** - * True if the given text/error indicates the underlying SDK or CLI has no - * usable credentials (e.g. Claude Code's "Not logged in · Please run - * /login"). The poll-loop swaps the raw output for a host-aware message - * since the user can't authenticate from chat. - * - * Paired with `authRequiredMessage()` — providers that implement one - * should implement both. The matcher is provider-specific because each - * SDK/CLI has its own auth-failure banner format. - */ - isAuthRequired?(text: string): boolean; - - /** - * User-facing remediation message returned when `isAuthRequired` matches. - * Provider-specific because the actionable instruction differs across - * providers (e.g. Claude vs Codex vs OpenCode each direct the operator - * to a different auth flow). Falls back to a generic message in the - * poll-loop if a provider implements `isAuthRequired` but not this. - */ - authRequiredMessage?(): string; } /** From 1452ed262b018c7f07430018cd156c6b1bc3e754 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:30:20 +0000 Subject: [PATCH 055/144] chore: bump version to 2.0.17 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 419e4b3..6287027 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.16", + "version": "2.0.17", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 34f361287746a507ffa952cc1c542fe35e4b5b5b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:30:22 +0000 Subject: [PATCH 056/144] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?35k=20tokens=20=C2=B7=2067%=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 cccf8c7..20b07a3 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 134k tokens, 67% of context window + + 135k tokens, 67% of context window @@ -15,8 +15,8 @@ tokens - - 134k + + 135k From 8dd004ca75c881d19139efde85dab8c6526d06f5 Mon Sep 17 00:00:00 2001 From: Mike Nolet Date: Thu, 30 Apr 2026 08:13:59 +0200 Subject: [PATCH 057/144] fix(scheduling): include routing in schedule_task content JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schedule_task MCP tool wrote routing fields (platform_id, channel_type, thread_id) onto the outbound system message's row columns, but handleSystemAction (src/delivery.ts) parses content JSON and forwards only that to handlers. handleScheduleTask (src/modules/scheduling/actions.ts) reads content.platformId/channelType/threadId — which the writer never populated — so every kind='task' row landed in messages_in with all-null routing. When host-sweep wakes a scheduled task, dispatchResultText's fast path requires routing on the message and bails when it's null, falling through to the "Routing recovery" retry prompt. End-user delivery still works because the agent can pick a destination from its destinations table on retry — so the bug went undetected, silently costing one extra LLM turn per scheduled-task wake. Sessions whose destinations table has no channel row (e.g. agent-only destinations) fail outright with a recovery loop. Fix: add the routing fields to the content JSON so the writer matches the contract handleScheduleTask already expects. cancel/pause/resume/update_task operate by id alone and don't need routing. --- container/agent-runner/src/mcp-tools/scheduling.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/container/agent-runner/src/mcp-tools/scheduling.ts b/container/agent-runner/src/mcp-tools/scheduling.ts index 00e41bb..9b8451d 100644 --- a/container/agent-runner/src/mcp-tools/scheduling.ts +++ b/container/agent-runner/src/mcp-tools/scheduling.ts @@ -89,6 +89,9 @@ export const scheduleTask: McpToolDefinition = { script, processAfter, recurrence, + platformId: r.platform_id, + channelType: r.channel_type, + threadId: r.thread_id, }), }); From 2a3be9ec7fc00b687489674258d6c8ffb35ce742 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 30 Apr 2026 09:40:44 +0300 Subject: [PATCH 058/144] extract attachment-naming, harden mimeType guard, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the MIME/type-to-extension maps and derivation helpers out of session-manager.ts into a dedicated attachment-naming module — keeps session-manager focused on session lifecycle and gives the helpers a natural home for unit tests alongside the existing attachment-safety module. Two small fixes alongside the extraction: - extForMime now guards `typeof mime !== 'string'` before .split, so a buggy bridge passing `mimeType: { ... }` (object) no longer crashes the inbound write loop. - deriveAttachmentName computes Date.now() once per call instead of twice, and tightens the explicit-name check to a string-and-truthy guard so non-string values fall through to derivation. Adds attachment-naming.test.ts with 11 cases covering MIME normalization (case + parameters), Telegram type fallback, the non-string defensive guard, and the bare-timestamp fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/attachment-naming.test.ts | 71 +++++++++++++++++++++++++++++++++++ src/attachment-naming.ts | 69 ++++++++++++++++++++++++++++++++++ src/session-manager.ts | 55 +-------------------------- 3 files changed, 141 insertions(+), 54 deletions(-) create mode 100644 src/attachment-naming.test.ts create mode 100644 src/attachment-naming.ts diff --git a/src/attachment-naming.test.ts b/src/attachment-naming.test.ts new file mode 100644 index 0000000..5ca13f1 --- /dev/null +++ b/src/attachment-naming.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; + +import { deriveAttachmentName, extForMime } from './attachment-naming.js'; + +describe('extForMime', () => { + it('returns empty for undefined / non-string / empty', () => { + expect(extForMime(undefined)).toBe(''); + expect(extForMime('')).toBe(''); + expect(extForMime({})).toBe(''); + expect(extForMime(null)).toBe(''); + expect(extForMime(42)).toBe(''); + }); + + it('maps common MIME types to canonical extensions', () => { + expect(extForMime('image/jpeg')).toBe('jpg'); + expect(extForMime('application/pdf')).toBe('pdf'); + expect(extForMime('audio/ogg')).toBe('ogg'); + }); + + it('strips parameters and is case-insensitive', () => { + expect(extForMime('image/JPEG; foo=bar')).toBe('jpg'); + expect(extForMime(' Application/PDF ')).toBe('pdf'); + expect(extForMime('text/plain; charset=utf-8')).toBe('txt'); + }); + + it('returns empty for unknown MIMEs', () => { + expect(extForMime('application/octet-stream')).toBe(''); + expect(extForMime('application/x-totally-made-up')).toBe(''); + }); +}); + +describe('deriveAttachmentName', () => { + it('returns explicit name when set, no derivation', () => { + expect(deriveAttachmentName({ name: 'photo.jpg', mimeType: 'application/pdf' })).toBe('photo.jpg'); + }); + + it('ignores empty / non-string explicit name and falls through to derivation', () => { + const out = deriveAttachmentName({ name: '', mimeType: 'application/pdf' }); + expect(out).toMatch(/^attachment-\d+\.pdf$/); + + const out2 = deriveAttachmentName({ name: 42, mimeType: 'application/pdf' }); + expect(out2).toMatch(/^attachment-\d+\.pdf$/); + }); + + it('derives extension from mimeType when no name', () => { + expect(deriveAttachmentName({ mimeType: 'application/pdf' })).toMatch(/^attachment-\d+\.pdf$/); + expect(deriveAttachmentName({ mimeType: 'image/jpeg' })).toMatch(/^attachment-\d+\.jpg$/); + }); + + it('falls back to att.type when mimeType is missing (Telegram photos/stickers)', () => { + expect(deriveAttachmentName({ type: 'photo' })).toMatch(/^attachment-\d+\.jpg$/); + expect(deriveAttachmentName({ type: 'sticker' })).toMatch(/^attachment-\d+\.webp$/); + expect(deriveAttachmentName({ type: 'voice' })).toMatch(/^attachment-\d+\.ogg$/); + expect(deriveAttachmentName({ type: 'animation' })).toMatch(/^attachment-\d+\.mp4$/); + }); + + it('case-insensitive att.type lookup', () => { + expect(deriveAttachmentName({ type: 'PHOTO' })).toMatch(/^attachment-\d+\.jpg$/); + }); + + it('returns bare timestamp when nothing matches', () => { + expect(deriveAttachmentName({})).toMatch(/^attachment-\d+$/); + expect(deriveAttachmentName({ mimeType: 'application/octet-stream' })).toMatch(/^attachment-\d+$/); + expect(deriveAttachmentName({ type: 'mystery-class' })).toMatch(/^attachment-\d+$/); + }); + + it('does not crash on non-string mimeType (defensive against buggy bridges)', () => { + expect(() => deriveAttachmentName({ mimeType: { foo: 'bar' } })).not.toThrow(); + expect(deriveAttachmentName({ mimeType: { foo: 'bar' } })).toMatch(/^attachment-\d+$/); + }); +}); diff --git a/src/attachment-naming.ts b/src/attachment-naming.ts new file mode 100644 index 0000000..2dfe8c1 --- /dev/null +++ b/src/attachment-naming.ts @@ -0,0 +1,69 @@ +/** + * Derive a safe, extensioned filename for inbound attachments when the + * channel bridge passes data without an explicit `name`. + * + * Two-step lookup: + * 1. `mimeType` → extension (Discord/Slack documents, Telegram document + * uploads — channels that set the MIME but not a filename). + * 2. `att.type` → extension (Telegram photos/stickers/voice/animations — + * coarse media-class set by the chat-sdk bridge with no MIME). + * + * Output is still passed through `isSafeAttachmentName` at the call site. + * The maps emit static values, so no derivation path can construct a + * traversal payload — only an attacker-controlled `att.name` can, and that + * goes through the safety guard unchanged. + */ + +// Map common MIME types to canonical file extensions. Without an extension, +// agents (and humans) can't tell what kind of file landed in the inbox, and +// tools keyed on extension (image viewers, exiftool, etc.) misbehave. +const MIME_TO_EXT: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', + 'image/heic': 'heic', + 'audio/ogg': 'ogg', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/mp4': 'm4a', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'video/quicktime': 'mov', + 'application/pdf': 'pdf', + 'text/plain': 'txt', + 'application/json': 'json', + 'application/zip': 'zip', +}; + +// Fallback when `mimeType` is missing — Telegram photos and stickers arrive +// without an explicit MIME on the attachment object. The channel bridge sets +// `att.type` to a coarse media-class (`photo` / `sticker` / `voice` / etc.) +// which is reliable enough to derive a canonical extension. Telegram's GIFs +// are actually MP4, hence `animation: 'mp4'`. +const TYPE_TO_EXT: Record = { + image: 'jpg', + photo: 'jpg', + sticker: 'webp', + voice: 'ogg', + audio: 'mp3', + video: 'mp4', + animation: 'mp4', +}; + +export function extForMime(mime: unknown): string { + if (typeof mime !== 'string' || !mime) return ''; + const clean = mime.split(';')[0].trim().toLowerCase(); + return MIME_TO_EXT[clean] ?? ''; +} + +export function deriveAttachmentName(att: Record): string { + const explicit = att.name; + if (typeof explicit === 'string' && explicit) return explicit; + let ext = extForMime(att.mimeType); + if (!ext && typeof att.type === 'string') { + ext = TYPE_TO_EXT[att.type.toLowerCase()] ?? ''; + } + const ts = Date.now(); + return ext ? `attachment-${ts}.${ext}` : `attachment-${ts}`; +} diff --git a/src/session-manager.ts b/src/session-manager.ts index 342c155..7751fba 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -14,6 +14,7 @@ import type Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; +import { deriveAttachmentName } from './attachment-naming.js'; import { isSafeAttachmentName } from './attachment-safety.js'; import type { OutboundFile } from './channels/adapter.js'; import { DATA_DIR } from './config.js'; @@ -230,60 +231,6 @@ export function writeSessionMessage( updateSession(sessionId, { last_active: new Date().toISOString() }); } -// Map common MIME types to canonical file extensions. Used to derive a -// usable suffix when the channel bridge passes an attachment without an -// explicit `name`. Without an extension, agents (and humans) can't tell -// what kind of file landed in the inbox. -const MIME_TO_EXT: Record = { - 'image/jpeg': 'jpg', - 'image/png': 'png', - 'image/webp': 'webp', - 'image/gif': 'gif', - 'image/heic': 'heic', - 'audio/ogg': 'ogg', - 'audio/mpeg': 'mp3', - 'audio/wav': 'wav', - 'audio/mp4': 'm4a', - 'video/mp4': 'mp4', - 'video/webm': 'webm', - 'video/quicktime': 'mov', - 'application/pdf': 'pdf', - 'text/plain': 'txt', - 'application/json': 'json', - 'application/zip': 'zip', -}; - -// Fallback when `mimeType` is missing — Telegram photos and stickers arrive -// without an explicit MIME on the attachment object. The channel bridge sets -// `att.type` to a coarse media-class (`photo` / `sticker` / `voice` / etc.) -// which is reliable enough to derive a canonical extension. Telegram's GIFs -// are actually MP4, hence `animation: 'mp4'`. -const TYPE_TO_EXT: Record = { - image: 'jpg', - photo: 'jpg', - sticker: 'webp', - voice: 'ogg', - audio: 'mp3', - video: 'mp4', - animation: 'mp4', -}; - -function extForMime(mime: string | undefined): string { - if (!mime) return ''; - const clean = mime.split(';')[0].trim().toLowerCase(); - return MIME_TO_EXT[clean] ?? ''; -} - -function deriveAttachmentName(att: Record): string { - const explicit = att.name as string | undefined; - if (explicit) return explicit; - let ext = extForMime(att.mimeType as string | undefined); - if (!ext && typeof att.type === 'string') { - ext = TYPE_TO_EXT[att.type.toLowerCase()] ?? ''; - } - return ext ? `attachment-${Date.now()}.${ext}` : `attachment-${Date.now()}`; -} - /** * If message content has attachments with base64 `data`, save them to * the session's inbox directory and replace with `localPath`. From 6e5e568da12a48e05dbfb0dcc8f10e67887936ff Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 30 Apr 2026 10:33:46 +0300 Subject: [PATCH 059/144] sanitize agent sent file names to prevent path traversal --- src/session-manager.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/session-manager.ts b/src/session-manager.ts index 996a750..edd4b08 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -372,6 +372,11 @@ export function readOutboxFiles( if (!fs.existsSync(outboxDir)) return undefined; const files: OutboundFile[] = []; for (const filename of filenames) { + // Reject any name that isn't a bare basename before touching the filesystem. + if (!isSafeAttachmentName(filename)) { + log.warn('Refused unsafe outbox filename — would escape outbox', { messageId, filename }); + continue; + } const filePath = path.join(outboxDir, filename); if (fs.existsSync(filePath)) { files.push({ filename, data: fs.readFileSync(filePath) }); From 15f286b73dd9c6efe5c81d2a09b0ed942247de30 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 07:34:23 +0000 Subject: [PATCH 060/144] chore: bump version to 2.0.18 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6287027..72020d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.17", + "version": "2.0.18", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 43f49b988ebdf202f43c25e739d0c53d127b99bc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 07:40:16 +0000 Subject: [PATCH 061/144] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?35k=20tokens=20=C2=B7=2068%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 20b07a3..86587f7 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 135k tokens, 67% of context window + + 135k tokens, 68% of context window From f828e2971cd22470a4f90f84ea6d0e6911dc50d2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 07:40:21 +0000 Subject: [PATCH 062/144] chore: bump version to 2.0.19 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 72020d7..2b36863 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.18", + "version": "2.0.19", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 8a205808e0eeb5910afa6a8605defc7b05efff3a Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Thu, 30 Apr 2026 07:56:34 +0000 Subject: [PATCH 063/144] fix(setup): wrap scratch agent cleanup in transaction, remove session data Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/delete-cli-agent.ts | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/scripts/delete-cli-agent.ts b/scripts/delete-cli-agent.ts index 01a9e33..c85679f 100644 --- a/scripts/delete-cli-agent.ts +++ b/scripts/delete-cli-agent.ts @@ -45,21 +45,20 @@ if (!ag) { process.exit(0); } -// Dynamically find every table with an agent_group_id column and delete -// matching rows. This is self-maintaining — new FK tables are picked up -// automatically without updating a hardcoded list. -const tables = db - .prepare( - `SELECT DISTINCT m.name FROM sqlite_master m - JOIN pragma_table_info(m.name) p ON p.name = 'agent_group_id' - WHERE m.type = 'table' AND m.name != 'agent_groups'`, - ) - .all() as { name: string }[]; -for (const { name } of tables) { - db.prepare(`DELETE FROM ${name} WHERE agent_group_id = ?`).run(ag.id); -} - -deleteAgentGroup(ag.id); +const cleanup = db.transaction(() => { + const tables = db + .prepare( + `SELECT DISTINCT m.name FROM sqlite_master m + JOIN pragma_table_info(m.name) p ON p.name = 'agent_group_id' + WHERE m.type = 'table' AND m.name != 'agent_groups'`, + ) + .all() as { name: string }[]; + for (const { name } of tables) { + db.prepare(`DELETE FROM ${name} WHERE agent_group_id = ?`).run(ag.id); + } + deleteAgentGroup(ag.id); +}); +cleanup(); // Remove the groups// directory. const groupDir = path.join(process.cwd(), 'groups', args.folder); @@ -67,4 +66,10 @@ if (fs.existsSync(groupDir)) { fs.rmSync(groupDir, { recursive: true }); } +// Remove session data on disk. +const sessionsDir = path.join(DATA_DIR, 'v2-sessions', ag.id); +if (fs.existsSync(sessionsDir)) { + fs.rmSync(sessionsDir, { recursive: true }); +} + console.log(`Deleted agent group ${ag.id} (${args.folder}).`); From 7755082a4ce7bb1276aa2694faa54e6b621b0813 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 29 Apr 2026 11:58:42 +0000 Subject: [PATCH 064/144] Add root user warning gate to Linux setup pre-flight Users running setup as root hit permission issues with containers, services, and file ownership. Warn early with an interactive prompt and provide step-by-step instructions to create a regular user. Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/nanoclaw.sh b/nanoclaw.sh index 058dbbf..4ab7b39 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -134,6 +134,39 @@ write_header # skips re-printing the wordmark, keeping the flow visually continuous. printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" +# ─── pre-flight: root user warning (Linux) ──────────────────────────── +if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then + printf ' %s\n' \ + "$(red 'Warning: you are running as root.')" + printf ' %s\n' \ + "$(dim "Running NanoClaw as root is not recommended. It can cause permission")" + printf ' %s\n\n' \ + "$(dim "issues with containers, services, and file ownership.")" + printf ' %s\n' \ + "$(dim "We recommend creating a regular user and running setup from there.")" + printf ' %s\n\n' \ + "$(dim "If you continue as root, some things may not work as expected.")" + read -r -p " $(bold 'Continue as root anyway?') [y/N] " ROOT_ANS Date: Wed, 29 Apr 2026 12:08:22 +0000 Subject: [PATCH 065/144] Change root warning from y/N prompt to numbered menu options Clearer UX: option 1 shows user creation instructions, option 2 explicitly continues as root (not recommended). Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 4ab7b39..06086b3 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -142,14 +142,12 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then "$(dim "Running NanoClaw as root is not recommended. It can cause permission")" printf ' %s\n\n' \ "$(dim "issues with containers, services, and file ownership.")" - printf ' %s\n' \ - "$(dim "We recommend creating a regular user and running setup from there.")" - printf ' %s\n\n' \ - "$(dim "If you continue as root, some things may not work as expected.")" - read -r -p " $(bold 'Continue as root anyway?') [y/N] " ROOT_ANS Date: Wed, 29 Apr 2026 12:12:54 +0000 Subject: [PATCH 066/144] Update root warning instructions: add root login step, fix ssh user Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 06086b3..8c0bf32 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -155,11 +155,12 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then ph_event setup_root_aborted printf '\n %s\n' "$(bold 'To set up a regular user:')" printf ' %s\n' "$(dim '1. Open another terminal (keep this one for reference)')" - printf ' %s\n' "$(dim '2. Create a new user: adduser nanoclaw')" - printf ' %s\n' "$(dim '3. Add to sudo group: usermod -aG sudo nanoclaw')" - printf ' %s\n' "$(dim '4. Log out of this SSH session: exit')" - printf ' %s\n' "$(dim '5. Log back in as the new user: ssh your-user@your-server')" - printf ' %s\n\n' "$(dim '6. Re-run setup: bash nanoclaw.sh')" + printf ' %s\n' "$(dim '2. Log in as root: ssh root@your-server')" + printf ' %s\n' "$(dim '3. Create a new user: adduser nanoclaw')" + printf ' %s\n' "$(dim '4. Add to sudo group: usermod -aG sudo nanoclaw')" + printf ' %s\n' "$(dim '5. Log out of this SSH session: exit')" + printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" + printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')" exit 1 ;; esac From dec1be6adc4e0f993a40cd1eaa7c6c735eb78350 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 29 Apr 2026 18:57:15 +0000 Subject: [PATCH 067/144] Add clone step to root warning user-creation instructions Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 8c0bf32..1813980 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -160,7 +160,8 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then printf ' %s\n' "$(dim '4. Add to sudo group: usermod -aG sudo nanoclaw')" printf ' %s\n' "$(dim '5. Log out of this SSH session: exit')" printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" - printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')" + printf ' %s\n' "$(dim '7. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" + printf ' %s\n\n' "$(dim '8. Re-run setup: bash nanoclaw.sh')" exit 1 ;; esac From 0a18c1d21a2516b3745a6de9261189c4a8fa1b7c Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 29 Apr 2026 21:12:14 +0000 Subject: [PATCH 068/144] Ensure user is in docker group before sg docker, revert workarounds The root cause of broken keyboard navigation was sg docker prompting for the (unset) group password when the user wasn't in the docker group. Fix by running sudo usermod -aG docker before sg docker. This makes the stty sane calls and p.confirm workaround unnecessary, so revert those. Also remove the manual docker group instruction from nanoclaw.sh since container.ts handles it automatically. Co-Authored-By: Claude Opus 4.6 --- setup/container.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/setup/container.ts b/setup/container.ts index 6ecd032..18de61a 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -127,11 +127,22 @@ export async function run(args: string[]): Promise { } // 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. + // groups are fixed at login, so `usermod -aG docker` doesn't affect us + // until next login. Ensure the user is in the docker group (install-docker.sh + // does this on fresh installs, but skips when Docker is already present), + // then re-exec 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')) { + // Ensure the current user is in the docker group — without this, + // sg will ask for the (typically unset) group password and fail. + const inGroup = spawnSync('id', ['-nG'], { encoding: 'utf-8' }); + if (!(inGroup.stdout ?? '').split(/\s+/).includes('docker')) { + log.info('Adding current user to docker group'); + spawnSync('sudo', ['usermod', '-aG', 'docker', process.env.USER ?? ''], { + stdio: 'inherit', + }); + } + log.info('Re-executing container step under `sg docker`'); const res = spawnSync( 'sg', From 3d2996541337f35e969bc6577c7793c9725e90b8 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 29 Apr 2026 21:23:42 +0000 Subject: [PATCH 069/144] Update root warning instructions: add SSH key copy, remove extra step Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 1813980..5dd366f 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -154,11 +154,11 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then *) ph_event setup_root_aborted printf '\n %s\n' "$(bold 'To set up a regular user:')" - printf ' %s\n' "$(dim '1. Open another terminal (keep this one for reference)')" - printf ' %s\n' "$(dim '2. Log in as root: ssh root@your-server')" - printf ' %s\n' "$(dim '3. Create a new user: adduser nanoclaw')" - printf ' %s\n' "$(dim '4. Add to sudo group: usermod -aG sudo nanoclaw')" - printf ' %s\n' "$(dim '5. Log out of this SSH session: exit')" + printf ' %s\n' "$(dim '1. Log in as root: ssh root@your-server')" + printf ' %s\n' "$(dim '2. Create a new user: adduser nanoclaw')" + printf ' %s\n' "$(dim '3. Add to sudo group: usermod -aG sudo nanoclaw')" + printf ' %s\n' "$(dim '4. Copy SSH keys to new user: cp -r ~/.ssh /home/nanoclaw/.ssh && chown -R nanoclaw:nanoclaw /home/nanoclaw/.ssh')" + printf ' %s\n' "$(dim '5. Log out: exit')" printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" printf ' %s\n' "$(dim '7. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" printf ' %s\n\n' "$(dim '8. Re-run setup: bash nanoclaw.sh')" From d07cd7afa0d873cb00658db4da2ca2fb64a45d3e Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 29 Apr 2026 21:38:15 +0000 Subject: [PATCH 070/144] Remove redundant root login step from user-creation instructions Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 5dd366f..fdb24a1 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -154,14 +154,13 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then *) ph_event setup_root_aborted printf '\n %s\n' "$(bold 'To set up a regular user:')" - printf ' %s\n' "$(dim '1. Log in as root: ssh root@your-server')" - printf ' %s\n' "$(dim '2. Create a new user: adduser nanoclaw')" - printf ' %s\n' "$(dim '3. Add to sudo group: usermod -aG sudo nanoclaw')" - printf ' %s\n' "$(dim '4. Copy SSH keys to new user: cp -r ~/.ssh /home/nanoclaw/.ssh && chown -R nanoclaw:nanoclaw /home/nanoclaw/.ssh')" - printf ' %s\n' "$(dim '5. Log out: exit')" - printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" - printf ' %s\n' "$(dim '7. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" - printf ' %s\n\n' "$(dim '8. Re-run setup: bash nanoclaw.sh')" + printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')" + printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')" + printf ' %s\n' "$(dim '3. Copy SSH keys to new user: cp -r ~/.ssh /home/nanoclaw/.ssh && chown -R nanoclaw:nanoclaw /home/nanoclaw/.ssh')" + printf ' %s\n' "$(dim '4. Log out: exit')" + printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')" + printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" + printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')" exit 1 ;; esac From 72837c1643dc39898b21e2fbef7fd4301490c54a Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Thu, 30 Apr 2026 07:44:49 +0000 Subject: [PATCH 071/144] Fix sg docker re-exec restarting setup from scratch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When maybeReexecUnderSg() re-launches setup:auto under `sg docker`, the new process had no memory of completed steps — it re-prompted the welcome menu, re-ran environment and container checks, and then failed on onecli because the earlier run's state was lost. Pass NANOCLAW_SKIP with completedStepNames() so the re-exec'd process skips already-finished steps, suppress the welcome menu and existing-env prompts on re-exec since the user already answered them. Co-Authored-By: Claude Opus 4.6 --- setup/auto.ts | 66 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 37e3cb6..425778f 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -85,17 +85,21 @@ async function main(): Promise { // Welcome menu — default path or open advanced overrides before any setup // work begins. Default lands on standard so Enter is the happy path. - const startChoice = ensureAnswer( - await brightSelect<'default' | 'advanced'>({ - message: 'How would you like to begin?', - options: [ - { value: 'default', label: 'Standard setup' }, - { value: 'advanced', label: 'Advanced', hint: 'override defaults' }, - ], - initialValue: 'default', - }), - ) as 'default' | 'advanced'; - setupLog.userInput('start_choice', startChoice); + // On sg re-exec, the user already chose — skip straight to standard. + let startChoice: 'default' | 'advanced' = 'default'; + if (process.env.NANOCLAW_REEXEC_SG !== '1') { + startChoice = ensureAnswer( + await brightSelect<'default' | 'advanced'>({ + message: 'How would you like to begin?', + options: [ + { value: 'default', label: 'Standard setup' }, + { value: 'advanced', label: 'Advanced', hint: 'override defaults' }, + ], + initialValue: 'default', + }), + ) as 'default' | 'advanced'; + setupLog.userInput('start_choice', startChoice); + } if (startChoice === 'advanced') { configValues = await runAdvancedScreen(configValues); applyToEnv(configValues); @@ -126,22 +130,28 @@ async function main(): Promise { // paste credentials again on a re-run. const existingEnv = detectExistingEnv(); if (existingEnv) { - const lines = Object.values(existingEnv.groups).map( - (g) => ` ${k.green('✓')} ${g.label}`, - ); - note(lines.join('\n'), 'Found existing configuration'); + // On sg re-exec, auto-reuse — the user already decided in the first run. + const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; + let reuseChoice: 'reuse' | 'fresh' = 'reuse'; - const reuseChoice = ensureAnswer( - await brightSelect({ - message: 'Use this existing environment?', - options: [ - { value: 'reuse', label: 'Yes, use what I already have', hint: 'recommended' }, - { value: 'fresh', label: 'No, start fresh' }, - ], - initialValue: 'reuse', - }), - ) as 'reuse' | 'fresh'; - setupLog.userInput('existing_env_choice', reuseChoice); + if (!isReexec) { + const lines = Object.values(existingEnv.groups).map( + (g) => ` ${k.green('✓')} ${g.label}`, + ); + note(lines.join('\n'), 'Found existing configuration'); + + reuseChoice = ensureAnswer( + await brightSelect({ + message: 'Use this existing environment?', + options: [ + { value: 'reuse', label: 'Yes, use what I already have', hint: 'recommended' }, + { value: 'fresh', label: 'No, start fresh' }, + ], + initialValue: 'reuse', + }), + ) as 'reuse' | 'fresh'; + setupLog.userInput('existing_env_choice', reuseChoice); + } if (reuseChoice === 'reuse') { for (const [key, value] of Object.entries(existingEnv.raw)) { @@ -1178,9 +1188,11 @@ function maybeReexecUnderSg(): void { 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 existingSkip = (process.env.NANOCLAW_SKIP ?? '').split(',').map((s) => s.trim()).filter(Boolean); + const skipList = [...new Set([...existingSkip, ...setupLog.completedStepNames()])].join(','); const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { stdio: 'inherit', - env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, + env: { ...process.env, NANOCLAW_REEXEC_SG: '1', ...(skipList ? { NANOCLAW_SKIP: skipList } : {}) }, }); process.exit(res.status ?? 1); } From 23a3fea868c21be09a2c31df2d70825db2d6eb3a Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Thu, 30 Apr 2026 09:26:34 +0000 Subject: [PATCH 072/144] Add passwordless sudo step to root warning instructions Setup steps like install-node.sh and install-docker.sh run sudo non-interactively. Without NOPASSWD, password prompts can silently hang when piped through the setup runner. Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index fdb24a1..fd35c47 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -156,9 +156,18 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then printf '\n %s\n' "$(bold 'To set up a regular user:')" printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')" printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')" - printf ' %s\n' "$(dim '3. Copy SSH keys to new user: cp -r ~/.ssh /home/nanoclaw/.ssh && chown -R nanoclaw:nanoclaw /home/nanoclaw/.ssh')" - printf ' %s\n' "$(dim '4. Log out: exit')" - printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')" + printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')" + printf ' %s\n' "$(dim '4. Copy SSH keys to new user: cp -r ~/.ssh /home/nanoclaw/.ssh && chown -R nanoclaw:nanoclaw /home/nanoclaw/.ssh')" + printf ' %s\n' "$(dim '5. Log out: exit')" + printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" + printf ' %s\n' "$(dim '7. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" + printf ' %s\n\n' "$(dim '8. Re-run setup: bash nanoclaw.sh')" + printf ' %s\n\n' "$(bold 'If you are using a web terminal (hosting provider console):')" + printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')" + printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')" + printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')" + printf ' %s\n' "$(dim '4. Log out: logout')" + printf ' %s\n' "$(dim '5. Log in as the new user at the login prompt')" printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')" exit 1 From d5388a168ba5ddf5676dbc5cdcdba11a155ecc07 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Thu, 30 Apr 2026 10:37:42 +0000 Subject: [PATCH 073/144] Replace web terminal instructions with SSH setup hint Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index fd35c47..57997f9 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -162,14 +162,8 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" printf ' %s\n' "$(dim '7. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" printf ' %s\n\n' "$(dim '8. Re-run setup: bash nanoclaw.sh')" - printf ' %s\n\n' "$(bold 'If you are using a web terminal (hosting provider console):')" - printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')" - printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')" - printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')" - printf ' %s\n' "$(dim '4. Log out: logout')" - printf ' %s\n' "$(dim '5. Log in as the new user at the login prompt')" - printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" - printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')" + printf ' %s\n' "$(dim 'Not using SSH? Refer to your hosting provider docs or ask your coding agent to help you set up SSH access.')" + printf '\n' exit 1 ;; esac From 35f8e9d2f5ef25f77fa122475aa9187b8ba78d08 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Thu, 30 Apr 2026 10:40:45 +0000 Subject: [PATCH 074/144] Move SSH hint above user-creation steps Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 57997f9..8693537 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -153,7 +153,8 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then ;; *) ph_event setup_root_aborted - printf '\n %s\n' "$(bold 'To set up a regular user:')" + printf '\n %s\n' "$(bold 'To set up a regular user (via SSH):')" + printf ' %s\n\n' "$(dim 'Not using SSH? Refer to your hosting provider docs or ask your coding agent to help you set up SSH access.')" printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')" printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')" printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')" @@ -162,8 +163,6 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" printf ' %s\n' "$(dim '7. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" printf ' %s\n\n' "$(dim '8. Re-run setup: bash nanoclaw.sh')" - printf ' %s\n' "$(dim 'Not using SSH? Refer to your hosting provider docs or ask your coding agent to help you set up SSH access.')" - printf '\n' exit 1 ;; esac From 7ce9922cde694e4d2a354df6c2fd64dca3606d3c Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 30 Apr 2026 12:54:42 +0200 Subject: [PATCH 075/144] fix(host-sweep): clear orphan processing_ack on kill to prevent claim-stuck loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the host kills a container (absolute-ceiling, claim-stuck, or crashed), resetStuckProcessingRows reset messages_in but left orphan rows in processing_ack. The next sweep tick spawned a fresh container and, on the same tick, ran enforceRunningContainerSla against outbound.db that still contained the previous container's claim with a hours-old status_changed timestamp — instant kill-claim, before the agent-runner could open outbound.db to run its own clearStaleProcessingAcks(). Loop until tries hit MAX_TRIES. Add deleteOrphanProcessingClaims() in session-db and call it at the end of resetStuckProcessingRows. Safe to write outbound.db here because the host only enters this path after killContainer (or when no container is running). Tests in host-sweep.test.ts cover the helper plus the regression: orphan claim from a 2h-old kill is now removed atomically with the messages_in reset, so the next sweep tick sees an empty claims list and the freshly respawned container survives long enough to start its agent-runner. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/db/session-db.ts | 13 ++++ src/host-sweep.test.ts | 150 ++++++++++++++++++++++++++++++++++++++++- src/host-sweep.ts | 21 ++++++ 3 files changed, 183 insertions(+), 1 deletion(-) diff --git a/src/db/session-db.ts b/src/db/session-db.ts index 48e9297..ca15276 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -180,6 +180,19 @@ export function getProcessingClaims(outDb: Database.Database): ProcessingClaim[] .all() as ProcessingClaim[]; } +/** + * Delete orphan 'processing' rows. Called by the host after killing a + * container so the leftover claim doesn't trip claim-stuck on the next sweep + * tick (which would kill the freshly respawned container before its + * agent-runner can run its own startup cleanup). + * + * Safe because the host only writes to outbound.db when no container is + * running (we just killed it). Returns the number of rows deleted. + */ +export function deleteOrphanProcessingClaims(outDb: Database.Database): number { + return outDb.prepare("DELETE FROM processing_ack WHERE status = 'processing'").run().changes; +} + export interface ContainerState { current_tool: string | null; tool_declared_timeout_ms: number | null; diff --git a/src/host-sweep.test.ts b/src/host-sweep.test.ts index eefcc8a..bd2e233 100644 --- a/src/host-sweep.test.ts +++ b/src/host-sweep.test.ts @@ -3,9 +3,17 @@ * ACTION-ITEMS item 9. Lives on the pure helper `decideStuckAction` so we * don't have to mock the filesystem or the container runner. */ +import Database from 'better-sqlite3'; import { describe, expect, it } from 'vitest'; -import { ABSOLUTE_CEILING_MS, CLAIM_STUCK_MS, decideStuckAction } from './host-sweep.js'; +import { deleteOrphanProcessingClaims, getProcessingClaims } from './db/session-db.js'; +import { + ABSOLUTE_CEILING_MS, + CLAIM_STUCK_MS, + _resetStuckProcessingRowsForTesting, + decideStuckAction, +} from './host-sweep.js'; +import type { Session } from './types.js'; const BASE = Date.parse('2026-04-20T12:00:00.000Z'); @@ -144,3 +152,143 @@ describe('decideStuckAction', () => { expect(res.action).toBe('ok'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Orphan claim cleanup (regression test for the SIGKILL → claim-stuck loop) +// +// Repro of the production bug seen 2026-04-30: container A claimed message M +// (writes processing_ack row with status='processing'). Host kills A by +// absolute-ceiling. Old behavior: messages_in.M was reset to pending but +// processing_ack.M survived. On the next sweep tick, wakeContainer spawned B, +// the same-tick SLA check saw M's stale claim age (hours), and SIGKILL'd B +// before agent-runner could run clearStaleProcessingAcks(). Loop. The fix +// deletes processing_ack 'processing' rows when the host kills/cleans the +// container, breaking the loop atomically. +// ───────────────────────────────────────────────────────────────────────────── + +function makeSessionDbs(): { inDb: Database.Database; outDb: Database.Database } { + const inDb = new Database(':memory:'); + inDb.exec(` + CREATE TABLE messages_in ( + id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, + kind TEXT NOT NULL, + timestamp TEXT NOT NULL, + 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, + content TEXT NOT NULL + ); + `); + const outDb = new Database(':memory:'); + outDb.exec(` + CREATE TABLE processing_ack ( + message_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + status_changed TEXT NOT NULL + ); + `); + return { inDb, outDb }; +} + +function fakeSession(): Session { + return { + id: 'sess-test', + agent_group_id: 'ag-test', + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: new Date().toISOString(), + }; +} + +describe('deleteOrphanProcessingClaims', () => { + it('removes only processing rows, leaves completed/failed alone', () => { + const { outDb } = makeSessionDbs(); + const ts = new Date().toISOString(); + outDb.prepare("INSERT INTO processing_ack VALUES ('m-proc', 'processing', ?)").run(ts); + outDb.prepare("INSERT INTO processing_ack VALUES ('m-done', 'completed', ?)").run(ts); + outDb.prepare("INSERT INTO processing_ack VALUES ('m-fail', 'failed', ?)").run(ts); + + const removed = deleteOrphanProcessingClaims(outDb); + + expect(removed).toBe(1); + const remaining = outDb.prepare('SELECT message_id, status FROM processing_ack ORDER BY message_id').all(); + expect(remaining).toEqual([ + { message_id: 'm-done', status: 'completed' }, + { message_id: 'm-fail', status: 'failed' }, + ]); + }); + + it('returns 0 when nothing to clear', () => { + const { outDb } = makeSessionDbs(); + expect(deleteOrphanProcessingClaims(outDb)).toBe(0); + }); +}); + +describe('resetStuckProcessingRows — orphan claim cleanup', () => { + it('deletes orphan processing_ack rows so next sweep tick does not see them', () => { + const { inDb, outDb } = makeSessionDbs(); + const claimedAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); // 2h ago + + // messages_in.status stays 'pending' during processing — only the + // container's processing_ack moves to 'processing'. See + // src/db/schema.ts header comment on processing_ack. + inDb + .prepare( + "INSERT INTO messages_in (id, seq, kind, timestamp, status, content) VALUES ('m-1', 1, 'chat', ?, 'pending', '{}')", + ) + .run(claimedAt); + outDb.prepare("INSERT INTO processing_ack VALUES ('m-1', 'processing', ?)").run(claimedAt); + + // Sanity: the orphan claim is what would trip claim-stuck. + expect(getProcessingClaims(outDb)).toHaveLength(1); + + _resetStuckProcessingRowsForTesting(inDb, outDb, fakeSession(), 'absolute-ceiling'); + + // Regression assertion: orphan claim is gone — next sweep tick will see + // an empty claims list and not kill the freshly respawned container. + expect(getProcessingClaims(outDb)).toEqual([]); + + // And the message itself was rescheduled with backoff (existing behavior). + const row = inDb.prepare('SELECT status, tries, process_after FROM messages_in WHERE id = ?').get('m-1') as { + status: string; + tries: number; + process_after: string | null; + }; + expect(row.status).toBe('pending'); + expect(row.tries).toBe(1); + expect(row.process_after).not.toBeNull(); + }); + + it('still clears orphan claims even when the inbound message has already been retried (skip path)', () => { + // Edge case: the inbound row was already rescheduled (process_after in + // future), so the per-message retry loop skips it. The orphan in + // processing_ack must still be removed — otherwise the bug remains. + const { inDb, outDb } = makeSessionDbs(); + const claimedAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + const future = new Date(Date.now() + 60_000).toISOString(); + + inDb + .prepare( + "INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, tries, content) VALUES ('m-2', 2, 'chat', ?, 'pending', ?, 1, '{}')", + ) + .run(claimedAt, future); + outDb.prepare("INSERT INTO processing_ack VALUES ('m-2', 'processing', ?)").run(claimedAt); + + _resetStuckProcessingRowsForTesting(inDb, outDb, fakeSession(), 'claim-stuck'); + + expect(getProcessingClaims(outDb)).toEqual([]); + const row = inDb.prepare('SELECT tries FROM messages_in WHERE id = ?').get('m-2') as { tries: number }; + expect(row.tries).toBe(1); // not bumped, the skip path held + }); +}); diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 69a4d61..30cdc64 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -33,6 +33,7 @@ import { getActiveSessions } from './db/sessions.js'; import { getAgentGroup } from './db/agent-groups.js'; import { countDueMessages, + deleteOrphanProcessingClaims, getContainerState, getMessageForRetry, getProcessingClaims, @@ -249,6 +250,15 @@ function enforceRunningContainerSla( resetStuckProcessingRows(inDb, outDb, session, 'claim-stuck'); } +export function _resetStuckProcessingRowsForTesting( + inDb: Database.Database, + outDb: Database.Database, + session: Session, + reason: string, +): void { + resetStuckProcessingRows(inDb, outDb, session, reason); +} + function resetStuckProcessingRows( inDb: Database.Database, outDb: Database.Database, @@ -285,4 +295,15 @@ function resetStuckProcessingRows( }); } } + + // Drop the orphan 'processing' rows. Without this, the next sweep tick + // would re-read them, see the old status_changed timestamp, conclude the + // freshly respawned container is stuck, and SIGKILL it before its + // agent-runner has a chance to run clearStaleProcessingAcks() on startup. + // We're safe to write outbound.db here because we just killed the container + // that owned it (or it crashed and left no writer behind). + const cleared = deleteOrphanProcessingClaims(outDb); + if (cleared > 0) { + log.info('Cleared orphan processing claims', { sessionId: session.id, cleared, reason }); + } } From e56132d04a55d1ac105095b67b8aec67fbcbd498 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Thu, 30 Apr 2026 11:33:20 +0000 Subject: [PATCH 076/144] Remove SSH key copy step from root warning instructions Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 8693537..d44b367 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -158,11 +158,10 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')" printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')" printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')" - printf ' %s\n' "$(dim '4. Copy SSH keys to new user: cp -r ~/.ssh /home/nanoclaw/.ssh && chown -R nanoclaw:nanoclaw /home/nanoclaw/.ssh')" - printf ' %s\n' "$(dim '5. Log out: exit')" - printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" - printf ' %s\n' "$(dim '7. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" - printf ' %s\n\n' "$(dim '8. Re-run setup: bash nanoclaw.sh')" + printf ' %s\n' "$(dim '4. Log out: exit')" + printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')" + printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" + printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')" exit 1 ;; esac From 5be15be139fd221f46abb22b6610e39fa4007cd4 Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Thu, 30 Apr 2026 12:07:53 +0000 Subject: [PATCH 077/144] fix: prevent telegram pairing spinner from flooding the terminal The spinner label exceeded terminal width, breaking clack's cursor-up redraw and causing each animation tick to print a new line instead of updating in-place. Wrap with fitToWidth() like other setup spinners. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/channels/telegram.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 5681fd1..1aa7cb5 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -33,7 +33,7 @@ import { spawnStep, writeStepEntry, } from '../lib/runner.js'; -import { accentGreen, brandBold, note } from '../lib/theme.js'; +import { accentGreen, brandBold, fitToWidth, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -254,11 +254,11 @@ async function runPairTelegram(): Promise< stopSpinner("Old code expired. Here's a fresh one."); } note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); - s.start('Waiting for you to send the code from Telegram…'); + s.start(fitToWidth('Waiting for you to send the code from Telegram…', '')); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`); - s.start('Waiting for the correct code…'); + s.start(fitToWidth('Waiting for the correct code…', '')); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM') { if (block.fields.STATUS === 'success') { From 1db98ee614c36bbf09d279b3a7d5e70c2691a995 Mon Sep 17 00:00:00 2001 From: Gabi Date: Thu, 30 Apr 2026 12:36:25 +0000 Subject: [PATCH 078/144] refactor(setup): check env vars per-step instead of upfront all-or-nothing Remove the grouped detectExistingEnv() block that asked "reuse all or start fresh" at the top of setup. Each channel step now reads credentials directly from .env on disk via readEnvKey() and offers to reuse them individually at the point of use. - Add readEnvKey() helper in setup/environment.ts - Remove ENV_KEY_GROUPS, ExistingEnvGroup, detectExistingEnv from auto.ts - Move detectRegisteredGroups skip to right before cli-agent step - Switch all channel files (telegram, discord, slack, teams, imessage) from process.env to readEnvKey() Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/auto.ts | 88 +++----------------------------------- setup/channels/discord.ts | 3 +- setup/channels/imessage.ts | 5 ++- setup/channels/slack.ts | 5 ++- setup/channels/teams.ts | 9 ++-- setup/channels/telegram.ts | 3 +- setup/environment.ts | 24 +++++++++++ 7 files changed, 44 insertions(+), 93 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 392bc13..da5f194 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -122,39 +122,6 @@ async function main(): Promise { } } - // Detect existing .env and offer to reuse it so the user doesn't have to - // paste credentials again on a re-run. - const existingEnv = detectExistingEnv(); - if (existingEnv) { - const lines = Object.values(existingEnv.groups).map( - (g) => ` ${k.green('✓')} ${g.label}`, - ); - note(lines.join('\n'), 'Found existing configuration'); - - const reuseChoice = ensureAnswer( - await brightSelect({ - message: 'Use this existing environment?', - options: [ - { value: 'reuse', label: 'Yes, use what I already have', hint: 'recommended' }, - { value: 'fresh', label: 'No, start fresh' }, - ], - initialValue: 'reuse', - }), - ) as 'reuse' | 'fresh'; - setupLog.userInput('existing_env_choice', reuseChoice); - - if (reuseChoice === 'reuse') { - for (const [key, value] of Object.entries(existingEnv.raw)) { - if (!process.env[key]) process.env[key] = value; - } - if (existingEnv.groups.onecli) skip.add('onecli'); - if (detectRegisteredGroups(process.cwd())) { - skip.add('cli-agent'); - skip.add('first-chat'); - } - } - } - if (!skip.has('container')) { p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4))); p.log.message( @@ -344,6 +311,11 @@ async function main(): Promise { return displayName; } + if (!skip.has('cli-agent') && detectRegisteredGroups(process.cwd())) { + skip.add('cli-agent'); + skip.add('first-chat'); + } + if (!skip.has('cli-agent')) { await resolveDisplayName(); const res = await runQuietStep( @@ -1063,56 +1035,6 @@ async function askChannelChoice(): Promise { // ─── interactive / env helpers ───────────────────────────────────────── -interface ExistingEnvGroup { - label: string; - keys: string[]; -} - -const ENV_KEY_GROUPS: Record = { - onecli: { label: 'OneCLI', keys: ['ONECLI_URL'] }, - telegram: { label: 'Telegram', keys: ['TELEGRAM_BOT_TOKEN'] }, - discord: { label: 'Discord', keys: ['DISCORD_BOT_TOKEN', 'DISCORD_APPLICATION_ID', 'DISCORD_PUBLIC_KEY'] }, - slack: { label: 'Slack', keys: ['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET'] }, - signal: { label: 'Signal', keys: ['SIGNAL_ACCOUNT'] }, - teams: { label: 'Teams', keys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE'] }, - whatsapp: { label: 'WhatsApp', keys: ['ASSISTANT_HAS_OWN_NUMBER'] }, - imessage: { label: 'iMessage', keys: ['IMESSAGE_LOCAL', 'IMESSAGE_ENABLED', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY'] }, -}; - -function detectExistingEnv(): { groups: Record; raw: Record } | null { - const envPath = path.join(process.cwd(), '.env'); - if (!fs.existsSync(envPath)) return null; - - let content: string; - try { - content = fs.readFileSync(envPath, 'utf-8'); - } catch { - return null; - } - - const raw: Record = {}; - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eq = trimmed.indexOf('='); - if (eq < 1) continue; - raw[trimmed.slice(0, eq)] = trimmed.slice(eq + 1); - } - - if (Object.keys(raw).length === 0) return null; - - const groups: Record = {}; - for (const [id, def] of Object.entries(ENV_KEY_GROUPS)) { - const found = def.keys.filter((key) => raw[key] !== undefined); - if (found.length > 0) { - groups[id] = { label: def.label, keys: found }; - } - } - - if (Object.keys(groups).length === 0) return null; - return { groups, raw }; -} - function anthropicSecretExists(): boolean { try { const res = spawnSync('onecli', ['secrets', 'list'], { diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 3638e4e..0c6ff89 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -32,6 +32,7 @@ import { confirmThenOpen } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { accentGreen, brandBody, note } from '../lib/theme.js'; +import { readEnvKey } from '../environment.js'; const DEFAULT_AGENT_NAME = 'Nano'; const DISCORD_API = 'https://discord.com/api/v10'; @@ -240,7 +241,7 @@ async function walkThroughServerCreation(): Promise { } async function collectDiscordToken(): Promise { - const existing = process.env.DISCORD_BOT_TOKEN?.trim(); + const existing = readEnvKey('DISCORD_BOT_TOKEN'); if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) { const reuse = ensureAnswer(await p.confirm({ message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`, diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index a2654c0..8c0b78d 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -37,6 +37,7 @@ import { brightSelect } from '../lib/bright-select.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { accentGreen, note, wrapForGutter } from '../lib/theme.js'; +import { readEnvKey } from '../environment.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -222,8 +223,8 @@ async function walkThroughFullDiskAccess(): Promise { } async function collectRemoteCreds(): Promise { - const existingUrl = process.env.IMESSAGE_SERVER_URL?.trim(); - const existingKey = process.env.IMESSAGE_API_KEY?.trim(); + const existingUrl = readEnvKey('IMESSAGE_SERVER_URL'); + const existingKey = readEnvKey('IMESSAGE_API_KEY'); if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) { const reuse = ensureAnswer(await p.confirm({ message: `Found existing Photon credentials (${existingUrl}). Use them?`, diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 167fa72..fc786c5 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -29,6 +29,7 @@ import { confirmThenOpen } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { accentGreen, note, wrapForGutter } from '../lib/theme.js'; +import { readEnvKey } from '../environment.js'; const SLACK_API = 'https://slack.com/api'; const SLACK_APPS_URL = 'https://api.slack.com/apps'; @@ -151,7 +152,7 @@ async function walkThroughAppCreation(): Promise { } async function collectBotToken(): Promise { - const existing = process.env.SLACK_BOT_TOKEN?.trim(); + const existing = readEnvKey('SLACK_BOT_TOKEN'); if (existing && existing.startsWith('xoxb-') && existing.length >= 24) { const reuse = ensureAnswer(await p.confirm({ message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`, @@ -185,7 +186,7 @@ async function collectBotToken(): Promise { } async function collectSigningSecret(): Promise { - const existing = process.env.SLACK_SIGNING_SECRET?.trim(); + const existing = readEnvKey('SLACK_SIGNING_SECRET'); if (existing && /^[a-f0-9]{16,}$/i.test(existing)) { const reuse = ensureAnswer(await p.confirm({ message: 'Found an existing Slack signing secret. Use it?', diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index 01839c4..41e2070 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -42,6 +42,7 @@ import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { buildTeamsAppPackage } from '../lib/teams-manifest.js'; import { note } from '../lib/theme.js'; import * as setupLog from '../logs.js'; +import { readEnvKey } from '../environment.js'; const CHANNEL = 'teams'; const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams'); @@ -60,8 +61,8 @@ export async function runTeamsChannel(_displayName: string): Promise { const collected: Collected = {}; const completed: string[] = []; - const existingAppId = process.env.TEAMS_APP_ID?.trim(); - const existingPassword = process.env.TEAMS_APP_PASSWORD?.trim(); + const existingAppId = readEnvKey('TEAMS_APP_ID'); + const existingPassword = readEnvKey('TEAMS_APP_PASSWORD'); if (existingAppId && existingPassword) { const reuse = ensureAnswer(await p.confirm({ message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`, @@ -70,9 +71,9 @@ export async function runTeamsChannel(_displayName: string): Promise { if (reuse) { collected.appId = existingAppId; collected.appPassword = existingPassword; - collected.appType = (process.env.TEAMS_APP_TYPE?.trim() as 'SingleTenant' | 'MultiTenant') || 'MultiTenant'; + collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant'; if (collected.appType === 'SingleTenant') { - collected.tenantId = process.env.TEAMS_APP_TENANT_ID?.trim(); + collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined; } setupLog.userInput('teams_credentials', 'reused-existing'); await installAdapter(collected); diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 1aa7cb5..bcfe393 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -34,6 +34,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { accentGreen, brandBold, fitToWidth, note } from '../lib/theme.js'; +import { readEnvKey } from '../environment.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -132,7 +133,7 @@ export async function runTelegramChannel(displayName: string): Promise { } async function collectTelegramToken(): Promise { - const existing = process.env.TELEGRAM_BOT_TOKEN?.trim(); + const existing = readEnvKey('TELEGRAM_BOT_TOKEN'); if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) { const reuse = ensureAnswer(await p.confirm({ message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`, diff --git a/setup/environment.ts b/setup/environment.ts index c351023..5960b0e 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -11,6 +11,30 @@ import { log } from '../src/log.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { emitStatus } from './status.js'; +/** + * Read a single key from `.env` on disk (not process.env). + * Returns the trimmed value or null if the key isn't set / file doesn't exist. + */ +export function readEnvKey(key: string, projectRoot?: string): string | null { + const envPath = path.join(projectRoot ?? process.cwd(), '.env'); + let content: string; + try { + content = fs.readFileSync(envPath, 'utf-8'); + } catch { + return null; + } + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 1) continue; + if (trimmed.slice(0, eq) === key) { + return trimmed.slice(eq + 1).trim() || null; + } + } + return null; +} + export function detectExistingDisplayName(projectRoot: string): string | null { const dbPath = path.join(projectRoot, 'data', 'v2.db'); if (!fs.existsSync(dbPath)) return null; From a66cd545d531a32ffada17ca9f235201657cf808 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 13:32:27 +0000 Subject: [PATCH 079/144] feat(setup): switch elapsed-time suffixes to "Xm Ys" past 60s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `fmtDuration(ms)` helper in `setup/lib/theme.ts` that returns `47s` under a minute and `1m 34s` from 60s onward, then routes every elapsed-time spinner suffix in the setup flow through it. Replaces the inline `Math.round((Date.now() - start) / 1000)` + `(${elapsed}s)` pattern at every site. Format is consistent past 60s — `1m 0s` over `1m` — so the live spinner doesn't change shape at every whole-minute crossing. Sites updated: setup/auto.ts, setup/lib/{runner,tz-from-claude, claude-assist}.ts, and setup/channels/{signal,whatsapp,telegram, discord,slack}.ts. Pre-allocated suffix budgets in `fitToWidth` calls bumped from `' (999s)'` to `' (99m 59s)'` so long-running steps don't blow past the reserved width. --- setup/auto.ts | 10 ++++------ setup/channels/discord.ts | 20 +++++++------------- setup/channels/signal.ts | 5 ++--- setup/channels/slack.ts | 14 +++++--------- setup/channels/telegram.ts | 8 +++----- setup/channels/whatsapp.ts | 5 ++--- setup/lib/claude-assist.ts | 8 +++----- setup/lib/runner.ts | 10 ++++------ setup/lib/theme.ts | 16 ++++++++++++++++ setup/lib/tz-from-claude.ts | 10 ++++------ 10 files changed, 50 insertions(+), 56 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 392bc13..94ffe20 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -53,7 +53,7 @@ import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-clau import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { emit as phEmit } from './lib/diagnostics.js'; -import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; +import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; @@ -579,18 +579,16 @@ async function confirmAssistantResponds(): Promise { const s = p.spinner(); const start = Date.now(); const label = 'Waking your assistant…'; - s.start(fitToWidth(label, ' (999s)')); + s.start(fitToWidth(label, ' (99m 59s)')); const tick = setInterval(() => { - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; 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)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; if (result === 'ok') { s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`); } else { diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 3638e4e..c25f2de 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -31,7 +31,7 @@ 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'; -import { accentGreen, brandBody, note } from '../lib/theme.js'; +import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const DISCORD_API = 'https://discord.com/api/v10'; @@ -289,9 +289,8 @@ async function validateDiscordToken(token: string): Promise { 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)`)}`); + s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('discord-validate', 'success', Date.now() - start, { BOT_USERNAME: data.username, BOT_ID: data.id ?? '', @@ -309,8 +308,7 @@ async function validateDiscordToken(token: string): Promise { '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); + s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('discord-validate', 'failed', Date.now() - start, { ERROR: message, @@ -338,7 +336,6 @@ async function fetchApplicationInfo(token: string): Promise { 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); @@ -351,7 +348,7 @@ async function fetchApplicationInfo(token: string): Promise { '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)`)}`); + s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); // 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 = @@ -369,8 +366,7 @@ async function fetchApplicationInfo(token: string): Promise { owner, }; } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('discord-app-info', 'failed', Date.now() - start, { ERROR: message, @@ -479,7 +475,6 @@ async function openDmChannel(token: string, userId: string): Promise { 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); @@ -492,14 +487,13 @@ async function openDmChannel(token: string, userId: string): Promise { 'Make sure the bot is in a server you\'re also in, then retry setup.', ); } - s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); 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); + s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('discord-open-dm', 'failed', Date.now() - start, { ERROR: message, diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 0c5718e..8462a56 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -44,7 +44,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { accentGreen, note } from '../lib/theme.js'; +import { accentGreen, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -324,8 +324,7 @@ async function restartService(): Promise { // 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)`)}`); + s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('signal-restart', 'success', Date.now() - start, { PLATFORM: platform, }); diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 167fa72..340eabc 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -28,7 +28,7 @@ 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'; -import { accentGreen, note, wrapForGutter } from '../lib/theme.js'; +import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js'; const SLACK_API = 'https://slack.com/api'; const SLACK_APPS_URL = 'https://api.slack.com/apps'; @@ -241,10 +241,9 @@ async function validateSlackToken(token: string): Promise { 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)`)}`, + `Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, ); const info: WorkspaceInfo = { teamName: data.team, @@ -273,8 +272,7 @@ async function validateSlackToken(token: string): Promise { : `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); + s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('slack-validate', 'failed', Date.now() - start, { ERROR: message, @@ -334,9 +332,8 @@ async function openDmChannel(token: string, userId: string): Promise { channel?: { id?: string }; error?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (data.ok && data.channel?.id) { - s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('slack-open-dm', 'success', Date.now() - start, { DM_CHANNEL_ID: data.channel.id, }); @@ -360,8 +357,7 @@ async function openDmChannel(token: string, userId: string): Promise { `Slack said "${reason}". Check the member ID and app permissions, then retry.`, ); } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('slack-open-dm', 'failed', Date.now() - start, { ERROR: message, diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 1aa7cb5..ad749eb 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -33,7 +33,7 @@ import { spawnStep, writeStepEntry, } from '../lib/runner.js'; -import { accentGreen, brandBold, fitToWidth, note } from '../lib/theme.js'; +import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -191,10 +191,9 @@ async function validateTelegramToken(token: string): Promise { 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(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`Found your bot: @${username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('telegram-validate', 'success', Date.now() - start, { BOT_USERNAME: username, BOT_ID: data.result.id ?? '', @@ -212,8 +211,7 @@ async function validateTelegramToken(token: string): Promise { 'Copy the token again from @BotFather and try setup once more.', ); } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('telegram-validate', 'failed', Date.now() - start, { ERROR: message, diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 96d23d5..922c985 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -46,7 +46,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { accentGreen, brandBody, brandBold, note } from '../lib/theme.js'; +import { accentGreen, brandBody, brandBold, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); @@ -379,8 +379,7 @@ async function restartService(): Promise { // 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)`)}`); + s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('whatsapp-restart', 'success', Date.now() - start, { PLATFORM: platform, }); diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index e76a4fc..187377e 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -28,7 +28,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { ensureAnswer } from './runner.js'; -import { brandBody, fitToWidth, note } from './theme.js'; +import { brandBody, fitToWidth, fmtDuration, note } from './theme.js'; export interface AssistContext { stepName: string; @@ -295,9 +295,8 @@ async function queryClaudeUnderSpinner( // 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 suffix = ` (${fmtDuration(Date.now() - start)})`; const header = fitToWidth('Asking Claude to diagnose…', suffix); out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`); @@ -355,8 +354,7 @@ async function queryClaudeUnderSpinner( clearBlock(); out.write(SHOW_CURSOR); process.off('exit', restoreCursorOnExit); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; if (kind === 'ok') { p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`); resolve(payload); diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index cf7a86d..6ffffed 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -20,7 +20,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 { brandBody, fitToWidth } from './theme.js'; +import { brandBody, fitToWidth, fmtDuration } from './theme.js'; export type Fields = Record; export type Block = { type: string; fields: Fields }; @@ -307,18 +307,16 @@ async function runUnderSpinner< ): Promise { const s = p.spinner(); const start = Date.now(); - s.start(fitToWidth(labels.running, ' (999s)')); + s.start(fitToWidth(labels.running, ' (99m 59s)')); const tick = setInterval(() => { - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; 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)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; if (result.ok) { const isSkipped = result.terminal?.fields.STATUS === 'skipped'; const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index 0dfa53f..2c80c8a 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -51,6 +51,22 @@ export function accentGreen(s: string): string { return k.green(s); } +/** + * Format an elapsed-time duration (in milliseconds) for the spinner + * suffixes setup writes everywhere. Sub-minute durations stay in plain + * seconds (`47s`); once the timer crosses 60 seconds we switch to the + * `Xm Ys` form (`2m 34s`) so a long step doesn't read as `247s` or + * similar. The format is consistent above 60s — `4m 0s` over `4m` — + * so live spinner output doesn't change shape at every whole minute. + */ +export function fmtDuration(ms: number): string { + const totalSec = Math.round(ms / 1000); + if (totalSec < 60) return `${totalSec}s`; + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${m}m ${s}s`; +} + /** * Brand body color for setup-flow prose. Used for card bodies (via the * `note()` formatter) and `p.log.*` body arguments — anywhere the diff --git a/setup/lib/tz-from-claude.ts b/setup/lib/tz-from-claude.ts index 5486fbb..f861f64 100644 --- a/setup/lib/tz-from-claude.ts +++ b/setup/lib/tz-from-claude.ts @@ -17,7 +17,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { isValidTimezone } from '../../src/timezone.js'; -import { fitToWidth } from './theme.js'; +import { fitToWidth, fmtDuration } from './theme.js'; export function claudeCliAvailable(): boolean { try { @@ -44,18 +44,16 @@ export async function resolveTimezoneViaClaude( const s = p.spinner(); const start = Date.now(); const label = 'Looking up that timezone…'; - s.start(fitToWidth(label, ' (999s)')); + s.start(fitToWidth(label, ' (99m 59s)')); const tick = setInterval(() => { - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; 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 suffix = ` (${fmtDuration(Date.now() - start)})`; const resolved = reply ? extractTimezone(reply) : null; if (resolved) { From 4d42bb95fb56bb264a44c345d5a2e51ec29a60cf Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 30 Apr 2026 10:03:36 +0000 Subject: [PATCH 080/144] feat(setup): skip browser-open prompts on headless devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the existing `isHeadless()` from setup/platform.ts into `confirmThenOpen`. When the helper detects a headless device (Linux without `DISPLAY`/`WAYLAND_DISPLAY`), both the "Press Enter to open your browser" prompt and the actual `openUrl(...)` call are skipped — there's no browser to launch and the user can't usefully press Enter to summon one. Why this is enough — the surrounding flow already supports the headless path implicitly: - Every `confirmThenOpen` call site sits beneath a `note(...)` that prints the URL and the steps the user needs to take. The URL is already visible to copy-paste onto another device. - Every site is followed by an explicit confirmation prompt ("Got your bot token?", "Done with the X?", etc.) that naturally serves as the headless user's "I finished the thing on my other device" signal. So the headless branch becomes: read the note, do the thing, answer the next prompt — without a useless "Press Enter to open your browser" detour in between. Coverage rationale (~95% accurate for the cases that actually cause user confusion today): - Linux + no `DISPLAY`/`WAYLAND_DISPLAY` → headless. Catches: • Raspberry Pi headless installs • Bare-metal Linux servers • SSH'd into Linux without X11 forwarding • CI environments on Linux • Linux containers (which have no display) - macOS → never headless. Even SSH'd Macs can usually still open URLs through the local user's session, so treating them as GUI-capable is the right default. - Windows → never headless (effectively always GUI in practice). The remaining ~5% are edge cases (someone manually unset `DISPLAY` on a desktop Linux session, etc.) that almost never happen accidentally and recover gracefully — the URL is still visible in the surrounding note. Six call sites in channel adapters (Discord ×3, Slack ×1, Telegram ×1, Teams ×1) all change behavior atomically through the single helper. No per-site copy changes needed; consistency is enforced by the central wiring. --- setup/lib/browser.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/setup/lib/browser.ts b/setup/lib/browser.ts index 9d801fa..fc6eb17 100644 --- a/setup/lib/browser.ts +++ b/setup/lib/browser.ts @@ -9,12 +9,18 @@ * `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. + * Enter when they're ready. On headless devices (no graphical session + * available) it skips both the prompt and the open: there's no browser + * to launch, the surrounding `note(...)` already shows the URL for + * copy-paste on another device, and the next prompt in the channel + * flow ("Got your bot token?" etc.) provides the natural completion + * confirmation. */ import { spawn } from 'child_process'; import * as p from '@clack/prompts'; +import { isHeadless } from '../platform.js'; import { ensureAnswer } from './runner.js'; /** Best-effort open of a URL in the user's default browser. Silent on failure. */ @@ -35,12 +41,15 @@ export function openUrl(url: string): void { /** * 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. + * URL from the note that precedes the prompt. On headless devices both + * the prompt and the open are skipped — there's no browser to time + * focus for, and the URL is already visible in the surrounding note. */ export async function confirmThenOpen( url: string, message = 'Press Enter to open your browser', ): Promise { + if (isHeadless()) return; ensureAnswer( await p.confirm({ message, From 6863e0f63bbbd6e52622f0fc99bf74c66c525bae Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 30 Apr 2026 10:31:43 +0000 Subject: [PATCH 081/144] feat(setup): label headless URL fallback with "Get started:" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a card's auto-open is gated on `confirmThenOpen`, the URL also appears inside the surrounding `note(...)` as a copy-paste fallback — rendered dim because on a GUI device the auto-open is doing the heavy lifting and the printed URL is just an incidental backup. On headless devices the auto-open doesn't run (per #2145), so the URL inside the note is the user's *only* path forward. A dim URL reads as "incidental reference" exactly when it should be reading as "this is the action." Adds `formatNoteLink(url)` to setup/lib/browser.ts: - GUI device → `k.dim(url)` (unchanged from today) - Headless device → `Get started: ` at full strength Replaces five call sites (Discord ×3, Slack ×1, Telegram ×1). Single helper, atomic switch via the same `isHeadless()` plumbing introduced in #2145, so the headless behavior across all five flows stays in sync. --- setup/channels/discord.ts | 8 ++++---- setup/channels/slack.ts | 4 ++-- setup/channels/telegram.ts | 4 ++-- setup/lib/browser.ts | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index c25f2de..1dec8e0 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -28,7 +28,7 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { brightSelect } from '../lib/bright-select.js'; -import { confirmThenOpen } from '../lib/browser.js'; +import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js'; @@ -165,7 +165,7 @@ async function walkThroughBotCreation(): Promise { ' 3. On the same tab, enable "Message Content Intent"', ' (under Privileged Gateway Intents)', '', - k.dim(url), + formatNoteLink(url), ].join('\n'), 'Create a Discord bot', ); @@ -225,7 +225,7 @@ async function walkThroughServerCreation(): Promise { ' 2. Choose "Create My Own" → "For me and my friends"', ' 3. Give it any name (e.g. "NanoClaw")', '', - k.dim(url), + formatNoteLink(url), ].join('\n'), 'Create a Discord server', ); @@ -447,7 +447,7 @@ async function promptInviteBot( ' 1. Pick any server you\'re in (a personal one is fine)', ' 2. Click "Authorize"', '', - k.dim(url), + formatNoteLink(url), ].join('\n'), 'Add bot to a server', ); diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 340eabc..03cbf46 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -25,7 +25,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { confirmThenOpen } from '../lib/browser.js'; +import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js'; @@ -136,7 +136,7 @@ async function walkThroughAppCreation(): Promise { ' 4. Basic Information → copy the "Signing Secret"', ' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)', '', - k.dim(SLACK_APPS_URL), + formatNoteLink(SLACK_APPS_URL), ].join('\n'), 'Create a Slack app', ); diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index ad749eb..7130f8b 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -21,7 +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 { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { type Block, @@ -51,7 +51,7 @@ export async function runTelegramChannel(displayName: string): Promise { [ `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, '', - k.dim(botUrl), + formatNoteLink(botUrl), ].join('\n'), 'Open Telegram', ); diff --git a/setup/lib/browser.ts b/setup/lib/browser.ts index fc6eb17..4fbcbd7 100644 --- a/setup/lib/browser.ts +++ b/setup/lib/browser.ts @@ -19,6 +19,7 @@ import { spawn } from 'child_process'; import * as p from '@clack/prompts'; +import k from 'kleur'; import { isHeadless } from '../platform.js'; import { ensureAnswer } from './runner.js'; @@ -38,6 +39,21 @@ export function openUrl(url: string): void { } } +/** + * Format a URL for display inside a setup `note(...)` card. On + * GUI devices the URL renders dim — it's a fallback in case the + * auto-open misses, and `confirmThenOpen` is doing the heavy + * lifting of getting the user there. On headless devices the + * URL becomes the user's only path forward, so we surface it + * with a "Get started:" label and full-strength text — copy- + * pasting onto another device is the actual action, not an + * incidental reference. + */ +export function formatNoteLink(url: string): string { + if (isHeadless()) return `Get started: ${url}`; + return k.dim(url); +} + /** * 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 From cb15e606c3115f6958c1788ce2d9caa7c413a1c9 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 30 Apr 2026 11:11:43 +0000 Subject: [PATCH 082/144] feat(setup): move URL fallback into the open-browser prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On GUI devices the URL was previously rendered dim inside the instructional `note(...)` card, then `confirmThenOpen` printed its prompt below: read the card, see the URL, then a separate "Press Enter to open the X" prompt with no link near it. Two visual moments for what's really one decision. This PR pulls the URL out of the card on GUI devices and relocates it directly under the action line of the confirm prompt, separated only by a dim "If browser does not appear, please visit: " line: │ ◆ Press Enter to open the Developer Portal │ If browser does not appear, please visit: … (dim) │ ● Yes / ○ No │ Action and fallback live as one prompt block — the user sees both at the same time, no need to scroll back up to grab the URL if the auto-open misses. Headless behavior is unchanged: `formatNoteLink` still emits "Get started: " inside the card on headless devices (per #2146), and `confirmThenOpen` still no-ops on headless (per #2145). The only thing that changed for headless is the leading `\n` in the helper output, which acts as a visual separator from the steps above. Five call sites adjusted (Discord ×3, Slack ×1, Telegram ×1) to use `.filter((line) => line !== null)` so the now-nullable `formatNoteLink` cleanly drops out of GUI-rendered cards. --- setup/channels/discord.ts | 9 +++------ setup/channels/slack.ts | 3 +-- setup/channels/telegram.ts | 3 +-- setup/lib/browser.ts | 39 ++++++++++++++++++++++---------------- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 1dec8e0..435956f 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -164,9 +164,8 @@ async function walkThroughBotCreation(): Promise { ' 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)', - '', formatNoteLink(url), - ].join('\n'), + ].filter((line): line is string => line !== null).join('\n'), 'Create a Discord bot', ); await confirmThenOpen(url, 'Press Enter to open the Developer Portal'); @@ -224,9 +223,8 @@ async function walkThroughServerCreation(): Promise { ' 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")', - '', formatNoteLink(url), - ].join('\n'), + ].filter((line): line is string => line !== null).join('\n'), 'Create a Discord server', ); await confirmThenOpen(url, 'Press Enter to open Discord'); @@ -446,9 +444,8 @@ async function promptInviteBot( '', ' 1. Pick any server you\'re in (a personal one is fine)', ' 2. Click "Authorize"', - '', formatNoteLink(url), - ].join('\n'), + ].filter((line): line is string => line !== null).join('\n'), 'Add bot to a server', ); await confirmThenOpen(url, 'Press Enter to open the invite page'); diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 03cbf46..24a10ce 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -135,9 +135,8 @@ async function walkThroughAppCreation(): Promise { ' 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-…)', - '', formatNoteLink(SLACK_APPS_URL), - ].join('\n'), + ].filter((line): line is string => line !== null).join('\n'), 'Create a Slack app', ); await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings'); diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 7130f8b..799a97f 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -50,9 +50,8 @@ export async function runTelegramChannel(displayName: string): Promise { note( [ `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, - '', formatNoteLink(botUrl), - ].join('\n'), + ].filter((line): line is string => line !== null).join('\n'), 'Open Telegram', ); await confirmThenOpen(botUrl, 'Press Enter to open Telegram'); diff --git a/setup/lib/browser.ts b/setup/lib/browser.ts index 4fbcbd7..7c5c970 100644 --- a/setup/lib/browser.ts +++ b/setup/lib/browser.ts @@ -40,35 +40,42 @@ export function openUrl(url: string): void { } /** - * Format a URL for display inside a setup `note(...)` card. On - * GUI devices the URL renders dim — it's a fallback in case the - * auto-open misses, and `confirmThenOpen` is doing the heavy - * lifting of getting the user there. On headless devices the - * URL becomes the user's only path forward, so we surface it - * with a "Get started:" label and full-strength text — copy- - * pasting onto another device is the actual action, not an - * incidental reference. + * Format a URL for inclusion in a setup `note(...)` card. On + * headless devices we surface the URL inside the card with a + * "Get started:" label at full strength — copy-pasting onto + * another device is the actual action, not an incidental + * reference. The leading `\n` acts as a visual separator from + * the body steps above; callers `.filter(line => line !== null)` + * before joining, so on GUI we drop the line entirely (and the + * URL ends up below the next-step confirm prompt as a "if + * browser does not appear, please visit" fallback — see + * `confirmThenOpen`). */ -export function formatNoteLink(url: string): string { - if (isHeadless()) return `Get started: ${url}`; - return k.dim(url); +export function formatNoteLink(url: string): string | null { + if (isHeadless()) return `\nGet started: ${url}`; + return null; } /** * 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. On headless devices both - * the prompt and the open are skipped — there's no browser to time - * focus for, and the URL is already visible in the surrounding note. + * to take focus. Proceeds on cancel as well. On headless devices both the + * prompt and the open are skipped — the URL is already surfaced inside + * the surrounding note (via `formatNoteLink`). + * + * On GUI devices the confirm message includes the fallback URL on the + * lines below the action ("If browser does not appear, please visit: + * " in dim) so the user has a copy-paste path right next to the + * action button without needing to scroll back up to the card. */ export async function confirmThenOpen( url: string, message = 'Press Enter to open your browser', ): Promise { if (isHeadless()) return; + const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`; ensureAnswer( await p.confirm({ - message, + message: `${message}${fallback}`, initialValue: true, }), ); From e51f6e0c4132bb7dec8facfbd5b7d50a26c590f6 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 30 Apr 2026 13:21:38 +0000 Subject: [PATCH 083/144] feat(setup): show under-the-sea lobster splash at boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single-line `NanoClaw` wordmark printed by nanoclaw.sh with a multi-line splash frame: the lobster mascot rendered as truecolor braille, drifting bubbles on either side, the figlet wordmark below (Nano in bold, Claw in cyan bold), three taglines — "Small.", "Runs on your machine.", "Yours to modify." — and a navy seafloor line. The frame is pre-rendered into `assets/setup-splash.txt` (built from `assets/nanoclaw-icon.png` via chafa for the lobster + figlet for the wordmark). nanoclaw.sh just streams the literal bytes — no runtime dependency on chafa, figlet, or ImageMagick. Total height: 30 lines. Visible width: ~40 columns (fits any terminal). Truecolor ANSI codes are used directly; terminals without truecolor support will see a degraded but still readable frame. Also removes the standalone "Small. Runs on your machine. Yours to modify." tagline line that nanoclaw.sh used to print above the bootstrap spinner — those taglines now appear inside the splash, so showing them again would duplicate. The wordmark-suppression flow downstream (`setup:auto` honoring `NANOCLAW_BOOTSTRAPPED=1`) is unchanged: the splash prints once in nanoclaw.sh, setup:auto's `printIntro()` sees the flag and keeps the clack `p.intro` line clean ("Let's get you set up."). --- assets/setup-splash.txt | 30 ++++++++++++++++++++++++++++++ nanoclaw.sh | 14 +++++++------- 2 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 assets/setup-splash.txt diff --git a/assets/setup-splash.txt b/assets/setup-splash.txt new file mode 100644 index 0000000..e4b77ec --- /dev/null +++ b/assets/setup-splash.txt @@ -0,0 +1,30 @@ + + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⣄⠘⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⡆⢸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ° + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠀⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⠀⠀⠀⢀⣤⣤⠀⢀⣄⠀ + ⠀⠀⠀⠀⣴⡿⡋⠀⠀⠀⠀⠀⢤⣾⣿⢛⢿⣏⠀⠀⠀⢰⣟⣽⡏⠀⣸⡿⣧ + o ⠀⠀⢀⣾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠘⠈⣧⣀⣿⣧⠀⠀⣿⣼⣿⣇⣾⠋⢠⣿ + ⠀⠀⣾⢃⠀⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⠀⣿⢻⣉⠉⠙⠠⣼⠇ + ⠀⣼⡏⠃⠀⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋⠀ o + ⢠⣿⡃⠀⠀⠀⠀⠀⠈⠀⠀⠉⠙⠁⠀⠀⠀⠐⣿⣿⣟⠁⣿⣿⠟⠋⠀⠀⠀ + ° ⢸⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀ + ⢸⣿⣿⣷⣤⣤⠀⣀⢀⠀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀ + ⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀ O + ⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠀⠀⠩⢻⣆⠀⠀⠀⠀⠀⠀⠀⠀ + ⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⠀⠀⣿⡆⠀⠀⠀⠀⠀⠀⠀ + ⠀⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠀⠠⣤⣿⡇⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄⠀⠀⠀⠀⠀⠀ + o ⠀⠀⠀⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋⠀⠀⠀⠀⠀ + + _ _  ___ _  +| \| |__ _ _ _ ___  / __| |__ ___ __ __ +| .` / _` | ' \/ _ \| (__| / _` \ V V / +|_|\_\__,_|_||_\___/ \___|_\__,_|\_/\_/  + + Small. + Runs on your machine. + Yours to modify. + +════════════════════════════════════════ diff --git a/nanoclaw.sh b/nanoclaw.sh index 058dbbf..a3e5c1d 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -129,10 +129,13 @@ rm -f "$PROGRESS_LOG" mkdir -p "$STEPS_DIR" "$LOGS_DIR" write_header -# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing, -# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and -# skips re-printing the wordmark, keeping the flow visually continuous. -printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" +# NanoClaw splash — under-the-sea lobster mascot in truecolor braille, +# with the figlet wordmark and taglines below. Pre-rendered into +# assets/setup-splash.txt (built from assets/nanoclaw-icon.png via chafa + +# figlet); the bash script just streams the literal frame. clack's intro +# then carries the "let's get you set up" framing — setup:auto sees +# NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark. +cat "$PROJECT_ROOT/assets/setup-splash.txt" # ─── pre-flight: Homebrew on macOS ───────────────────────────────────── # setup/install-node.sh and setup/install-docker.sh both require `brew` on @@ -188,9 +191,6 @@ BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log" 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 "Small. Runs on your machine. Yours to modify.")" spinner_start "$BOOTSTRAP_LABEL" # Run in the background so we can tick elapsed time. Capture exit code via From ccfdf2dd7576603ba9832a5eeed430ee43e2e35a Mon Sep 17 00:00:00 2001 From: Claw <728255-_ky@users.noreply.gitlab.com> Date: Thu, 30 Apr 2026 15:06:01 -0400 Subject: [PATCH 084/144] fix(agent-runner): open inbound.db fresh per messages_in read Cached singleton can return stale rows on virtiofs/NFS mounts, causing follow-up messages to silently never be polled. Add openInboundDb() with mmap_size=0 and switch the three messages_in readers to it. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/db/connection.ts | 28 +++++++- container/agent-runner/src/db/messages-in.ts | 75 ++++++++++++-------- 2 files changed, 71 insertions(+), 32 deletions(-) diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 3f0e73b..3ca44a8 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -28,11 +28,37 @@ let _inbound: Database | null = null; let _outbound: Database | null = null; let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH; -/** Inbound DB — container opens read-only (host is the sole writer). */ +/** + * Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled. + * + * Use this (not getInboundDb) for readers that need to see host-written rows + * promptly — e.g. messages_in polling. Caller must .close() the returned + * connection (try/finally). + * + * Needed for mounts where host writes don't reliably invalidate + * SQLite's caches: virtiofs (Colima, Lima, Podman Machine, Apple + * Container), NFS. + * + * Cost is microseconds per query, so safe for universal use. + */ +export function openInboundDb(): Database { + const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('PRAGMA mmap_size = 0'); + return db; +} + +/** + * Inbound DB — long-lived singleton, OK for tables the host writes once + * at spawn and never again (destinations, session_routing). For + * messages_in polling — where the host writes continuously and a stale + * view causes the pollHandle hang — use `openInboundDb()` instead. + */ export function getInboundDb(): Database { if (!_inbound) { _inbound = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); _inbound.exec('PRAGMA busy_timeout = 5000'); + _inbound.exec('PRAGMA mmap_size = 0'); } return _inbound; } diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 4ecf818..88906ed 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -8,7 +8,7 @@ * processing_ack. The host reads processing_ack to sync message lifecycle. */ import { getConfig } from '../config.js'; -import { getInboundDb, getOutboundDb } from './connection.js'; +import { openInboundDb, getOutboundDb } from './connection.js'; export interface MessageInRow { id: string; @@ -50,31 +50,35 @@ function getMaxMessagesPerPrompt(): number { * trigger=1 separately (see src/db/session-db.ts). */ export function getPendingMessages(): MessageInRow[] { - const inbound = getInboundDb(); + const inbound = openInboundDb(); const outbound = getOutboundDb(); - const pending = inbound - .prepare( - `SELECT * FROM messages_in - WHERE status = 'pending' - AND (process_after IS NULL OR datetime(process_after) <= datetime('now')) - ORDER BY seq DESC - LIMIT ?`, - ) - .all(getMaxMessagesPerPrompt()) as MessageInRow[]; + try { + const pending = inbound + .prepare( + `SELECT * FROM messages_in + WHERE status = 'pending' + AND (process_after IS NULL OR datetime(process_after) <= datetime('now')) + ORDER BY seq DESC + LIMIT ?`, + ) + .all(getMaxMessagesPerPrompt()) as MessageInRow[]; - if (pending.length === 0) return []; + if (pending.length === 0) return []; - // Filter out messages already acknowledged in outbound.db - const ackedIds = new Set( - (outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map( - (r) => r.message_id, - ), - ); + // Filter out messages already acknowledged in outbound.db + const ackedIds = new Set( + (outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map( + (r) => r.message_id, + ), + ); - // 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(); + // 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(); + } finally { + inbound.close(); + } } /** Mark messages as processing — writes to processing_ack in outbound.db. */ @@ -112,7 +116,12 @@ export function markFailed(id: string): void { /** Get a message by ID (read from inbound.db). */ export function getMessageIn(id: string): MessageInRow | undefined { - return getInboundDb().prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined; + const inbound = openInboundDb(); + try { + return inbound.prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined; + } finally { + inbound.close(); + } } /** @@ -120,19 +129,23 @@ export function getMessageIn(id: string): MessageInRow | undefined { * Reads from inbound.db, checks processing_ack to skip already-handled responses. */ export function findQuestionResponse(questionId: string): MessageInRow | undefined { - const inbound = getInboundDb(); + const inbound = openInboundDb(); const outbound = getOutboundDb(); - const response = inbound - .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?") - .get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined; + try { + const response = inbound + .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?") + .get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined; - if (!response) return undefined; + if (!response) return undefined; - // Check it hasn't been acked already - const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id); - if (acked) return undefined; + // Check it hasn't been acked already + const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id); + if (acked) return undefined; - return response; + return response; + } finally { + inbound.close(); + } } From 0218159ef032934730b09e9d9e3634192a3d0d2f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 19:54:21 +0000 Subject: [PATCH 085/144] chore: bump version to 2.0.20 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2b36863..556269c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.19", + "version": "2.0.20", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 46cd91c306368da416308b2d449db984b817ff61 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 19:54:26 +0000 Subject: [PATCH 086/144] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?38k=20tokens=20=C2=B7=2069%=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 86587f7..5832849 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 135k tokens, 68% of context window + + 138k tokens, 69% of context window @@ -15,8 +15,8 @@ tokens - - 135k + + 138k From 3d6a9b74f3cec320b93293bff85d4872bfbeb985 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 30 Apr 2026 23:16:34 +0300 Subject: [PATCH 087/144] review: surface ping-test cleanup failures + restore copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes the post-ping `_ping-test` cleanup through `spawnQuiet` + `setupLog.step` so a non-zero exit from `delete-cli-agent.ts` lands in `logs/setup-steps/cleanup-cli-agent.log` and the progression log, and prints a one-line warn to the user. Previously the spawnSync was fire-and-forget with `stdio: 'ignore'`, leaving an orphan agent group silently if cleanup failed. Restores the original copy on the cli-agent step labels, the ping explainer paragraph, and the post-ping spinner stop line — those copy changes are out of scope for this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 846baef..2610c23 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -51,10 +51,11 @@ import { pollHealth } from './onecli.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.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 { ensureAnswer, fail, runQuietChild, runQuietStep, spawnQuiet } from './lib/runner.js'; import { emit as phEmit } from './lib/diagnostics.js'; import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; + const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); @@ -320,8 +321,8 @@ async function main(): Promise { const res = await runQuietStep( 'cli-agent', { - running: 'Preparing connection test…', - done: 'Ready to test.', + running: 'Bringing your assistant online…', + done: 'Assistant wired up.', }, ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME, '--folder', '_ping-test'], ); @@ -336,7 +337,7 @@ async function main(): Promise { p.log.message( brandBody( dimWrap( - 'Checking your assistant can respond — first startup takes 30–60 seconds.', + "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, ), ), @@ -344,9 +345,27 @@ async function main(): Promise { const ping = await confirmAssistantResponds(); if (ping === 'ok') { phEmit('first_chat_ready'); - spawnSync('pnpm', ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', '_ping-test'], { - stdio: 'ignore', - }); + const cleanupRawLog = setupLog.stepRawLog('cleanup-cli-agent'); + const cleanupStart = Date.now(); + const cleanup = await spawnQuiet( + 'pnpm', + ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', '_ping-test'], + cleanupRawLog, + ); + setupLog.step( + 'cleanup-cli-agent', + cleanup.ok ? 'success' : 'failed', + Date.now() - cleanupStart, + { exit_code: cleanup.exitCode }, + cleanupRawLog, + ); + if (!cleanup.ok) { + p.log.warn( + brandBody( + `Couldn't clean up the test agent — it may still appear in your agent list. See ${cleanupRawLog} for details.`, + ), + ); + } const next = ensureAnswer( await brightSelect<'continue' | 'chat'>({ message: 'What next?', @@ -580,7 +599,7 @@ async function confirmAssistantResponds(): Promise { clearInterval(tick); const suffix = ` (${fmtDuration(Date.now() - start)})`; if (result === 'ok') { - s.stop(`${k.bold(fitToWidth('Connection verified.', 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."; From 58d875b3c35dec20695b270480a62c7143f62b62 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 21:31:18 +0000 Subject: [PATCH 088/144] chore: bump version to 2.0.21 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 556269c..ecdc1aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.20", + "version": "2.0.21", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 5ab1a2733c0fde0eb6879180b026e06e9f8e9725 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 1 May 2026 00:55:46 +0300 Subject: [PATCH 089/144] review: catch follow-up poll errors + re-check done before push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes on top of the follow-up pre-task-script work: 1. The void async IIFE inside the interval handler had no catch, so a throw from the dynamic import or applyPreTaskScripts escaped as an unhandled rejection — terminating the container. The initial-batch path is wrapped by processQuery's outer try/catch; the follow-up path needs its own. Now logs the error and lets the next tick retry. 2. Re-check `done` immediately before query.push. The flag can flip true while applyPreTaskScripts is awaited (outer stream finishes during the script execution); without the re-check we'd push into a closed query. Claimed messages get released by the host's processing-claim sweep — same recovery posture as the rest of the poller. Co-Authored-By: Michael Zazon Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 589b80b..986489f 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -302,12 +302,23 @@ async function processQuery( // MODULE-HOOK:scheduling-pre-task-followup:end if (keep.length === 0) return; + // Re-check done — the outer query may have finished while the script + // was awaited. Pushing into a closed stream is wasted work; the + // claimed messages get released by the host's processing-claim sweep. + if (done) return; const keptIds = keep.map((m) => m.id); const prompt = formatMessages(keep); log(`Pushing ${keep.length} follow-up message(s) into active query`); query.push(prompt); markCompleted(keptIds); + } catch (err) { + // Without this catch the rejection escapes the void IIFE and Node + // terminates the container on unhandled-rejection. The initial-batch + // path is wrapped by processQuery's outer try/catch; the follow-up + // path is not, so it needs its own. + const errMsg = err instanceof Error ? err.message : String(err); + log(`Follow-up poll error: ${errMsg}`); } finally { pollInFlight = false; } From 8977f0d0be1a4bf9dd6c1bc1ad5c2ec4c96614de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 21:57:45 +0000 Subject: [PATCH 090/144] chore: bump version to 2.0.22 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ecdc1aa..df9cb99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.21", + "version": "2.0.22", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 852009dcb15979fb77a012fd3d80b4565528a77a Mon Sep 17 00:00:00 2001 From: hinotoi-agent Date: Sat, 25 Apr 2026 11:38:12 +0800 Subject: [PATCH 091/144] fix(container): confine outbound attachment paths --- src/host-core.test.ts | 54 ++++++++++++++++++++++++++++++++++++ src/session-manager.ts | 63 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 110 insertions(+), 7 deletions(-) diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 2bb72d4..cbbaf27 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -23,6 +23,8 @@ import { sessionDir, inboundDbPath, outboundDbPath, + readOutboxFiles, + clearOutbox, } from './session-manager.js'; import { getSession, findSession } from './db/sessions.js'; import type { InboundEvent } from './channels/adapter.js'; @@ -108,6 +110,58 @@ describe('session manager', () => { outDb.close(); }); + it('should reject outbound attachment filenames that escape the message outbox', () => { + initSessionFolder('ag-1', 'sess-test'); + const dir = sessionDir('ag-1', 'sess-test'); + const msgOutbox = path.join(dir, 'outbox', 'msg-1'); + fs.mkdirSync(msgOutbox, { recursive: true }); + + const outside = path.join(TEST_DIR, 'outside.txt'); + fs.writeFileSync(outside, 'outside secret'); + + expect(readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['../../../../../outside.txt'])).toBeUndefined(); + }); + + it('should reject outbound attachment symlinks that escape the message outbox', () => { + initSessionFolder('ag-1', 'sess-test'); + const dir = sessionDir('ag-1', 'sess-test'); + const msgOutbox = path.join(dir, 'outbox', 'msg-1'); + fs.mkdirSync(msgOutbox, { recursive: true }); + + const outside = path.join(TEST_DIR, 'outside.txt'); + fs.writeFileSync(outside, 'outside secret'); + fs.symlinkSync('../../../../../outside.txt', path.join(msgOutbox, 'safe-name.txt')); + + expect(readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['safe-name.txt'])).toBeUndefined(); + }); + + it('should not recursively delete outside the outbox for unsafe message ids', () => { + initSessionFolder('ag-1', 'sess-test'); + const victimDir = path.join(TEST_DIR, 'victim-dir'); + fs.mkdirSync(victimDir, { recursive: true }); + fs.writeFileSync(path.join(victimDir, 'keep.txt'), 'do not delete'); + + clearOutbox('ag-1', 'sess-test', '../../../../victim-dir'); + + expect(fs.existsSync(path.join(victimDir, 'keep.txt'))).toBe(true); + }); + + it('should still read and clear normal basename outbox files', () => { + initSessionFolder('ag-1', 'sess-test'); + const dir = sessionDir('ag-1', 'sess-test'); + const msgOutbox = path.join(dir, 'outbox', 'msg-1'); + fs.mkdirSync(msgOutbox, { recursive: true }); + fs.writeFileSync(path.join(msgOutbox, 'result.txt'), 'ok'); + + const files = readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['result.txt']); + expect(files).toHaveLength(1); + expect(files?.[0]?.filename).toBe('result.txt'); + expect(files?.[0]?.data.toString()).toBe('ok'); + + clearOutbox('ag-1', 'sess-test', 'msg-1'); + expect(fs.existsSync(msgOutbox)).toBe(false); + }); + it('should resolve to existing session (shared mode)', () => { const { session: s1, created: c1 } = resolveSession('ag-1', 'mg-1', null, 'shared'); expect(c1).toBe(true); diff --git a/src/session-manager.ts b/src/session-manager.ts index 96bca96..2772443 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -21,7 +21,6 @@ import { DATA_DIR } from './config.js'; import { getMessagingGroup } from './db/messaging-groups.js'; import { createSession, - findSession, findSessionByAgentGroup, findSessionForAgent, getSession, @@ -38,6 +37,11 @@ import { import { log } from './log.js'; import type { Session } from './types.js'; +function isPathInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + /** Root directory for all session data. */ export function sessionsBaseDir(): string { return path.join(DATA_DIR, 'v2-sessions'); @@ -369,19 +373,48 @@ export function readOutboxFiles( messageId: string, filenames: string[], ): OutboundFile[] | undefined { + if (!isSafeAttachmentName(messageId)) { + log.warn('Rejecting unsafe outbox message id', { messageId }); + return undefined; + } + const outboxDir = path.join(sessionDir(agentGroupId, sessionId), 'outbox', messageId); if (!fs.existsSync(outboxDir)) return undefined; + + let realOutboxDir: string; + try { + const stat = fs.lstatSync(outboxDir); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + log.warn('Rejecting unsafe outbox directory', { messageId, outboxDir }); + return undefined; + } + realOutboxDir = fs.realpathSync(outboxDir); + } catch (err) { + log.warn('Failed to inspect outbox directory', { messageId, err }); + return undefined; + } + const files: OutboundFile[] = []; for (const filename of filenames) { - // Reject any name that isn't a bare basename before touching the filesystem. if (!isSafeAttachmentName(filename)) { - log.warn('Refused unsafe outbox filename — would escape outbox', { messageId, filename }); + log.warn('Refused unsafe outbox filename, would escape outbox', { messageId, filename }); continue; } + const filePath = path.join(outboxDir, filename); - if (fs.existsSync(filePath)) { - files.push({ filename, data: fs.readFileSync(filePath) }); - } else { + try { + const stat = fs.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink()) { + log.warn('Rejecting unsafe outbox file', { messageId, filename }); + continue; + } + const realFilePath = fs.realpathSync(filePath); + if (!isPathInside(realOutboxDir, realFilePath)) { + log.warn('Rejecting outbox file outside message directory', { messageId, filename }); + continue; + } + files.push({ filename, data: fs.readFileSync(realFilePath) }); + } catch { log.warn('Outbox file not found', { messageId, filename }); } } @@ -395,10 +428,26 @@ export function readOutboxFiles( * thrown error would trigger the delivery retry path and deliver twice. */ export function clearOutbox(agentGroupId: string, sessionId: string, messageId: string): void { + if (!isSafeAttachmentName(messageId)) { + log.warn('Rejecting unsafe outbox cleanup message id', { messageId }); + return; + } + const outboxDir = path.join(sessionDir(agentGroupId, sessionId), 'outbox', messageId); if (!fs.existsSync(outboxDir)) return; try { - fs.rmSync(outboxDir, { recursive: true, force: true }); + const stat = fs.lstatSync(outboxDir); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + log.warn('Rejecting unsafe outbox cleanup directory', { messageId, outboxDir }); + return; + } + const realOutboxBase = fs.realpathSync(path.join(sessionDir(agentGroupId, sessionId), 'outbox')); + const realOutboxDir = fs.realpathSync(outboxDir); + if (!isPathInside(realOutboxBase, realOutboxDir)) { + log.warn('Rejecting outbox cleanup outside session outbox', { messageId, outboxDir }); + return; + } + fs.rmSync(realOutboxDir, { recursive: true, force: true }); } catch (err) { log.warn('Outbox cleanup failed (message already delivered)', { messageId, err }); } From fc3c11b6b901f128cadc3b3fa9583e64e38edb9b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 1 May 2026 01:26:18 +0300 Subject: [PATCH 092/144] fix(session-manager): apply outbox path-confinement to inbound attachments Mirrors the four defenses on the outbound side onto extractAttachmentFiles: 1. Reject unsafe messageId via isSafeAttachmentName before any inbox path is built. WhatsApp passes msg.key.id through raw and that field is client generated, so a peer can craft it; future end to end encrypted adapters will have the same property. 2. lstatSync on the inbox dir refuses a pre placed symlink before mkdirSync would silently follow it. 3. realpathSync + isPathInside contains the resolved dir under the session inbox root. 4. writeFileSync uses the wx flag so a pre placed symlink at the file path is refused atomically by the kernel; EEXIST surfaces as a logged skip. Threat: the session dir is mounted writable into the container at /workspace, so a compromised agent can pre place inbox// as a symlink and wait for a chat message with a matching id to redirect the host write. The four guards together close that window. Consolidates with the existing isSafeAttachmentName helper from attachment-safety.ts rather than introducing a duplicate basename validator inside session-manager. Co-Authored-By: Daisuke Tsuji Co-Authored-By: Claude Opus 4.7 (1M context) --- src/host-core.test.ts | 89 ++++++++++++++++++++++++++++++++++ src/session-manager.ts | 105 +++++++++++++++++++++++++++++++---------- 2 files changed, 169 insertions(+), 25 deletions(-) diff --git a/src/host-core.test.ts b/src/host-core.test.ts index cbbaf27..043b6b1 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -162,6 +162,95 @@ describe('session manager', () => { expect(fs.existsSync(msgOutbox)).toBe(false); }); + it('should reject inbound attachment writes through a pre-placed symlinked inbox dir', () => { + initSessionFolder('ag-1', 'sess-test'); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + // The container has /workspace write access, so it can pre create + // inbox/ as a symlink to escape. + const inboxRoot = path.join(sessionDir('ag-1', session.id), 'inbox'); + fs.mkdirSync(inboxRoot, { recursive: true }); + const evilTarget = path.join(TEST_DIR, 'evil-target'); + fs.mkdirSync(evilTarget, { recursive: true }); + fs.symlinkSync(evilTarget, path.join(inboxRoot, 'msg-evil')); + + writeSessionMessage('ag-1', session.id, { + id: 'msg-evil', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'evil', + attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }], + }), + }); + + expect(fs.existsSync(path.join(evilTarget, 'photo.png'))).toBe(false); + }); + + it('should refuse to follow a pre-existing symlink at the inbound attachment path', () => { + initSessionFolder('ag-1', 'sess-test'); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + // The container pre creates inbox//photo.png as a symlink to a + // host file. Without the wx flag, writeFileSync would follow it. + const inboxDir = path.join(sessionDir('ag-1', session.id), 'inbox', 'msg-sym'); + fs.mkdirSync(inboxDir, { recursive: true }); + const outside = path.join(TEST_DIR, 'outside.txt'); + fs.writeFileSync(outside, 'ORIGINAL'); + fs.symlinkSync(outside, path.join(inboxDir, 'photo.png')); + + writeSessionMessage('ag-1', session.id, { + id: 'msg-sym', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'sym', + attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }], + }), + }); + + expect(fs.readFileSync(outside, 'utf-8')).toBe('ORIGINAL'); + }); + + it('should reject inbound attachments when messageId is unsafe', () => { + initSessionFolder('ag-1', 'sess-test'); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + writeSessionMessage('ag-1', session.id, { + id: '../../escape', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'msgid', + attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }], + }), + }); + + const inboxRoot = path.join(sessionDir('ag-1', session.id), 'inbox'); + if (fs.existsSync(inboxRoot)) { + expect(fs.readdirSync(inboxRoot)).toEqual([]); + } + }); + + it('should still save inbound attachments with safe basenames', () => { + initSessionFolder('ag-1', 'sess-test'); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + writeSessionMessage('ag-1', session.id, { + id: 'msg-ok', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'ok', + attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }], + }), + }); + + const expected = path.join(sessionDir('ag-1', session.id), 'inbox', 'msg-ok', 'photo.png'); + expect(fs.existsSync(expected)).toBe(true); + expect(fs.readFileSync(expected, 'utf-8')).toBe('PNGBYTES'); + }); + it('should resolve to existing session (shared mode)', () => { const { session: s1, created: c1 } = resolveSession('ag-1', 'mg-1', null, 'shared'); expect(c1).toBe(true); diff --git a/src/session-manager.ts b/src/session-manager.ts index 2772443..6b00655 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -238,6 +238,20 @@ export function writeSessionMessage( /** * If message content has attachments with base64 `data`, save them to * the session's inbox directory and replace with `localPath`. + * + * Both `messageId` and `att.name` originate in untrusted input. WhatsApp + * passes `msg.key.id` through raw (and that field is client generated, so a + * peer can craft it), and other adapters may follow. The session dir is + * mounted writable into the container, so a compromised agent can also + * pre-place a symlink at `inbox//` and wait for a chat message + * with a matching id to redirect the host's write. + * + * Defenses, mirrored from the outbound side: + * 1. basename check on `messageId` and `filename`. + * 2. lstat of the inbox dir to refuse pre-placed symlinks. + * 3. realpath-based containment under the session inbox root. + * 4. `wx` flag on writeFileSync to refuse following a pre-existing symlink + * at the target file path or overwriting any existing file. */ function extractAttachmentFiles( agentGroupId: string, @@ -255,34 +269,75 @@ function extractAttachmentFiles( const attachments = parsed.attachments as Array> | undefined; if (!Array.isArray(attachments)) return contentStr; + if (!isSafeAttachmentName(messageId)) { + log.warn('Rejecting unsafe inbound message id', { messageId }); + return contentStr; + } + let changed = false; for (const att of attachments) { - if (typeof att.data === 'string') { - // The name field is attacker-controlled: chat platforms with E2E - // attachment encryption (WhatsApp, Matrix) cannot sanitize filename - // server-side, and other adapters pass att.name through raw. Without - // this guard, `path.join(inboxDir, '../../...')` writes anywhere the - // host process has fs permission — see Signal Desktop's Nov 2025 - // attachment-fileName advisory for the same archetype. - const rawName = deriveAttachmentName(att); - const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`; - if (filename !== rawName) { - log.warn('Refused unsafe attachment filename — would escape inbox', { - messageId, - rawName, - replacement: filename, - }); - } - const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId); - fs.mkdirSync(inboxDir, { recursive: true }); - const filePath = path.join(inboxDir, filename); - fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64')); - att.name = filename; - att.localPath = `inbox/${messageId}/${filename}`; - delete att.data; - changed = true; - log.debug('Saved attachment to inbox', { messageId, filename, size: att.size }); + if (typeof att.data !== 'string') continue; + + const rawName = deriveAttachmentName(att); + const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`; + if (filename !== rawName) { + log.warn('Refused unsafe attachment filename, would escape inbox', { + messageId, + rawName, + replacement: filename, + }); } + + const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId); + + // Refuse to mkdir through a symlink that the container may have pre placed + // at inboxDir. With recursive:true, mkdirSync would silently no op on a + // pre existing symlink and the subsequent writeFileSync would follow it. + if (fs.existsSync(inboxDir)) { + const stat = fs.lstatSync(inboxDir); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + log.warn('Rejecting unsafe inbox directory', { messageId, inboxDir }); + continue; + } + } + fs.mkdirSync(inboxDir, { recursive: true }); + + let realInboxDir: string; + try { + realInboxDir = fs.realpathSync(inboxDir); + } catch (err) { + log.warn('Failed to resolve inbox directory', { messageId, err }); + continue; + } + const inboxRoot = path.join(sessionDir(agentGroupId, sessionId), 'inbox'); + if (!isPathInside(fs.realpathSync(inboxRoot), realInboxDir)) { + log.warn('Inbox directory escaped session inbox root', { messageId, inboxDir }); + continue; + } + + const filePath = path.join(inboxDir, filename); + try { + // wx = exclusive create. Refuses to follow a pre existing symlink or + // overwrite any existing file. The host expects to be the sole writer + // of these attachments. + fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64'), { flag: 'wx' }); + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code === 'EEXIST') { + log.warn('Inbox attachment target already exists, refusing to overwrite', { + messageId, + filename, + }); + continue; + } + throw err; + } + + att.name = filename; + att.localPath = `inbox/${messageId}/${filename}`; + delete att.data; + changed = true; + log.debug('Saved attachment to inbox', { messageId, filename, size: att.size }); } return changed ? JSON.stringify(parsed) : contentStr; From 7ac8dd0f6de8d5d8c57f0183384183c3a9a5c0ea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 22:28:25 +0000 Subject: [PATCH 093/144] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?39k=20tokens=20=C2=B7=2069%=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 5832849..d6afa67 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 138k tokens, 69% of context window + + 139k tokens, 69% of context window @@ -15,8 +15,8 @@ tokens - - 138k + + 139k From 28c38ae28b9b7cc10d120b3af57d2185b379d575 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 30 Apr 2026 23:00:02 +0000 Subject: [PATCH 094/144] fix(container): pin vercel to 52.2.1 to dodge broken 53.0.1 publish vercel@53.0.1 declares a dep on @vercel/static-build@2.9.22 which is not published on npm (only 2.9.21 exists), breaking every fresh container build that resolves vercel@latest. 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 4b4cf22..efa58b6 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -21,7 +21,7 @@ ARG INSTALL_CJK_FONTS=false # across all users. ARG CLAUDE_CODE_VERSION=2.1.116 ARG AGENT_BROWSER_VERSION=latest -ARG VERCEL_VERSION=latest +ARG VERCEL_VERSION=52.2.1 ARG BUN_VERSION=1.3.12 # ---- System dependencies ----------------------------------------------------- From 8c962d3f73edcb627c3043b5fe7595d7d8755422 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 23:00:24 +0000 Subject: [PATCH 095/144] chore: bump version to 2.0.23 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index df9cb99..146af58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.22", + "version": "2.0.23", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From a590fbd830e79af7822a99976e8c29ac9ebaa5cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 13:30:19 +0000 Subject: [PATCH 096/144] chore: bump version to 2.0.24 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 146af58..e8c1dc9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.23", + "version": "2.0.24", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 663d9a409190bd1e79fa505fae04644dcdab2429 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 13:30:23 +0000 Subject: [PATCH 097/144] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?39k=20tokens=20=C2=B7=2070%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index d6afa67..8f04fa8 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 139k tokens, 69% of context window + + 139k tokens, 70% of context window @@ -10,7 +10,7 @@ - + From a71d2a4e2c7d7bc477ebb5fc25c8e37415cf37d1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 16:03:16 +0000 Subject: [PATCH 098/144] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?40k=20tokens=20=C2=B7=2070%=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 8f04fa8..d0bd6da 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 139k tokens, 70% of context window + + 140k tokens, 70% of context window @@ -15,8 +15,8 @@ tokens - - 139k + + 140k From 897b77029659220a247aa8ca16a89001126edb98 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 16:03:18 +0000 Subject: [PATCH 099/144] chore: bump version to 2.0.25 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e8c1dc9..d3ccf04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.24", + "version": "2.0.25", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 1b08b58fcd80a2019b5cf012904798901b087fc8 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 1 May 2026 17:03:02 +0000 Subject: [PATCH 100/144] setup: drop redundant agent ping; harden auth detection and OAuth paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify: remove the CLI ping; cli-agent step earlier in setup already proved the round-trip works, and the test agent gets cleaned up before verify runs — so the ping was guaranteed to fail on installs that wired a messaging app instead of staying CLI-only. Status now collapses to service-running ∧ credentials ∧ ≥1 wired group. - agent-ping: catch Claude Code's "Please run /login" / "Not logged in" / "Invalid API key" banners so a successfully-spawned agent that has no credentials no longer reports as 'ok'. - auth paste: validate the full sk-ant-oat…AA shape; when the cleaned input is under 90 chars, surface a truncation-specific hint pointing at terminal wrap as the likely cause. Strip internal whitespace at both validate and assignment so multi-line pastes that survive clack also go through cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 25 +++++++++--------- setup/lib/agent-ping.test.ts | 9 +++++++ setup/lib/agent-ping.ts | 5 +++- setup/verify.test.ts | 51 ++++++++++++++---------------------- setup/verify.ts | 38 +++++++-------------------- 5 files changed, 54 insertions(+), 74 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index f977571..c468abc 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -491,14 +491,6 @@ async function main(): Promise { 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( @@ -518,7 +510,6 @@ async function main(): Promise { 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', @@ -777,15 +768,25 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise { message: `Paste your ${label}`, clearOnError: true, validate: (v) => { - if (!v || !v.trim()) return 'Required'; - if (!v.trim().startsWith(prefix)) { + // Strip any internal whitespace so a line-wrapped paste that did + // survive into clack can still validate. The mid-token-newline + // case where clack only sees the first line is caught by the + // shape check below. + const cleaned = (v ?? '').replace(/\s+/g, ''); + if (!cleaned) return 'Required'; + if (!cleaned.startsWith(prefix)) { return `Should start with ${prefix}…`; } + if (method === 'oauth' && !/^sk-ant-oat[A-Za-z0-9_-]{80,500}AA$/.test(cleaned)) { + return cleaned.length < 90 + ? 'Token looks truncated — line breaks in the paste can cut it off. Widen your terminal so the token fits on one line, then paste again.' + : "Token shape doesn't look right (expected sk-ant-oat…AA)."; + } return undefined; }, }), ); - const token = (answer as string).trim(); + const token = (answer as string).replace(/\s+/g, ''); const res = await runQuietChild( 'auth', diff --git a/setup/lib/agent-ping.test.ts b/setup/lib/agent-ping.test.ts index 5f2be2c..3578ec1 100644 --- a/setup/lib/agent-ping.test.ts +++ b/setup/lib/agent-ping.test.ts @@ -20,6 +20,15 @@ describe('classifyPingResult', () => { expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error'); }); + it('detects Claude Code login banners printed as a chat reply', () => { + expect( + classifyPingResult(0, 'Invalid API key · Please run /login'), + ).toBe('auth_error'); + expect( + classifyPingResult(0, 'Not logged in · Please run /login'), + ).toBe('auth_error'); + }); + it('preserves socket errors', () => { expect(classifyPingResult(2, '')).toBe('socket_error'); }); diff --git a/setup/lib/agent-ping.ts b/setup/lib/agent-ping.ts index 49c5fe2..5682c2f 100644 --- a/setup/lib/agent-ping.ts +++ b/setup/lib/agent-ping.ts @@ -20,7 +20,10 @@ export function classifyPingResult(exitCode: number | null, stdout: string, stde if ( /Invalid bearer token/i.test(output) || /authentication[_ ]error/i.test(output) || - /Failed to authenticate/i.test(output) + /Failed to authenticate/i.test(output) || + /Please run \/login/i.test(output) || + /Not logged in/i.test(output) || + /Invalid API key/i.test(output) ) { return 'auth_error'; } diff --git a/setup/verify.test.ts b/setup/verify.test.ts index 1e09acd..444b2cd 100644 --- a/setup/verify.test.ts +++ b/setup/verify.test.ts @@ -5,45 +5,14 @@ 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', () => { + it('accepts a healthy install with at least one wired agent group', () => { 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({ @@ -52,4 +21,22 @@ describe('determineVerifyStatus', () => { }), ).toBe('failed'); }); + + it('fails when the service is not running', () => { + expect( + determineVerifyStatus({ + ...healthyBase, + service: 'stopped', + }), + ).toBe('failed'); + }); + + it('fails when credentials are missing', () => { + expect( + determineVerifyStatus({ + ...healthyBase, + credentials: 'missing', + }), + ).toBe('failed'); + }); }); diff --git a/setup/verify.ts b/setup/verify.ts index 30a5408..de1160c 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -14,7 +14,6 @@ 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, type PingResult } from './lib/agent-ping.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { getPlatform, @@ -33,11 +32,12 @@ export async function run(_args: string[]): Promise { // 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. + // Why the mismatch matters: the host reads `/data/v2.db` and + // 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), nothing in this checkout is actually + // wired up. Surface the mismatch directly so the user knows to point the + // service at the right folder. let service: | 'not_found' | 'stopped' @@ -186,7 +186,6 @@ export async function run(_args: string[]): Promise { if (has('IMESSAGE_ENABLED')) channelAuth.imessage = 'configured'; const configuredChannels = Object.keys(channelAuth); - const anyChannelConfigured = configuredChannels.length > 0; // 5. Check registered groups in v2 central DB (agent_groups + messaging_group_agents) let registeredGroups = 0; @@ -218,23 +217,12 @@ 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' | 'auth_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. A CLI-only install is valid when the local - // agent round-trip succeeds; messaging app credentials are optional. + // Determine overall status. The cli-agent step earlier in setup already + // proved the agent round-trip works; verify is a static health check. const status = determineVerifyStatus({ service, credentials, - anyChannelConfigured, registeredGroups, - agentPing, }); log.info('Verification complete', { status, channelAuth }); @@ -247,7 +235,6 @@ 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', }); @@ -258,18 +245,11 @@ export async function run(_args: string[]): Promise { 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') + input.registeredGroups > 0 ? 'success' : 'failed'; } From 1d73b2986a891629a7e220ae4668d7d8b1acf5e8 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 1 May 2026 20:13:38 +0000 Subject: [PATCH 101/144] =?UTF-8?q?feat:=20add=20migrate-v2.sh=20=E2=80=94?= =?UTF-8?q?=20standalone=20v1=20=E2=86=92=20v2=20migration=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New entry point: `bash migrate-v2.sh` from the v2 checkout. Replaces the old setup-embedded migration flow with a standalone 4-phase script + rewritten Claude skill for the interactive parts. Phase 0: Bootstrap (Node/pnpm/deps via setup.sh) + find v1 Phase 1: Core state (env, DB, groups, sessions, tasks) Phase 2: Channels (clack multiselect, auth copy, code install) Phase 3: Infrastructure (OneCLI, auth, Docker, skills, container build) Service switchover: stop v1 → start v2 → test → keep or revert Phase 4: Handoff → exec claude "/migrate-from-v1" The skill handles: owner seeding, access policy, CLAUDE.local.md cleanup, container config validation, fork customization porting. Key fixes found during testing: - triggerToEngage: requires_trigger=0 must override non-empty pattern - unknown_sender_policy defaults to 'public' (strict drops all msgs before owner is seeded) - Service revert must stop v2 (parse unit name from step log, not early tsx one-liner that can fail) - Session continuity: copy JSONL from -workspace-group/ to -workspace-agent/ and write continuation:claude into outbound.db - container_config.additionalMounts written directly to container.json (same shape in v1 and v2) - EXIT trap writes handoff.json; explicit write_handoff before exec Includes migrate-v2-reset.sh for dev iteration and docs/migration-dev.md for testing/debugging reference. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/migrate-from-v1/SKILL.md | 243 ++++++--- docs/migration-dev.md | 139 +++++ migrate-v2-reset.sh | 69 +++ migrate-v2.sh | 641 ++++++++++++++++++++++++ setup/migrate-v1/shared.ts | 8 +- setup/migrate-v2/channel-auth.ts | 134 +++++ setup/migrate-v2/db.ts | 162 ++++++ setup/migrate-v2/env.ts | 81 +++ setup/migrate-v2/groups.ts | 120 +++++ setup/migrate-v2/select-channels.ts | 63 +++ setup/migrate-v2/sessions.ts | 181 +++++++ setup/migrate-v2/switchover-prompt.ts | 53 ++ setup/migrate-v2/tasks.ts | 158 ++++++ 13 files changed, 1976 insertions(+), 76 deletions(-) create mode 100644 docs/migration-dev.md create mode 100644 migrate-v2-reset.sh create mode 100644 migrate-v2.sh create mode 100644 setup/migrate-v2/channel-auth.ts create mode 100644 setup/migrate-v2/db.ts create mode 100644 setup/migrate-v2/env.ts create mode 100644 setup/migrate-v2/groups.ts create mode 100644 setup/migrate-v2/select-channels.ts create mode 100644 setup/migrate-v2/sessions.ts create mode 100644 setup/migrate-v2/switchover-prompt.ts create mode 100644 setup/migrate-v2/tasks.ts diff --git a/.claude/skills/migrate-from-v1/SKILL.md b/.claude/skills/migrate-from-v1/SKILL.md index 810ae03..6362fe6 100644 --- a/.claude/skills/migrate-from-v1/SKILL.md +++ b/.claude/skills/migrate-from-v1/SKILL.md @@ -1,55 +1,69 @@ --- name: migrate-from-v1 -description: Finish migrating a NanoClaw v1 install into v2. Run this after `bash nanoclaw.sh` has completed its automated migration step. Seeds the owner user, applies v1 access defaults, fixes any migration sub-step that didn't finish, and interviews the user about custom v1 code to port forward. Triggers on "migrate from v1", "finish migration", "v1 migration", or automatically after setup when `logs/setup-migration/handoff.json` exists. +description: Finish migrating a NanoClaw v1 install into v2. Run after `bash migrate-v2.sh` completes. Seeds the owner, cleans up CLAUDE.local.md files, reconciles container configs, and helps port custom v1 code. Triggers on "migrate from v1", "finish migration", "v1 migration". --- -# Migrate from v1 to v2 +# Finish v1 → v2 migration -> ⚠️ **Experimental.** This skill and the setup migration step are early. Remind the user to back up `data/v2.db` + `groups/` before making destructive changes, and prefer small, reversible edits. Not recommended yet for high-stakes production installs. +`bash migrate-v2.sh` already ran the deterministic migration. It handled: -The setup flow's `migration` step (in `setup/migrate-v1.ts`) already ran a best-effort automated pass. Your job is to finish what it couldn't do automatically, then interview the user about any custom code they had in v1 and help port it forward. +- .env keys merged +- v2 DB seeded (agent_groups, messaging_groups, wiring) +- Group folders copied (v1 CLAUDE.md → v2 CLAUDE.local.md) +- Session data copied with conversation continuity +- Scheduled tasks ported +- Channel code installed +- Container skills copied +- Container image built -Read [docs/v1-to-v2-changes.md](../../../docs/v1-to-v2-changes.md) before doing anything — it's the vocabulary for where v1 things moved to in v2. +Your job is the parts that need human judgment: triage any failed steps, seed the owner, clean up CLAUDE.local.md files, reconcile configs, and port any fork customizations. -## What the automation did +Read `logs/setup-migration/handoff.json` first — it has `overall_status`, per-step results in `steps`, and a `followups` list. -The setup flow ran these sub-steps (each as its own progression-log entry): +## Phase 0: Triage failed steps -| Sub-step | What it did | -|----------|-------------| -| `migrate-detect` | Found v1 install on disk (scanned `~/nanoclaw`, `~/.nanoclaw`, `~/Code/nanoclaw`, etc., or `$NANOCLAW_V1_PATH`). | -| `migrate-validate` | Checked v1 DB has expected tables + required columns. | -| `migrate-db` | Seeded `agent_groups` + `messaging_groups` + `messaging_group_agents` from `registered_groups`. Mapped `trigger_pattern`/`requires_trigger` → `engage_mode`/`engage_pattern`. Did NOT seed `users`/`user_roles`. | -| `migrate-groups` | Copied v1 `groups//` to v2. v1 `CLAUDE.md` → v2 `CLAUDE.local.md`. v1 `container_config` JSON → `.v1-container-config.json` sidecar (don't silent-map to v2's `container.json`). | -| `migrate-env` | Merged v1 `.env` keys into v2 `.env` (never overwrote existing keys). | -| `migrate-channel-auth` | Copied non-env auth state per channel (Baileys keystore, matrix state, etc.) based on `CHANNEL_AUTH_REGISTRY` in `setup/migrate-v1/shared.ts`. | -| `migrate-channels` | Ran `setup/install-.sh` for each channel detected in `registered_groups`. | -| `migrate-tasks` | Ported active v1 `scheduled_tasks` into each session's `inbound.db` as `kind='task'` rows. Inactive tasks dumped to `inactive-tasks.json` for reference. | +Check `handoff.json` → `overall_status`. If `"success"`, skip to Phase 1. -## Artifacts to read first +If `"partial"`, walk `handoff.steps` — each has `status` and `log` (path to the raw log file). For each failed step: -- `logs/setup-migration/handoff.json` — **start here.** Structured summary of every sub-step: `status`, `fields`, `notes`, plus detected channels, group selection, and a top-level `followups` list. The top-level `overall_status` tells you at a glance what kind of session this is. -- `logs/setup.log` — the progression log. Each `migrate-*` sub-step has one entry with status, duration, and a pointer to its raw log. -- `logs/setup-steps/NN-migrate-*.log` — raw per-sub-step stdout+stderr. Read these when a step failed or you need to understand why. -- `logs/setup-migration/schema-mismatch.json` — only exists if `migrate-validate` rejected the v1 DB shape. Describes what was missing. -- `logs/setup-migration/inactive-tasks.json` — v1 scheduled tasks we didn't migrate (completed, stopped, or unmappable schedule types). +1. Read its log file at `handoff.step_logs_dir/.log`. +2. Explain what failed in one sentence. +3. Fix it if mechanical (re-run the step script, hand-write a DB insert, copy a missed file). The step scripts are at `setup/migrate-v2/.ts` and accept `` as the first argument. +4. Use `AskUserQuestion` when a judgment call is needed. -## Flow +Common failures: +- **1b-db failed**: JID couldn't be parsed. Ask the user for the channel type, insert `agent_groups` + `messaging_groups` manually. +- **1d-sessions failed**: v2 DB wasn't seeded yet. Re-run after fixing 1b. +- **1e-tasks failed**: session doesn't exist yet. Re-run after fixing 1d. +- **2c-install-\ failed**: `git fetch origin channels` may have failed (network). Try again, or ask the user to run manually. +- **3e-container-build failed**: Docker issue. Read the build log, suggest fixes. -### Phase A — always run: owner seeding + access policy +After resolving all failures, proceed to Phase 1. -The automation deliberately did not seed `users`, `user_roles`, or flip `messaging_groups.unknown_sender_policy`. v1 has no ground truth for who the owner is, and no single source for the "anyone can message / only known users" setting. Ask the user. +## Phase 1: Owner and access -1. Read `handoff.json` → `detected_channels` to know which channel(s) to address the user on. -2. Use `AskUserQuestion` to ask "Which handle on `` is yours?" with options pulled from context if you have any hints (e.g. recent v1 message senders), plus "Let me type it" and "Use a different channel." Build the user id as `:`. -3. Insert into v2 central DB (`data/v2.db`): - - `users(id, kind, display_name, created_at)` — use the channel_type as `kind`. - - `user_roles(user_id, role='owner', agent_group_id=NULL, granted_by=NULL, granted_at=now)`. -4. Ask "In v1, could anyone message your assistant, or only known users?" via `AskUserQuestion`: - - "Anyone could message it" → update every row in `messaging_groups` (for migrated channel_types) to `unknown_sender_policy='public'`. - - "Only known users" → leave `unknown_sender_policy='strict'`; walk the user through seeding `agent_group_members` rows for each trusted handle they name. +v2 auto-creates a `users` row for every sender it sees (via `extractAndUpsertUser` in `src/modules/permissions/index.ts`). By the time this skill runs, the owner's row likely already exists — it just needs the `owner` role granted. -Use the DB helpers in `src/db/agent-groups.ts`, `src/db/messaging-groups.ts`, and `src/db/user-roles.ts` rather than hand-rolling SQL — they keep the companion `agent_destinations` and indexes correct. Always init the central DB first: +**User ID format**: always `:`. Each channel populates this differently: +- **Telegram**: `telegram:` (e.g. `telegram:6037840640`) +- **Discord**: `discord:` (e.g. `discord:123456789012345678`) +- **WhatsApp**: `whatsapp:@s.whatsapp.net` (e.g. `whatsapp:14155551234@s.whatsapp.net`) +- **Slack**: `slack:` (e.g. `slack:U04ABCDEF`) +- **Others**: `:` + +**Steps:** + +1. Query `users` table: `SELECT id, kind, display_name FROM users`. +2. If exactly one user exists, confirm: `AskUserQuestion`: "Is `` (``) you?" — Yes / No, let me type it. +3. If multiple users exist, present them as options in `AskUserQuestion`. +4. If no users exist yet (service hasn't received a message), ask the user to send a test message first, then re-query. +5. Once confirmed, check `user_roles` — if the owner role already exists, skip. Otherwise insert: + ```sql + INSERT INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) + VALUES ('', 'owner', NULL, NULL, datetime('now')) + ``` + +Use the DB helpers in `src/db/user-roles.ts` — they keep indexes correct. Init the DB first: ```ts import { initDb } from '../src/db/connection.js'; @@ -60,61 +74,144 @@ const db = initDb(path.join(DATA_DIR, 'v2.db')); runMigrations(db); ``` -### Phase B — branch on `handoff.json: overall_status` +### Access policy -**If `overall_status === 'success'`** and `followups` is empty: go straight to Phase C (customization interview). +After seeding the owner, discuss the access policy. v2's `messaging_groups.unknown_sender_policy` controls who can interact with the bot. `migrate-v2.sh` set it to `public` so the bot would respond during the switchover test, but the user may want to tighten it. -**Otherwise (partial, failed, or non-empty followups)**: walk `handoff.steps` and `handoff.followups` top-to-bottom. For each entry: +Present the options via `AskUserQuestion`: -- Read the step's `fields` and `notes` and its raw log (`logs/setup-steps/NN-.log`). -- Explain the situation to the user in one sentence, then propose a fix. -- Do the fix yourself when it's mechanical (re-running an install script, seeding a missed `agent_destinations` row, re-copying a channel's auth files, manually translating an unsupported `schedule_type`). Use `AskUserQuestion` when a judgment call is needed (is this orphan channel worth keeping? is this v1 container_config still relevant?). +1. **Public** (current) — anyone can message the bot. Good for personal DM bots. +2. **Known users only** — only users in `agent_group_members` can trigger the bot. Others are silently dropped. +3. **Approval required** — unknown senders trigger an approval request to the owner. Good for group chats where you want to vet new members. -Common cases: +If the user picks option 2 or 3, seed the known users from v1's message history. The v1 database is at `/store/messages.db`. It has a `messages` table with `sender` and `sender_name` columns. For each group: -- **`migrate-validate` status=failed**: the v1 DB had an unexpected shape. Read `schema-mismatch.json`. If tables are missing, the user may have run a very old or customized v1 — ask before trying to salvage. If only columns are missing, you can often proceed by hand-writing the SELECT with the columns that exist. -- **`migrate-db` status=partial, SKIPPED>0**: some `registered_groups` rows didn't seed. The `notes` field of the step entry names each failed folder. Most commonly: a JID we couldn't parse. Ask the user whether to manually wire each. -- **`migrate-channels` status=partial, some entries `not_supported`**: v1 had channels v2 doesn't ship a skill for yet. Ask the user whether to keep the `messaging_groups` rows (they'll stay orphaned until v2 grows the adapter) or delete them. -- **`migrate-channel-auth` has `files_missing`**: for WhatsApp specifically, encryption sessions often can't survive the copy — tell the user a fresh pair may be needed via `/add-whatsapp`. -- **Per-folder `.v1-container-config.json` sidecars exist**: read each, discuss with the user, and translate to v2's `groups//container.json` format. +```sql +-- v1: unique senders per chat (excluding bot messages) +SELECT DISTINCT sender, sender_name +FROM messages +WHERE chat_jid = '' AND is_from_me = 0 AND sender IS NOT NULL +``` -### Phase C — customizations (fork-aware) +The `sender` value is a platform handle (e.g. `6037840640` for Telegram). Build the v2 user ID by inferring the channel type from the chat JID prefix (use `parseJid` from `setup/migrate-v1/shared.ts`) and combining: `:`. -NanoClaw recommends running on a fork, so most real v1 installs have at least some customizations. +For each sender: +1. Upsert into `users(id, kind, display_name)` if not already present. +2. Insert into `agent_group_members(user_id, agent_group_id)` for each agent group wired to that messaging group. -**Start with divergence detection.** In the v1 repo at `handoff.v1_path`: +Show the user the list of senders being imported and let them deselect any they don't want. + +Then update the messaging groups: +```sql +UPDATE messaging_groups SET unknown_sender_policy = '' +WHERE id IN (SELECT id FROM messaging_groups WHERE channel_type IN ()) +``` + +## Phase 2: Clean up CLAUDE.local.md + +The migration copied v1's entire CLAUDE.md into CLAUDE.local.md for each group. This file now contains v1 boilerplate that v2 handles through its own composed fragments (`container/CLAUDE.md` + `.claude-fragments/module-*.md`). The user's customizations are buried inside. + +For each group that has a `CLAUDE.local.md`: + +1. Read the file. +2. Read the v1 template it was based on. Determine which template by checking the v1 install: + - If the group had `is_main=1` in v1's `registered_groups`, the template was `groups/main/CLAUDE.md` + - Otherwise, the template was `groups/global/CLAUDE.md` + - The v1 path is in `handoff.json` → `v1_path` +3. Diff the file against the template. Identify sections that are: + - **Stock boilerplate** (identical to template) — remove. v2's fragments cover this. + - **User customizations** (added sections, modified sections) — keep. +4. The following v1 sections are now handled by v2 fragments and should be removed even if slightly modified: + - "What You Can Do" → v2 runtime system prompt + - "Communication" / "Internal thoughts" / "Sub-agents" → `container/CLAUDE.md` + `module-core.md` + - "Your Workspace" / workspace path references → `container/CLAUDE.md` + - "Memory" (the stock version) → `container/CLAUDE.md` + - "Message Formatting" → `container/CLAUDE.md` + - "Admin Context" → v2 uses `user_roles`, not is_main + - "Authentication" → v2 uses OneCLI + - "Container Mounts" → v2 mounts are different + - "Managing Groups" / "Finding Available Groups" / "Registered Groups Config" → v2 entity model, no IPC + - "Global Memory" → v2 has `.claude-shared.md` symlink + - "Scheduling for Other Groups" → `module-scheduling.md` + - "Task Scripts" → `module-scheduling.md` + - "Sender Allowlist" → v2 uses `unknown_sender_policy` + `user_roles` +5. Fix path references in kept sections: + - `/workspace/group/` → `/workspace/agent/` + - `/workspace/project/` → these paths don't exist in v2; discuss with the user + - `/workspace/ipc/` → gone; remove references + - `/workspace/extra/` → v2 uses `container.json` `additionalMounts`; keep but note the path may change +6. Keep the `# Name` heading and first paragraph (identity) — this is the user's agent personality. +7. Show the user the proposed new CLAUDE.local.md before writing it. Use `AskUserQuestion`: "Here's what I'd keep — look right?" with options to approve, edit, or keep the original. + +If a CLAUDE.local.md has no user customizations (pure template copy), write a minimal file with just the identity heading. + +## Phase 3: Container config + +`migrate-v2.sh` writes `container.json` directly from v1's `container_config` (the `additionalMounts` shape is identical). If the v1 config was unparseable, it falls back to a `.v1-container-config.json` sidecar. + +For each group, check: + +1. If `container.json` exists, read it and verify the `additionalMounts` host paths are still valid on this machine. Flag any that don't exist. +2. If `.v1-container-config.json` exists (parse failure fallback), read it, discuss with the user, and write a proper `container.json`. Then delete the sidecar. +3. Check for `env` or `packages` fields — `env` may overlap with OneCLI vault, `packages` (apt/npm) are portable. + +## Phase 4: Fork customizations + +Check whether the user's v1 install was a customized fork. ```bash cd -git remote -v # identify the upstream remote -git log --oneline /main..HEAD # commits ahead of upstream +git remote -v +git log --oneline /main..HEAD 2>/dev/null ``` -If the log is **empty**: stock v1. Tell the user "no customizations to port" and skip the rest of Phase C. +If no commits ahead of upstream: stock v1, skip this phase. -If the log has commits, show them to the user and offer a scope via `AskUserQuestion`: +If there are commits: -1. **Mechanical** (recommended) — copy the portable categories (skills, docs), stash the rest as reference. -2. **Full interview** — walk each commit with me, decide one-by-one. Use `Explore` sub-agents for diffs > 10 files. -3. **Reference only** — stash everything to `docs/v1-fork-reference/`, copy nothing now. - -**Portability rules of thumb:** -- **Portable**: `container/skills/*`, `.claude/skills/*`, `docs/*`, top-level config. Scan each with `scanForV1Patterns` (in `setup/migrate-v1/shared.ts`) before copying — clean ones land as-is, dirty ones get a followup. -- **Not portable**: `src/*` (host) and `container/agent-runner/src/*` (agent-runner). v2's architecture is fundamentally different (Node host with split session DBs vs v1's single process + IPC file queue). Stash to `docs/v1-fork-reference/` with a README explaining the v1→v2 mapping — **don't translate**. Mechanical translation is a trap; let the user rebuild the feature on v2 primitives. -- **Already handled**: `groups/*` — `migrate-groups` copied these and flagged v1 patterns. Don't redo in Phase C. -- **Case by case**: `package.json` deps — check whether v2 already has each; never add to v2's lockfile without approval (supply-chain `minimumReleaseAge` applies). - -When stashing, write `docs/v1-fork-reference/README.md` with commits list, stashed source files, and the suggested porting plan. +1. Show the commit list to the user. +2. `AskUserQuestion`: "How do you want to handle your v1 customizations?" + - **Copy portable items** (recommended) — copy `container/skills/*`, `.claude/skills/*`, `docs/*`. Scan each with `scanForV1Patterns` from `setup/migrate-v1/shared.ts`. + - **Full walkthrough** — go commit by commit, decide together. + - **Reference only** — stash to `docs/v1-fork-reference/` for later. +3. Source code (`src/*`, `container/agent-runner/src/*`) is NOT portable — v2's architecture is fundamentally different. Stash to `docs/v1-fork-reference/` with a README explaining what each file did. Don't translate. ## Principles -- **Never silently copy code.** Read, explain, propose, apply. Show diffs before applying when non-trivial. -- **Credentials are masked when displayed** (first 4 + `...` + last 4 characters). The handoff file doesn't contain values; keep it that way. -- **The v1 checkout is read-only.** We never delete or modify `~/nanoclaw`. If the user wants to retire it later, that's a separate conversation. -- **No migration re-runs.** The `migrate-*` sub-steps are idempotent, but re-running them from inside this skill is almost always the wrong move — finish by hand. Re-running is for when the user re-runs `bash nanoclaw.sh`. -- **`handoff.json` is source of truth across context compactions.** If the conversation gets compacted mid-work, re-read it and `git status` to recover state. Do not maintain a separate state file. +- **v1 checkout is read-only.** Never modify files under `handoff.v1_path`. +- **Show before writing.** Show diffs/proposed content before modifying CLAUDE.local.md or container.json. +- **Mask credentials** when displaying (first 4 + `...` + last 4 characters). +- **`handoff.json` is the recovery point.** If context gets compacted, re-read it and `git status` to recover state. -## When you're done +## Setup steps you can run -- Delete `logs/setup-migration/handoff.json` once every followup is cleared and the user confirms. The file is a working document, not a record — if the user wants a record, offer to move it to `docs/migration-.md` before deleting. -- Tell the user: if the service is running (check `launchctl list | grep nanoclaw` on macOS or `systemctl --user status nanoclaw*` on Linux), restart it so the seeded `users` / `user_roles` / any channel installs take effect. +The setup flow at `setup/index.ts` has individual steps you can invoke if something is missing or failed: + +```bash +pnpm exec tsx setup/index.ts --step +``` + +| Step | When to use | +|------|-------------| +| `onecli` | OneCLI not installed or not healthy | +| `auth` | No Anthropic credential in vault | +| `container` | Container image needs rebuild | +| `service` | Service not installed or not running | +| `mounts` | Mount allowlist missing | +| `verify` | End-to-end health check (run after everything else) | +| `environment` | System check (Node, dirs) | + +## When done + +1. Run the verify step to confirm everything works: + ```bash + pnpm exec tsx setup/index.ts --step verify + ``` +2. Delete `logs/setup-migration/handoff.json` — offer to save as `docs/migration-.md` first. +3. Restart the service if running so changes take effect: + ```bash + # Linux + systemctl --user restart nanoclaw-v2-* + # macOS + launchctl kickstart -k gui/$(id -u)/com.nanoclaw-v2-* + ``` diff --git a/docs/migration-dev.md b/docs/migration-dev.md new file mode 100644 index 0000000..60feb4e --- /dev/null +++ b/docs/migration-dev.md @@ -0,0 +1,139 @@ +# v1 → v2 Migration — Development Guide + +How to test, develop, and debug the migration flow. + +## Quick start + +```bash +# Full cycle: reset → migrate → Claude finishes +bash migrate-v2-reset.sh && bash migrate-v2.sh +``` + +## Architecture + +Two-part migration: + +1. **`migrate-v2.sh`** — deterministic bash script. Handles prerequisites, DB seeding, file copies, channel install, container build, service switchover. Writes `logs/setup-migration/handoff.json` then `exec`s into Claude. + +2. **`/migrate-from-v1` skill** — Claude-driven. Reads the handoff, seeds owner/roles, cleans up CLAUDE.local.md, validates container configs, ports fork customizations. + +## File layout + +``` +migrate-v2.sh # Entry point +migrate-v2-reset.sh # Wipe v2 state for re-testing +setup/migrate-v2/ + env.ts # Phase 1a: merge .env + db.ts # Phase 1b: seed v2 DB + groups.ts # Phase 1c: copy group folders + container.json + sessions.ts # Phase 1d: copy sessions + set continuation + tasks.ts # Phase 1e: port scheduled tasks + channel-auth.ts # Phase 2b: copy channel auth state + select-channels.ts # Phase 2a: clack multiselect + switchover-prompt.ts # Service switch prompts +setup/migrate-v1/shared.ts # Shared helpers (JID parsing, trigger mapping, etc.) +.claude/skills/migrate-from-v1/ # The Claude skill +logs/setup-migration/handoff.json # Written by migrate-v2.sh, read by skill +logs/migrate-steps/*.log # Per-step raw output +``` + +## Development loop + +```bash +# Reset v2 to clean state (keeps node_modules) +bash migrate-v2-reset.sh + +# Run migration with non-interactive channel selection +NANOCLAW_CHANNELS="telegram" bash migrate-v2.sh + +# Or run interactively (clack multiselect) +bash migrate-v2.sh +``` + +`migrate-v2-reset.sh` wipes: `data/`, `logs/`, `.env`, `groups/` (restores git-tracked), `container/skills/` (restores git-tracked), `src/channels/` (restores git-tracked). + +It does NOT wipe `node_modules/` (expensive to reinstall). + +## Testing individual steps + +Each step is a standalone TypeScript file: + +```bash +# Run a single step (after pnpm install) +pnpm exec tsx setup/migrate-v2/env.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/db.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/groups.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/sessions.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/tasks.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/channel-auth.ts /path/to/v1 telegram discord +``` + +Each prints `OK:
`, `SKIPPED:`, or errors to stdout. Exit 0 on success/skip, non-zero on failure. + +## Debugging + +### Check what was migrated + +```bash +# Agent groups +sqlite3 data/v2.db "SELECT * FROM agent_groups" + +# Messaging groups + wiring +sqlite3 data/v2.db "SELECT mg.id, mg.channel_type, mg.platform_id, mg.unknown_sender_policy, mga.engage_mode, mga.engage_pattern FROM messaging_groups mg JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id" + +# Sessions +sqlite3 data/v2.db "SELECT * FROM sessions" + +# Users and roles +sqlite3 data/v2.db "SELECT * FROM users" +sqlite3 data/v2.db "SELECT * FROM user_roles" + +# Session continuation (which Claude Code session will be resumed) +AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups LIMIT 1") +SESS_ID=$(sqlite3 data/v2.db "SELECT id FROM sessions LIMIT 1") +sqlite3 data/v2-sessions/$AG_ID/$SESS_ID/outbound.db "SELECT * FROM session_state" + +# Scheduled tasks +sqlite3 data/v2-sessions/$AG_ID/$SESS_ID/inbound.db "SELECT id, kind, recurrence, status FROM messages_in WHERE kind='task'" +``` + +### Check handoff + +```bash +python3 -m json.tool logs/setup-migration/handoff.json +``` + +### Common issues + +**Bot doesn't respond after switchover:** +1. Check both services aren't running: `systemctl --user list-units 'nanoclaw*'` +2. Check error log: `tail logs/nanoclaw.error.log` +3. Check sender policy: `sqlite3 data/v2.db "SELECT unknown_sender_policy FROM messaging_groups"` — must be `public` before owner is seeded +4. Check engage pattern: `sqlite3 data/v2.db "SELECT engage_mode, engage_pattern FROM messaging_group_agents"` — should be `pattern` / `.` for respond-to-everything + +**Session not continuing from v1:** +1. Check continuation is set: see "Session continuation" query above +2. Check JSONL exists at the right path: `ls data/v2-sessions//.claude-shared/projects/-workspace-agent/` +3. The v1 session JSONL should be copied from `-workspace-group/` to `-workspace-agent/` (v2 container CWD is `/workspace/agent`) + +**Service switchover revert didn't work:** +1. The v2 service name is `nanoclaw-v2-` — find it: `systemctl --user list-units 'nanoclaw*'` +2. Manually stop: `systemctl --user stop && systemctl --user disable ` +3. Restart v1: `systemctl --user start nanoclaw` + +### Step logs + +Each step writes raw output to `logs/migrate-steps/.log`. Read these when a step fails: + +```bash +cat logs/migrate-steps/1b-db.log +cat logs/migrate-steps/1d-sessions.log +``` + +## Key decisions + +- `unknown_sender_policy` is set to `public` during migration so the bot responds immediately. The `/migrate-from-v1` skill tightens it after seeding the owner. +- `requires_trigger=0` in v1 takes priority over a non-empty `trigger_pattern` — it means "respond to everything." +- v1 `container_config.additionalMounts` is written directly to v2 `container.json` (same shape). +- v1 Claude Code sessions are copied from `-workspace-group/` to `-workspace-agent/` and the session ID is written to `outbound.db` as `continuation:claude` so the agent-runner resumes the same conversation. +- `exec claude "/migrate-from-v1"` at the end replaces the bash process — `write_handoff` is called explicitly before `exec` since EXIT traps don't fire on `exec`. diff --git a/migrate-v2-reset.sh b/migrate-v2-reset.sh new file mode 100644 index 0000000..b795745 --- /dev/null +++ b/migrate-v2-reset.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# migrate-v2-reset.sh — Wipe v2 migration state back to clean. +# +# For development iteration: +# bash migrate-v2-reset.sh && bash migrate-v2.sh +# +# What it removes: +# - data/ (v2 DBs, session state) +# - logs/ (migration + setup logs) +# - .env (merged env keys) +# - groups/*/ (non-git group folders copied from v1) +# +# What it restores: +# - groups/global/CLAUDE.md and groups/main/CLAUDE.md from git +# +# What it does NOT touch: +# - node_modules/ (expensive to reinstall, keep it) +# - The v1 install (read-only, never modified) + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_ROOT" + +use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; } +dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; } +green() { use_ansi && printf '\033[32m%s\033[0m' "$1" || printf '%s' "$1"; } + +clean() { + local target=$1 label=$2 + if [ -e "$target" ]; then + rm -rf "$target" + printf '%s Removed %s\n' "$(green '✓')" "$label" + fi +} + +echo +printf '%s\n\n' "$(dim 'Resetting v2 migration state…')" + +clean "data" "data/" +clean "logs" "logs/" +clean ".env" ".env" + +# Remove all group folders, then restore the two git-tracked ones +if [ -d "groups" ]; then + rm -rf groups + printf '%s Removed %s\n' "$(green '✓')" "groups/" +fi +git checkout -- groups/ 2>/dev/null || true +printf '%s Restored %s\n' "$(green '✓')" "groups/ from git" + +# Restore container/skills/ to git state (remove v1-copied skills) +git checkout -- container/skills/ 2>/dev/null || true +# Remove any untracked skill dirs that were copied from v1 +for d in container/skills/*/; do + [ -d "$d" ] || continue + if ! git ls-files --error-unmatch "$d" >/dev/null 2>&1; then + rm -rf "$d" + fi +done +printf '%s Restored %s\n' "$(green '✓')" "container/skills/ from git" + +# Restore channel code (src/channels/) to git state +git checkout -- src/channels/ 2>/dev/null || true +printf '%s Restored %s\n' "$(green '✓')" "src/channels/ from git" + +echo +printf '%s\n\n' "$(dim 'Clean. Run: bash migrate-v2.sh')" diff --git a/migrate-v2.sh b/migrate-v2.sh new file mode 100644 index 0000000..325b491 --- /dev/null +++ b/migrate-v2.sh @@ -0,0 +1,641 @@ +#!/usr/bin/env bash +# +# migrate-v2.sh — Migrate a NanoClaw v1 install into this v2 checkout. +# +# Run from the v2 directory: +# bash migrate-v2.sh +# +# Finds v1 automatically (sibling directory, or $NANOCLAW_V1_PATH). +# Installs prerequisites (Node, pnpm, deps) via the existing setup.sh +# bootstrap, then runs the migration steps. +# +# Idempotent — safe to re-run. Use migrate-v2-reset.sh to wipe v2 state +# back to clean for development iteration. + +set -uo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_ROOT" + +LOGS_DIR="$PROJECT_ROOT/logs" +STEPS_DIR="$LOGS_DIR/migrate-steps" +MIGRATE_LOG="$LOGS_DIR/migrate-v2.log" + +# Defaults for variables that may not be set if we exit early +V1_PATH="" +V1_VERSION="unknown" +ONECLI_OK=false +SERVICE_SWITCHED=false +SELECTED_CHANNELS=() +ABORTED_AT="" + +# Write handoff.json on any exit so the skill can always read it +write_handoff() { + local handoff_dir="$LOGS_DIR/setup-migration" + mkdir -p "$handoff_dir" + + local has_failures=false + for step_name in "${!STEP_RESULTS[@]}"; do + [ "${STEP_RESULTS[$step_name]}" = "failed" ] && has_failures=true + done + + local overall="success" + $has_failures && overall="partial" + [ -n "$ABORTED_AT" ] && overall="failed" + + local steps_json="{" + for step_name in "${!STEP_RESULTS[@]}"; do + steps_json="${steps_json}\"${step_name}\": {\"status\": \"${STEP_RESULTS[$step_name]}\", \"log\": \"logs/migrate-steps/${step_name}.log\"}," + done + steps_json="${steps_json%,}}" + + cat > "$handoff_dir/handoff.json" </dev/null | sed 's/,$//')], + "onecli_healthy": $ONECLI_OK, + "service_switched": $SERVICE_SWITCHED, + "steps": $steps_json, + "step_logs_dir": "logs/migrate-steps", + "followups": [ + "Seed owner user and access policy", + "Review CLAUDE.local.md files for v1-specific patterns", + "Verify container.json mount paths are valid" + ] +} +HANDOFF_EOF +} + +trap write_handoff EXIT + +abort() { + ABORTED_AT="$1" + log "ABORTED at $1" + exit 1 +} + +# ─── output helpers ────────────────────────────────────────────────────── + +use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; } +dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; } +green() { use_ansi && printf '\033[32m%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"; } +clear_line() { use_ansi && printf '\r\033[2K' || printf '\n'; } + +step_ok() { printf '%s %s\n' "$(green '✓')" "$1"; } +step_fail() { printf '%s %s\n' "$(red '✗')" "$1"; } +step_skip() { printf '%s %s\n' "$(dim '–')" "$1"; } +step_info() { printf '%s %s\n' "$(dim '·')" "$1"; } + +ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$MIGRATE_LOG" +} + +# ─── init logs ─────────────────────────────────────────────────────────── + +mkdir -p "$STEPS_DIR" +{ + echo "## $(ts_utc) · migrate-v2.sh started" + echo " cwd: $PROJECT_ROOT" + echo "" +} > "$MIGRATE_LOG" + +echo +bold "NanoClaw v1 → v2 migration" +echo +echo + +# ─── phase 0a: bootstrap prerequisites ────────────────────────────────── + +step_info "Installing prerequisites (Node, pnpm, dependencies)…" + +BOOTSTRAP_RAW="$STEPS_DIR/01-bootstrap.log" +export NANOCLAW_BOOTSTRAP_LOG="$BOOTSTRAP_RAW" + +if bash "$PROJECT_ROOT/setup.sh" > "$BOOTSTRAP_RAW" 2>&1; then + # Parse the status block from setup.sh output + STATUS=$(grep '^STATUS:' "$BOOTSTRAP_RAW" | head -1 | sed 's/^STATUS: *//') + NODE_VERSION=$(grep '^NODE_VERSION:' "$BOOTSTRAP_RAW" | head -1 | sed 's/^NODE_VERSION: *//') + + if [ "$STATUS" = "success" ]; then + step_ok "Prerequisites ready $(dim "(node $NODE_VERSION)")" + log "Bootstrap succeeded: node=$NODE_VERSION" + else + step_fail "Bootstrap reported: $STATUS" + echo + dim " See: $BOOTSTRAP_RAW" + echo + abort "bootstrap" + fi +else + step_fail "Bootstrap failed" + echo + echo "$(dim '── last 20 lines ──')" + tail -20 "$BOOTSTRAP_RAW" 2>/dev/null || true + echo + dim " Full log: $BOOTSTRAP_RAW" + echo + abort "bootstrap" +fi + +# setup.sh may have installed pnpm to a prefix not on our PATH — replay +# the same lookup nanoclaw.sh does. +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 + +if ! command -v pnpm >/dev/null 2>&1; then + step_fail "pnpm not found after bootstrap" + abort "pnpm-missing" +fi + +# ─── phase 0b: find v1 install ────────────────────────────────────────── + +find_v1() { + # Explicit override + if [ -n "${NANOCLAW_V1_PATH:-}" ]; then + if [ -f "$NANOCLAW_V1_PATH/store/messages.db" ]; then + echo "$NANOCLAW_V1_PATH" + return 0 + fi + step_fail "NANOCLAW_V1_PATH=$NANOCLAW_V1_PATH does not contain store/messages.db" + return 1 + fi + + # Scan sibling directories for anything claw-ish with a v1 DB + local parent + parent="$(dirname "$PROJECT_ROOT")" + for entry in "$parent"/*/; do + [ -d "$entry" ] || continue + # Skip ourselves + [ "$(cd "$entry" && pwd)" = "$PROJECT_ROOT" ] && continue + # Must have the v1 DB + [ -f "$entry/store/messages.db" ] || continue + # Must not be v2 (check package.json version) + if [ -f "$entry/package.json" ]; then + local ver + ver=$(grep '"version"' "$entry/package.json" 2>/dev/null | head -1 | sed -E 's/.*"([0-9]+)\..*/\1/') + [ "$ver" = "2" ] && continue + fi + echo "$(cd "$entry" && pwd)" + return 0 + done + + return 1 +} + +V1_PATH="" +if V1_PATH=$(find_v1); then + V1_VERSION=$(grep '"version"' "$V1_PATH/package.json" 2>/dev/null | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || echo "unknown") + step_ok "Found v1 at $(dim "$V1_PATH") $(dim "(v$V1_VERSION)")" + log "v1 found: $V1_PATH (v$V1_VERSION)" +else + step_fail "No v1 install found" + echo + echo " $(dim 'Set NANOCLAW_V1_PATH to point at your v1 checkout:')" + echo " $(dim 'NANOCLAW_V1_PATH=~/nanoclaw bash migrate-v2.sh')" + echo + abort "v1-not-found" +fi + +# ─── phase 0c: validate v1 DB ─────────────────────────────────────────── + +V1_DB="$V1_PATH/store/messages.db" + +# Quick schema check — make sure the tables we need exist +TABLES=$(sqlite3 "$V1_DB" ".tables" 2>/dev/null || true) + +if echo "$TABLES" | grep -q "registered_groups"; then + step_ok "v1 database has registered_groups" +else + step_fail "v1 database missing registered_groups table" + abort "v1-db-invalid" +fi + +# Show what we found +GROUP_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0) +TASK_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM scheduled_tasks WHERE status='active'" 2>/dev/null || echo 0) +ENV_KEYS=0 +if [ -f "$V1_PATH/.env" ]; then + ENV_KEYS=$(grep -c '=' "$V1_PATH/.env" 2>/dev/null || echo 0) +fi + +step_info "v1 state: $(bold "$GROUP_COUNT") groups, $(bold "$TASK_COUNT") active tasks, $(bold "$ENV_KEYS") env keys" + +echo +step_ok "Phase 0 complete — ready to migrate" +echo +log "Phase 0 complete: groups=$GROUP_COUNT tasks=$TASK_COUNT env_keys=$ENV_KEYS" + +export NANOCLAW_V1_PATH="$V1_PATH" +export NANOCLAW_V2_PATH="$PROJECT_ROOT" + +# ─── run_step helper ───────────────────────────────────────────────────── +# Runs a TypeScript migration step, captures output, reports success/failure. + +# Track step outcomes for handoff.json +declare -A STEP_RESULTS + +run_step() { + local name=$1 label=$2 script=$3 + shift 3 + local raw="$STEPS_DIR/${name}.log" + + if pnpm exec tsx "$script" "$@" > "$raw" 2>&1; then + local result + result=$(grep '^OK:' "$raw" | head -1 || true) + step_ok "$label $(dim "$result")" + log "$name: $result" + STEP_RESULTS[$name]="success" + elif grep -q '^SKIPPED:' "$raw" 2>/dev/null; then + local reason + reason=$(grep '^SKIPPED:' "$raw" | head -1 | sed 's/^SKIPPED://') + step_skip "$label $(dim "($reason)")" + log "$name: skipped ($reason)" + STEP_RESULTS[$name]="skipped" + else + step_fail "$label" + echo + tail -10 "$raw" 2>/dev/null | while IFS= read -r line; do + echo " $(dim "$line")" + done + echo + log "$name: FAILED (see $raw)" + STEP_RESULTS[$name]="failed" + fi +} + +# ─── phase 1: core state ──────────────────────────────────────────────── + +echo "$(bold 'Phase 1: Core state')" +echo + +run_step "1a-env" \ + "Merge .env" \ + "setup/migrate-v2/env.ts" "$V1_PATH" + +run_step "1b-db" \ + "Seed v2 database" \ + "setup/migrate-v2/db.ts" "$V1_PATH" + +run_step "1c-groups" \ + "Copy group folders" \ + "setup/migrate-v2/groups.ts" "$V1_PATH" + +run_step "1d-sessions" \ + "Copy session data" \ + "setup/migrate-v2/sessions.ts" "$V1_PATH" + +run_step "1e-tasks" \ + "Port scheduled tasks" \ + "setup/migrate-v2/tasks.ts" "$V1_PATH" + +echo +step_ok "Phase 1 complete" +echo + +# ─── phase 2: channels (interactive) ──────────────────────────────────── + +echo "$(bold 'Phase 2: Channels')" +echo + +# Channel selection — clack multiselect (interactive) or NANOCLAW_CHANNELS env var. +# NANOCLAW_CHANNELS accepts comma-separated channel names: "telegram,discord" +SELECTED_CHANNELS=() +CHANNEL_SELECT_OUT="$STEPS_DIR/2a-channels-selected.txt" + +pnpm exec tsx setup/migrate-v2/select-channels.ts "$CHANNEL_SELECT_OUT" || true + +if [ -f "$CHANNEL_SELECT_OUT" ]; then + while IFS= read -r ch; do + [ -n "$ch" ] && SELECTED_CHANNELS+=("$ch") + done < "$CHANNEL_SELECT_OUT" +fi + +if [ ${#SELECTED_CHANNELS[@]} -eq 0 ]; then + echo + step_skip "No channels selected" +else + echo + step_info "Selected: ${SELECTED_CHANNELS[*]}" + echo + + # 2b. Copy channel auth state + run_step "2b-channel-auth" \ + "Copy channel credentials" \ + "setup/migrate-v2/channel-auth.ts" "$V1_PATH" "${SELECTED_CHANNELS[@]}" + + # 2c. Install channel code + for ch in "${SELECTED_CHANNELS[@]}"; do + INSTALL_SCRIPT="setup/install-${ch}.sh" + if [ -f "$INSTALL_SCRIPT" ]; then + STEP_LOG="$STEPS_DIR/2c-install-${ch}.log" + if bash "$INSTALL_SCRIPT" > "$STEP_LOG" 2>&1; then + STATUS_LINE=$(grep '^STATUS:' "$STEP_LOG" | head -1 | sed 's/^STATUS: *//') + if [ "$STATUS_LINE" = "already-installed" ]; then + step_skip "Install $ch $(dim "(already installed)")" + else + step_ok "Install $ch" + fi + log "install-$ch: $STATUS_LINE" + else + step_fail "Install $ch" + tail -5 "$STEP_LOG" 2>/dev/null | while IFS= read -r line; do + echo " $(dim "$line")" + done + log "install-$ch: FAILED (see $STEP_LOG)" + fi + else + step_skip "Install $ch $(dim "(no install script)")" + fi + done +fi + +echo +step_ok "Phase 2 complete" +echo + +# ─── phase 3: infrastructure ──────────────────────────────────────────── + +echo "$(bold 'Phase 3: Infrastructure')" +echo + +# 3a. OneCLI — detect or install via setup step +ONECLI_OK=false +ONECLI_URL_FROM_ENV=$(grep '^ONECLI_URL=' .env 2>/dev/null | head -1 | sed 's/^ONECLI_URL=//') +ONECLI_URL_CHECK="${ONECLI_URL_FROM_ENV:-http://127.0.0.1:10254}" + +if curl -sf "${ONECLI_URL_CHECK}/health" >/dev/null 2>&1; then + step_ok "OneCLI running at $(dim "$ONECLI_URL_CHECK")" + ONECLI_OK=true + log "OneCLI: running at $ONECLI_URL_CHECK" +else + # Run the setup onecli step — it handles install, reuse, and health checks + step_info "Setting up OneCLI…" + ONECLI_LOG="$STEPS_DIR/3a-onecli.log" + ONECLI_ERR="$STEPS_DIR/3a-onecli.err" + if pnpm exec tsx setup/index.ts --step onecli > "$ONECLI_LOG" 2>"$ONECLI_ERR"; then + step_ok "OneCLI ready" + ONECLI_OK=true + STEP_RESULTS["3a-onecli"]="success" + log "OneCLI: installed/configured" + else + step_fail "OneCLI setup failed $(dim "(see $ONECLI_LOG)")" + STEP_RESULTS["3a-onecli"]="failed" + log "OneCLI: FAILED" + fi +fi + +# 3b. Anthropic credential — run the auth setup step if no credential found +if grep -qE '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)=' .env 2>/dev/null; then + step_ok "Anthropic credential found in .env" + log "Anthropic credential: found in .env" +elif [ "$ONECLI_OK" = "true" ]; then + step_info "Registering Anthropic credential…" + AUTH_LOG="$STEPS_DIR/3b-auth.log" + AUTH_ERR="$STEPS_DIR/3b-auth.err" + if pnpm exec tsx setup/index.ts --step auth > "$AUTH_LOG" 2>"$AUTH_ERR"; then + step_ok "Anthropic credential registered" + STEP_RESULTS["3b-auth"]="success" + log "Anthropic credential: registered via auth step" + else + step_fail "Auth setup failed $(dim "(see $AUTH_LOG)")" + STEP_RESULTS["3b-auth"]="failed" + log "Anthropic credential: FAILED" + fi +else + step_info "No Anthropic credential $(dim "(OneCLI not available — add manually to .env)")" + log "Anthropic credential: skipped (no OneCLI)" +fi + +# 3c. Docker check +if command -v docker >/dev/null 2>&1; then + DOCKER_V=$(docker --version 2>/dev/null | head -1) + step_ok "Docker available $(dim "($DOCKER_V)")" + log "Docker: $DOCKER_V" +else + step_fail "Docker not found" + step_info "$(dim "Install Docker: bash setup/install-docker.sh")" + log "Docker: not found" +fi + +# 3d. Copy container skills from v1 that v2 doesn't have +V1_SKILLS_DIR="$V1_PATH/container/skills" +V2_SKILLS_DIR="$PROJECT_ROOT/container/skills" + +if [ -d "$V1_SKILLS_DIR" ]; then + SKILLS_COPIED=0 + SKILLS_SKIPPED=0 + for skill_dir in "$V1_SKILLS_DIR"/*/; do + [ -d "$skill_dir" ] || continue + skill_name=$(basename "$skill_dir") + if [ -d "$V2_SKILLS_DIR/$skill_name" ]; then + SKILLS_SKIPPED=$((SKILLS_SKIPPED + 1)) + else + cp -r "$skill_dir" "$V2_SKILLS_DIR/$skill_name" + SKILLS_COPIED=$((SKILLS_COPIED + 1)) + fi + done + if [ $SKILLS_COPIED -gt 0 ]; then + step_ok "Copied $SKILLS_COPIED container skills $(dim "(skipped $SKILLS_SKIPPED already in v2)")" + else + step_skip "All v1 container skills already in v2 $(dim "($SKILLS_SKIPPED)")" + fi + log "Container skills: copied=$SKILLS_COPIED skipped=$SKILLS_SKIPPED" +else + step_skip "No v1 container skills" +fi + +# 3e. Build agent container image +if command -v docker >/dev/null 2>&1; then + step_info "Building agent container image…" + BUILD_LOG="$STEPS_DIR/3e-container-build.log" + if bash container/build.sh > "$BUILD_LOG" 2>&1; then + step_ok "Container image built" + log "Container build: success" + else + step_fail "Container build failed" + tail -10 "$BUILD_LOG" 2>/dev/null | while IFS= read -r line; do + echo " $(dim "$line")" + done + log "Container build: FAILED (see $BUILD_LOG)" + fi +else + step_skip "Container build $(dim "(no Docker)")" +fi + +echo +step_ok "Phase 3 complete" +echo + +# ─── service switchover ───────────────────────────────────────────────── + +echo "$(bold 'Service switchover')" +echo + +# Detect platform and service names +V1_SERVICE="" +V2_SERVICE="" +PLATFORM_SERVICE="" + +if [ "$(uname -s)" = "Darwin" ]; then + PLATFORM_SERVICE="launchd" + V1_SERVICE="com.nanoclaw" + # v2 uses install-slug for unique service names + V2_SERVICE=$(pnpm exec tsx -e "import{getLaunchdLabel}from'./src/install-slug.js';console.log(getLaunchdLabel())" 2>/dev/null || echo "") +elif [ "$(uname -s)" = "Linux" ]; then + PLATFORM_SERVICE="systemd" + V1_SERVICE="nanoclaw" + V2_SERVICE=$(pnpm exec tsx -e "import{getSystemdUnit}from'./src/install-slug.js';console.log(getSystemdUnit())" 2>/dev/null || echo "") +fi + +# Check if v1 service is running +V1_RUNNING=false +if [ "$PLATFORM_SERVICE" = "systemd" ]; then + systemctl --user is-active "$V1_SERVICE" >/dev/null 2>&1 && V1_RUNNING=true +elif [ "$PLATFORM_SERVICE" = "launchd" ]; then + launchctl list "$V1_SERVICE" >/dev/null 2>&1 && V1_RUNNING=true +fi + +SERVICE_SWITCHED=false +if [ "$V1_RUNNING" = "true" ]; then + step_info "v1 service is running $(dim "($V1_SERVICE)")" + + # Ask user if they want to switch + SWITCH_ANSWER_FILE=$(mktemp) + pnpm exec tsx setup/migrate-v2/switchover-prompt.ts --offer-switch "$SWITCH_ANSWER_FILE" || true + SWITCH_ANSWER=$(cat "$SWITCH_ANSWER_FILE" 2>/dev/null || echo "skip") + rm -f "$SWITCH_ANSWER_FILE" + + if [ "$SWITCH_ANSWER" = "switch" ]; then + # Stop v1 + if [ "$PLATFORM_SERVICE" = "systemd" ]; then + systemctl --user stop "$V1_SERVICE" 2>/dev/null && step_ok "Stopped v1 service" || step_fail "Could not stop v1" + elif [ "$PLATFORM_SERVICE" = "launchd" ]; then + launchctl unload ~/Library/LaunchAgents/${V1_SERVICE}.plist 2>/dev/null && step_ok "Stopped v1 service" || step_fail "Could not stop v1" + fi + + # Install and start v2 service + V2_SERVICE_LOG="$STEPS_DIR/service-install.log" + V2_SERVICE_ERR="$STEPS_DIR/service-install.err" + if pnpm exec tsx setup/index.ts --step service > "$V2_SERVICE_LOG" 2>"$V2_SERVICE_ERR"; then + # Parse the actual unit name from the service step stdout (clean, no ANSI) + if [ "$PLATFORM_SERVICE" = "systemd" ]; then + V2_SERVICE=$(grep '^SERVICE_UNIT:' "$V2_SERVICE_LOG" | head -1 | sed 's/^SERVICE_UNIT: *//') + elif [ "$PLATFORM_SERVICE" = "launchd" ]; then + V2_SERVICE=$(grep '^SERVICE_LABEL:' "$V2_SERVICE_LOG" | head -1 | sed 's/^SERVICE_LABEL: *//') + fi + step_ok "v2 service installed and started $(dim "($V2_SERVICE)")" + else + step_fail "Could not start v2 service $(dim "(see $V2_SERVICE_LOG)")" + fi + + SERVICE_SWITCHED=true + echo + step_info "v2 is running — send a test message to your bot" + echo + + # Ask: keep or revert? + KEEP_ANSWER_FILE=$(mktemp) + pnpm exec tsx setup/migrate-v2/switchover-prompt.ts --keep-or-revert "$KEEP_ANSWER_FILE" || true + KEEP_ANSWER=$(cat "$KEEP_ANSWER_FILE" 2>/dev/null || echo "keep") + rm -f "$KEEP_ANSWER_FILE" + + if [ "$KEEP_ANSWER" = "revert" ]; then + # Stop v2 + if [ "$PLATFORM_SERVICE" = "systemd" ] && [ -n "$V2_SERVICE" ]; then + systemctl --user stop "$V2_SERVICE" 2>/dev/null || true + systemctl --user disable "$V2_SERVICE" 2>/dev/null || true + elif [ "$PLATFORM_SERVICE" = "launchd" ] && [ -n "$V2_SERVICE" ]; then + launchctl unload ~/Library/LaunchAgents/${V2_SERVICE}.plist 2>/dev/null || true + fi + + # Restart v1 + if [ "$PLATFORM_SERVICE" = "systemd" ]; then + systemctl --user start "$V1_SERVICE" 2>/dev/null || true + elif [ "$PLATFORM_SERVICE" = "launchd" ]; then + launchctl load ~/Library/LaunchAgents/${V1_SERVICE}.plist 2>/dev/null || true + fi + + step_ok "Reverted to v1 service" + SERVICE_SWITCHED=false + else + step_ok "Keeping v2 service" + # Disable v1 from auto-starting + if [ "$PLATFORM_SERVICE" = "systemd" ]; then + systemctl --user disable "$V1_SERVICE" 2>/dev/null || true + fi + fi + else + step_skip "Service switchover skipped" + fi +else + step_skip "v1 service not running — nothing to switch" +fi + +echo + +# ─── phase 4: handoff ─────────────────────────────────────────────────── +# handoff.json is written by the EXIT trap (write_handoff) — always, even on +# abort. Here we just print the summary. + +echo "$(bold 'Phase 4: Handoff')" +echo + +step_ok "Wrote handoff summary" + +# Summary +echo +echo "$(bold '── Migration complete ──')" +echo +echo " $(dim 'v1:') $V1_PATH" +echo " $(dim 'v2:') $PROJECT_ROOT" +echo +echo " $(bold 'What was done:')" +echo " $(green '✓') .env keys merged" +echo " $(green '✓') Database seeded (agent groups, messaging groups, wiring)" +echo " $(green '✓') Group folders copied (CLAUDE.md → CLAUDE.local.md)" +echo " $(green '✓') Session data copied" +echo " $(green '✓') Scheduled tasks ported" +if [ ${#SELECTED_CHANNELS[@]} -gt 0 ]; then +echo " $(green '✓') Channels installed: ${SELECTED_CHANNELS[*]}" +fi +echo " $(green '✓') Container skills copied" +echo " $(green '✓') Container image built" +echo +echo " $(bold 'What still needs a human:')" +if [ "$ONECLI_OK" = "false" ]; then +echo " $(dim '·') Set up OneCLI: pnpm exec tsx setup/index.ts --step onecli" +fi +if ! grep -qE '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)=' .env 2>/dev/null; then +echo " $(dim '·') Add Anthropic credential to .env or OneCLI vault" +fi +echo " $(dim '·') Run $(bold '/migrate-from-v1') in Claude to finish:" +echo " $(dim '- Seed your owner account')" +echo " $(dim '- Set access policies')" +echo " $(dim '- Port any custom v1 code')" +echo +echo " $(dim "Handoff: $LOGS_DIR/setup-migration/handoff.json")" +echo " $(dim "Full log: $MIGRATE_LOG")" +echo " $(dim "Step logs: $STEPS_DIR/")" +echo + +# ─── hand off to Claude ───────────────────────────────────────────────── + +if command -v claude >/dev/null 2>&1; then + write_handoff + trap - EXIT + exec claude "/migrate-from-v1" +fi diff --git a/setup/migrate-v1/shared.ts b/setup/migrate-v1/shared.ts index 4597fcf..4d2cd92 100644 --- a/setup/migrate-v1/shared.ts +++ b/setup/migrate-v1/shared.ts @@ -431,12 +431,14 @@ export function triggerToEngage(input: { if (pattern === '.' || pattern === '.*') { return { engage_mode: 'pattern', engage_pattern: '.' }; } - if (pattern) { - return { engage_mode: 'pattern', engage_pattern: pattern }; - } + // requires_trigger=0 means "respond to everything" regardless of pattern. + // The pattern was used for mention highlighting, not message gating. if (!requiresTrigger) { return { engage_mode: 'pattern', engage_pattern: '.' }; } + if (pattern) { + return { engage_mode: 'pattern', engage_pattern: pattern }; + } return { engage_mode: 'mention', engage_pattern: null }; } diff --git a/setup/migrate-v2/channel-auth.ts b/setup/migrate-v2/channel-auth.ts new file mode 100644 index 0000000..788ae9d --- /dev/null +++ b/setup/migrate-v2/channel-auth.ts @@ -0,0 +1,134 @@ +/** + * migrate-v2 step: channel-auth + * + * Copy channel auth state from v1 to v2 for selected channels. + * Handles both env keys and on-disk auth files (Baileys, Matrix, etc.) + * per the CHANNEL_AUTH_REGISTRY. + * + * Usage: pnpm exec tsx setup/migrate-v2/channel-auth.ts [channel2...] + */ +import fs from 'fs'; +import path from 'path'; + +import { CHANNEL_AUTH_REGISTRY } from '../migrate-v1/shared.js'; + +function parseEnv(filePath: string): Map { + const out = new Map(); + if (!fs.existsSync(filePath)) return out; + for (const line of fs.readFileSync(filePath, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + out.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1)); + } + return out; +} + +function appendEnvKey(envPath: string, key: string, value: string): boolean { + const existing = parseEnv(envPath); + if (existing.has(key)) return false; + + let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8') : ''; + if (content && !content.endsWith('\n')) content += '\n'; + content += `${key}=${value}\n`; + fs.writeFileSync(envPath, content); + return true; +} + +function copyGlob(v1Root: string, v2Root: string, relativePath: string): string[] { + const src = path.join(v1Root, relativePath); + if (!fs.existsSync(src)) return []; + + const copied: string[] = []; + const stat = fs.statSync(src); + + if (stat.isFile()) { + const dst = path.join(v2Root, relativePath); + if (!fs.existsSync(dst)) { + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.copyFileSync(src, dst); + copied.push(relativePath); + } + } else if (stat.isDirectory()) { + const dst = path.join(v2Root, relativePath); + fs.mkdirSync(dst, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const sub = path.join(relativePath, entry.name); + copied.push(...copyGlob(v1Root, v2Root, sub)); + } + } + + return copied; +} + +function main(): void { + const args = process.argv.slice(2); + const v1Path = args[0]; + const channels = args.slice(1); + + if (!v1Path || channels.length === 0) { + console.error('Usage: tsx setup/migrate-v2/channel-auth.ts [channel2...]'); + process.exit(1); + } + + const v1EnvPath = path.join(v1Path, '.env'); + const v2EnvPath = path.join(process.cwd(), '.env'); + const v1Env = parseEnv(v1EnvPath); + + let envKeysCopied = 0; + let filesCopied = 0; + let channelsProcessed = 0; + const missing: string[] = []; + + for (const channel of channels) { + const spec = CHANNEL_AUTH_REGISTRY[channel]; + if (!spec) { + // Unknown channel — just try copying env keys with common naming + channelsProcessed++; + continue; + } + + // Copy env keys + for (const key of spec.v1EnvKeys) { + const value = v1Env.get(key); + if (value) { + if (appendEnvKey(v2EnvPath, key, value)) { + envKeysCopied++; + } + } + } + + // Check required v2 keys — report missing ones + const v2Env = parseEnv(v2EnvPath); + for (const req of spec.requiredV2Keys) { + if (!v2Env.has(req.key)) { + missing.push(`${channel}:${req.key} (${req.where})`); + } + } + + // Copy on-disk auth files + for (const candidate of spec.candidatePaths) { + const copied = copyGlob(v1Path, process.cwd(), candidate); + filesCopied += copied.length; + } + + channelsProcessed++; + } + + // Sync to data/env/env + if (fs.existsSync(v2EnvPath)) { + const containerEnvDir = path.join(process.cwd(), 'data', 'env'); + try { + fs.mkdirSync(containerEnvDir, { recursive: true }); + fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env')); + } catch { /* non-fatal */ } + } + + console.log(`OK:channels=${channelsProcessed},env_keys=${envKeysCopied},files=${filesCopied}`); + if (missing.length > 0) { + console.log(`MISSING:${missing.join(',')}`); + } +} + +main(); diff --git a/setup/migrate-v2/db.ts b/setup/migrate-v2/db.ts new file mode 100644 index 0000000..141b267 --- /dev/null +++ b/setup/migrate-v2/db.ts @@ -0,0 +1,162 @@ +/** + * migrate-v2 step: db + * + * Seed v2.db from v1's registered_groups table. + * Creates agent_groups, messaging_groups, and messaging_group_agents. + * + * Does NOT seed users/user_roles — the /migrate-from-v1 skill handles that. + * + * Idempotent: re-running skips rows that already exist. + * + * Usage: pnpm exec tsx setup/migrate-v2/db.ts + */ +import fs from 'fs'; +import path from 'path'; + +import Database from 'better-sqlite3'; + +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 { + generateId, + parseJid, + triggerToEngage, + JID_PREFIX_TO_CHANNEL, +} from '../migrate-v1/shared.js'; + +interface V1Group { + jid: string; + name: string; + folder: string; + trigger_pattern: string | null; + requires_trigger: number | null; + is_main: number | null; +} + +function main(): void { + const v1Path = process.argv[2]; + if (!v1Path) { + console.error('Usage: tsx setup/migrate-v2/db.ts '); + process.exit(1); + } + + const v1DbPath = path.join(v1Path, 'store', 'messages.db'); + if (!fs.existsSync(v1DbPath)) { + console.error(`v1 DB not found: ${v1DbPath}`); + process.exit(1); + } + + // Read v1 groups + const v1Db = new Database(v1DbPath, { readonly: true, fileMustExist: true }); + + // v1 schema varies — channel_name was a late addition. Query only the + // columns we know exist in all v1 installs. + const v1Groups = v1Db + .prepare('SELECT jid, name, folder, trigger_pattern, requires_trigger, is_main FROM registered_groups') + .all() as V1Group[]; + v1Db.close(); + + if (v1Groups.length === 0) { + console.log('SKIPPED:no registered groups in v1'); + process.exit(0); + } + + // Init v2 DB + fs.mkdirSync(path.join(process.cwd(), 'data'), { recursive: true }); + const v2Db = initDb(path.join(DATA_DIR, 'v2.db')); + runMigrations(v2Db); + + let created = 0; + let reused = 0; + let skipped = 0; + const errors: string[] = []; + + for (const g of v1Groups) { + const parsed = parseJid(g.jid); + if (!parsed) { + skipped++; + errors.push(`Could not parse JID: ${g.jid}`); + continue; + } + + const channelType = parsed.channel_type; + const platformId = parsed.raw.startsWith(`${channelType}:`) + ? parsed.raw + : `${channelType}:${parsed.id}`; + const createdAt = new Date().toISOString(); + + try { + // agent_group — one per folder + let ag = getAgentGroupByFolder(g.folder); + if (!ag) { + createAgentGroup({ + id: generateId('ag'), + name: g.name || g.folder, + folder: g.folder, + agent_provider: null, + created_at: createdAt, + }); + ag = getAgentGroupByFolder(g.folder)!; + } + + // messaging_group — one per (channel_type, platform_id) + let mg = getMessagingGroupByPlatform(channelType, platformId); + if (!mg) { + createMessagingGroup({ + id: generateId('mg'), + channel_type: channelType, + platform_id: platformId, + name: g.name || null, + is_group: 1, + unknown_sender_policy: 'public', + created_at: createdAt, + }); + mg = getMessagingGroupByPlatform(channelType, platformId)!; + } + + // messaging_group_agents — wire them + const existing = getMessagingGroupAgentByPair(mg.id, ag.id); + if (!existing) { + const engage = triggerToEngage({ + trigger_pattern: g.trigger_pattern, + requires_trigger: g.requires_trigger, + }); + createMessagingGroupAgent({ + id: generateId('mga'), + messaging_group_id: mg.id, + agent_group_id: ag.id, + engage_mode: engage.engage_mode, + engage_pattern: engage.engage_pattern, + sender_scope: 'all', + ignored_message_policy: 'drop', + session_mode: 'shared', + priority: 0, + created_at: createdAt, + }); + created++; + } else { + reused++; + } + } catch (err) { + skipped++; + errors.push(`${g.folder}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + v2Db.close(); + + console.log(`OK:groups=${v1Groups.length},created=${created},reused=${reused},skipped=${skipped}`); + if (errors.length > 0) { + for (const e of errors) console.log(`ERROR:${e}`); + } +} + +main(); diff --git a/setup/migrate-v2/env.ts b/setup/migrate-v2/env.ts new file mode 100644 index 0000000..5ac52f6 --- /dev/null +++ b/setup/migrate-v2/env.ts @@ -0,0 +1,81 @@ +/** + * migrate-v2 step: env + * + * Copy every key from v1 .env into v2 .env. Never overwrites existing v2 + * keys. Idempotent — re-running skips keys already present. + * + * Usage: pnpm exec tsx setup/migrate-v2/env.ts + */ +import fs from 'fs'; +import path from 'path'; + +function parseEnv(text: string): Map { + const out = new Map(); + for (const raw of text.split('\n')) { + const line = raw.trimEnd(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue; + out.set(key, line); + } + return out; +} + +function main(): void { + const v1Path = process.argv[2]; + if (!v1Path) { + console.error('Usage: tsx setup/migrate-v2/env.ts '); + process.exit(1); + } + + const v1EnvPath = path.join(v1Path, '.env'); + if (!fs.existsSync(v1EnvPath)) { + console.log('SKIPPED:no v1 .env'); + process.exit(0); + } + + const v2EnvPath = path.join(process.cwd(), '.env'); + const v1Lines = parseEnv(fs.readFileSync(v1EnvPath, 'utf-8')); + const v2Text = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; + const v2Lines = parseEnv(v2Text); + + const copied: string[] = []; + const skipped: string[] = []; + const appended: string[] = []; + + const BLOCK_START = '# ── migrated from v1 ──'; + const alreadyMigrated = v2Text.includes(BLOCK_START); + + for (const [key, raw] of v1Lines) { + if (v2Lines.has(key)) { + skipped.push(key); + continue; + } + copied.push(key); + appended.push(raw); + } + + if (appended.length > 0) { + let result = v2Text; + if (result && !result.endsWith('\n')) result += '\n'; + if (!alreadyMigrated) result += `\n${BLOCK_START}\n`; + result += appended.join('\n') + '\n'; + fs.writeFileSync(v2EnvPath, result); + } + + // Sync to data/env/env (container reads from here) + const containerEnvDir = path.join(process.cwd(), 'data', 'env'); + try { + fs.mkdirSync(containerEnvDir, { recursive: true }); + fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env')); + } catch { + // Non-fatal + } + + console.log(`OK:copied=${copied.length},skipped=${skipped.length}`); + if (copied.length > 0) console.log(`COPIED:${copied.join(',')}`); +} + +main(); diff --git a/setup/migrate-v2/groups.ts b/setup/migrate-v2/groups.ts new file mode 100644 index 0000000..beb88be --- /dev/null +++ b/setup/migrate-v2/groups.ts @@ -0,0 +1,120 @@ +/** + * migrate-v2 step: groups + * + * Copy v1 group folders into v2. + * - v1 CLAUDE.md → v2 CLAUDE.local.md (v2 composes CLAUDE.md at spawn) + * - v1 container_config → .v1-container-config.json sidecar + * - All other files copied (no overwrite) + * - Also copies global/ if it exists + * + * Idempotent — does not overwrite files that already exist in v2. + * + * Usage: pnpm exec tsx setup/migrate-v2/groups.ts + */ +import fs from 'fs'; +import path from 'path'; + +import Database from 'better-sqlite3'; + +const SKIP_NAMES = new Set(['CLAUDE.md', 'logs', '.git', '.DS_Store', 'node_modules']); + +/** Copy a directory tree, skipping SKIP_NAMES. Never overwrites existing files. */ +function copyTree(src: string, dst: string): number { + let written = 0; + if (!fs.existsSync(src)) return 0; + fs.mkdirSync(dst, { recursive: true }); + + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (SKIP_NAMES.has(entry.name)) continue; + const s = path.join(src, entry.name); + const d = path.join(dst, entry.name); + + if (entry.isDirectory()) { + written += copyTree(s, d); + continue; + } + if (fs.existsSync(d)) continue; + fs.copyFileSync(s, d); + written += 1; + } + return written; +} + +function main(): void { + const v1Path = process.argv[2]; + if (!v1Path) { + console.error('Usage: tsx setup/migrate-v2/groups.ts '); + process.exit(1); + } + + const v1GroupsDir = path.join(v1Path, 'groups'); + const v2GroupsDir = path.join(process.cwd(), 'groups'); + + if (!fs.existsSync(v1GroupsDir)) { + console.log('SKIPPED:no v1 groups/ directory'); + process.exit(0); + } + + // Get all folders from v1 DB to know which groups are registered + const v1DbPath = path.join(v1Path, 'store', 'messages.db'); + const registeredFolders = new Set(); + if (fs.existsSync(v1DbPath)) { + const v1Db = new Database(v1DbPath, { readonly: true, fileMustExist: true }); + const rows = v1Db + .prepare('SELECT folder, container_config FROM registered_groups') + .all() as Array<{ folder: string; container_config: string | null }>; + const containerConfigs = new Map(); + for (const r of rows) { + registeredFolders.add(r.folder); + containerConfigs.set(r.folder, r.container_config); + } + v1Db.close(); + + // Write container.json from v1 container_config. + // The additionalMounts shape is identical between v1 and v2. + for (const [folder, config] of containerConfigs) { + if (!config) continue; + const v2Folder = path.join(v2GroupsDir, folder); + const containerJson = path.join(v2Folder, 'container.json'); + if (fs.existsSync(containerJson)) continue; + fs.mkdirSync(v2Folder, { recursive: true }); + try { + const parsed = JSON.parse(config) as Record; + fs.writeFileSync(containerJson, JSON.stringify(parsed, null, 2)); + } catch { + // Unparseable config — write as sidecar for the skill to handle + fs.writeFileSync(path.join(v2Folder, '.v1-container-config.json'), config); + } + } + } + + // Copy all v1 group folders (registered + global + any extras) + let foldersCopied = 0; + let claudesMigrated = 0; + let filesCopied = 0; + + for (const entry of fs.readdirSync(v1GroupsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const folder = entry.name; + const v1Folder = path.join(v1GroupsDir, folder); + const v2Folder = path.join(v2GroupsDir, folder); + + fs.mkdirSync(v2Folder, { recursive: true }); + + // CLAUDE.md → CLAUDE.local.md + const v1Claude = path.join(v1Folder, 'CLAUDE.md'); + const v2Local = path.join(v2Folder, 'CLAUDE.local.md'); + if (fs.existsSync(v1Claude) && !fs.existsSync(v2Local)) { + fs.copyFileSync(v1Claude, v2Local); + claudesMigrated++; + } + + // Copy everything else + filesCopied += copyTree(v1Folder, v2Folder); + foldersCopied++; + } + + console.log(`OK:folders=${foldersCopied},claudes=${claudesMigrated},files=${filesCopied}`); +} + +main(); diff --git a/setup/migrate-v2/select-channels.ts b/setup/migrate-v2/select-channels.ts new file mode 100644 index 0000000..eecf1ab --- /dev/null +++ b/setup/migrate-v2/select-channels.ts @@ -0,0 +1,63 @@ +/** + * migrate-v2: interactive channel selection via clack multiselect. + * + * Writes selected channel names (one per line) to the file path given as + * the first argument. Clack renders to the terminal normally. + * + * If NANOCLAW_CHANNELS env var is set (comma-separated names), skips the + * prompt and writes those directly. + * + * Usage: pnpm exec tsx setup/migrate-v2/select-channels.ts + */ +import fs from 'fs'; + +import * as p from '@clack/prompts'; + +const CHANNELS = [ + { value: 'telegram', label: 'Telegram' }, + { value: 'discord', label: 'Discord' }, + { value: 'slack', label: 'Slack' }, + { value: 'whatsapp', label: 'WhatsApp' }, + { value: 'teams', label: 'Microsoft Teams' }, + { value: 'matrix', label: 'Matrix' }, + { value: 'imessage', label: 'iMessage' }, + { value: 'webex', label: 'Webex' }, + { value: 'gchat', label: 'Google Chat' }, + { value: 'resend', label: 'Resend (email)' }, + { value: 'github', label: 'GitHub' }, + { value: 'linear', label: 'Linear' }, + { value: 'whatsapp-cloud', label: 'WhatsApp Cloud API' }, +]; + +const VALID_NAMES = new Set(CHANNELS.map((c) => c.value)); + +async function main(): Promise { + const outFile = process.argv[2]; + if (!outFile) { + console.error('Usage: tsx setup/migrate-v2/select-channels.ts '); + process.exit(1); + } + + // Non-interactive: NANOCLAW_CHANNELS="telegram,discord" + const envChannels = process.env.NANOCLAW_CHANNELS?.trim(); + if (envChannels) { + const names = envChannels.split(',').map((s) => s.trim()).filter((s) => VALID_NAMES.has(s)); + fs.writeFileSync(outFile, names.join('\n') + '\n'); + return; + } + + const selected = await p.multiselect({ + message: 'Which channels do you want to set up?', + options: CHANNELS, + required: false, + }); + + if (p.isCancel(selected)) { + fs.writeFileSync(outFile, ''); + return; + } + + fs.writeFileSync(outFile, (selected as string[]).join('\n') + '\n'); +} + +main(); diff --git a/setup/migrate-v2/sessions.ts b/setup/migrate-v2/sessions.ts new file mode 100644 index 0000000..0299dec --- /dev/null +++ b/setup/migrate-v2/sessions.ts @@ -0,0 +1,181 @@ +/** + * migrate-v2 step: sessions + * + * For each v1 session folder, create a proper v2 session: + * 1. Create a sessions row in v2.db (via resolveSession) + * 2. Initialize the session folder (inbound.db, outbound.db, outbox/) + * 3. Write session routing so the container knows where to reply + * 4. Copy v1 .claude/ state into v2's .claude-shared/ directory + * + * v1: data/sessions//.claude/ (settings, conversation history, skills) + * v2: data/v2-sessions//.claude-shared/ + session folder + * + * v1's agent-runner-src/ is NOT copied — v2 uses a completely different + * Bun-based agent-runner. + * + * Idempotent — reuses existing sessions, does not overwrite files. + * + * Usage: pnpm exec tsx setup/migrate-v2/sessions.ts + */ +import fs from 'fs'; +import path from 'path'; + +import Database from 'better-sqlite3'; + +import { DATA_DIR } from '../../src/config.js'; +import { initDb, closeDb } from '../../src/db/connection.js'; +import { getAllAgentGroups } from '../../src/db/agent-groups.js'; +import { getMessagingGroupsByAgentGroup } from '../../src/db/messaging-groups.js'; +import { runMigrations } from '../../src/db/migrations/index.js'; +import { + resolveSession, + writeSessionRouting, + outboundDbPath, +} from '../../src/session-manager.js'; + +const SKIP_NAMES = new Set(['.DS_Store']); + +/** Recursively copy, never overwriting existing files. */ +function copyTree(src: string, dst: string): number { + let written = 0; + if (!fs.existsSync(src)) return 0; + fs.mkdirSync(dst, { recursive: true }); + + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (SKIP_NAMES.has(entry.name)) continue; + const s = path.join(src, entry.name); + const d = path.join(dst, entry.name); + + if (entry.isDirectory()) { + written += copyTree(s, d); + continue; + } + if (fs.existsSync(d)) continue; + fs.copyFileSync(s, d); + written += 1; + } + return written; +} + +function main(): void { + const v1Path = process.argv[2]; + if (!v1Path) { + console.error('Usage: tsx setup/migrate-v2/sessions.ts '); + process.exit(1); + } + + const v1SessionsDir = path.join(v1Path, 'data', 'sessions'); + if (!fs.existsSync(v1SessionsDir)) { + console.log('SKIPPED:no v1 data/sessions/ directory'); + process.exit(0); + } + + // Init v2 central DB + const v2DbPath = path.join(DATA_DIR, 'v2.db'); + if (!fs.existsSync(v2DbPath)) { + console.error('v2.db not found — run db step first'); + process.exit(1); + } + + const v2Db = initDb(v2DbPath); + runMigrations(v2Db); + + const agentGroups = getAllAgentGroups(); + const folderToAg = new Map(); + for (const ag of agentGroups) { + folderToAg.set(ag.folder, ag); + } + + let sessionsCreated = 0; + let sessionsReused = 0; + let sessionsSkipped = 0; + let filesCopied = 0; + + for (const entry of fs.readdirSync(v1SessionsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const folder = entry.name; + + const ag = folderToAg.get(folder); + if (!ag) { + sessionsSkipped++; + continue; + } + + // Find the messaging groups wired to this agent group + const messagingGroups = getMessagingGroupsByAgentGroup(ag.id); + if (messagingGroups.length === 0) { + sessionsSkipped++; + continue; + } + + // Create a session for each messaging group (v1 had one session per + // folder, v2 has one per agent_group + messaging_group pair) + for (const mg of messagingGroups) { + const { session, created } = resolveSession(ag.id, mg.id, null, 'shared'); + + if (created) { + // Write routing so the container knows where to reply + writeSessionRouting(ag.id, session.id); + sessionsCreated++; + } else { + sessionsReused++; + } + } + + // Copy v1 .claude/ state into v2's .claude-shared/ directory + // This is per-agent-group, shared across all sessions for that group + const v1ClaudeDir = path.join(v1SessionsDir, folder, '.claude'); + if (fs.existsSync(v1ClaudeDir)) { + const v2ClaudeDir = path.join(DATA_DIR, 'v2-sessions', ag.id, '.claude-shared'); + filesCopied += copyTree(v1ClaudeDir, v2ClaudeDir); + + // v1 containers worked in /workspace/group, v2 works in /workspace/agent. + // Claude Code stores sessions under projects//. Copy the v1 + // project dir to the v2 path so Claude Code finds the conversation history. + const projectsDir = path.join(v2ClaudeDir, 'projects'); + const v1ProjectDir = path.join(projectsDir, '-workspace-group'); + const v2ProjectDir = path.join(projectsDir, '-workspace-agent'); + if (fs.existsSync(v1ProjectDir) && !fs.existsSync(v2ProjectDir)) { + filesCopied += copyTree(v1ProjectDir, v2ProjectDir); + } + + // Write the v1 Claude Code session ID as the continuation in outbound.db + // so the agent-runner resumes the exact same conversation. + // The session ID is the JSONL filename (without extension) under the + // project dir. + const sourceDir = fs.existsSync(v2ProjectDir) ? v2ProjectDir : v1ProjectDir; + if (fs.existsSync(sourceDir)) { + const jsonlFiles = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.jsonl')); + if (jsonlFiles.length > 0) { + // Use the most recent JSONL file (by mtime from v1) + const v1SessionId = jsonlFiles + .map((f) => ({ + name: f.replace('.jsonl', ''), + mtime: fs.statSync(path.join(sourceDir, f)).mtimeMs, + })) + .sort((a, b) => b.mtime - a.mtime)[0].name; + + // Write into each v2 session's outbound.db for this agent group + const sessions = getMessagingGroupsByAgentGroup(ag.id); + for (const mg of sessions) { + const { session } = resolveSession(ag.id, mg.id, null, 'shared'); + const obPath = outboundDbPath(ag.id, session.id); + if (fs.existsSync(obPath)) { + const ob = new Database(obPath); + ob.prepare( + "INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES ('continuation:claude', ?, ?)", + ).run(v1SessionId, new Date().toISOString()); + ob.close(); + } + } + } + } + } + } + + closeDb(); + + console.log(`OK:created=${sessionsCreated},reused=${sessionsReused},skipped=${sessionsSkipped},files=${filesCopied}`); +} + +main(); diff --git a/setup/migrate-v2/switchover-prompt.ts b/setup/migrate-v2/switchover-prompt.ts new file mode 100644 index 0000000..996b302 --- /dev/null +++ b/setup/migrate-v2/switchover-prompt.ts @@ -0,0 +1,53 @@ +/** + * migrate-v2: service switchover prompts. + * + * Writes a single word to the output file: + * --offer-switch → "switch" | "skip" + * --keep-or-revert → "keep" | "revert" + * + * Clack renders to the terminal normally. + * + * Usage: pnpm exec tsx setup/migrate-v2/switchover-prompt.ts --offer-switch + */ +import fs from 'fs'; + +import * as p from '@clack/prompts'; + +async function main(): Promise { + const mode = process.argv[2]; + const outFile = process.argv[3]; + + if (!outFile) { + console.error('Usage: tsx setup/migrate-v2/switchover-prompt.ts <--offer-switch|--keep-or-revert> '); + process.exit(1); + } + + if (mode === '--offer-switch') { + const answer = await p.select({ + message: 'Want to stop the v1 service and start v2 so you can test?', + options: [ + { value: 'switch', label: 'Yes, switch to v2 now', hint: 'you can switch back after' }, + { value: 'skip', label: 'No, skip for now', hint: 'start v2 manually later' }, + ], + }); + fs.writeFileSync(outFile, p.isCancel(answer) ? 'skip' : String(answer)); + return; + } + + if (mode === '--keep-or-revert') { + const answer = await p.select({ + message: 'Keep v2 running, or switch back to v1?', + options: [ + { value: 'keep', label: 'Keep v2', hint: 'v1 stays stopped' }, + { value: 'revert', label: 'Switch back to v1', hint: 'stop v2, restart v1' }, + ], + }); + fs.writeFileSync(outFile, p.isCancel(answer) ? 'revert' : String(answer)); + return; + } + + console.error('Usage: --offer-switch | --keep-or-revert'); + process.exit(1); +} + +main(); diff --git a/setup/migrate-v2/tasks.ts b/setup/migrate-v2/tasks.ts new file mode 100644 index 0000000..9ba570a --- /dev/null +++ b/setup/migrate-v2/tasks.ts @@ -0,0 +1,158 @@ +/** + * migrate-v2 step: tasks + * + * Port v1 scheduled_tasks into v2 session inbound DBs. + * + * v1: scheduled_tasks table (schedule_type, schedule_value, next_run) + * v2: messages_in rows with kind='task' in per-session inbound.db + * + * Requires: db step must have run first (agent_groups + messaging_groups seeded). + * + * Usage: pnpm exec tsx setup/migrate-v2/tasks.ts + */ +import fs from 'fs'; +import path from 'path'; + +import Database from 'better-sqlite3'; + +import { DATA_DIR } from '../../src/config.js'; +import { initDb, closeDb } from '../../src/db/connection.js'; +import { getAgentGroupByFolder } from '../../src/db/agent-groups.js'; +import { getMessagingGroupByPlatform } from '../../src/db/messaging-groups.js'; +import { runMigrations } from '../../src/db/migrations/index.js'; +import { insertTask } from '../../src/modules/scheduling/db.js'; +import { openInboundDb, resolveSession } from '../../src/session-manager.js'; +import { parseJid, v2PlatformId } from '../migrate-v1/shared.js'; + +interface V1Task { + id: string; + group_folder: string; + chat_jid: string; + prompt: string; + schedule_type: string; + schedule_value: string; + next_run: string | null; + status: string; + context_mode: string | null; + script: string | null; +} + +function toCron(t: V1Task): { processAfter: string; recurrence: string | null } | null { + const now = new Date().toISOString(); + + if (t.schedule_type === 'cron') { + const fields = t.schedule_value.trim().split(/\s+/).length; + if (fields < 5 || fields > 6) return null; + return { processAfter: t.next_run || now, recurrence: t.schedule_value.trim() }; + } + + if (t.schedule_type === 'interval') { + const m = /^(\d+)([smhd])$/.exec(t.schedule_value.trim()); + if (!m) return null; + const n = parseInt(m[1], 10); + const unit = m[2]; + if (!n || n < 1) return null; + let cron: string | null = null; + if (unit === 'm' && n < 60) cron = `*/${n} * * * *`; + else if (unit === 'h' && n < 24) cron = `0 */${n} * * *`; + else if (unit === 'd' && n < 28) cron = `0 0 */${n} * *`; + if (!cron) return null; + return { processAfter: t.next_run || now, recurrence: cron }; + } + + if (t.schedule_type === 'once' || t.schedule_type === 'at') { + return { processAfter: t.next_run || t.schedule_value || now, recurrence: null }; + } + + return null; +} + +function main(): void { + const v1Path = process.argv[2]; + if (!v1Path) { + console.error('Usage: tsx setup/migrate-v2/tasks.ts '); + process.exit(1); + } + + const v1DbPath = path.join(v1Path, 'store', 'messages.db'); + if (!fs.existsSync(v1DbPath)) { + console.log('SKIPPED:no v1 DB'); + process.exit(0); + } + + // Read v1 tasks + const v1Db = new Database(v1DbPath, { readonly: true, fileMustExist: true }); + const allTasks = v1Db.prepare('SELECT * FROM scheduled_tasks').all() as V1Task[]; + v1Db.close(); + + const activeTasks = allTasks.filter((t) => t.status === 'active'); + if (activeTasks.length === 0) { + console.log('SKIPPED:no active tasks'); + process.exit(0); + } + + // Init v2 central DB + const v2DbPath = path.join(DATA_DIR, 'v2.db'); + if (!fs.existsSync(v2DbPath)) { + console.error('v2.db not found — run db step first'); + process.exit(1); + } + const v2Db = initDb(v2DbPath); + runMigrations(v2Db); + + let migrated = 0; + let skipped = 0; + let failed = 0; + + for (const t of activeTasks) { + try { + const ag = getAgentGroupByFolder(t.group_folder); + if (!ag) { skipped++; continue; } + + const parsed = parseJid(t.chat_jid); + if (!parsed) { skipped++; continue; } + + const platformId = v2PlatformId(parsed.channel_type, t.chat_jid); + const mg = getMessagingGroupByPlatform(parsed.channel_type, platformId); + if (!mg) { skipped++; continue; } + + const scheduling = toCron(t); + if (!scheduling) { skipped++; continue; } + + const { session } = resolveSession(ag.id, mg.id, null, 'shared'); + const inboxDb = openInboundDb(ag.id, session.id); + try { + // Idempotence check + const existing = inboxDb + .prepare("SELECT id FROM messages_in WHERE id = ? AND kind = 'task'") + .get(t.id) as { id: string } | undefined; + if (existing) { skipped++; continue; } + + insertTask(inboxDb, { + id: t.id, + processAfter: scheduling.processAfter, + recurrence: scheduling.recurrence, + platformId, + channelType: parsed.channel_type, + threadId: null, + content: JSON.stringify({ + prompt: t.prompt, + script: t.script ?? null, + migrated_from_v1: { original_id: t.id, context_mode: t.context_mode ?? null }, + }), + }); + migrated++; + } finally { + inboxDb.close(); + } + } catch (err) { + failed++; + console.error(`TASK_ERROR:${t.id}:${err instanceof Error ? err.message : String(err)}`); + } + } + + closeDb(); + console.log(`OK:active=${activeTasks.length},migrated=${migrated},skipped=${skipped},failed=${failed}`); +} + +main(); From 67eb85d818f641cdfb5898eafe7c4d3ae1f4084b Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 1 May 2026 20:20:06 +0000 Subject: [PATCH 102/144] chore: remove old setup-embedded migration steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old migration flow (detect → validate → db → groups → env → channel-auth → channels → tasks) ran inside `bash nanoclaw.sh` via setup/auto.ts. Replaced by the standalone `bash migrate-v2.sh` flow. Deleted: - setup/migrate-v1.ts (orchestrator) - setup/migrate-v1/{detect,validate,db,env,groups,channel-auth,channels,tasks}.ts Kept: - setup/migrate-v1/shared.ts (used by new migrate-v2/ steps) Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/auto.ts | 14 +- setup/index.ts | 9 +- setup/migrate-v1.ts | 257 ------------------------- setup/migrate-v1/channel-auth.ts | 262 ------------------------- setup/migrate-v1/channels.ts | 172 ----------------- setup/migrate-v1/db.ts | 321 ------------------------------- setup/migrate-v1/detect.ts | 107 ----------- setup/migrate-v1/env.ts | 135 ------------- setup/migrate-v1/groups.ts | 230 ---------------------- setup/migrate-v1/tasks.ts | 307 ----------------------------- setup/migrate-v1/validate.ts | 213 -------------------- 11 files changed, 4 insertions(+), 2023 deletions(-) delete mode 100644 setup/migrate-v1.ts delete mode 100644 setup/migrate-v1/channel-auth.ts delete mode 100644 setup/migrate-v1/channels.ts delete mode 100644 setup/migrate-v1/db.ts delete mode 100644 setup/migrate-v1/detect.ts delete mode 100644 setup/migrate-v1/env.ts delete mode 100644 setup/migrate-v1/groups.ts delete mode 100644 setup/migrate-v1/tasks.ts delete mode 100644 setup/migrate-v1/validate.ts diff --git a/setup/auto.ts b/setup/auto.ts index ab0cbb4..147eed4 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -14,10 +14,8 @@ * "Terminal Agent". * NANOCLAW_SKIP comma-separated step names to skip * (environment|container|onecli|auth|mounts| - * service|cli-agent|timezone|migration|channel| + * service|cli-agent|timezone|channel| * verify|first-chat) - * NANOCLAW_V1_PATH explicit path to a v1 install to migrate - * from (default: scan common locations) * * Timezone is auto-detected after the CLI agent step. UTC resolves are * confirmed with the user, and free-text replies fall through to a @@ -41,7 +39,6 @@ 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 { runMigrateV1 } from './migrate-v1.js'; import { applyToEnv, parseFlags, @@ -437,13 +434,8 @@ async function main(): Promise { await runTimezoneStep(); } - if (!skip.has('migration')) { - // Runs silently when there's no v1 install; otherwise orchestrates the - // detect → validate → db → groups → env → channel-auth → channels → - // tasks sub-steps and writes logs/setup-migration/handoff.json for the - // /migrate-from-v1 skill to pick up. - await runMigrateV1(); - } + // v1 → v2 migration is handled by `bash migrate-v2.sh`, not the setup flow. + // Users migrating from v1 run that script before (or instead of) setup. let channelChoice: ChannelChoice = 'skip'; diff --git a/setup/index.ts b/setup/index.ts index a6c66ec..ee02e45 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -14,6 +14,7 @@ const STEPS: Record< environment: () => import('./environment.js'), container: () => import('./container.js'), register: () => import('./register.js'), + 'pair-telegram': () => import('./pair-telegram.js'), groups: () => import('./groups.js'), 'whatsapp-auth': () => import('./whatsapp-auth.js'), 'signal-auth': () => import('./signal-auth.js'), @@ -23,14 +24,6 @@ const STEPS: Record< onecli: () => import('./onecli.js'), auth: () => import('./auth.js'), 'cli-agent': () => import('./cli-agent.js'), - 'migrate-detect': () => import('./migrate-v1/detect.js'), - 'migrate-validate': () => import('./migrate-v1/validate.js'), - 'migrate-db': () => import('./migrate-v1/db.js'), - 'migrate-groups': () => import('./migrate-v1/groups.js'), - 'migrate-env': () => import('./migrate-v1/env.js'), - 'migrate-channel-auth': () => import('./migrate-v1/channel-auth.js'), - 'migrate-channels': () => import('./migrate-v1/channels.js'), - 'migrate-tasks': () => import('./migrate-v1/tasks.js'), }; async function main(): Promise { diff --git a/setup/migrate-v1.ts b/setup/migrate-v1.ts deleted file mode 100644 index a3c1883..0000000 --- a/setup/migrate-v1.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * v1 → v2 migration orchestrator. Called from setup/auto.ts after the - * timezone step and before the channel step. - * - * Silent happy path: if no v1 install is found, we emit one "skipped" step - * and return. Users on a fresh v2 install never see anything. - * - * When v1 IS found: detect → [confirm] → group-selection prompt → validate - * → db → groups → env → channel-auth → channels → tasks → handoff. - * Every sub-step is a separate entry in the progression log; failures never - * abort the chain (the handoff file records them for the skill to finish). - * - * After everything runs, a one-line note points the user at the - * `/migrate-from-v1` skill. - */ -import fs from 'fs'; -import path from 'path'; - -import * as p from '@clack/prompts'; -import Database from 'better-sqlite3'; -import k from 'kleur'; - -import { ensureAnswer, runQuietStep } from './lib/runner.js'; -import { wrapForGutter } from './lib/theme.js'; -import * as setupLog from './logs.js'; -import { - HANDOFF_PATH, - MIGRATION_DIR, - inferChannelType, - readHandoff, - v1PathsFor, - writeHandoff, -} from './migrate-v1/shared.js'; - -/** - * Count groups in v1's registered_groups, split by whether the channel_type - * can be inferred. Uses the same `inferChannelType` logic as migrate-db so - * the displayed count matches what will actually get seeded. Open-and-close - * because this runs in the orchestrator before migrate-db's child process. - */ -function countV1Groups(v1Root: string): { total: number; wired: number } { - const dbPath = v1PathsFor(v1Root).db; - try { - const db = new Database(dbPath, { readonly: true, fileMustExist: true }); - const rows = db - .prepare('SELECT jid, channel_name FROM registered_groups') - .all() as Array<{ jid: string; channel_name: string | null }>; - db.close(); - let wired = 0; - for (const r of rows) { - if (inferChannelType(r.jid, r.channel_name)) wired++; - } - return { total: rows.length, wired }; - } catch { - return { total: 0, wired: 0 }; - } -} - -async function askGroupSelection(counts: { total: number; wired: number }): Promise<'all' | 'wired-only' | 'cancel'> { - // Non-interactive escape hatch for CI / re-runs / scripted migrations. - // NANOCLAW_MIGRATE_SELECTION = 'all' | 'wired-only' | 'cancel'. - const envChoice = process.env.NANOCLAW_MIGRATE_SELECTION?.trim(); - if (envChoice === 'all' || envChoice === 'wired-only' || envChoice === 'cancel') { - setupLog.userInput('migrate_selection', `${envChoice} (from NANOCLAW_MIGRATE_SELECTION)`); - return envChoice; - } - // Most v1 installs accumulated many orphan folders. Default the user to - // wired-only (the ones we can actually route) — explicit opt-in for "all". - const choice = ensureAnswer( - await p.select({ - message: `Found ${counts.total} v1 group folders (${counts.wired} wired to a channel). Which to bring over?`, - options: [ - { - value: 'wired-only', - label: `Only the ${counts.wired} wired ones`, - hint: 'recommended — skips orphans', - }, - { - value: 'all', - label: `All ${counts.total} folders`, - hint: 'brings dead/orphan folders over too', - }, - { - value: 'cancel', - label: 'Skip migration', - hint: "I'll migrate later", - }, - ], - }), - ) as 'all' | 'wired-only' | 'cancel'; - setupLog.userInput('migrate_selection', choice); - return choice; -} - -/** - * Finalize the handoff record after every sub-step has run. Computes an - * overall status from per-step statuses: anything `failed` → partial; - * anything `partial` → partial; else success. - */ -function finalizeHandoff(): 'success' | 'partial' | 'failed' { - const h = readHandoff(); - const statuses = Object.values(h.steps).map((s) => s?.status); - const anyFailed = statuses.includes('failed'); - const anyPartial = statuses.includes('partial'); - const overall: 'success' | 'partial' | 'failed' = anyFailed - ? 'partial' // DB or files may have landed; the skill can pick up the rest - : anyPartial - ? 'partial' - : 'success'; - h.overall_status = overall; - writeHandoff(h); - return overall; -} - -function printHandoffNote(overall: 'success' | 'partial' | 'failed'): void { - const relHandoff = path.relative(process.cwd(), HANDOFF_PATH); - const lines: string[] = []; - if (overall === 'success') { - lines.push( - wrapForGutter( - 'Your v1 install has been migrated. Run `/migrate-from-v1` in Claude next — it will seed your owner account and help port any custom code you had.', - 4, - ), - ); - } else { - lines.push( - wrapForGutter( - 'Migration finished with some items for a human. Run `/migrate-from-v1` in Claude — it will read the handoff, finish the unfinished steps, and walk through custom code.', - 4, - ), - ); - } - lines.push(''); - lines.push(k.dim(` Handoff: ${relHandoff}`)); - lines.push(k.dim(` Full log: ${setupLog.progressLogPath}`)); - lines.push(k.dim(` Raw logs: ${setupLog.stepsDir}/`)); - p.note(lines.join('\n'), 'Migration handoff'); -} - -export async function runMigrateV1(): Promise<'proceeded' | 'skipped' | 'cancelled'> { - // 0. Ensure migration log dir exists before any sub-step writes to it. - fs.mkdirSync(MIGRATION_DIR, { recursive: true }); - - // 1. Detect. If nothing obvious, give the user one subtle chance to point - // us at a non-standard path — then accept silently. - const detect = await runQuietStep('migrate-detect', { - running: 'Checking for a previous NanoClaw install…', - done: 'Found a previous install.', - skipped: 'No previous install to migrate.', - }); - - const v1Found = detect.ok && detect.terminal?.fields.STATUS === 'success'; - - if (!v1Found) { - // Silent skip — the 99% case is a fresh install with no v1 anywhere. - // Prompting for a custom path on every fresh run is UX noise. Users - // with a v1 at a non-standard location use `NANOCLAW_V1_PATH= - // bash nanoclaw.sh` (documented in README + setup/auto.ts header). - return 'skipped'; - } - - // 2. Ask the user which groups to bring over. - const h = readHandoff(); - if (!h.v1_path) { - // Shouldn't happen — detect set it if v1Found. Guard anyway. - return 'skipped'; - } - - // Experimental warning — fires only when a v1 install is found, so stock - // v2 users (no v1 to migrate) never see it. Not a blocker; the user can - // still proceed. Skip when NANOCLAW_MIGRATE_SELECTION is set (scripted / - // CI runs have already accepted the risk by defining their selection). - if (!process.env.NANOCLAW_MIGRATE_SELECTION) { - p.log.warn( - wrapForGutter( - 'v1 → v2 migration is experimental. Back up your v2 state (data/v2.db, groups/) before continuing. Not recommended for high-stakes production installs — it does a best-effort port and a human still has to finish via /migrate-from-v1.', - 4, - ), - ); - } - - const counts = countV1Groups(h.v1_path); - const selection = await askGroupSelection(counts); - if (selection === 'cancel') { - // Mark the handoff so the skill can still see what would have happened. - const ho = readHandoff(); - ho.overall_status = 'skipped'; - writeHandoff(ho); - return 'cancelled'; - } - - // 3. Validate — if it fails, subsequent steps will short-circuit the - // DB-dependent parts. Groups + env still run. - await runQuietStep('migrate-validate', { - running: "Checking the v1 database's shape…", - done: 'v1 database looks good.', - failed: "v1 database didn't match what I expected.", - skipped: 'Skipped database validation.', - }); - - // 4. DB seeding — parameterized by the user's selection. - await runQuietStep( - 'migrate-db', - { - running: 'Seeding v2 agents and channels from v1…', - done: 'Seeded v2 database.', - skipped: 'Skipped database seeding.', - failed: "Couldn't seed the v2 database.", - }, - ['--selection', selection], - ); - - // 5. Group folders. - await runQuietStep('migrate-groups', { - running: 'Copying group folders…', - done: 'Group folders copied.', - skipped: 'Skipped group-folder copy.', - failed: "Couldn't copy some group folders.", - }); - - // 6. Env keys. - await runQuietStep('migrate-env', { - running: 'Merging v1 .env into v2 .env…', - done: 'Env keys migrated.', - skipped: 'No env keys to migrate.', - failed: "Couldn't merge .env.", - }); - - // 7. Non-env channel auth (Baileys keystore, matrix state, etc.). - await runQuietStep('migrate-channel-auth', { - running: 'Copying channel auth files…', - done: 'Channel auth copied.', - skipped: 'No channel auth to copy.', - failed: 'Some channel auth files need attention.', - }); - - // 8. Install v2 channel adapters for the detected channels. - await runQuietStep('migrate-channels', { - running: 'Installing v2 channel adapters…', - done: 'Channel adapters installed.', - skipped: 'No channels to install.', - failed: 'Some channel adapters need attention.', - }); - - // 9. Scheduled tasks. - await runQuietStep('migrate-tasks', { - running: 'Porting scheduled tasks…', - done: 'Scheduled tasks ported.', - skipped: 'No scheduled tasks to port.', - failed: 'Some scheduled tasks need attention.', - }); - - // 10. Finalize + hand off. - const overall = finalizeHandoff(); - printHandoffNote(overall); - return 'proceeded'; -} diff --git a/setup/migrate-v1/channel-auth.ts b/setup/migrate-v1/channel-auth.ts deleted file mode 100644 index 8c8a415..0000000 --- a/setup/migrate-v1/channel-auth.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Step: migrate-channel-auth - * - * For each channel detected in migrate-db, copy non-.env auth state from v1 - * to the matching v2 location. Env keys are handled by migrate-env (this - * step reads the registry to confirm they made it over, but doesn't rewrite - * them). Files are copied from the first matching candidate path in the - * registry — missing paths are recorded so the skill can prompt the user. - * - * Destination uses the same relative path on v2 (e.g. v1 has - * `data/sessions/baileys/` → v2 gets `data/sessions/baileys/`). If v2 already - * has a different file/dir at that path, we skip and flag it — never clobber. - */ -import fs from 'fs'; -import path from 'path'; - -import { emitStatus } from '../status.js'; -import { - CHANNEL_AUTH_REGISTRY, - autoResolveV2Keys, - readHandoff, - recordStep, - v1PathsFor, - writeHandoff, -} from './shared.js'; - -/** - * Copy file or directory tree from src to dst. `force: false` means existing - * files on the v2 side are never clobbered — important because we'd otherwise - * overwrite auth state the user may have set up on v2 directly. Returns a - * rough count of files copied (post-hoc walk of the destination). - */ -function copyRecursive(src: string, dst: string): number { - if (!fs.existsSync(src)) return 0; - fs.mkdirSync(path.dirname(dst), { recursive: true }); - fs.cpSync(src, dst, { recursive: true, force: false, errorOnExist: false }); - return countFilesUnder(dst); -} - -function countFilesUnder(p: string): number { - if (!fs.existsSync(p)) return 0; - if (fs.statSync(p).isFile()) return 1; - let n = 0; - for (const entry of fs.readdirSync(p, { withFileTypes: true })) { - n += countFilesUnder(path.join(p, entry.name)); - } - return n; -} - -export async function run(_args: string[]): Promise { - const h = readHandoff(); - if (!h.v1_path) { - recordStep('migrate-channel-auth', { - status: 'skipped', - fields: { REASON: 'detect-not-run' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_CHANNEL_AUTH', { STATUS: 'skipped', REASON: 'no_v1_path' }); - return; - } - - const channels = h.detected_channels; - if (channels.length === 0) { - recordStep('migrate-channel-auth', { - status: 'skipped', - fields: { REASON: 'no-channels-detected' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_CHANNEL_AUTH', { STATUS: 'skipped', REASON: 'no_channels' }); - return; - } - - const v1Paths = v1PathsFor(h.v1_path); - const v1Env = fs.existsSync(v1Paths.env) ? fs.readFileSync(v1Paths.env, 'utf-8') : ''; - const v1EnvKeys = new Set( - v1Env - .split('\n') - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith('#')) - .map((line) => line.split('=')[0].trim()) - .filter(Boolean), - ); - - const results: typeof h.channel_auth = []; - const followups: string[] = []; - let anyMissingRequired = false; - - for (const ch of channels) { - const spec = CHANNEL_AUTH_REGISTRY[ch.channel_type]; - if (!spec) { - // Unknown channel — give the skill enough context to drive a useful - // interview instead of a generic "we don't know." Scan v1's .env for - // keys that look related (substring match on channel name + common - // suffixes) and list v1 state directories the user should check. - const haystack = ch.channel_type.toLowerCase(); - const candidateEnvKeys = [...v1EnvKeys].filter((k) => { - const lk = k.toLowerCase(); - return ( - lk.includes(haystack) || - (haystack.length >= 3 && lk.includes(haystack.slice(0, 3))) - ); - }); - const v1DataDirs = ['data', 'store', 'data/sessions'] - .map((d) => path.join(h.v1_path, d)) - .filter((p) => fs.existsSync(p)); - - results.push({ - channel_type: ch.channel_type, - env_keys_copied: [], - files_copied: [], - files_missing: [], - notes: `Unknown channel (not in CHANNEL_AUTH_REGISTRY). Inferred via ${ch.source}. Candidate v1 env keys: ${candidateEnvKeys.join(', ') || 'none found'}. Check v1 dirs: ${v1DataDirs.join(', ') || '(none)'}.`, - }); - followups.push( - `Channel "${ch.channel_type}" (${ch.group_count} group(s), inferred via ${ch.source}) is not in the auth registry. ` + - `Candidate v1 env keys that may belong to it: ${candidateEnvKeys.length > 0 ? candidateEnvKeys.join(', ') : '(none obvious)'}. ` + - `Check v1 for on-disk auth state under ${v1DataDirs.join(', ') || '(no standard dirs found)'}. ` + - `The skill should interview the user, then add a registry entry to setup/migrate-v1/shared.ts for future migrations.`, - ); - continue; - } - - const envKeysPresentInV1 = spec.v1EnvKeys.filter((key) => v1EnvKeys.has(key)); - - // Check v2's .env for required keys the v2 adapter needs to boot. v1 - // may not have had all of them (e.g. v1's Discord used discord.js - // directly and never stored DISCORD_PUBLIC_KEY which v2's Chat SDK - // requires). Try to auto-resolve the gap by calling the channel's API - // with the v1 credential; fall through to a followup for anything we - // can't resolve. - const v2EnvPath = path.join(process.cwd(), '.env'); - const v1EnvMap = new Map(); - for (const line of v1Env.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eq = trimmed.indexOf('='); - if (eq <= 0) continue; - v1EnvMap.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1)); - } - - // Also let the resolver reach into v2's .env (migrate-env already merged - // v1 keys into v2). Either source is fine for derivation inputs. - const v2EnvPre = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; - const v2EnvPreMap = new Map(); - for (const line of v2EnvPre.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eq = trimmed.indexOf('='); - if (eq <= 0) continue; - v2EnvPreMap.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1)); - } - - const resolved = await autoResolveV2Keys( - ch.channel_type, - (key) => v1EnvMap.get(key) ?? v2EnvPreMap.get(key), - ); - const resolvedKeys = Object.keys(resolved); - if (resolvedKeys.length > 0) { - // Append to v2 .env (never overwriting existing values) + sync the - // container-side copy. Log keys, never values. - let text = v2EnvPre; - if (text && !text.endsWith('\n')) text += '\n'; - for (const [key, value] of Object.entries(resolved)) { - if (v2EnvPreMap.has(key)) continue; - text += `${key}=${value}\n`; - } - fs.writeFileSync(v2EnvPath, text); - try { - const containerEnvDir = path.join(process.cwd(), 'data', 'env'); - fs.mkdirSync(containerEnvDir, { recursive: true }); - fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env')); - } catch { - // Best-effort; service restart rehydrates it if needed. - } - } - - // Re-read v2 .env after possible resolution to compute the real gap. - const v2Env = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; - const v2EnvKeys = new Set( - v2Env - .split('\n') - .map((l) => l.trim()) - .filter((l) => l && !l.startsWith('#')) - .map((l) => l.split('=')[0].trim()) - .filter(Boolean), - ); - const missingRequired = spec.requiredV2Keys.filter((r) => !v2EnvKeys.has(r.key)); - if (missingRequired.length > 0) { - anyMissingRequired = true; - followups.push( - `Channel "${ch.channel_type}" is missing required v2 keys in .env: ${missingRequired - .map((r) => `${r.key} (${r.where})`) - .join('; ')}. The v2 adapter won't boot until these are set.`, - ); - } - - const filesCopied: string[] = []; - const filesMissing: string[] = []; - - for (const relPath of spec.candidatePaths) { - const src = path.join(h.v1_path, relPath); - if (!fs.existsSync(src)) continue; - - const dst = path.join(process.cwd(), relPath); - if (fs.existsSync(dst)) { - followups.push( - `Channel "${ch.channel_type}": v2 already has ${relPath} — left untouched. Reconcile manually if needed.`, - ); - filesMissing.push(`${relPath} (already exists in v2)`); - continue; - } - - try { - const count = copyRecursive(src, dst); - filesCopied.push(`${relPath} (${count} files)`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - filesMissing.push(`${relPath} (copy failed: ${message})`); - followups.push(`Channel "${ch.channel_type}": failed to copy ${relPath} — ${message}`); - } - } - - if (spec.candidatePaths.length > 0 && filesCopied.length === 0) { - filesMissing.push(`(no candidate paths existed under ${h.v1_path})`); - } - - results.push({ - channel_type: ch.channel_type, - env_keys_copied: [...envKeysPresentInV1, ...resolvedKeys.map((k) => `${k} (auto-resolved)`)], - files_copied: filesCopied, - files_missing: filesMissing, - notes: spec.note ?? '', - }); - } - - const handoffAfter = readHandoff(); - handoffAfter.channel_auth = results; - handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; - writeHandoff(handoffAfter); - - const anyFileMissing = results.some((r) => r.files_missing.length > 0); - const anyPartial = anyFileMissing || anyMissingRequired; - recordStep('migrate-channel-auth', { - status: anyPartial ? 'partial' : 'success', - fields: { - CHANNELS: channels.map((c) => c.channel_type).join(','), - FILES_COPIED: results.reduce((sum, r) => sum + r.files_copied.length, 0), - FILES_MISSING: results.reduce((sum, r) => sum + r.files_missing.length, 0), - }, - notes: followups, - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_CHANNEL_AUTH', { - STATUS: anyPartial ? 'partial' : 'success', - CHANNELS: channels.map((c) => c.channel_type).join(','), - FILES_COPIED: String(results.reduce((sum, r) => sum + r.files_copied.length, 0)), - FILES_MISSING: String(results.reduce((sum, r) => sum + r.files_missing.length, 0)), - }); -} diff --git a/setup/migrate-v1/channels.ts b/setup/migrate-v1/channels.ts deleted file mode 100644 index 89df966..0000000 --- a/setup/migrate-v1/channels.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Step: migrate-channels - * - * For each channel detected in migrate-db, run the corresponding v2 - * `setup/install-.sh` script in non-interactive mode. The script - * copies the adapter from the `channels` branch, installs the pinned - * dependency, and rebuilds. Credentials in v2 `.env` (migrate-env already - * copied them) are picked up automatically on the next service restart. - * - * This step does NOT run the pairing flow for each channel (that needs - * interactive prompts). The user is guided through pairing by the normal - * channel-selection step in setup/auto.ts, which happens immediately after - * migration. Installing the adapter first means that step won't have to - * re-install. - * - * Channels not supported in v2 are recorded in the handoff as - * `not_supported` so the skill can raise them with the user. - */ -import { spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import { log } from '../../src/log.js'; -import { emitStatus } from '../status.js'; -import { - installScriptForChannel, - readHandoff, - recordStep, - writeHandoff, -} from './shared.js'; - -function runScript(script: string): Promise<{ code: number; stdout: string; stderr: string }> { - return new Promise((resolve) => { - const child = spawn('bash', [script], { - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, MIGRATION_NONINTERACTIVE: '1' }, - }); - // Capture both streams silently — the parent is under a clack spinner, - // and forwarding to stdout/stderr would break the spinner UI. The full - // transcript still lands in this step's raw log via the parent's tee - // (runner.ts: spawnStep writes this step's stdout/stderr to logs/setup- - // steps/NN-migrate-channels.log already). - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (c: Buffer) => { - stdout += c.toString('utf-8'); - }); - child.stderr.on('data', (c: Buffer) => { - stderr += c.toString('utf-8'); - }); - child.on('close', (code) => - resolve({ code: code ?? 1, stdout, stderr }), - ); - child.on('error', () => - resolve({ code: 1, stdout, stderr: stderr || 'spawn_error' }), - ); - }); -} - -export async function run(_args: string[]): Promise { - const h = readHandoff(); - if (!h.v1_path) { - recordStep('migrate-channels', { - status: 'skipped', - fields: { REASON: 'detect-not-run' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_CHANNELS', { STATUS: 'skipped', REASON: 'no_v1_path' }); - return; - } - - const channels = h.detected_channels; - if (channels.length === 0) { - recordStep('migrate-channels', { - status: 'skipped', - fields: { REASON: 'no-channels-detected' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_CHANNELS', { STATUS: 'skipped', REASON: 'no_channels' }); - return; - } - - const results: typeof h.channels_installed = []; - const followups: string[] = []; - - for (const ch of channels) { - const script = installScriptForChannel(ch.channel_type); - if (!script) { - results.push({ - channel_type: ch.channel_type, - status: 'not_supported', - }); - followups.push( - `Channel "${ch.channel_type}" has no v2 install script. The /migrate-from-v1 skill should ask the user whether to keep it as an orphan messaging_group or drop it.`, - ); - continue; - } - - const absoluteScript = path.join(process.cwd(), script); - if (!fs.existsSync(absoluteScript)) { - results.push({ - channel_type: ch.channel_type, - status: 'failed', - error: `install script missing at ${script}`, - }); - followups.push(`Install script for "${ch.channel_type}" missing at ${script} — this is a v2 repo issue, not a user issue.`); - continue; - } - - log.info('Running channel install script', { channel: ch.channel_type, script: absoluteScript }); - const { code, stdout, stderr } = await runScript(absoluteScript); - // Persist the install-script output to a sidecar so the skill can read it - // if diagnosis is needed. The parent's tee already captures our own - // stdout/stderr but the nested script's output is lost otherwise. - try { - const sidecar = path.join( - process.cwd(), - 'logs', - 'setup-migration', - `install-${ch.channel_type}.log`, - ); - fs.mkdirSync(path.dirname(sidecar), { recursive: true }); - fs.writeFileSync(sidecar, `# ${script}\n# exit ${code}\n\n=== stdout ===\n${stdout}\n=== stderr ===\n${stderr}\n`); - } catch { - // Sidecar is diagnostic-only — don't abort if the log dir is unwritable. - } - if (code === 0) { - results.push({ channel_type: ch.channel_type, status: 'success' }); - } else { - results.push({ - channel_type: ch.channel_type, - status: 'failed', - error: stderr.trim().slice(0, 400) || `exit ${code}`, - }); - followups.push( - `Installing "${ch.channel_type}" failed (exit ${code}). The /migrate-from-v1 skill should retry ${script} or walk the user through /add-${ch.channel_type}.`, - ); - } - } - - const handoffAfter = readHandoff(); - handoffAfter.channels_installed = results; - handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; - writeHandoff(handoffAfter); - - // `not_supported` is an expected/known outcome for channels whose v1 adapter - // has no v2 equivalent yet. It's a followup for the skill to raise — not a - // partial success. Only real install failures degrade status. - const anyFailed = results.some((r) => r.status === 'failed'); - const status: 'success' | 'partial' | 'failed' = anyFailed ? 'partial' : 'success'; - - recordStep('migrate-channels', { - status, - fields: { - INSTALLED: results.filter((r) => r.status === 'success').length, - FAILED: results.filter((r) => r.status === 'failed').length, - NOT_SUPPORTED: results.filter((r) => r.status === 'not_supported').length, - CHANNELS: results.map((r) => `${r.channel_type}=${r.status}`).join(','), - }, - notes: followups, - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_CHANNELS', { - STATUS: status, - INSTALLED: String(results.filter((r) => r.status === 'success').length), - FAILED: String(results.filter((r) => r.status === 'failed').length), - NOT_SUPPORTED: String(results.filter((r) => r.status === 'not_supported').length), - }); -} diff --git a/setup/migrate-v1/db.ts b/setup/migrate-v1/db.ts deleted file mode 100644 index d338214..0000000 --- a/setup/migrate-v1/db.ts +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Step: migrate-db - * - * Seed v2.db with the essentials derived from v1's `registered_groups`: - * - agent_groups: one per v1 folder the user selected - * - messaging_groups: one per distinct (channel_type, platform_id) pair - * - messaging_group_agents: the wiring between them, with engage fields - * backfilled from v1's trigger_pattern / requires_trigger - * - * Does NOT seed users, user_roles, or agent_group_members. v1 has no ground - * truth for them — the /migrate-from-v1 skill interviews the user for the - * owner and seeds those tables. - * - * Idempotent: re-running skips any (folder) agent_group, (channel, platform_id) - * messaging_group, and (mg, ag) wiring that already exist. Safe to re-run - * after a partial failure. - * - * Expects `--selection ` where mode is 'all' | 'wired-only'. The - * orchestrator asks the user via clack and passes the result. - */ -import fs from 'fs'; -import path from 'path'; - -import Database from 'better-sqlite3'; - -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 { log } from '../../src/log.js'; -import { emitStatus } from '../status.js'; -import { - fetchBotGuilds, - generateId, - inferChannelType, - readHandoff, - recordStep, - triggerToEngage, - v1PathsFor, - v2PlatformId, - writeHandoff, -} from './shared.js'; - -interface V1Group { - jid: string; - name: string; - folder: string; - trigger_pattern: string | null; - requires_trigger: number | null; - is_main: number | null; - channel_name: string | null; -} - -interface DbArgs { - selection: 'all' | 'wired-only'; -} - -function parseArgs(args: string[]): DbArgs { - let selection: 'all' | 'wired-only' = 'wired-only'; - for (let i = 0; i < args.length; i++) { - if (args[i] === '--selection') { - const v = args[++i]; - if (v === 'all' || v === 'wired-only') selection = v; - } - } - return { selection }; -} - -export async function run(args: string[]): Promise { - const parsed = parseArgs(args); - const h = readHandoff(); - - if (!h.v1_path) { - recordStep('migrate-db', { - status: 'skipped', - fields: { REASON: 'detect-not-run' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_DB', { STATUS: 'skipped', REASON: 'no_v1_path' }); - return; - } - - const validate = h.steps['migrate-validate']; - if (validate && validate.status === 'failed') { - recordStep('migrate-db', { - status: 'skipped', - fields: { REASON: 'validate-failed' }, - notes: ['DB shape did not validate; skipping DB migration.'], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_DB', { STATUS: 'skipped', REASON: 'validate_failed' }); - return; - } - - const paths = v1PathsFor(h.v1_path); - let v1Db: Database.Database; - try { - v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - recordStep('migrate-db', { - status: 'failed', - fields: { REASON: 'v1-db-open-failed' }, - notes: [message], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_DB', { STATUS: 'failed', REASON: 'v1_db_open_failed', ERROR: message }); - return; - } - - const v1Groups = v1Db - .prepare( - 'SELECT jid, name, folder, trigger_pattern, requires_trigger, is_main, channel_name FROM registered_groups', - ) - .all() as V1Group[]; - v1Db.close(); - - // Filter by selection mode. "wired-only" keeps rows where we can confidently - // say which channel they belong to — either `channel_name` is set, or the - // JID prefix resolves to a known channel type. - const selected: V1Group[] = []; - const detectedChannels = new Map(); - - for (const g of v1Groups) { - const channelType = inferChannelType(g.jid, g.channel_name); - const source: 'channel_name' | 'jid_prefix' = g.channel_name?.trim() ? 'channel_name' : 'jid_prefix'; - if (!channelType) { - // Can't infer — skip in both modes; the skill raises it with the user. - continue; - } - if (parsed.selection === 'wired-only' && source === 'jid_prefix' && !channelType) { - continue; - } - selected.push(g); - const entry = detectedChannels.get(channelType) ?? { source, count: 0 }; - entry.count += 1; - // Prefer explicit channel_name as the source if any row had it. - if (source === 'channel_name') entry.source = 'channel_name'; - detectedChannels.set(channelType, entry); - } - - h.group_selection = { - mode: parsed.selection, - selected_folders: selected.map((g) => g.folder), - total_v1_groups: v1Groups.length, - wired_v1_groups: selected.length, - }; - h.detected_channels = [...detectedChannels.entries()].map(([channel_type, info]) => ({ - channel_type, - source: info.source, - group_count: info.count, - })); - writeHandoff(h); - - // For channels where v2's platform_id includes a component v1 didn't record - // (Discord's guild id), fetch the bot's guilds up-front. If the bot is in - // a single guild we can splice that id into every platform_id; otherwise - // fall back to the v1-format id (v2's channel-registration flow will repair - // on first message). Done ONCE per channel_type, not per-row, so this is - // cheap regardless of group count. - const v1EnvText = fs.existsSync(paths.env) ? fs.readFileSync(paths.env, 'utf-8') : ''; - const v1EnvMap = new Map(); - for (const line of v1EnvText.split('\n')) { - const t = line.trim(); - if (!t || t.startsWith('#')) continue; - const eq = t.indexOf('='); - if (eq <= 0) continue; - v1EnvMap.set(t.slice(0, eq).trim(), t.slice(eq + 1)); - } - const singleGuildByChannel = new Map(); - for (const channelType of detectedChannels.keys()) { - const info = await fetchBotGuilds(channelType, (k) => v1EnvMap.get(k)); - if (info && info.guildIds.length === 1) { - singleGuildByChannel.set(channelType, info.guildIds[0]); - } - } - - // Initialize v2.db (creates schema if not present — runMigrations is no-op - // when the schema is already current, so this is safe on a live v2 install). - fs.mkdirSync(path.join(process.cwd(), 'data'), { recursive: true }); - const v2Path = path.join(DATA_DIR, 'v2.db'); - const v2Db = initDb(v2Path); - runMigrations(v2Db); - - let agentGroupsCreated = 0; - let agentGroupsReused = 0; - let messagingGroupsCreated = 0; - let messagingGroupsReused = 0; - let wiringsCreated = 0; - let wiringsReused = 0; - let skipped = 0; - const followups: string[] = []; - - for (const g of selected) { - const channelType = inferChannelType(g.jid, g.channel_name); - if (!channelType) { - skipped += 1; - continue; - } - - const guildId = singleGuildByChannel.get(channelType); - const platformId = v2PlatformId(channelType, g.jid, { guildId }); - const createdAt = new Date().toISOString(); - - try { - // agent_group — one per folder - let ag = getAgentGroupByFolder(g.folder); - if (!ag) { - createAgentGroup({ - id: generateId('ag'), - name: g.name || g.folder, - folder: g.folder, - agent_provider: null, - created_at: createdAt, - }); - ag = getAgentGroupByFolder(g.folder)!; - agentGroupsCreated += 1; - } else { - agentGroupsReused += 1; - } - - // messaging_group — one per (channel_type, platform_id) - let mg = getMessagingGroupByPlatform(channelType, platformId); - if (!mg) { - createMessagingGroup({ - id: generateId('mg'), - channel_type: channelType, - platform_id: platformId, - name: g.name || null, - is_group: 1, // v1 didn't distinguish; default to group (safe for routing) - unknown_sender_policy: 'strict', // skill's interview flips this if v1 was "public" - created_at: createdAt, - }); - mg = getMessagingGroupByPlatform(channelType, platformId)!; - messagingGroupsCreated += 1; - } else { - messagingGroupsReused += 1; - } - - // messaging_group_agents — wire them if not already wired - const existingWiring = getMessagingGroupAgentByPair(mg.id, ag.id); - if (!existingWiring) { - const engage = triggerToEngage({ - trigger_pattern: g.trigger_pattern, - requires_trigger: g.requires_trigger, - }); - createMessagingGroupAgent({ - id: generateId('mga'), - messaging_group_id: mg.id, - agent_group_id: ag.id, - engage_mode: engage.engage_mode, - engage_pattern: engage.engage_pattern, - sender_scope: 'all', - ignored_message_policy: 'drop', - session_mode: 'shared', - priority: 0, - created_at: createdAt, - }); - wiringsCreated += 1; - } else { - wiringsReused += 1; - } - - if (g.is_main === 1) { - followups.push( - `Folder "${g.folder}" was the v1 main group (is_main=1). v2 has no is_main flag — the /migrate-from-v1 skill should grant this folder's channel to the owner user when it runs.`, - ); - } - } catch (err) { - skipped += 1; - const message = err instanceof Error ? err.message : String(err); - log.error('Failed to seed v1 group', { folder: g.folder, err: message }); - followups.push(`Folder "${g.folder}" failed to seed: ${message}`); - } - } - - v2Db.close(); - - const partial = skipped > 0; - const handoffAfter = readHandoff(); - handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; - writeHandoff(handoffAfter); - - recordStep('migrate-db', { - status: partial ? 'partial' : 'success', - fields: { - SELECTION: parsed.selection, - V1_GROUPS_TOTAL: v1Groups.length, - SELECTED: selected.length, - AGENT_GROUPS_CREATED: agentGroupsCreated, - AGENT_GROUPS_REUSED: agentGroupsReused, - MESSAGING_GROUPS_CREATED: messagingGroupsCreated, - MESSAGING_GROUPS_REUSED: messagingGroupsReused, - WIRINGS_CREATED: wiringsCreated, - WIRINGS_REUSED: wiringsReused, - SKIPPED: skipped, - CHANNELS: [...detectedChannels.keys()].join(','), - }, - notes: followups, - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_DB', { - STATUS: partial ? 'partial' : 'success', - SELECTION: parsed.selection, - V1_GROUPS_TOTAL: String(v1Groups.length), - SELECTED: String(selected.length), - AGENT_GROUPS_CREATED: String(agentGroupsCreated), - MESSAGING_GROUPS_CREATED: String(messagingGroupsCreated), - WIRINGS_CREATED: String(wiringsCreated), - SKIPPED: String(skipped), - CHANNELS: [...detectedChannels.keys()].join(',') || 'none', - }); -} diff --git a/setup/migrate-v1/detect.ts b/setup/migrate-v1/detect.ts deleted file mode 100644 index 983531d..0000000 --- a/setup/migrate-v1/detect.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Step: migrate-detect - * - * Find a v1 install on disk. Scans the standard candidate paths; if none - * matches, exits with a NOT_FOUND status (the orchestrator then offers a - * clack prompt so the user can point at a custom path). - * - * Never prompts — this step is pure discovery so it stays safe to run under - * NANOCLAW_SKIP= without blocking on stdin. - */ -import fs from 'fs'; -import path from 'path'; - -import { emitStatus } from '../status.js'; -import { - defaultV1Candidates, - looksLikeV1Install, - readHandoff, - recordStep, - v1PathsFor, - writeHandoff, -} from './shared.js'; - -interface DetectArgs { - /** Explicit path to check, skipping the default candidate list. */ - path?: string; -} - -function parseArgs(args: string[]): DetectArgs { - const out: DetectArgs = {}; - for (let i = 0; i < args.length; i++) { - if (args[i] === '--path') { - out.path = args[++i] || undefined; - } - } - return out; -} - -export async function run(args: string[]): Promise { - const parsed = parseArgs(args); - - // An explicit path — either from --path or $NANOCLAW_V1_PATH — is - // authoritative. If it doesn't validate, we don't fall through to - // the default candidate list. That keeps the user's explicit intent - // from being silently overridden. - const envOverride = process.env.NANOCLAW_V1_PATH?.trim(); - const explicit = parsed.path ?? envOverride ?? null; - const candidates = explicit ? [explicit] : defaultV1Candidates(); - - for (const candidate of candidates) { - const absolute = path.resolve(candidate); - // Don't self-match — if the candidate resolves to the v2 checkout we're - // running inside, skip it. Protects users who cloned v2 into `~/nanoclaw` - // after deleting v1. - if (absolute === path.resolve(process.cwd())) continue; - - const check = looksLikeV1Install(absolute); - if (!check.ok) continue; - - const paths = v1PathsFor(absolute); - let version = 'unknown'; - try { - const pkg = JSON.parse(fs.readFileSync(paths.packageJson, 'utf-8')) as { version?: string }; - version = pkg.version ?? 'unknown'; - } catch { - // Already sanity-checked by looksLikeV1Install — a failure here means - // the file changed under us between calls. Unlikely, not fatal. - } - - const h = readHandoff(); - h.v1_path = absolute; - h.v1_version = version; - writeHandoff(h); - - recordStep('migrate-detect', { - status: 'success', - fields: { V1_PATH: absolute, V1_VERSION: version }, - notes: [], - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_DETECT', { - STATUS: 'success', - V1_PATH: absolute, - V1_VERSION: version, - DB_PATH: paths.db, - ENV_PATH: paths.env, - GROUPS_PATH: paths.groups, - }); - return; - } - - // Nothing matched. Not an error — most v2 installs are fresh, not migrations. - const scanned = candidates.map((c) => path.resolve(c)).join(','); - recordStep('migrate-detect', { - status: 'skipped', - fields: { REASON: 'no-v1-install-found' }, - notes: [`Scanned: ${scanned}`], - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_DETECT', { - STATUS: 'skipped', - REASON: 'not_found', - CANDIDATES_SCANNED: String(candidates.length), - }); -} diff --git a/setup/migrate-v1/env.ts b/setup/migrate-v1/env.ts deleted file mode 100644 index e2530e0..0000000 --- a/setup/migrate-v1/env.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Step: migrate-env - * - * Copy every key from v1 `.env` to v2 `.env`. Preserves v2 values that - * already exist (never overwrites). Skips lines that don't look like a - * `KEY=value` pair. - * - * Why copy everything, not a curated list? v1 installs accumulate - * project-specific keys (custom MCP creds, feature flags, webhook tokens) - * that the migration can't enumerate ahead of time. The user explicitly - * asked for everything. We log what we carried so the skill can review. - * - * Security note: we do NOT log values here — only keys. The raw log already - * contains the file contents; we don't echo them to stdout. - */ -import fs from 'fs'; -import path from 'path'; - -import { emitStatus } from '../status.js'; -import { readHandoff, recordStep, v1PathsFor } from './shared.js'; - -interface EnvLine { - key: string; - value: string; - raw: string; -} - -function parseEnv(text: string): EnvLine[] { - const out: EnvLine[] = []; - for (const raw of text.split('\n')) { - const line = raw.trimEnd(); - if (!line) continue; - if (line.startsWith('#')) continue; - const eq = line.indexOf('='); - if (eq <= 0) continue; - const key = line.slice(0, eq).trim(); - if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue; - const value = line.slice(eq + 1); - out.push({ key, value, raw: line }); - } - return out; -} - -export async function run(_args: string[]): Promise { - const h = readHandoff(); - if (!h.v1_path) { - recordStep('migrate-env', { - status: 'skipped', - fields: { REASON: 'detect-not-run' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_ENV', { STATUS: 'skipped', REASON: 'no_v1_path' }); - return; - } - - const paths = v1PathsFor(h.v1_path); - if (!fs.existsSync(paths.env)) { - recordStep('migrate-env', { - status: 'skipped', - fields: { REASON: 'v1-env-missing' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_ENV', { STATUS: 'skipped', REASON: 'v1_env_missing' }); - return; - } - - const v2EnvPath = path.join(process.cwd(), '.env'); - const v1Text = fs.readFileSync(paths.env, 'utf-8'); - const v1Lines = parseEnv(v1Text); - - let v2Text = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; - const v2Lines = parseEnv(v2Text); - const v2Keys = new Set(v2Lines.map((l) => l.key)); - - const copied: string[] = []; - const skipped: string[] = []; - const appended: string[] = []; - - // Tag the appended block so a later re-run can find it and not double-append. - const BLOCK_START = '# ── migrated from v1 ──'; - const alreadyMigrated = v2Text.includes(BLOCK_START); - - for (const line of v1Lines) { - if (v2Keys.has(line.key)) { - skipped.push(line.key); - continue; - } - copied.push(line.key); - appended.push(line.raw); - } - - if (appended.length > 0) { - const suffix = [ - v2Text.endsWith('\n') || v2Text === '' ? '' : '\n', - alreadyMigrated ? '' : `\n${BLOCK_START}\n`, - appended.join('\n'), - '\n', - ].join(''); - v2Text = v2Text + suffix; - fs.writeFileSync(v2EnvPath, v2Text); - } - - // Container reads from data/env/env (host mounts it). Keep it in sync. - const containerEnvDir = path.join(process.cwd(), 'data', 'env'); - try { - fs.mkdirSync(containerEnvDir, { recursive: true }); - fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env')); - } catch { - // Non-fatal; the service restart (later step) will rehydrate if needed. - } - - recordStep('migrate-env', { - status: 'success', - fields: { - KEYS_COPIED: copied.length, - KEYS_SKIPPED_EXISTING: skipped.length, - V1_ENV: paths.env, - V2_ENV: v2EnvPath, - }, - notes: [ - copied.length > 0 ? `Copied: ${copied.join(', ')}` : '', - skipped.length > 0 ? `Skipped (already in v2 .env): ${skipped.join(', ')}` : '', - ].filter(Boolean), - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_ENV', { - STATUS: 'success', - KEYS_COPIED: String(copied.length), - KEYS_SKIPPED_EXISTING: String(skipped.length), - COPIED_KEYS: copied.join(',') || 'none', - }); -} diff --git a/setup/migrate-v1/groups.ts b/setup/migrate-v1/groups.ts deleted file mode 100644 index 206f441..0000000 --- a/setup/migrate-v1/groups.ts +++ /dev/null @@ -1,230 +0,0 @@ -/** - * Step: migrate-groups - * - * Copy v1 group folders into v2. For each folder selected in migrate-db: - * - Create groups// in v2 if missing - * - Copy v1's CLAUDE.md to v2 as CLAUDE.local.md (v2 composes CLAUDE.md at - * container spawn — don't write directly to CLAUDE.md) - * - If v1 had a container_config JSON, write it to .v1-container-config.json - * for the /migrate-from-v1 skill to reconcile (v2's container.json shape - * has drifted enough that a silent 1:1 copy would be wrong) - * - Preserve any other non-standard files from the v1 folder (e.g. SOUL.md, - * personality.md, custom subdirs) — rsync-style, skipping destination files - * that already exist. - * - * Does not overwrite files already present in v2 — re-running is safe. - */ -import fs from 'fs'; -import path from 'path'; - -import Database from 'better-sqlite3'; - -import { log } from '../../src/log.js'; -import { emitStatus } from '../status.js'; -import { - readHandoff, - recordStep, - safeJsonStringify, - scanForV1Patterns, - v1PathsFor, - writeHandoff, -} from './shared.js'; - -const SKIP_NAMES = new Set(['CLAUDE.md', 'logs', '.git', '.DS_Store', 'node_modules']); - -/** - * Copy everything in src except SKIP_NAMES. CLAUDE.md is handled separately. - * Returns the count of files actually written (skipped-existing not counted). - */ -function copyTree(src: string, dst: string): number { - let written = 0; - if (!fs.existsSync(src)) return 0; - fs.mkdirSync(dst, { recursive: true }); - - for (const entry of fs.readdirSync(src, { withFileTypes: true })) { - if (SKIP_NAMES.has(entry.name)) continue; - const s = path.join(src, entry.name); - const d = path.join(dst, entry.name); - - if (entry.isDirectory()) { - written += copyTree(s, d); - continue; - } - // Don't clobber files v2 already has (e.g. CLAUDE.local.md that the - // operator already wrote). Append-only semantics for this step. - if (fs.existsSync(d)) continue; - fs.copyFileSync(s, d); - written += 1; - } - return written; -} - -export async function run(_args: string[]): Promise { - const h = readHandoff(); - if (!h.v1_path) { - recordStep('migrate-groups', { - status: 'skipped', - fields: { REASON: 'detect-not-run' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_GROUPS', { STATUS: 'skipped', REASON: 'no_v1_path' }); - return; - } - - if (h.group_selection.selected_folders.length === 0) { - recordStep('migrate-groups', { - status: 'skipped', - fields: { REASON: 'no-folders-selected' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_GROUPS', { STATUS: 'skipped', REASON: 'no_selection' }); - return; - } - - const paths = v1PathsFor(h.v1_path); - const v2GroupsDir = path.join(process.cwd(), 'groups'); - fs.mkdirSync(v2GroupsDir, { recursive: true }); - - // Pull container_config for each selected folder up-front so we can write - // the .v1-container-config.json sidecar without holding the DB open per-folder. - const containerConfigs = new Map(); - try { - const v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); - const rows = v1Db - .prepare('SELECT folder, container_config FROM registered_groups WHERE folder IN (SELECT value FROM json_each(?))') - .all(JSON.stringify(h.group_selection.selected_folders)) as Array<{ folder: string; container_config: string | null }>; - for (const r of rows) containerConfigs.set(r.folder, r.container_config); - v1Db.close(); - } catch (err) { - // Older sqlite without json_each would break the query. Fall back to - // per-folder reads — slower but reliable. - log.info('Falling back to per-folder container_config lookup', { err }); - try { - const v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); - const stmt = v1Db.prepare('SELECT container_config FROM registered_groups WHERE folder = ?'); - for (const folder of h.group_selection.selected_folders) { - const row = stmt.get(folder) as { container_config: string | null } | undefined; - containerConfigs.set(folder, row?.container_config ?? null); - } - v1Db.close(); - } catch { - // Give up — we still migrate files; the skill handles missing config. - } - } - - let foldersProcessed = 0; - let foldersSkippedMissing = 0; - let claudeMdMigrated = 0; - let claudeLocalPreserved = 0; - let containerConfigsStashed = 0; - let otherFilesCopied = 0; - const followups: string[] = []; - - for (const folder of h.group_selection.selected_folders) { - const v1Folder = path.join(paths.groups, folder); - const v2Folder = path.join(v2GroupsDir, folder); - - if (!fs.existsSync(v1Folder)) { - foldersSkippedMissing += 1; - followups.push( - `Folder "${folder}" was in v1's registered_groups but not on disk at ${v1Folder} — DB entry was seeded, no files to migrate.`, - ); - continue; - } - - fs.mkdirSync(v2Folder, { recursive: true }); - - // CLAUDE.md → CLAUDE.local.md. Don't write CLAUDE.md directly — v2's - // group-init.ts composes that file from shared + fragments + local. - const v1Claude = path.join(v1Folder, 'CLAUDE.md'); - const v2Local = path.join(v2Folder, 'CLAUDE.local.md'); - let claudeContent: string | null = null; - if (fs.existsSync(v1Claude)) { - if (fs.existsSync(v2Local)) { - claudeLocalPreserved += 1; - try { - claudeContent = fs.readFileSync(v2Local, 'utf-8'); - } catch { - claudeContent = null; - } - } else { - try { - claudeContent = fs.readFileSync(v1Claude, 'utf-8'); - fs.writeFileSync(v2Local, claudeContent); - claudeMdMigrated += 1; - } catch (err) { - followups.push(`Failed to copy CLAUDE.md for "${folder}": ${err instanceof Error ? err.message : err}`); - } - } - } - - // Scan the copied content for v1-specific infrastructure patterns. If we - // find any, add a followup so the /migrate-from-v1 skill can triage the - // file with the user. We DON'T edit the file — v1 CLAUDE.md can be - // author-specific and heuristic translation is worse than a flag. - if (claudeContent) { - const matches = scanForV1Patterns(claudeContent); - if (matches.length > 0) { - const summary = matches - .map((m) => `${m.description} (lines ${m.lines.join(',')})`) - .join('; '); - followups.push( - `Folder "${folder}" CLAUDE.local.md references v1-specific infrastructure: ${summary}. The skill should read the file and translate patterns using docs/v1-to-v2-changes.md.`, - ); - } - } - - // Stash container_config JSON so the skill can reconcile it. - const config = containerConfigs.get(folder); - if (config) { - const sidecar = path.join(v2Folder, '.v1-container-config.json'); - try { - // Pretty-print so humans can read it during reconciliation. - const parsed = JSON.parse(config) as unknown; - fs.writeFileSync(sidecar, safeJsonStringify(parsed)); - containerConfigsStashed += 1; - followups.push( - `Folder "${folder}" has a v1 container_config — stashed at ${path.relative(process.cwd(), sidecar)}. The /migrate-from-v1 skill will map it to v2's container.json shape.`, - ); - } catch { - // Non-JSON container_config — write raw so the skill can still read it. - fs.writeFileSync(sidecar, config); - containerConfigsStashed += 1; - } - } - - otherFilesCopied += copyTree(v1Folder, v2Folder); - foldersProcessed += 1; - } - - // Merge followups. - const handoffAfter = readHandoff(); - handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; - writeHandoff(handoffAfter); - - const partial = foldersSkippedMissing > 0; - recordStep('migrate-groups', { - status: partial ? 'partial' : 'success', - fields: { - FOLDERS_PROCESSED: foldersProcessed, - FOLDERS_SKIPPED_MISSING: foldersSkippedMissing, - CLAUDE_MD_MIGRATED: claudeMdMigrated, - CLAUDE_LOCAL_PRESERVED: claudeLocalPreserved, - CONTAINER_CONFIGS_STASHED: containerConfigsStashed, - OTHER_FILES_COPIED: otherFilesCopied, - }, - notes: followups, - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_GROUPS', { - STATUS: partial ? 'partial' : 'success', - FOLDERS_PROCESSED: String(foldersProcessed), - FOLDERS_SKIPPED_MISSING: String(foldersSkippedMissing), - CLAUDE_MD_MIGRATED: String(claudeMdMigrated), - CONTAINER_CONFIGS_STASHED: String(containerConfigsStashed), - OTHER_FILES_COPIED: String(otherFilesCopied), - }); -} diff --git a/setup/migrate-v1/tasks.ts b/setup/migrate-v1/tasks.ts deleted file mode 100644 index 836be7c..0000000 --- a/setup/migrate-v1/tasks.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * Step: migrate-tasks - * - * Port v1's `scheduled_tasks` into v2's session inbound DBs. v1 had a - * dedicated table with its own scheduling grammar; v2 treats tasks as - * `messages_in` rows with `kind='task'`, `process_after`, and `recurrence` - * (cron string). See docs/v1-to-v2-changes.md "Scheduling". - * - * Flow per v1 row: - * 1. Resolve (agent_group_id, messaging_group_id) from v1 (group_folder, chat_jid) - * 2. resolveSession() — creates the session on demand if absent - * 3. insertTask() into the session's inbound.db - * - * Active v1 rows (status='active') are migrated. Completed/stopped rows get - * exported to logs/setup-migration/inactive-tasks.json for reference. - * - * v1's schedule_type / schedule_value are mapped to cron here. Known types: - * 'cron' → schedule_value is already a cron string - * 'interval' → e.g. '5m'/'1h' → cron equivalent (best effort) - * 'once' → no recurrence, process_after = schedule_value if parseable - * Unknown types go to inactive-tasks.json with a note. - */ -import fs from 'fs'; -import path from 'path'; - -import Database from 'better-sqlite3'; - -import { DATA_DIR } from '../../src/config.js'; -import { initDb, closeDb } from '../../src/db/connection.js'; -import { getAgentGroupByFolder } from '../../src/db/agent-groups.js'; -import { getMessagingGroupByPlatform } from '../../src/db/messaging-groups.js'; -import { runMigrations } from '../../src/db/migrations/index.js'; -import { log } from '../../src/log.js'; -import { insertTask } from '../../src/modules/scheduling/db.js'; -import { openInboundDb, resolveSession } from '../../src/session-manager.js'; -import { emitStatus } from '../status.js'; -import { - INACTIVE_TASKS_PATH, - MIGRATION_DIR, - inferChannelType, - readHandoff, - recordStep, - safeJsonStringify, - v1PathsFor, - v2PlatformId, - writeHandoff, -} from './shared.js'; - -interface V1Task { - id: string; - group_folder: string; - chat_jid: string; - prompt: string; - schedule_type: string; - schedule_value: string; - next_run: string | null; - last_run: string | null; - status: string; - context_mode: string | null; - script: string | null; -} - -/** Convert v1 schedule_type + schedule_value into (processAfter, recurrence). */ -function toProcessAfterAndRecurrence(t: V1Task): { - processAfter: string; - recurrence: string | null; - note?: string; -} | null { - const now = new Date().toISOString(); - - if (t.schedule_type === 'cron') { - // Validate shape — 5 or 6 fields separated by whitespace. cron-parser is - // the runtime source of truth; here we just reject obvious garbage so - // we don't insert tasks that will explode on the first sweep tick. - const fields = t.schedule_value.trim().split(/\s+/).length; - if (fields < 5 || fields > 6) return null; - return { - processAfter: t.next_run || now, - recurrence: t.schedule_value.trim(), - }; - } - - if (t.schedule_type === 'interval') { - // '5m' → '*/5 * * * *'; '1h' → '0 * * * *'; '1d' → '0 0 * * *'. - // Best effort — any unit we don't recognize falls through to null. - const m = /^(\d+)([smhd])$/.exec(t.schedule_value.trim()); - if (!m) return null; - const n = parseInt(m[1], 10); - const unit = m[2]; - if (!n || n < 1) return null; - let cron: string | null = null; - if (unit === 'm' && n < 60) cron = `*/${n} * * * *`; - else if (unit === 'h' && n < 24) cron = `0 */${n} * * *`; - else if (unit === 'd' && n < 28) cron = `0 0 */${n} * *`; - if (!cron) return null; - return { processAfter: t.next_run || now, recurrence: cron }; - } - - if (t.schedule_type === 'once' || t.schedule_type === 'at') { - return { - processAfter: t.next_run || t.schedule_value || now, - recurrence: null, - }; - } - - return null; -} - -export async function run(_args: string[]): Promise { - const h = readHandoff(); - if (!h.v1_path) { - recordStep('migrate-tasks', { - status: 'skipped', - fields: { REASON: 'detect-not-run' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_TASKS', { STATUS: 'skipped', REASON: 'no_v1_path' }); - return; - } - - const validate = h.steps['migrate-validate']; - if (validate && validate.status === 'failed') { - recordStep('migrate-tasks', { - status: 'skipped', - fields: { REASON: 'validate-failed' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_TASKS', { STATUS: 'skipped', REASON: 'validate_failed' }); - return; - } - - const paths = v1PathsFor(h.v1_path); - - // Read v1 tasks into memory so we can close the v1 DB before we open v2's - // central DB via initDb() (which is a module singleton and doesn't love - // having two files open through it). - let activeTasks: V1Task[] = []; - let inactiveTasks: V1Task[] = []; - try { - const v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); - const all = v1Db.prepare('SELECT * FROM scheduled_tasks').all() as V1Task[]; - v1Db.close(); - activeTasks = all.filter((t) => t.status === 'active'); - inactiveTasks = all.filter((t) => t.status !== 'active'); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - recordStep('migrate-tasks', { - status: 'failed', - fields: { REASON: 'v1-read-failed' }, - notes: [message], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_TASKS', { STATUS: 'failed', REASON: 'v1_read_failed', ERROR: message }); - return; - } - - if (activeTasks.length === 0 && inactiveTasks.length === 0) { - recordStep('migrate-tasks', { - status: 'skipped', - fields: { REASON: 'no-v1-tasks' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_TASKS', { STATUS: 'skipped', REASON: 'no_v1_tasks' }); - return; - } - - // Dump inactive tasks for reference — always, even if there are no active ones. - if (inactiveTasks.length > 0) { - fs.mkdirSync(MIGRATION_DIR, { recursive: true }); - fs.writeFileSync(INACTIVE_TASKS_PATH, safeJsonStringify({ tasks: inactiveTasks })); - } - - // Connect to v2 central DB to resolve (folder → ag) and (channel+pid → mg). - const v2Path = path.join(DATA_DIR, 'v2.db'); - fs.mkdirSync(path.dirname(v2Path), { recursive: true }); - const v2Db = initDb(v2Path); - runMigrations(v2Db); - - const followups: string[] = []; - let migrated = 0; - let failed = 0; - let skipped = 0; - - for (const t of activeTasks) { - try { - const ag = getAgentGroupByFolder(t.group_folder); - if (!ag) { - skipped += 1; - followups.push( - `Task "${t.id}" (folder "${t.group_folder}"): agent_group not seeded in v2 — run migrate-db first or deselect the task.`, - ); - continue; - } - - const channelType = inferChannelType(t.chat_jid, null); - if (!channelType) { - skipped += 1; - followups.push(`Task "${t.id}": could not infer channel from chat_jid "${t.chat_jid}".`); - continue; - } - const platformId = v2PlatformId(channelType, t.chat_jid); - const mg = getMessagingGroupByPlatform(channelType, platformId); - if (!mg) { - skipped += 1; - followups.push( - `Task "${t.id}": messaging_group for (${channelType}, ${platformId}) not seeded. Add the channel then re-run this step.`, - ); - continue; - } - - const scheduling = toProcessAfterAndRecurrence(t); - if (!scheduling) { - skipped += 1; - followups.push( - `Task "${t.id}": schedule_type "${t.schedule_type}" / value "${t.schedule_value}" did not map to a v2 cron — exported to inactive-tasks.json for manual review.`, - ); - inactiveTasks.push(t); - continue; - } - - // resolveSession creates (ag, mg) session if not present; 'shared' mode - // matches v1 which had one session per group_folder. - const { session } = resolveSession(ag.id, mg.id, null, 'shared'); - const inboxDb = openInboundDb(ag.id, session.id); - try { - // Idempotence: skip if we've already migrated this task id. We use the - // v1 task id verbatim as the v2 messages_in.id (stable — lets users - // re-run migration without duplicate-key errors or shadow tasks). - const existing = inboxDb - .prepare("SELECT id FROM messages_in WHERE id = ? AND kind = 'task'") - .get(t.id) as { id: string } | undefined; - if (existing) { - skipped += 1; - continue; - } - - insertTask(inboxDb, { - id: t.id, - processAfter: scheduling.processAfter, - recurrence: scheduling.recurrence, - platformId, - channelType, - threadId: null, - content: JSON.stringify({ - prompt: t.prompt, - script: t.script ?? null, - migrated_from_v1: { original_id: t.id, context_mode: t.context_mode ?? null }, - }), - }); - } finally { - inboxDb.close(); - } - - log.info('Migrated v1 scheduled task', { taskId: t.id, session: session.id, mg: mg.id }); - migrated += 1; - } catch (err) { - failed += 1; - const message = err instanceof Error ? err.message : String(err); - followups.push(`Task "${t.id}" failed to migrate: ${message}`); - } - } - - // Re-dump inactive tasks in case scheduling-translation pushed any in. - if (inactiveTasks.length > 0) { - fs.writeFileSync(INACTIVE_TASKS_PATH, safeJsonStringify({ tasks: inactiveTasks })); - } - - closeDb(); - - const handoffAfter = readHandoff(); - handoffAfter.tasks = { - v1_active: activeTasks.length, - v1_inactive: inactiveTasks.length, - migrated, - failed, - skipped, - }; - handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; - writeHandoff(handoffAfter); - - const partial = failed > 0 || skipped > 0; - recordStep('migrate-tasks', { - status: failed > 0 ? 'partial' : partial ? 'partial' : 'success', - fields: { - V1_ACTIVE: activeTasks.length, - V1_INACTIVE: inactiveTasks.length, - MIGRATED: migrated, - FAILED: failed, - SKIPPED: skipped, - INACTIVE_EXPORT: inactiveTasks.length > 0 ? INACTIVE_TASKS_PATH : '', - }, - notes: followups, - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_TASKS', { - STATUS: partial ? 'partial' : 'success', - V1_ACTIVE: String(activeTasks.length), - V1_INACTIVE: String(inactiveTasks.length), - MIGRATED: String(migrated), - FAILED: String(failed), - SKIPPED: String(skipped), - }); -} diff --git a/setup/migrate-v1/validate.ts b/setup/migrate-v1/validate.ts deleted file mode 100644 index 73cd377..0000000 --- a/setup/migrate-v1/validate.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Step: migrate-validate - * - * Before touching v1 data, assert the DB has the shape we expect. We know - * v1's schema (see docs/v1-to-v2-changes.md "Entity model") — different - * shapes happened over v1's development, but by v1.2.x the `registered_groups` - * columns and `scheduled_tasks` columns stabilized. If we see something else, - * we bail early so later steps don't write garbage to v2.db. - * - * Output: - * - `logs/setup-migration/schema-mismatch.json` on failure (read by the skill) - * - Status block MIGRATE_VALIDATE with OK/FAILED - * - Even on failure, subsequent steps still run — they'll short-circuit - * on their own if validate marked the DB unusable. This keeps env + group - * folder migration working when only the DB is broken. - */ -import fs from 'fs'; - -import Database from 'better-sqlite3'; - -import { emitStatus } from '../status.js'; -import { - SCHEMA_MISMATCH_PATH, - readHandoff, - recordStep, - safeJsonStringify, - v1PathsFor, -} from './shared.js'; - -const EXPECTED_TABLES = [ - 'registered_groups', - 'scheduled_tasks', - 'chats', - 'messages', - 'sessions', - 'router_state', -]; - -const REQUIRED_REGISTERED_GROUPS_COLUMNS = [ - 'jid', - 'name', - 'folder', - 'trigger_pattern', - 'added_at', - 'requires_trigger', -]; - -const REQUIRED_SCHEDULED_TASKS_COLUMNS = [ - 'id', - 'group_folder', - 'chat_jid', - 'prompt', - 'schedule_type', - 'schedule_value', - 'status', -]; - -interface TableInfo { - table: string; - columns: string[]; - missing_columns: string[]; -} - -export async function run(_args: string[]): Promise { - const h = readHandoff(); - if (!h.v1_path) { - recordStep('migrate-validate', { - status: 'skipped', - fields: { REASON: 'detect-not-run' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_VALIDATE', { STATUS: 'skipped', REASON: 'no_v1_path' }); - return; - } - - const paths = v1PathsFor(h.v1_path); - if (!fs.existsSync(paths.db)) { - recordStep('migrate-validate', { - status: 'failed', - fields: { REASON: 'db-missing', DB_PATH: paths.db }, - notes: ['v1 DB file does not exist at expected path'], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_VALIDATE', { - STATUS: 'failed', - REASON: 'db_missing', - DB_PATH: paths.db, - }); - return; - } - - let db: Database.Database; - try { - db = new Database(paths.db, { readonly: true, fileMustExist: true }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - recordStep('migrate-validate', { - status: 'failed', - fields: { REASON: 'db-open-failed' }, - notes: [message], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_VALIDATE', { - STATUS: 'failed', - REASON: 'db_open_failed', - ERROR: message, - }); - return; - } - - try { - const tableRows = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") - .all() as Array<{ name: string }>; - const tables = new Set(tableRows.map((r) => r.name)); - - const missingTables = EXPECTED_TABLES.filter((t) => !tables.has(t)); - const tableInfos: TableInfo[] = []; - - for (const t of EXPECTED_TABLES) { - if (!tables.has(t)) continue; - const cols = db.prepare(`PRAGMA table_info(${t})`).all() as Array<{ name: string }>; - const columnNames = cols.map((c) => c.name); - const missing = - t === 'registered_groups' - ? REQUIRED_REGISTERED_GROUPS_COLUMNS.filter((c) => !columnNames.includes(c)) - : t === 'scheduled_tasks' - ? REQUIRED_SCHEDULED_TASKS_COLUMNS.filter((c) => !columnNames.includes(c)) - : []; - tableInfos.push({ table: t, columns: columnNames, missing_columns: missing }); - } - - const columnMismatches = tableInfos.filter((t) => t.missing_columns.length > 0); - const groupCount = - tables.has('registered_groups') - ? ((db.prepare('SELECT COUNT(*) AS c FROM registered_groups').get() as { c: number }).c) - : 0; - const taskCount = - tables.has('scheduled_tasks') - ? ((db.prepare('SELECT COUNT(*) AS c FROM scheduled_tasks').get() as { c: number }).c) - : 0; - - db.close(); - - if (missingTables.length > 0 || columnMismatches.length > 0) { - const mismatch = { - v1_path: h.v1_path, - v1_version: h.v1_version, - present_tables: [...tables].sort(), - missing_tables: missingTables, - column_mismatches: columnMismatches, - scanned_at: new Date().toISOString(), - }; - fs.writeFileSync(SCHEMA_MISMATCH_PATH, safeJsonStringify(mismatch)); - - recordStep('migrate-validate', { - status: 'failed', - fields: { - MISSING_TABLES: missingTables.join(',') || 'none', - COLUMN_MISMATCHES: String(columnMismatches.length), - REPORT: SCHEMA_MISMATCH_PATH, - }, - notes: [ - missingTables.length > 0 ? `Missing tables: ${missingTables.join(', ')}` : '', - columnMismatches.length > 0 - ? `Column mismatches in: ${columnMismatches.map((c) => c.table).join(', ')}` - : '', - ].filter(Boolean), - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_VALIDATE', { - STATUS: 'failed', - REASON: 'schema_mismatch', - MISSING_TABLES: missingTables.join(',') || 'none', - COLUMN_MISMATCHES: String(columnMismatches.length), - REPORT: SCHEMA_MISMATCH_PATH, - }); - return; - } - - recordStep('migrate-validate', { - status: 'success', - fields: { - V1_GROUPS: groupCount, - V1_TASKS: taskCount, - }, - notes: [], - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_VALIDATE', { - STATUS: 'success', - V1_GROUPS: String(groupCount), - V1_TASKS: String(taskCount), - }); - } catch (err) { - db.close(); - const message = err instanceof Error ? err.message : String(err); - recordStep('migrate-validate', { - status: 'failed', - fields: { REASON: 'validate-error' }, - notes: [message], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_VALIDATE', { - STATUS: 'failed', - REASON: 'validate_error', - ERROR: message, - }); - } -} From f35be24aeff4845b9d57019e6e452c3e57271c04 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 1 May 2026 20:23:34 +0000 Subject: [PATCH 103/144] chore: move shared helpers to migrate-v2/, delete migrate-v1/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracted the helpers we use (JID parsing, trigger mapping, channel auth registry, generateId, v2PlatformId) into setup/migrate-v2/shared.ts. Deleted setup/migrate-v1/ entirely — no code references it anymore. Updated README, CLAUDE.md, docs/v1-to-v2-changes.md, and docs/migration-dev.md to reference the new paths and migrate-v2.sh entry point. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/migrate-from-v1/SKILL.md | 4 +- CLAUDE.md | 5 +- README.md | 2 +- docs/migration-dev.md | 2 +- docs/v1-to-v2-changes.md | 22 +- setup/migrate-v1/shared.ts | 731 ------------------------ setup/migrate-v2/channel-auth.ts | 2 +- setup/migrate-v2/db.ts | 3 +- setup/migrate-v2/shared.ts | 189 ++++++ setup/migrate-v2/tasks.ts | 2 +- 10 files changed, 210 insertions(+), 752 deletions(-) delete mode 100644 setup/migrate-v1/shared.ts create mode 100644 setup/migrate-v2/shared.ts diff --git a/.claude/skills/migrate-from-v1/SKILL.md b/.claude/skills/migrate-from-v1/SKILL.md index 6362fe6..52d8293 100644 --- a/.claude/skills/migrate-from-v1/SKILL.md +++ b/.claude/skills/migrate-from-v1/SKILL.md @@ -93,7 +93,7 @@ FROM messages WHERE chat_jid = '' AND is_from_me = 0 AND sender IS NOT NULL ``` -The `sender` value is a platform handle (e.g. `6037840640` for Telegram). Build the v2 user ID by inferring the channel type from the chat JID prefix (use `parseJid` from `setup/migrate-v1/shared.ts`) and combining: `:`. +The `sender` value is a platform handle (e.g. `6037840640` for Telegram). Build the v2 user ID by inferring the channel type from the chat JID prefix (use `parseJid` from `setup/migrate-v2/shared.ts`) and combining: `:`. For each sender: 1. Upsert into `users(id, kind, display_name)` if not already present. @@ -171,7 +171,7 @@ If there are commits: 1. Show the commit list to the user. 2. `AskUserQuestion`: "How do you want to handle your v1 customizations?" - - **Copy portable items** (recommended) — copy `container/skills/*`, `.claude/skills/*`, `docs/*`. Scan each with `scanForV1Patterns` from `setup/migrate-v1/shared.ts`. + - **Copy portable items** (recommended) — copy `container/skills/*`, `.claude/skills/*`, `docs/*`. Scan each with `scanForV1Patterns` from `setup/migrate-v2/shared.ts`. - **Full walkthrough** — go commit by commit, decide together. - **Reference only** — stash to `docs/v1-fork-reference/` for later. 3. Source code (`src/*`, `container/agent-runner/src/*`) is NOT portable — v2's architecture is fundamentally different. Stash to `docs/v1-fork-reference/` with a README explaining what each file did. Don't translate. diff --git a/CLAUDE.md b/CLAUDE.md index e070bce..e65515a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f | `container/skills/` | Container skills mounted into every agent session | | `groups//` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) | | `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) | -| `setup/migrate-v1.ts` + `setup/migrate-v1/` | v1→v2 migration (**experimental**); runs inside `setup/auto.ts` before channel pairing. Seeds `agent_groups` + `messaging_groups` + wirings from v1's `registered_groups`, copies group folders, merges `.env`, installs channel adapters, ports scheduled tasks. Writes `logs/setup-migration/handoff.json` for the `/migrate-from-v1` skill to pick up (owner seeding + fork customizations). | +| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). | ## Channels and Providers (skill-installed) @@ -233,7 +233,8 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac | [docs/setup-wiring.md](docs/setup-wiring.md) | What's wired, what's open in the setup flow | | [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 | -| [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) | v1→v2 architecture diff — vocabulary for where v1 things moved. Referenced by the migration step and `/migrate-from-v1` skill | +| [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) | v1→v2 architecture diff — vocabulary for where v1 things moved | +| [docs/migration-dev.md](docs/migration-dev.md) | Migration development guide — testing, debugging, dev loop | ## Container Build Cache diff --git a/README.md b/README.md index 9228d40..1b485c9 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ 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. -**Coming from v1?** `nanoclaw.sh` detects your old install (scans siblings of the v2 checkout + common `$HOME` locations) and migrates automatically; `/migrate-from-v1` in Claude finishes owner setup and helps port custom fork work. If v1 is at a non-standard path, run with `NANOCLAW_V1_PATH=/path/to/nanoclaw bash nanoclaw.sh`. See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md). _Experimental — back up `data/v2.db` and `groups/` first; not recommended yet for high-stakes production installs._ +**Coming from v1?** Run `bash migrate-v2.sh` instead of `nanoclaw.sh`. It finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), migrates state, installs channels, and hands off to Claude for owner setup and custom code porting. See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) and [docs/migration-dev.md](docs/migration-dev.md). ## Philosophy diff --git a/docs/migration-dev.md b/docs/migration-dev.md index 60feb4e..35ef536 100644 --- a/docs/migration-dev.md +++ b/docs/migration-dev.md @@ -31,7 +31,7 @@ setup/migrate-v2/ channel-auth.ts # Phase 2b: copy channel auth state select-channels.ts # Phase 2a: clack multiselect switchover-prompt.ts # Service switch prompts -setup/migrate-v1/shared.ts # Shared helpers (JID parsing, trigger mapping, etc.) +setup/migrate-v2/shared.ts # Shared helpers (JID parsing, trigger mapping, etc.) .claude/skills/migrate-from-v1/ # The Claude skill logs/setup-migration/handoff.json # Written by migrate-v2.sh, read by skill logs/migrate-steps/*.log # Per-step raw output diff --git a/docs/v1-to-v2-changes.md b/docs/v1-to-v2-changes.md index f81fb58..082bf30 100644 --- a/docs/v1-to-v2-changes.md +++ b/docs/v1-to-v2-changes.md @@ -1,6 +1,6 @@ # NanoClaw v1 → v2 — what changed -Big-picture differences between NanoClaw v1 (the `~/nanoclaw` checkout you've been running) and v2 (this rewrite). Not a migration guide — that's what `bash nanoclaw.sh` and the `/migrate-from-v1` skill are for. This doc is the **vocabulary**: when something has moved or been renamed, find it here. +Big-picture differences between NanoClaw v1 (the `~/nanoclaw` checkout you've been running) and v2 (this rewrite). Not a migration guide — that's what `bash migrate-v2.sh` and the `/migrate-from-v1` skill are for. This doc is the **vocabulary**: when something has moved or been renamed, find it here. Read this before touching the migration code or porting customizations forward. @@ -37,7 +37,7 @@ Consequences: - v1 `requires_trigger=0` or pattern was `.`/`.*` → v2 `engage_mode='pattern'`, `engage_pattern='.'` (the "always" flavor) - no pattern and requires a trigger → v2 `engage_mode='mention'` - `sender_scope` and `ignored_message_policy` are new; defaults `all` / `drop` -- **JID decomposition.** v1's `jid` column stored `dc:12345` / `tg:67890`. v2 splits this into `channel_type` + `platform_id`. Concretely: `dc:12345` becomes `channel_type='discord'`, `platform_id='discord:12345'`. Prefix aliases (`dc` → `discord`, `tg` → `telegram`, `wa` → `whatsapp`) are in `setup/migrate-v1/shared.ts`. +- **JID decomposition.** v1's `jid` column stored `dc:12345` / `tg:67890`. v2 splits this into `channel_type` + `platform_id`. Concretely: `dc:12345` becomes `channel_type='discord'`, `platform_id='discord:12345'`. Prefix aliases (`dc` → `discord`, `tg` → `telegram`, `wa` → `whatsapp`) are in `setup/migrate-v2/shared.ts`. - **`channel_name` was unreliable in v1.** Many rows had it empty; the actual channel had to be guessed from the JID prefix. v2's `channel_type` is always explicit. --- @@ -99,7 +99,7 @@ Gotcha: auto-created agents default to `selective` secret mode — no secrets at Idempotent — re-running is a no-op. Pinned versions keep the supply chain honest. The automated migration detects which channels were wired in v1 (via distinct `channel_name` / JID prefix) and runs the matching `setup/install-.sh` for each. Channels in v1 that don't have a v2 skill (rare now, more common as v2 catches up) are recorded in the handoff file for the `/migrate-from-v1` skill to raise with the user. -**Channel auth beyond `.env`.** Some channels store session state on disk (Baileys WhatsApp keystore, Matrix sync state, iMessage tokens). The `channel-auth` sub-step has a per-channel registry (`setup/migrate-v1/shared.ts: CHANNEL_AUTH_REGISTRY`) that knows which file globs to copy alongside env keys. +**Channel auth beyond `.env`.** Some channels store session state on disk (Baileys WhatsApp keystore, Matrix sync state, iMessage tokens). The `channel-auth` step has a per-channel registry (`setup/migrate-v2/shared.ts: CHANNEL_AUTH_REGISTRY`) that knows which file globs to copy alongside env keys. --- @@ -162,11 +162,11 @@ Lockfiles: host uses `pnpm-lock.yaml`, agent-runner uses `bun.lock`. `minimumRel ## Migration surface — where the code lives -- `setup/migrate-v1.ts` — orchestrator called from `setup/auto.ts` between the timezone and channel steps. -- `setup/migrate-v1/.ts` — registered in `setup/index.ts` STEPS; each runs under `runQuietStep`. -- `logs/setup.log` — progression log. Each sub-step appends one entry. -- `logs/setup-steps/NN-migrate-.log` — raw per-sub-step stdout/stderr. -- `logs/setup-migration/handoff.json` — summary of what was migrated, what failed, what was deferred. Read by the `/migrate-from-v1` skill. -- `logs/setup-migration/schema-mismatch.json` — written only if `migrate-validate` finds a v1 DB that doesn't match the expected shape. The skill uses this to decide what to hand back to the user. -- `logs/setup-migration/inactive-tasks.json` — completed or stopped v1 `scheduled_tasks`, exported for reference. -- `.claude/skills/migrate-from-v1/SKILL.md` — tells Claude how to finish anything the automation couldn't and then interview the user about custom code changes. +- `migrate-v2.sh` — entry point: `bash migrate-v2.sh` from the v2 checkout. +- `setup/migrate-v2/*.ts` — individual migration steps (env, db, groups, sessions, tasks, channel-auth, select-channels, switchover-prompt). +- `setup/migrate-v2/shared.ts` — JID parsing, trigger mapping, channel auth registry. +- `logs/setup-migration/handoff.json` — written by `migrate-v2.sh`, read by the `/migrate-from-v1` skill. +- `logs/migrate-steps/*.log` — raw per-step stdout. +- `.claude/skills/migrate-from-v1/SKILL.md` — Claude skill for owner seeding, CLAUDE.md cleanup, container config validation, fork porting. +- `migrate-v2-reset.sh` — development helper to wipe v2 state for re-testing. +- See [docs/migration-dev.md](migration-dev.md) for the full development guide. diff --git a/setup/migrate-v1/shared.ts b/setup/migrate-v1/shared.ts deleted file mode 100644 index 4d2cd92..0000000 --- a/setup/migrate-v1/shared.ts +++ /dev/null @@ -1,731 +0,0 @@ -/** - * Shared types, constants, and helpers for the v1 → v2 migration. - * - * The migration is a sequence of small steps registered in setup/index.ts - * (migrate-detect, migrate-validate, migrate-db, …). Every step: - * - Reads state it needs from `logs/setup-migration/handoff.json` - * - Writes its own outcome back to that handoff file - * - Emits exactly one `=== NANOCLAW SETUP: MIGRATE_ ===` block on stdout - * - * No step aborts the chain on failure — the orchestrator in setup/migrate-v1.ts - * reads the handoff after each step to decide whether to continue, skip, or - * hand off to the Claude `/migrate-from-v1` skill. - */ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -// ── Paths ────────────────────────────────────────────────────────────── - -export const MIGRATION_DIR = path.join('logs', 'setup-migration'); -export const HANDOFF_PATH = path.join(MIGRATION_DIR, 'handoff.json'); -export const SCHEMA_MISMATCH_PATH = path.join(MIGRATION_DIR, 'schema-mismatch.json'); -export const INACTIVE_TASKS_PATH = path.join(MIGRATION_DIR, 'inactive-tasks.json'); - -// ── V1 install discovery ─────────────────────────────────────────────── - -/** - * Default candidate paths to scan for a v1 install. Combines: - * - `$NANOCLAW_V1_PATH` (explicit override, takes priority) - * - Sibling directories of the v2 checkout whose name contains "nanoclaw" - * or "clawdbot" (most common layout — v1 lives next to v2) - * - Common checkout locations under $HOME - * - Common XDG-style state dirs (.nanoclaw, .clawdbot — v1's predecessor) - * - * Kept generic — don't bake specific usernames in. Deduped so a path that - * satisfies multiple rules only appears once. - */ -export function defaultV1Candidates(): string[] { - const home = os.homedir(); - const cwd = process.cwd(); - const cwdParent = path.dirname(cwd); - - const siblings: string[] = []; - try { - if (fs.existsSync(cwdParent)) { - for (const entry of fs.readdirSync(cwdParent, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const lower = entry.name.toLowerCase(); - // Match anything claw-ish next to v2: "nanoclaw", "nanoclaw-v1", - // "clawdbot", user's fork name like "nanoclaw-prod". Excludes the - // v2 checkout we're running from so we don't self-match. - if (!lower.includes('claw')) continue; - const full = path.join(cwdParent, entry.name); - if (path.resolve(full) === path.resolve(cwd)) continue; - siblings.push(full); - } - } - } catch { - // Can't list parent — fall through to the fixed list. - } - - const fixed = [ - path.join(home, 'nanoclaw'), - path.join(home, '.nanoclaw'), - path.join(home, 'clawdbot'), - path.join(home, '.clawdbot'), - path.join(home, 'Code', 'nanoclaw'), - path.join(home, 'code', 'nanoclaw'), - path.join(home, 'projects', 'nanoclaw'), - path.join(home, 'Projects', 'nanoclaw'), - path.join(home, 'src', 'nanoclaw'), - path.join(home, 'dev', 'nanoclaw'), - path.join(home, 'workspace', 'nanoclaw'), - path.join(home, 'Documents', 'nanoclaw'), - path.join(home, 'GitHub', 'nanoclaw'), - path.join(home, 'github', 'nanoclaw'), - path.join(home, 'repos', 'nanoclaw'), - ]; - - // NANOCLAW_V1_PATH is handled authoritatively by detect.ts — if it's set, - // detect doesn't call this function at all. So we only build the - // auto-discovery list here. - const all = [...siblings, ...fixed]; - - // Dedupe by resolved path. A sibling "nanoclaw" and a fixed "$HOME/nanoclaw" - // often resolve to the same thing on single-user machines. - const seen = new Set(); - const out: string[] = []; - for (const p of all) { - const resolved = path.resolve(p); - if (seen.has(resolved)) continue; - seen.add(resolved); - out.push(p); - } - return out; -} - -export interface V1Paths { - root: string; - db: string; - env: string; - groups: string; - packageJson: string; -} - -/** - * Build the expected v1 file layout relative to a root. All paths are returned - * even if they don't exist — callers check existence on the ones they care about. - */ -export function v1PathsFor(root: string): V1Paths { - return { - root, - db: path.join(root, 'store', 'messages.db'), - env: path.join(root, '.env'), - groups: path.join(root, 'groups'), - packageJson: path.join(root, 'package.json'), - }; -} - -/** - * Quick "does this path look like a v1 install?" check — used by detect. - * - * Strategy: the strongest signal is `store/messages.db`, so that's required. - * The package.json check is a weaker corroboration — forks may rename - * `"name"` or strip it, so we allow: - * - `name` missing or non-string - * - `name` containing "nanoclaw" or "clawdbot" (case-insensitive) - * We reject only if `name` looks like a completely unrelated project, OR - * the version is 2.x (the v2 rewrite itself). - * - * This keeps stock + forked v1 installs detectable while filtering out - * unrelated repos that happen to have a `store/messages.db`. - */ -export function looksLikeV1Install(root: string): { ok: boolean; reason?: string } { - if (!fs.existsSync(root)) return { ok: false, reason: 'root_missing' }; - const { db, packageJson } = v1PathsFor(root); - if (!fs.existsSync(db)) return { ok: false, reason: 'db_missing' }; - - // package.json is optional — a user may have stripped it, or be running - // from a state-only dir (.nanoclaw). The DB shape is checked separately - // by migrate-validate, which is authoritative for "is this schema v1?" - if (!fs.existsSync(packageJson)) return { ok: true }; - - try { - const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf-8')) as { name?: string; version?: string }; - const name = (pkg.name ?? '').toLowerCase(); - if (pkg.version && /^2\./.test(pkg.version)) return { ok: false, reason: 'already_v2' }; - if (name && !name.includes('nanoclaw') && !name.includes('clawdbot')) { - return { ok: false, reason: 'unrelated_project' }; - } - } catch { - // Broken package.json doesn't rule out v1 — DB presence is enough. - return { ok: true }; - } - return { ok: true }; -} - -// ── Handoff state (single source of truth across sub-steps) ──────────── - -/** - * Rich state shared between migration sub-steps. Each step reads the whole - * file, merges its section, and writes it back. Never hand-edit — it's - * consumed by the `/migrate-from-v1` skill too. - * - * All paths stored are ABSOLUTE, so subsequent steps don't need to guess - * about cwd. Relative paths would be a footgun once the skill reads this - * file later from a different cwd. - */ -export interface Handoff { - version: 1; - started_at: string; - v1_path: string | null; - v1_version: string | null; - - /** Overall status once migrate-handoff finalizes the run. */ - overall_status: 'pending' | 'success' | 'partial' | 'failed' | 'skipped'; - - steps: Partial>; - - /** Group folders the user chose to bring over (migrate-db populates). */ - group_selection: { - mode: 'all' | 'wired-only' | 'cancelled' | null; - selected_folders: string[]; - total_v1_groups: number; - wired_v1_groups: number; - }; - - /** Distinct channels inferred from v1 registered_groups. */ - detected_channels: Array<{ - channel_type: string; - source: 'channel_name' | 'jid_prefix'; - group_count: number; - }>; - - /** Per-channel auth copy results (migrate-channel-auth populates). */ - channel_auth: Array<{ - channel_type: string; - env_keys_copied: string[]; - files_copied: string[]; - files_missing: string[]; - notes: string; - }>; - - /** Result of each `setup/install-.sh` invocation. */ - channels_installed: Array<{ - channel_type: string; - status: 'success' | 'failed' | 'skipped' | 'not_supported'; - error?: string; - }>; - - /** Scheduled-task migration results (migrate-tasks populates). */ - tasks: { - v1_active: number; - v1_inactive: number; - migrated: number; - failed: number; - skipped: number; - }; - - /** Things the skill must finish manually. Always safe to append to. */ - followups: string[]; -} - -export type MigrateStep = - | 'migrate-detect' - | 'migrate-validate' - | 'migrate-db' - | 'migrate-groups' - | 'migrate-env' - | 'migrate-channel-auth' - | 'migrate-channels' - | 'migrate-tasks' - | 'migrate-handoff'; - -export interface StepOutcome { - status: 'success' | 'partial' | 'failed' | 'skipped'; - fields: Record; - notes: string[]; - at: string; -} - -function emptyHandoff(): Handoff { - return { - version: 1, - started_at: new Date().toISOString(), - v1_path: null, - v1_version: null, - overall_status: 'pending', - steps: {}, - group_selection: { - mode: null, - selected_folders: [], - total_v1_groups: 0, - wired_v1_groups: 0, - }, - detected_channels: [], - channel_auth: [], - channels_installed: [], - tasks: { v1_active: 0, v1_inactive: 0, migrated: 0, failed: 0, skipped: 0 }, - followups: [], - }; -} - -/** Read the handoff, creating an empty one if it doesn't exist yet. */ -export function readHandoff(): Handoff { - fs.mkdirSync(MIGRATION_DIR, { recursive: true }); - if (!fs.existsSync(HANDOFF_PATH)) return emptyHandoff(); - try { - const parsed = JSON.parse(fs.readFileSync(HANDOFF_PATH, 'utf-8')) as Handoff; - if (parsed.version !== 1) throw new Error(`unsupported handoff version ${parsed.version}`); - return parsed; - } catch { - // Broken handoff shouldn't wedge the migration — start fresh and let the - // step that called us re-record its outcome. - return emptyHandoff(); - } -} - -/** Persist a handoff mutation atomically (write-tmp + rename). */ -export function writeHandoff(h: Handoff): void { - fs.mkdirSync(MIGRATION_DIR, { recursive: true }); - const tmp = HANDOFF_PATH + '.tmp'; - fs.writeFileSync(tmp, JSON.stringify(h, null, 2)); - fs.renameSync(tmp, HANDOFF_PATH); -} - -/** Convenience: merge a step outcome into the handoff and persist. */ -export function recordStep(step: MigrateStep, outcome: StepOutcome): void { - const h = readHandoff(); - h.steps[step] = outcome; - writeHandoff(h); -} - -// ── JID parsing + channel inference ──────────────────────────────────── - -/** - * v1 stored chat identifiers as `:` in `registered_groups.jid`. - * The prefix was often a short code (`dc` for Discord, `tg` for Telegram) - * that doesn't match v2's `channel_type` names. This table normalizes them. - * - * Unknown prefixes fall through as-is (`channel_type = prefix`) so a channel - * we didn't anticipate still ends up with a distinct messaging_group per - * chat — the skill can reconcile it interactively. - */ -export const JID_PREFIX_TO_CHANNEL: Record = { - dc: 'discord', - discord: 'discord', - tg: 'telegram', - telegram: 'telegram', - wa: 'whatsapp', - whatsapp: 'whatsapp', - slack: 'slack', - matrix: 'matrix', - mx: 'matrix', - teams: 'teams', - imessage: 'imessage', - im: 'imessage', - email: 'email', - webex: 'webex', - gchat: 'gchat', - linear: 'linear', - github: 'github', -}; - -export interface ParsedJid { - raw: string; - prefix: string; - id: string; - channel_type: string; -} - -export function parseJid(raw: string): ParsedJid | null { - const colon = raw.indexOf(':'); - if (colon === -1) return null; - const prefix = raw.slice(0, colon).toLowerCase(); - const id = raw.slice(colon + 1); - if (!prefix || !id) return null; - return { - raw, - prefix, - id, - channel_type: JID_PREFIX_TO_CHANNEL[prefix] ?? prefix, - }; -} - -/** - * Prefer an explicit v1 `channel_name` when one is set; fall back to the JID - * prefix. v1 left `channel_name` empty on most rows (it was a late addition), - * so the JID prefix is often the only honest source. - */ -export function inferChannelType(jid: string, channelName: string | null): string | null { - if (channelName && channelName.trim()) return channelName.trim(); - const parsed = parseJid(jid); - return parsed?.channel_type ?? null; -} - -/** - * v2's messaging_groups.platform_id is always prefixed with the channel_type - * (see setup/register.ts:118-120). This helper normalizes v1's `jid` into - * that shape so router lookups at runtime find the right row. - * - * Some channels need extra structure on the id itself. Discord's Chat SDK - * emits `discord::` at runtime but v1 only stored - * `dc:` (no guild). Callers that know the guild (e.g. bot with - * a single guild) can pass it via `extra`; otherwise the returned id will - * be the v1-format `discord:` and will be repaired on first - * message via v2's channel-registration approval flow. - */ -export function v2PlatformId(channelType: string, jid: string, extra?: { guildId?: string }): string { - const parsed = parseJid(jid); - const id = parsed?.id ?? jid; - const prefixed = id.startsWith(`${channelType}:`) ? id : `${channelType}:${id}`; - // For Discord: splice the guild id in between when we know it and the id - // isn't already in `:` form. - if (channelType === 'discord' && extra?.guildId) { - const body = prefixed.slice(`discord:`.length); - if (!body.includes(':')) return `discord:${extra.guildId}:${body}`; - } - return prefixed; -} - -/** - * Fetch the bot's guild memberships for a channel_type so migrate-db can - * form platform_ids matching what the v2 adapter emits at runtime. Returns - * null on any failure (network, auth, rate limit, unsupported channel_type) - * — callers fall back to the v1-format platform_id, which works but may - * trigger v2's channel-registration flow on first message. - * - * Currently handles Discord. Extending to other channels: the function - * needs a "single-or-multi guild?" shape; for single-guild bots the caller - * can splice the guild id globally, for multi-guild a per-channel lookup - * is needed and the caller should probably bail (rate-limit risk). - */ -export async function fetchBotGuilds( - channelType: string, - v1EnvLookup: (key: string) => string | undefined, -): Promise<{ guildIds: string[] } | null> { - if (channelType !== 'discord') return null; - const token = v1EnvLookup('DISCORD_BOT_TOKEN'); - if (!token) return null; - try { - const resp = await fetch('https://discord.com/api/v10/users/@me/guilds', { - headers: { Authorization: `Bot ${token}` }, - }); - if (!resp.ok) return null; - const data = (await resp.json()) as Array<{ id?: string }>; - const guildIds = data.map((g) => g.id).filter((id): id is string => typeof id === 'string'); - return { guildIds }; - } catch { - return null; - } -} - -// ── Trigger rules → engage mode (ports migration 010's backfill) ─────── - -/** - * Mirrors the backfill() logic in src/db/migrations/010-engage-modes.ts so - * rows written by the migration land in the same shape as rows written by - * setup/register.ts (which goes through migration 010 at boot). - */ -export function triggerToEngage(input: { - trigger_pattern: string | null; - requires_trigger: number | null; -}): { - engage_mode: 'pattern' | 'mention' | 'mention-sticky'; - engage_pattern: string | null; -} { - const pattern = input.trigger_pattern && input.trigger_pattern.trim().length > 0 ? input.trigger_pattern : null; - const requiresTrigger = input.requires_trigger !== 0; // NULL/1 → true; 0 → false - - if (pattern === '.' || pattern === '.*') { - return { engage_mode: 'pattern', engage_pattern: '.' }; - } - // requires_trigger=0 means "respond to everything" regardless of pattern. - // The pattern was used for mention highlighting, not message gating. - if (!requiresTrigger) { - return { engage_mode: 'pattern', engage_pattern: '.' }; - } - if (pattern) { - return { engage_mode: 'pattern', engage_pattern: pattern }; - } - return { engage_mode: 'mention', engage_pattern: null }; -} - -// ── Channel auth registry (non-.env state per channel) ───────────────── - -/** - * Describes the auth surface for a channel beyond `.env`. Each entry tells - * the channel-auth step: - * - * - `v1EnvKeys`: env keys we might find on the v1 side and carry over - * - `requiredV2Keys`: env keys v2's adapter REQUIRES to boot — if missing - * from v2's merged .env after migrate-env runs, a followup is emitted so - * the user knows exactly what to add (and where to get it). - * - `candidatePaths`: relative paths under the v1 root that may hold - * on-disk auth state (WhatsApp keystore, matrix sync state, etc.) - * - `note`: short human-readable hint surfaced to the user - * - * Unknown channels fall through as {v1EnvKeys:[], requiredV2Keys:[], - * candidatePaths:[]} — the skill asks the user how to proceed. - * - * Keep `requiredV2Keys` honest: list only what the v2 adapter actually - * refuses to boot without. False positives spam the followups; false - * negatives let the agent silently fail. Verify against the actual - * `@chat-adapter/` package when adding/updating entries. - */ -export interface ChannelAuthSpec { - v1EnvKeys: string[]; - requiredV2Keys: { key: string; where: string }[]; - candidatePaths: string[]; - note?: string; -} - -export const CHANNEL_AUTH_REGISTRY: Record = { - discord: { - v1EnvKeys: ['DISCORD_BOT_TOKEN', 'DISCORD_CLIENT_ID', 'DISCORD_GUILD_ID'], - // v1 used raw discord.js (bot token only). v2 uses Chat SDK which needs - // the interaction-verification public key + application id on top. - requiredV2Keys: [ - { key: 'DISCORD_BOT_TOKEN', where: 'Discord Developer Portal → Application → Bot → Token' }, - { key: 'DISCORD_APPLICATION_ID', where: 'Discord Developer Portal → Application → General → Application ID' }, - { key: 'DISCORD_PUBLIC_KEY', where: 'Discord Developer Portal → Application → General → Public Key' }, - ], - candidatePaths: [], - note: 'v1 used raw discord.js (bot token only). v2 uses Chat SDK and needs APPLICATION_ID + PUBLIC_KEY too.', - }, - 'discord-supervisor': { - v1EnvKeys: ['DISCORD_SUPERVISOR_BOT_TOKEN'], - requiredV2Keys: [], - candidatePaths: [], - note: 'v1-specific secondary bot. v2 does not have a native supervisor channel; the token is preserved in .env for the skill to reconcile.', - }, - telegram: { - v1EnvKeys: ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_API_ID', 'TELEGRAM_API_HASH'], - requiredV2Keys: [ - { key: 'TELEGRAM_BOT_TOKEN', where: 'BotFather on Telegram → /mybots → Bot → API Token' }, - ], - candidatePaths: ['data/sessions/telegram', 'store/telegram-session'], - }, - whatsapp: { - v1EnvKeys: ['WHATSAPP_PHONE', 'WHATSAPP_OWNER'], - requiredV2Keys: [], - candidatePaths: [ - 'data/sessions/baileys', - 'data/baileys_auth', - 'store/auth_info_baileys', - 'store/baileys', - 'auth_info_baileys', - ], - note: 'Baileys keystore — copying is best-effort. Encryption sessions may still need a fresh pair via /add-whatsapp.', - }, - matrix: { - v1EnvKeys: ['MATRIX_HOMESERVER', 'MATRIX_USER_ID', 'MATRIX_ACCESS_TOKEN'], - requiredV2Keys: [ - { key: 'MATRIX_HOMESERVER', where: 'your Matrix homeserver URL (e.g. https://matrix.org)' }, - { key: 'MATRIX_ACCESS_TOKEN', where: 'Element → Settings → Help & About → Access Token (keep secret)' }, - ], - candidatePaths: ['data/matrix-store', 'store/matrix', 'data/sessions/matrix'], - }, - slack: { - v1EnvKeys: ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'SLACK_SIGNING_SECRET'], - requiredV2Keys: [ - { key: 'SLACK_BOT_TOKEN', where: 'Slack app → OAuth & Permissions → Bot User OAuth Token (xoxb-…)' }, - { key: 'SLACK_SIGNING_SECRET', where: 'Slack app → Basic Information → Signing Secret' }, - ], - candidatePaths: [], - }, - teams: { - v1EnvKeys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_TENANT_ID'], - requiredV2Keys: [ - { key: 'TEAMS_APP_ID', where: 'Azure portal → App registration → Application (client) ID' }, - { key: 'TEAMS_APP_PASSWORD', where: 'Azure portal → App registration → Certificates & secrets' }, - ], - candidatePaths: [], - }, - imessage: { - v1EnvKeys: ['IMESSAGE_PHOTON_URL', 'IMESSAGE_PHOTON_TOKEN'], - requiredV2Keys: [], - candidatePaths: ['data/imessage', 'store/imessage'], - }, - webex: { - v1EnvKeys: ['WEBEX_BOT_TOKEN'], - requiredV2Keys: [{ key: 'WEBEX_BOT_TOKEN', where: 'Webex developer portal → Bot → Bot Access Token' }], - candidatePaths: [], - }, - gchat: { - v1EnvKeys: ['GCHAT_SERVICE_ACCOUNT', 'GCHAT_WEBHOOK_URL'], - requiredV2Keys: [], - candidatePaths: ['data/gchat-credentials.json', 'store/gchat-sa.json'], - }, - resend: { - v1EnvKeys: ['RESEND_API_KEY', 'RESEND_FROM'], - requiredV2Keys: [{ key: 'RESEND_API_KEY', where: 'resend.com → API Keys' }], - candidatePaths: [], - }, - github: { - v1EnvKeys: ['GITHUB_WEBHOOK_SECRET', 'GITHUB_APP_ID', 'GITHUB_PRIVATE_KEY_PATH'], - requiredV2Keys: [], - candidatePaths: [], - note: 'Webhook channel — secrets carry over, but GitHub webhook URLs are new per v2 install.', - }, - linear: { - v1EnvKeys: ['LINEAR_API_KEY', 'LINEAR_WEBHOOK_SECRET'], - requiredV2Keys: [{ key: 'LINEAR_API_KEY', where: 'Linear → Settings → API → Personal API keys' }], - candidatePaths: [], - }, -}; - -/** - * For channels where v2's adapter needs keys v1 never stored (e.g. Discord's - * Chat SDK wants DISCORD_APPLICATION_ID + DISCORD_PUBLIC_KEY, but v1 used - * raw discord.js with just the bot token), try to derive the missing keys - * from the v1 creds we already have by calling the channel's API. - * - * Returns a map of key → value for what we successfully resolved. - * Never throws; returns `{}` on any failure (network, auth, unexpected - * shape). The caller writes the resolved keys to v2 .env, then re-checks - * `requiredV2Keys` so the step reports `success` instead of `partial` when - * auto-resolution covered the gap. - * - * Adding a new channel resolver: pull the needed values from an endpoint - * that accepts only the v1-side credential (bot token, API key). Don't - * prompt, don't log values. If the endpoint has rate limits, keep this - * best-effort and fail silently. - */ -export async function autoResolveV2Keys( - channelType: string, - v1EnvLookup: (key: string) => string | undefined, -): Promise> { - if (channelType === 'discord') { - const token = v1EnvLookup('DISCORD_BOT_TOKEN'); - if (!token) return {}; - try { - const resp = await fetch('https://discord.com/api/v10/oauth2/applications/@me', { - headers: { Authorization: `Bot ${token}` }, - }); - if (!resp.ok) return {}; - const data = (await resp.json()) as { id?: string; verify_key?: string }; - const out: Record = {}; - if (typeof data.id === 'string' && data.id) out.DISCORD_APPLICATION_ID = data.id; - if (typeof data.verify_key === 'string' && data.verify_key) { - out.DISCORD_PUBLIC_KEY = data.verify_key; - } - return out; - } catch { - return {}; - } - } - - return {}; -} - -/** - * Map a v2 `channel_type` name to the corresponding `setup/install-.sh` - * script, if one exists. `null` means no v2 skill is available yet — the - * handoff lists the channel as "not supported" and the skill raises it with - * the user. - */ -export function installScriptForChannel(channelType: string): string | null { - const known = new Set([ - 'discord', - 'telegram', - 'whatsapp', - 'whatsapp-cloud', - 'teams', - 'slack', - 'matrix', - 'imessage', - 'webex', - 'gchat', - 'resend', - 'github', - 'linear', - ]); - if (!known.has(channelType)) return null; - return `setup/install-${channelType}.sh`; -} - -// ── Misc helpers ─────────────────────────────────────────────────────── - -export function generateId(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -// ── v1-specific pattern scan (for migrate-groups) ────────────────────── - -/** - * Tight set of v1-only infrastructure patterns. When one of these shows up - * in a copied CLAUDE.md, the content referencing v1 plumbing that is genuinely - * gone in v2 (IPC file queue, single-DB paths, v1 pr-factory conventions). - * - * Deliberately excludes portable patterns — `mcp__nanoclaw__*` tool names, - * `agent-browser`, generic `/workspace/` paths — which v2 supports the same - * way. The list is scan-only; the migration does NOT modify file content. It - * just adds a followup so the /migrate-from-v1 skill can triage each file - * with the user. - * - * Keep this list conservative: false positives spam the skill with noise, - * false negatives leave the user with silently-broken agents. When adding, - * include a comment naming the specific v1 thing each pattern points at. - */ -export interface V1PatternMatch { - pattern: string; - description: string; - lines: number[]; -} - -const V1_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ - { - pattern: /\/workspace\/ipc\/tasks/, - description: "v1 IPC file queue (gone in v2 — agents talk to the host via session DBs, not JSON files)", - }, - { - pattern: /\/workspace\/extra\/project\/store\b/, - description: "v1-specific mount + store/ path (v2 mounts differ; state lives under data/)", - }, - { - pattern: /\bstore\/messages\.db\b/, - description: "v1 central DB path (v2 uses data/v2.db + data/v2-sessions//{inbound,outbound}.db)", - }, - { - pattern: /"clear_session"|"retrigger"/, - description: "v1 IPC task types (no v2 equivalent; use session lifecycle + the scheduling MCP tool instead)", - }, - { - pattern: /\[PR_CONTEXT:/, - description: "v1 pr-factory context-tag convention (specific to the supervisor group; needs reworking in v2)", - }, - { - pattern: /\brequires_trigger\b|\btrigger_pattern\b/, - description: "v1 column names on registered_groups (v2 uses engage_mode + engage_pattern on messaging_group_agents)", - }, - { - pattern: /\bchatJid\b(?!\s*[:=]\s*["']dc:)/, - description: "v1 routing key (v2 uses messaging_group_id or channel_type+platform_id)", - }, -]; - -/** Scan a CLAUDE.md-ish text blob for v1-specific infrastructure patterns. */ -export function scanForV1Patterns(text: string): V1PatternMatch[] { - const matches: V1PatternMatch[] = []; - const lines = text.split('\n'); - - for (const entry of V1_PATTERNS) { - const hitLines: number[] = []; - for (let i = 0; i < lines.length; i++) { - if (entry.pattern.test(lines[i])) { - hitLines.push(i + 1); - } - } - if (hitLines.length > 0) { - matches.push({ - pattern: entry.pattern.source, - description: entry.description, - // Cap to first 5 line numbers — we're generating a followup summary, - // not a code index. Full context is in the file itself. - lines: hitLines.slice(0, 5), - }); - } - } - - return matches; -} - -export function safeJsonStringify(value: unknown): string { - try { - return JSON.stringify(value, null, 2); - } catch { - return '{"error":"unserializable"}'; - } -} diff --git a/setup/migrate-v2/channel-auth.ts b/setup/migrate-v2/channel-auth.ts index 788ae9d..05d706d 100644 --- a/setup/migrate-v2/channel-auth.ts +++ b/setup/migrate-v2/channel-auth.ts @@ -10,7 +10,7 @@ import fs from 'fs'; import path from 'path'; -import { CHANNEL_AUTH_REGISTRY } from '../migrate-v1/shared.js'; +import { CHANNEL_AUTH_REGISTRY } from './shared.js'; function parseEnv(filePath: string): Map { const out = new Map(); diff --git a/setup/migrate-v2/db.ts b/setup/migrate-v2/db.ts index 141b267..f33ec2b 100644 --- a/setup/migrate-v2/db.ts +++ b/setup/migrate-v2/db.ts @@ -29,8 +29,7 @@ import { generateId, parseJid, triggerToEngage, - JID_PREFIX_TO_CHANNEL, -} from '../migrate-v1/shared.js'; +} from './shared.js'; interface V1Group { jid: string; diff --git a/setup/migrate-v2/shared.ts b/setup/migrate-v2/shared.ts new file mode 100644 index 0000000..62f2236 --- /dev/null +++ b/setup/migrate-v2/shared.ts @@ -0,0 +1,189 @@ +/** + * Shared helpers for the v1 → v2 migration steps. + */ + +// ── JID parsing ───────────────────────────────────────────────────────── + +/** v1 JID prefix → v2 channel_type. Unknown prefixes pass through as-is. */ +export const JID_PREFIX_TO_CHANNEL: Record = { + dc: 'discord', + discord: 'discord', + tg: 'telegram', + telegram: 'telegram', + wa: 'whatsapp', + whatsapp: 'whatsapp', + slack: 'slack', + matrix: 'matrix', + mx: 'matrix', + teams: 'teams', + imessage: 'imessage', + im: 'imessage', + email: 'email', + webex: 'webex', + gchat: 'gchat', + linear: 'linear', + github: 'github', +}; + +export interface ParsedJid { + raw: string; + prefix: string; + id: string; + channel_type: string; +} + +export function parseJid(raw: string): ParsedJid | null { + const colon = raw.indexOf(':'); + if (colon === -1) return null; + const prefix = raw.slice(0, colon).toLowerCase(); + const id = raw.slice(colon + 1); + if (!prefix || !id) return null; + return { + raw, + prefix, + id, + channel_type: JID_PREFIX_TO_CHANNEL[prefix] ?? prefix, + }; +} + +/** + * Build a v2 platform_id from a v1 JID. v2's messaging_groups.platform_id + * is always `:`. + */ +export function v2PlatformId(channelType: string, jid: string): string { + const parsed = parseJid(jid); + const id = parsed?.id ?? jid; + return id.startsWith(`${channelType}:`) ? id : `${channelType}:${id}`; +} + +// ── Trigger mapping ───────────────────────────────────────────────────── + +/** + * Map v1's trigger_pattern + requires_trigger to v2's engage_mode + engage_pattern. + * + * Key rule: requires_trigger=0 means "respond to everything" regardless + * of the pattern value. The pattern was for mention highlighting, not gating. + */ +export function triggerToEngage(input: { + trigger_pattern: string | null; + requires_trigger: number | null; +}): { + engage_mode: 'pattern' | 'mention' | 'mention-sticky'; + engage_pattern: string | null; +} { + const pattern = input.trigger_pattern && input.trigger_pattern.trim().length > 0 ? input.trigger_pattern : null; + const requiresTrigger = input.requires_trigger !== 0; + + if (pattern === '.' || pattern === '.*') { + return { engage_mode: 'pattern', engage_pattern: '.' }; + } + if (!requiresTrigger) { + return { engage_mode: 'pattern', engage_pattern: '.' }; + } + if (pattern) { + return { engage_mode: 'pattern', engage_pattern: pattern }; + } + return { engage_mode: 'mention', engage_pattern: null }; +} + +// ── ID generation ─────────────────────────────────────────────────────── + +export function generateId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +// ── Channel auth registry ─────────────────────────────────────────────── + +export interface ChannelAuthSpec { + v1EnvKeys: string[]; + requiredV2Keys: { key: string; where: string }[]; + candidatePaths: string[]; + note?: string; +} + +export const CHANNEL_AUTH_REGISTRY: Record = { + discord: { + v1EnvKeys: ['DISCORD_BOT_TOKEN', 'DISCORD_CLIENT_ID', 'DISCORD_GUILD_ID'], + requiredV2Keys: [ + { key: 'DISCORD_BOT_TOKEN', where: 'Discord Developer Portal → Application → Bot → Token' }, + { key: 'DISCORD_APPLICATION_ID', where: 'Discord Developer Portal → Application → General → Application ID' }, + { key: 'DISCORD_PUBLIC_KEY', where: 'Discord Developer Portal → Application → General → Public Key' }, + ], + candidatePaths: [], + note: 'v1 used raw discord.js (bot token only). v2 uses Chat SDK and needs APPLICATION_ID + PUBLIC_KEY too.', + }, + telegram: { + v1EnvKeys: ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_API_ID', 'TELEGRAM_API_HASH'], + requiredV2Keys: [ + { key: 'TELEGRAM_BOT_TOKEN', where: 'BotFather on Telegram → /mybots → Bot → API Token' }, + ], + candidatePaths: ['data/sessions/telegram', 'store/telegram-session'], + }, + whatsapp: { + v1EnvKeys: ['WHATSAPP_PHONE', 'WHATSAPP_OWNER'], + requiredV2Keys: [], + candidatePaths: [ + 'data/sessions/baileys', + 'data/baileys_auth', + 'store/auth_info_baileys', + 'store/baileys', + 'auth_info_baileys', + ], + note: 'Baileys keystore — copying is best-effort. Encryption sessions may still need a fresh pair via /add-whatsapp.', + }, + matrix: { + v1EnvKeys: ['MATRIX_HOMESERVER', 'MATRIX_USER_ID', 'MATRIX_ACCESS_TOKEN'], + requiredV2Keys: [ + { key: 'MATRIX_HOMESERVER', where: 'your Matrix homeserver URL (e.g. https://matrix.org)' }, + { key: 'MATRIX_ACCESS_TOKEN', where: 'Element → Settings → Help & About → Access Token (keep secret)' }, + ], + candidatePaths: ['data/matrix-store', 'store/matrix', 'data/sessions/matrix'], + }, + slack: { + v1EnvKeys: ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'SLACK_SIGNING_SECRET'], + requiredV2Keys: [ + { key: 'SLACK_BOT_TOKEN', where: 'Slack app → OAuth & Permissions → Bot User OAuth Token (xoxb-…)' }, + { key: 'SLACK_SIGNING_SECRET', where: 'Slack app → Basic Information → Signing Secret' }, + ], + candidatePaths: [], + }, + teams: { + v1EnvKeys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_TENANT_ID'], + requiredV2Keys: [ + { key: 'TEAMS_APP_ID', where: 'Azure portal → App registration → Application (client) ID' }, + { key: 'TEAMS_APP_PASSWORD', where: 'Azure portal → App registration → Certificates & secrets' }, + ], + candidatePaths: [], + }, + imessage: { + v1EnvKeys: ['IMESSAGE_PHOTON_URL', 'IMESSAGE_PHOTON_TOKEN'], + requiredV2Keys: [], + candidatePaths: ['data/imessage', 'store/imessage'], + }, + webex: { + v1EnvKeys: ['WEBEX_BOT_TOKEN'], + requiredV2Keys: [{ key: 'WEBEX_BOT_TOKEN', where: 'Webex developer portal → Bot → Bot Access Token' }], + candidatePaths: [], + }, + gchat: { + v1EnvKeys: ['GCHAT_SERVICE_ACCOUNT', 'GCHAT_WEBHOOK_URL'], + requiredV2Keys: [], + candidatePaths: ['data/gchat-credentials.json', 'store/gchat-sa.json'], + }, + resend: { + v1EnvKeys: ['RESEND_API_KEY', 'RESEND_FROM'], + requiredV2Keys: [{ key: 'RESEND_API_KEY', where: 'resend.com → API Keys' }], + candidatePaths: [], + }, + github: { + v1EnvKeys: ['GITHUB_WEBHOOK_SECRET', 'GITHUB_APP_ID', 'GITHUB_PRIVATE_KEY_PATH'], + requiredV2Keys: [], + candidatePaths: [], + note: 'Webhook channel — secrets carry over, but GitHub webhook URLs are new per v2 install.', + }, + linear: { + v1EnvKeys: ['LINEAR_API_KEY', 'LINEAR_WEBHOOK_SECRET'], + requiredV2Keys: [{ key: 'LINEAR_API_KEY', where: 'Linear → Settings → API → Personal API keys' }], + candidatePaths: [], + }, +}; diff --git a/setup/migrate-v2/tasks.ts b/setup/migrate-v2/tasks.ts index 9ba570a..6a7efbe 100644 --- a/setup/migrate-v2/tasks.ts +++ b/setup/migrate-v2/tasks.ts @@ -22,7 +22,7 @@ import { getMessagingGroupByPlatform } from '../../src/db/messaging-groups.js'; import { runMigrations } from '../../src/db/migrations/index.js'; import { insertTask } from '../../src/modules/scheduling/db.js'; import { openInboundDb, resolveSession } from '../../src/session-manager.js'; -import { parseJid, v2PlatformId } from '../migrate-v1/shared.js'; +import { parseJid, v2PlatformId } from './shared.js'; interface V1Task { id: string; From 00a30e3effcf24178b1636c9b5c0e5ecd39c86aa Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 1 May 2026 20:24:39 +0000 Subject: [PATCH 104/144] docs: update changelog, remove experimental label from migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration is no longer experimental — it's been tested end-to-end with service switchover, session continuity, and revert. Updated the changelog entry to reflect the new migrate-v2.sh flow. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf6992b..e297d3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ For detailed release notes, see the [full changelog on the documentation site](h ## [Unreleased] -- **v1 → v2 migration (experimental).** `bash nanoclaw.sh` now detects a v1 install (`~/nanoclaw`, `~/.nanoclaw`, siblings of the v2 checkout, or `$NANOCLAW_V1_PATH`) and runs a best-effort port before channel pairing: seeds `agent_groups`/`messaging_groups`/wirings from v1's `registered_groups` (with trigger_pattern → engage_mode/engage_pattern), copies group folders, merges `.env`, installs v2 channel adapters, and ports `scheduled_tasks`. Hands off to `/migrate-from-v1` for owner seeding and fork customizations. Experimental — back up `data/v2.db` and `groups/` first; see [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md). +- **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md` → `CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md). ## [2.0.0] - 2026-04-22 From cf3fcc18d45fe9f73d30a5dd9c9dc8b1fbf98842 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 1 May 2026 20:28:04 +0000 Subject: [PATCH 105/144] fix: install Docker if missing, don't skip container build migrate-v2.sh now runs setup/install-docker.sh when Docker isn't found instead of just printing a message. The container build step reports failure (not skip) when Docker is unavailable so the skill can triage it. Co-Authored-By: Claude Opus 4.6 (1M context) --- migrate-v2.sh | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/migrate-v2.sh b/migrate-v2.sh index 325b491..edae888 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -420,15 +420,24 @@ else log "Anthropic credential: skipped (no OneCLI)" fi -# 3c. Docker check +# 3c. Docker — install if missing if command -v docker >/dev/null 2>&1; then DOCKER_V=$(docker --version 2>/dev/null | head -1) step_ok "Docker available $(dim "($DOCKER_V)")" log "Docker: $DOCKER_V" else - step_fail "Docker not found" - step_info "$(dim "Install Docker: bash setup/install-docker.sh")" - log "Docker: not found" + step_info "Installing Docker…" + DOCKER_LOG="$STEPS_DIR/3c-docker.log" + if bash setup/install-docker.sh > "$DOCKER_LOG" 2>&1; then + hash -r 2>/dev/null || true + step_ok "Docker installed" + STEP_RESULTS["3c-docker"]="success" + log "Docker: installed" + else + step_fail "Docker install failed $(dim "(see $DOCKER_LOG)")" + STEP_RESULTS["3c-docker"]="failed" + log "Docker: FAILED" + fi fi # 3d. Copy container skills from v1 that v2 doesn't have @@ -464,16 +473,20 @@ if command -v docker >/dev/null 2>&1; then BUILD_LOG="$STEPS_DIR/3e-container-build.log" if bash container/build.sh > "$BUILD_LOG" 2>&1; then step_ok "Container image built" + STEP_RESULTS["3e-build"]="success" log "Container build: success" else step_fail "Container build failed" + STEP_RESULTS["3e-build"]="failed" tail -10 "$BUILD_LOG" 2>/dev/null | while IFS= read -r line; do echo " $(dim "$line")" done log "Container build: FAILED (see $BUILD_LOG)" fi else - step_skip "Container build $(dim "(no Docker)")" + step_fail "Docker not available — cannot build container" + STEP_RESULTS["3e-build"]="failed" + log "Container build: skipped (no Docker)" fi echo From ce9f17523856359636154fdd6cfed58e086758c6 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 1 May 2026 20:28:45 +0000 Subject: [PATCH 106/144] =?UTF-8?q?fix:=20reorder=20phase=203=20=E2=80=94?= =?UTF-8?q?=20Docker=20before=20OneCLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OneCLI runs in a Docker container, so Docker must be installed first. Reordered: Docker (3a) → OneCLI (3b) → Auth (3c) → Skills (3d) → Build (3e). OneCLI install now skips with a clear message if Docker isn't available. Co-Authored-By: Claude Opus 4.6 (1M context) --- migrate-v2.sh | 67 +++++++++++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/migrate-v2.sh b/migrate-v2.sh index edae888..d790c32 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -372,7 +372,27 @@ echo echo "$(bold 'Phase 3: Infrastructure')" echo -# 3a. OneCLI — detect or install via setup step +# 3a. Docker — install if missing (OneCLI needs it) +if command -v docker >/dev/null 2>&1; then + DOCKER_V=$(docker --version 2>/dev/null | head -1) + step_ok "Docker available $(dim "($DOCKER_V)")" + log "Docker: $DOCKER_V" +else + step_info "Installing Docker…" + DOCKER_LOG="$STEPS_DIR/3a-docker.log" + if bash setup/install-docker.sh > "$DOCKER_LOG" 2>&1; then + hash -r 2>/dev/null || true + step_ok "Docker installed" + STEP_RESULTS["3a-docker"]="success" + log "Docker: installed" + else + step_fail "Docker install failed $(dim "(see $DOCKER_LOG)")" + STEP_RESULTS["3a-docker"]="failed" + log "Docker: FAILED" + fi +fi + +# 3b. OneCLI — detect or install via setup step (requires Docker) ONECLI_OK=false ONECLI_URL_FROM_ENV=$(grep '^ONECLI_URL=' .env 2>/dev/null | head -1 | sed 's/^ONECLI_URL=//') ONECLI_URL_CHECK="${ONECLI_URL_FROM_ENV:-http://127.0.0.1:10254}" @@ -381,38 +401,41 @@ if curl -sf "${ONECLI_URL_CHECK}/health" >/dev/null 2>&1; then step_ok "OneCLI running at $(dim "$ONECLI_URL_CHECK")" ONECLI_OK=true log "OneCLI: running at $ONECLI_URL_CHECK" -else - # Run the setup onecli step — it handles install, reuse, and health checks +elif command -v docker >/dev/null 2>&1; then step_info "Setting up OneCLI…" - ONECLI_LOG="$STEPS_DIR/3a-onecli.log" - ONECLI_ERR="$STEPS_DIR/3a-onecli.err" + ONECLI_LOG="$STEPS_DIR/3b-onecli.log" + ONECLI_ERR="$STEPS_DIR/3b-onecli.err" if pnpm exec tsx setup/index.ts --step onecli > "$ONECLI_LOG" 2>"$ONECLI_ERR"; then step_ok "OneCLI ready" ONECLI_OK=true - STEP_RESULTS["3a-onecli"]="success" + STEP_RESULTS["3b-onecli"]="success" log "OneCLI: installed/configured" else step_fail "OneCLI setup failed $(dim "(see $ONECLI_LOG)")" - STEP_RESULTS["3a-onecli"]="failed" + STEP_RESULTS["3b-onecli"]="failed" log "OneCLI: FAILED" fi +else + step_fail "OneCLI needs Docker $(dim "(install Docker first)")" + STEP_RESULTS["3b-onecli"]="failed" + log "OneCLI: skipped (no Docker)" fi -# 3b. Anthropic credential — run the auth setup step if no credential found +# 3c. Anthropic credential — run the auth setup step if no credential found if grep -qE '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)=' .env 2>/dev/null; then step_ok "Anthropic credential found in .env" log "Anthropic credential: found in .env" elif [ "$ONECLI_OK" = "true" ]; then step_info "Registering Anthropic credential…" - AUTH_LOG="$STEPS_DIR/3b-auth.log" - AUTH_ERR="$STEPS_DIR/3b-auth.err" + AUTH_LOG="$STEPS_DIR/3c-auth.log" + AUTH_ERR="$STEPS_DIR/3c-auth.err" if pnpm exec tsx setup/index.ts --step auth > "$AUTH_LOG" 2>"$AUTH_ERR"; then step_ok "Anthropic credential registered" - STEP_RESULTS["3b-auth"]="success" + STEP_RESULTS["3c-auth"]="success" log "Anthropic credential: registered via auth step" else step_fail "Auth setup failed $(dim "(see $AUTH_LOG)")" - STEP_RESULTS["3b-auth"]="failed" + STEP_RESULTS["3c-auth"]="failed" log "Anthropic credential: FAILED" fi else @@ -420,26 +443,6 @@ else log "Anthropic credential: skipped (no OneCLI)" fi -# 3c. Docker — install if missing -if command -v docker >/dev/null 2>&1; then - DOCKER_V=$(docker --version 2>/dev/null | head -1) - step_ok "Docker available $(dim "($DOCKER_V)")" - log "Docker: $DOCKER_V" -else - step_info "Installing Docker…" - DOCKER_LOG="$STEPS_DIR/3c-docker.log" - if bash setup/install-docker.sh > "$DOCKER_LOG" 2>&1; then - hash -r 2>/dev/null || true - step_ok "Docker installed" - STEP_RESULTS["3c-docker"]="success" - log "Docker: installed" - else - step_fail "Docker install failed $(dim "(see $DOCKER_LOG)")" - STEP_RESULTS["3c-docker"]="failed" - log "Docker: FAILED" - fi -fi - # 3d. Copy container skills from v1 that v2 doesn't have V1_SKILLS_DIR="$V1_PATH/container/skills" V2_SKILLS_DIR="$PROJECT_ROOT/container/skills" From 1ebb2dc8d266eea716c106a2c9a63d65da114709 Mon Sep 17 00:00:00 2001 From: Mike Nolet Date: Sat, 2 May 2026 07:33:07 +0200 Subject: [PATCH 107/144] fix(poll-loop): slash commands silently broken on warm containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follow-up poller filtered /clear out of every tick without acking the row, and pushed every other slash command through plain formatMessages() (XML wrapping). On a warm container the outer while(true) loop never regains control, so: - /clear sat pending in messages_in forever (no response at all) - /compact, /cost, /context, /files, /remote-control arrived at the SDK as XML-wrapped user text and were never dispatched as commands Both modes are invisible to host monitoring: rows are either left pending without a processing_ack claim, or marked completed normally; heartbeat keeps firing inside the SDK event loop. When the follow-up poller observes any slash command (admin or passthrough — categorizeMessage decides), end the active query so the current turn winds down cleanly and the outer loop wakes, re-fetches the same pending set, and runs them through the canonical path (/clear handler + formatMessagesWithCommands raw dispatch). Leave the rows untouched so the outer-loop fetch sees the same set the poller saw. Cost: each slash command on a warm container forces close+reopen of the SDK stream — a few seconds of subprocess startup. The Anthropic prompt cache is server-side with a 5-min TTL keyed on prefix hash, so stream lifecycle does not affect cache lifetime; close+reopen within 5 min still gets cache hits. Also corrects the warm-stream rationale comment on processQuery, which implied keeping the stream open preserved cache warmth — it doesn't. Testing evidence — cache stays warm across stream close+reopen: Turn 1 (warm session): Usage: in=6 out=245 cache_create=92 cache_read=22996 Full cache hit (22996 tokens). Turn 2 — /clear arrives: Pending slash command — ending stream so outer loop can process Clearing session (resetting continuation) Usage: in=6 out=95 cache_create=9393 cache_read=13600 System prompt + tool defs (~13600 tokens) still hit cache; conversation history is gone (continuation reset) so the new turn writes fresh context. Turn 3 — /cost arrives: Pending slash command — ending stream so outer loop can process Usage: in=0 out=0 cache_create=0 cache_read=0 wall=0.0s api=0.0s /cost is a CLI built-in: dispatched locally by the SDK, no API call. Pre-fix this would have arrived as XML-wrapped user text and never dispatched — confirms the broader fix works. Turn 4 (next chat after /cost): Usage: in=6 out=142 cache_create=328 cache_read=22993 Full cache hit again (22993 tokens read, 328 written). Despite the /cost-induced stream close+reopen, the server-side prompt cache survived: the new sdkQuery() resumed the same continuation, the request prefix matched the cached entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/formatter.ts | 12 +++++++++ container/agent-runner/src/poll-loop.ts | 36 ++++++++++++++++++------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index c0475b2..348d5ab 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -66,6 +66,18 @@ export function isClearCommand(msg: MessageInRow): boolean { return text.toLowerCase().startsWith('/clear'); } +/** + * True for any chat that needs the outer loop's command path: /clear plus + * admin/passthrough slash commands the SDK can only dispatch when they are + * a query's first input. Used by the follow-up poller to bail out and let + * the outer loop reopen the query. + */ +export function isRunnerCommand(msg: MessageInRow): boolean { + if (msg.kind !== 'chat' && msg.kind !== 'chat-sdk') return false; + const cat = categorizeMessage(msg).category; + return cat === 'admin' || cat === 'passthrough'; +} + // 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/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 986489f..e825184 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -7,7 +7,7 @@ import { migrateLegacyContinuation, setContinuation, } from './db/session-state.js'; -import { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js'; +import { formatMessages, extractRouting, categorizeMessage, isClearCommand, isRunnerCommand, stripInternalTags, type RoutingContext } from './formatter.js'; import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js'; const POLL_INTERVAL_MS = 1000; @@ -255,30 +255,46 @@ async function processQuery( let done = false; // 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). + // We do NOT force-end the stream on silence — keeping the query open avoids + // re-spawning the SDK subprocess (~few seconds) and re-loading the .jsonl + // transcript on every turn. The Anthropic prompt cache is server-side with + // a 5-min TTL keyed on prefix hash, so stream lifecycle does NOT affect + // cache lifetime — close+reopen within 5 min still gets cache hits. // 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. let pollInFlight = false; + let endedForCommand = false; const pollHandle = setInterval(() => { - if (done || pollInFlight) return; + if (done || pollInFlight || endedForCommand) return; pollInFlight = true; void (async () => { try { - // Skip system messages (MCP tool responses) and /clear (needs fresh query). + const pending = getPendingMessages(); + + // Slash commands need a fresh query: /clear resets the SDK's + // resume id (fixed at sdkQuery() time); admin/passthrough commands + // (/compact, /cost, …) only dispatch when they're the first input + // of a query — pushed mid-stream they arrive as plain text and + // the SDK never runs them. End the stream and leave the rows + // pending; the outer loop handles them on next iteration via the + // canonical command path + formatMessagesWithCommands. + if (pending.some((m) => isRunnerCommand(m))) { + log('Pending slash command — ending stream so outer loop can process'); + endedForCommand = true; + query.end(); + return; + } + + // Skip system messages (MCP tool responses). // 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; - return true; - }); + const newMessages = pending.filter((m) => m.kind !== 'system'); if (newMessages.length === 0) return; const newIds = newMessages.map((m) => m.id); From 8d022fd9daf259647e0be1664582336fe2ec643d Mon Sep 17 00:00:00 2001 From: Charlie Savage Date: Fri, 1 May 2026 23:44:07 -0700 Subject: [PATCH 108/144] fix(host-sweep): reopen outbound DB as writable for orphan claim cleanup PR #2151 added deleteOrphanProcessingClaims() to resetStuckProcessingRows(), but outDb is always opened readonly (openOutboundDb uses immutable: true). The write silently failed, leaving orphan processing_ack rows that block future message delivery for the session. Fix: add openOutboundDbRw() alongside the existing readonly opener and use it in resetStuckProcessingRows() to open a short-lived writable handle just for the delete. The readonly handle is still used for all reads above. Co-Authored-By: Claude Sonnet 4.6 --- src/db/session-db.ts | 8 ++++++++ src/host-sweep.ts | 17 +++++++++++++---- src/session-manager.ts | 6 ++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/db/session-db.ts b/src/db/session-db.ts index ca15276..addc39d 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -32,6 +32,14 @@ export function openOutboundDb(dbPath: string): Database.Database { return db; } +/** Open the outbound DB for a session with write access. Only safe to call when no container is running. */ +export function openOutboundDbRw(dbPath: string): Database.Database { + const db = new Database(dbPath); + db.pragma('journal_mode = DELETE'); + db.pragma('busy_timeout = 5000'); + return db; +} + export function upsertSessionRouting( db: Database.Database, routing: { channel_type: string | null; platform_id: string | null; thread_id: string | null }, diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 30cdc64..09c82ac 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -43,7 +43,7 @@ import { type ContainerState, } from './db/session-db.js'; import { log } from './log.js'; -import { openInboundDb, openOutboundDb, inboundDbPath, heartbeatPath } from './session-manager.js'; +import { openInboundDb, openOutboundDb, openOutboundDbRw, inboundDbPath, heartbeatPath } from './session-manager.js'; import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js'; import type { Session } from './types.js'; @@ -302,8 +302,17 @@ function resetStuckProcessingRows( // agent-runner has a chance to run clearStaleProcessingAcks() on startup. // We're safe to write outbound.db here because we just killed the container // that owned it (or it crashed and left no writer behind). - const cleared = deleteOrphanProcessingClaims(outDb); - if (cleared > 0) { - log.info('Cleared orphan processing claims', { sessionId: session.id, cleared, reason }); + // outDb was opened readonly for reads above; reopen with write access for this delete. + let outDbRw: Database.Database | null = null; + try { + outDbRw = openOutboundDbRw(session.agent_group_id, session.id); + const cleared = deleteOrphanProcessingClaims(outDbRw); + if (cleared > 0) { + log.info('Cleared orphan processing claims', { sessionId: session.id, cleared, reason }); + } + } catch (err) { + log.warn('Failed to clear orphan processing claims', { sessionId: session.id, err }); + } finally { + outDbRw?.close(); } } diff --git a/src/session-manager.ts b/src/session-manager.ts index 6b00655..e3f3f7a 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -30,6 +30,7 @@ import { ensureSchema, openInboundDb as openInboundDbRaw, openOutboundDb as openOutboundDbRaw, + openOutboundDbRw as openOutboundDbRwRaw, upsertSessionRouting, insertMessage, migrateMessagesInTable, @@ -355,6 +356,11 @@ export function openOutboundDb(agentGroupId: string, sessionId: string): Databas return openOutboundDbRaw(outboundDbPath(agentGroupId, sessionId)); } +/** Open the outbound DB for a session with write access. Only safe to call when no container is running. */ +export function openOutboundDbRw(agentGroupId: string, sessionId: string): Database.Database { + return openOutboundDbRwRaw(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 From aec7ddd09932e6a1e86306c71ce898b51ff7bb34 Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sat, 2 May 2026 14:32:34 +0300 Subject: [PATCH 109/144] fix(migrate-v2): correct JID parsing, Discord guildId lookup, silent failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared.ts: parseJid now recognizes raw Baileys WhatsApp JIDs (`@s.whatsapp.net`, `@g.us`, etc.); v2PlatformId returns the raw JID for whatsapp to match what the runtime adapter emits. Without this, every WhatsApp group in a v1 install was silently skipped. - discord-resolver.ts: new helper that uses DISCORD_BOT_TOKEN to look up channelId → guildId via the Discord API, since v1 stored only the channel id but v2 needs `discord::`. Best-effort: on missing/invalid token or network error, returns empty resolver and the affected groups are skipped with the reason surfaced per channel. - db.ts, tasks.ts: route Discord groups through the resolver; other channels go through v2PlatformId unchanged. Resolver only built when at least one Discord group exists, so non-Discord installs incur no network. - db.ts: when every v1 group is skipped, exit non-zero with a FAIL line instead of `OK:groups=N,...,skipped=N`, so the wrapper doesn't hide total failure under a successful-looking summary. - migrate-v2.sh: run_step now surfaces ERROR: lines from successful steps (with count + first 3 + raw log path); phase 2c install loop populates STEP_RESULTS so install failures show in handoff.json instead of silently passing. - sessions.ts: copyTree skips dangling symlinks (e.g. v1's `.claude/debug/latest`) instead of crashing the entire step. Co-Authored-By: Claude Opus 4.7 (1M context) --- migrate-v2.sh | 20 +++- setup/migrate-v2/db.ts | 57 +++++++++- setup/migrate-v2/discord-resolver.test.ts | 115 +++++++++++++++++++++ setup/migrate-v2/discord-resolver.ts | 120 ++++++++++++++++++++++ setup/migrate-v2/sessions.ts | 2 + setup/migrate-v2/shared.ts | 22 +++- setup/migrate-v2/tasks.ts | 26 ++++- 7 files changed, 351 insertions(+), 11 deletions(-) create mode 100644 setup/migrate-v2/discord-resolver.test.ts create mode 100644 setup/migrate-v2/discord-resolver.ts diff --git a/migrate-v2.sh b/migrate-v2.sh index d790c32..38a0f0d 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -259,6 +259,18 @@ run_step() { step_ok "$label $(dim "$result")" log "$name: $result" STEP_RESULTS[$name]="success" + # Surface partial errors (rows skipped due to parse/lookup failures) + # even when the step exited successfully — they're easy to miss in the + # raw log and have caused silent migrations before. + if grep -q '^ERROR:' "$raw" 2>/dev/null; then + local err_count + err_count=$(grep -c '^ERROR:' "$raw") + echo " $(dim "${err_count} error(s) reported — see $raw")" + grep '^ERROR:' "$raw" | head -3 | while IFS= read -r line; do + echo " $(dim "$line")" + done + log "$name: ${err_count} non-fatal errors" + fi elif grep -q '^SKIPPED:' "$raw" 2>/dev/null; then local reason reason=$(grep '^SKIPPED:' "$raw" | head -1 | sed 's/^SKIPPED://') @@ -340,14 +352,17 @@ else # 2c. Install channel code for ch in "${SELECTED_CHANNELS[@]}"; do INSTALL_SCRIPT="setup/install-${ch}.sh" + STEP_NAME="2c-install-${ch}" if [ -f "$INSTALL_SCRIPT" ]; then - STEP_LOG="$STEPS_DIR/2c-install-${ch}.log" + STEP_LOG="$STEPS_DIR/${STEP_NAME}.log" if bash "$INSTALL_SCRIPT" > "$STEP_LOG" 2>&1; then STATUS_LINE=$(grep '^STATUS:' "$STEP_LOG" | head -1 | sed 's/^STATUS: *//') if [ "$STATUS_LINE" = "already-installed" ]; then step_skip "Install $ch $(dim "(already installed)")" + STEP_RESULTS[$STEP_NAME]="skipped" else step_ok "Install $ch" + STEP_RESULTS[$STEP_NAME]="success" fi log "install-$ch: $STATUS_LINE" else @@ -356,9 +371,12 @@ else echo " $(dim "$line")" done log "install-$ch: FAILED (see $STEP_LOG)" + STEP_RESULTS[$STEP_NAME]="failed" fi else step_skip "Install $ch $(dim "(no install script)")" + log "install-$ch: no install script" + STEP_RESULTS[$STEP_NAME]="failed" fi done fi diff --git a/setup/migrate-v2/db.ts b/setup/migrate-v2/db.ts index f33ec2b..fb15ab0 100644 --- a/setup/migrate-v2/db.ts +++ b/setup/migrate-v2/db.ts @@ -25,10 +25,13 @@ import { getMessagingGroupByPlatform, } from '../../src/db/messaging-groups.js'; import { runMigrations } from '../../src/db/migrations/index.js'; +import { readEnvFile } from '../../src/env.js'; +import { buildDiscordResolver, type DiscordResolver } from './discord-resolver.js'; import { generateId, parseJid, triggerToEngage, + v2PlatformId, } from './shared.js'; interface V1Group { @@ -40,7 +43,7 @@ interface V1Group { is_main: number | null; } -function main(): void { +async function main(): Promise { const v1Path = process.argv[2]; if (!v1Path) { console.error('Usage: tsx setup/migrate-v2/db.ts '); @@ -78,6 +81,24 @@ function main(): void { let skipped = 0; const errors: string[] = []; + // v1 stored Discord groups as `dc:` (no guildId). v2 needs + // `discord::`. If there are any Discord groups, use + // the bot token (carried forward by 1a-env) to look up each channel's + // guild via the Discord API. On any failure the resolver returns null + // for every channel and the affected groups skip with a clear warning. + let discordResolver: DiscordResolver | null = null; + const hasDiscord = v1Groups.some((g) => parseJid(g.jid)?.channel_type === 'discord'); + if (hasDiscord) { + const env = readEnvFile(['DISCORD_BOT_TOKEN']); + discordResolver = await buildDiscordResolver(env.DISCORD_BOT_TOKEN ?? ''); + const stats = discordResolver.stats(); + if (stats.reason) { + console.log(`WARN:discord resolver disabled: ${stats.reason}`); + } else { + console.log(`INFO:discord resolver: ${stats.guilds} guild(s), ${stats.channels} channel(s)`); + } + } + for (const g of v1Groups) { const parsed = parseJid(g.jid); if (!parsed) { @@ -87,9 +108,22 @@ function main(): void { } const channelType = parsed.channel_type; - const platformId = parsed.raw.startsWith(`${channelType}:`) - ? parsed.raw - : `${channelType}:${parsed.id}`; + let platformId: string; + if (channelType === 'discord') { + const resolved = discordResolver?.resolve(parsed.id) ?? null; + if (!resolved) { + const stats = discordResolver?.stats(); + const why = stats?.reason + ? `discord resolver unavailable (${stats.reason})` + : 'not found in any guild the bot can see — re-add the bot to that server and re-run, or rewire after migration'; + skipped++; + errors.push(`Discord channel ${parsed.id} (${g.folder}): ${why}`); + continue; + } + platformId = resolved; + } else { + platformId = v2PlatformId(channelType, parsed.raw); + } const createdAt = new Date().toISOString(); try { @@ -152,10 +186,23 @@ function main(): void { v2Db.close(); + // If every group was skipped, the migration didn't actually do anything. + // Treat that as failure so the wrapper script surfaces it instead of + // hiding it under an `OK:` line. + const totalDone = created + reused; + if (v1Groups.length > 0 && totalDone === 0) { + console.error(`FAIL:groups=${v1Groups.length},created=0,reused=0,skipped=${skipped}`); + for (const e of errors) console.error(`ERROR:${e}`); + process.exit(1); + } + console.log(`OK:groups=${v1Groups.length},created=${created},reused=${reused},skipped=${skipped}`); if (errors.length > 0) { for (const e of errors) console.log(`ERROR:${e}`); } } -main(); +main().catch((err) => { + console.error(`FAIL:${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/setup/migrate-v2/discord-resolver.test.ts b/setup/migrate-v2/discord-resolver.test.ts new file mode 100644 index 0000000..31e63f7 --- /dev/null +++ b/setup/migrate-v2/discord-resolver.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { buildDiscordResolver } from './discord-resolver.js'; + +function mockFetch(handlers: Record): typeof fetch { + return vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const match = Object.keys(handlers).find((k) => url.startsWith(k)); + if (!match) throw new Error(`unexpected fetch: ${url}`); + const body = handlers[match]; + if (body instanceof Error) throw body; + if (typeof body === 'object' && body !== null && 'status' in body && (body as { status?: number }).status) { + const r = body as { status: number; statusText?: string; body?: string }; + return new Response(r.body ?? '', { status: r.status, statusText: r.statusText ?? '' }); + } + return new Response(JSON.stringify(body), { status: 200 }); + }) as unknown as typeof fetch; +} + +describe('buildDiscordResolver', () => { + it('returns empty resolver when token is missing', async () => { + const r = await buildDiscordResolver(''); + expect(r.stats()).toMatchObject({ guilds: 0, channels: 0 }); + expect(r.stats().reason).toMatch(/no DISCORD_BOT_TOKEN/); + expect(r.resolve('any')).toBeNull(); + }); + + it('resolves channels to guild-prefixed platform ids', async () => { + const fetchImpl = mockFetch({ + 'https://discord.com/api/v10/users/@me/guilds': [ + { id: 'g1', name: 'Guild 1' }, + { id: 'g2', name: 'Guild 2' }, + ], + 'https://discord.com/api/v10/guilds/g1/channels': [ + { id: 'c1' }, + { id: 'c2' }, + ], + 'https://discord.com/api/v10/guilds/g2/channels': [ + { id: 'c3' }, + ], + }); + + const r = await buildDiscordResolver('valid-token', fetchImpl); + + expect(r.stats()).toEqual({ guilds: 2, channels: 3 }); + expect(r.resolve('c1')).toBe('discord:g1:c1'); + expect(r.resolve('c2')).toBe('discord:g1:c2'); + expect(r.resolve('c3')).toBe('discord:g2:c3'); + expect(r.resolve('cX')).toBeNull(); + }); + + it('returns disabled resolver on 401', async () => { + const fetchImpl = mockFetch({ + 'https://discord.com/api/v10/users/@me/guilds': { + status: 401, + statusText: 'Unauthorized', + body: '{"message":"401: Unauthorized","code":0}', + }, + }); + + const r = await buildDiscordResolver('bad-token', fetchImpl); + expect(r.stats().guilds).toBe(0); + expect(r.stats().reason).toMatch(/401/); + expect(r.resolve('c1')).toBeNull(); + }); + + it('keeps partial results when one guild lookup fails', async () => { + const fetchImpl = mockFetch({ + 'https://discord.com/api/v10/users/@me/guilds': [ + { id: 'g1', name: 'Good Guild' }, + { id: 'g2', name: 'Bad Guild' }, + ], + 'https://discord.com/api/v10/guilds/g1/channels': [{ id: 'c1' }], + 'https://discord.com/api/v10/guilds/g2/channels': { + status: 403, + statusText: 'Forbidden', + body: '{}', + }, + }); + + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const r = await buildDiscordResolver('valid-token', fetchImpl); + errSpy.mockRestore(); + + expect(r.resolve('c1')).toBe('discord:g1:c1'); + expect(r.stats().guilds).toBe(2); + expect(r.stats().channels).toBe(1); + }); + + it('paginates the guild list', async () => { + // First page: 200 guilds (g0..g199); second page: 1 guild (g200); third call would not happen. + const page1 = Array.from({ length: 200 }, (_, i) => ({ id: `g${i}`, name: `G${i}` })); + const page2 = [{ id: 'g200', name: 'G200' }]; + let call = 0; + const fetchImpl = vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes('/users/@me/guilds')) { + call++; + const body = call === 1 ? page1 : page2; + return new Response(JSON.stringify(body), { status: 200 }); + } + // Every guild has one channel named after itself + const m = /\/guilds\/([^/]+)\/channels/.exec(url); + const gid = m ? m[1] : ''; + return new Response(JSON.stringify([{ id: `c-${gid}` }]), { status: 200 }); + }) as unknown as typeof fetch; + + const r = await buildDiscordResolver('valid-token', fetchImpl); + + expect(r.stats().guilds).toBe(201); + expect(r.stats().channels).toBe(201); + expect(r.resolve('c-g0')).toBe('discord:g0:c-g0'); + expect(r.resolve('c-g200')).toBe('discord:g200:c-g200'); + }); +}); diff --git a/setup/migrate-v2/discord-resolver.ts b/setup/migrate-v2/discord-resolver.ts new file mode 100644 index 0000000..17b9a9f --- /dev/null +++ b/setup/migrate-v2/discord-resolver.ts @@ -0,0 +1,120 @@ +/** + * Discord channel → guild resolver for the v1 → v2 migration. + * + * v1 stored Discord groups as `dc:` — only the channel id, not + * the guild id. v2's `@chat-adapter/discord` encodes `platform_id` as + * `discord::`, so we can't reconstruct it from v1 data + * alone. Instead, we use the v1 bot token (carried forward by 1a-env) to + * query the Discord API and build a channelId → guildId map. + * + * Network calls are best-effort: on auth failure or network error, the + * resolver returns null for every channel and the caller falls back to + * skipping with a clear warning. + */ + +const DISCORD_API = 'https://discord.com/api/v10'; + +interface Guild { + id: string; + name: string; +} + +interface Channel { + id: string; + name?: string; +} + +export interface DiscordResolver { + /** Returns `discord::` or null if the channel isn't visible to the bot. */ + resolve(channelId: string): string | null; + /** Diagnostic info — guild count and total channel count discovered. */ + stats(): { guilds: number; channels: number; reason?: string }; +} + +/** A no-op resolver that returns null for every lookup with a stored reason. */ +function emptyResolver(reason: string): DiscordResolver { + return { + resolve: () => null, + stats: () => ({ guilds: 0, channels: 0, reason }), + }; +} + +type FetchFn = typeof fetch; + +async function getJson(url: string, token: string, fetchImpl: FetchFn): Promise { + const res = await fetchImpl(url, { + headers: { + Authorization: `Bot ${token}`, + 'User-Agent': 'NanoClaw-Migration (https://github.com/qwibitai/nanoclaw, 2.x)', + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Discord API ${res.status} ${res.statusText}: ${body.slice(0, 200)}`); + } + return (await res.json()) as T; +} + +/** + * Build a Discord resolver by enumerating every guild the bot is in and + * every channel in those guilds. Returns an empty resolver on any error. + * + * Costs: 1 + N HTTP calls (N = guild count). Discord's global rate limit + * is 50 req/s; even installs with hundreds of guilds finish in under a + * second of network time. + */ +export async function buildDiscordResolver( + token: string, + fetchImpl: FetchFn = fetch, +): Promise { + if (!token) return emptyResolver('no DISCORD_BOT_TOKEN in .env'); + + // Page through guilds. Default page size is 200; loop until short page. + const guilds: Guild[] = []; + let after: string | null = null; + try { + while (true) { + const url = new URL(`${DISCORD_API}/users/@me/guilds`); + url.searchParams.set('limit', '200'); + if (after) url.searchParams.set('after', after); + const page = await getJson(url.toString(), token, fetchImpl); + guilds.push(...page); + if (page.length < 200) break; + after = page[page.length - 1].id; + } + } catch (err) { + return emptyResolver(`failed to list guilds: ${err instanceof Error ? err.message : String(err)}`); + } + + // Per-guild channel enumeration. + const channelToGuild = new Map(); + for (const guild of guilds) { + try { + const channels = await getJson( + `${DISCORD_API}/guilds/${guild.id}/channels`, + token, + fetchImpl, + ); + for (const ch of channels) { + channelToGuild.set(ch.id, guild.id); + } + } catch (err) { + // Skip this guild but keep going — partial results are still useful. + // The caller logs which channels couldn't be resolved. + console.error( + `WARN:discord-resolver: failed to enumerate guild ${guild.id} (${guild.name}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + return { + resolve(channelId: string): string | null { + const guildId = channelToGuild.get(channelId); + if (!guildId) return null; + return `discord:${guildId}:${channelId}`; + }, + stats: () => ({ guilds: guilds.length, channels: channelToGuild.size }), + }; +} diff --git a/setup/migrate-v2/sessions.ts b/setup/migrate-v2/sessions.ts index 0299dec..7ca8516 100644 --- a/setup/migrate-v2/sessions.ts +++ b/setup/migrate-v2/sessions.ts @@ -50,6 +50,8 @@ function copyTree(src: string, dst: string): number { written += copyTree(s, d); continue; } + // Skip dangling symlinks (e.g. v1's .claude/debug/latest pointer). + if (entry.isSymbolicLink() && !fs.existsSync(s)) continue; if (fs.existsSync(d)) continue; fs.copyFileSync(s, d); written += 1; diff --git a/setup/migrate-v2/shared.ts b/setup/migrate-v2/shared.ts index 62f2236..ff219df 100644 --- a/setup/migrate-v2/shared.ts +++ b/setup/migrate-v2/shared.ts @@ -32,7 +32,19 @@ export interface ParsedJid { channel_type: string; } +/** WhatsApp (Baileys) JID hosts. v1 stored these raw, with no `wa:` prefix. */ +const WA_JID_HOSTS = new Set(['s.whatsapp.net', 'g.us', 'lid', 'broadcast', 'newsletter']); + +function isWhatsappJid(raw: string): boolean { + const at = raw.lastIndexOf('@'); + if (at === -1) return false; + return WA_JID_HOSTS.has(raw.slice(at + 1).toLowerCase()); +} + export function parseJid(raw: string): ParsedJid | null { + if (isWhatsappJid(raw)) { + return { raw, prefix: 'whatsapp', id: raw, channel_type: 'whatsapp' }; + } const colon = raw.indexOf(':'); if (colon === -1) return null; const prefix = raw.slice(0, colon).toLowerCase(); @@ -47,10 +59,16 @@ export function parseJid(raw: string): ParsedJid | null { } /** - * Build a v2 platform_id from a v1 JID. v2's messaging_groups.platform_id - * is always `:`. + * Build a v2 platform_id from a v1 JID, in the format the runtime adapter + * for that channel emits. WhatsApp uses the raw Baileys JID (`@`, + * no prefix). Other channels use `:`. */ export function v2PlatformId(channelType: string, jid: string): string { + if (channelType === 'whatsapp') { + // Strip any v1 `wa:`/`whatsapp:` prefix; otherwise pass through raw. + const parsed = parseJid(jid); + return parsed?.channel_type === 'whatsapp' ? parsed.id : jid; + } const parsed = parseJid(jid); const id = parsed?.id ?? jid; return id.startsWith(`${channelType}:`) ? id : `${channelType}:${id}`; diff --git a/setup/migrate-v2/tasks.ts b/setup/migrate-v2/tasks.ts index 6a7efbe..4d3b3b5 100644 --- a/setup/migrate-v2/tasks.ts +++ b/setup/migrate-v2/tasks.ts @@ -22,6 +22,8 @@ import { getMessagingGroupByPlatform } from '../../src/db/messaging-groups.js'; import { runMigrations } from '../../src/db/migrations/index.js'; import { insertTask } from '../../src/modules/scheduling/db.js'; import { openInboundDb, resolveSession } from '../../src/session-manager.js'; +import { readEnvFile } from '../../src/env.js'; +import { buildDiscordResolver, type DiscordResolver } from './discord-resolver.js'; import { parseJid, v2PlatformId } from './shared.js'; interface V1Task { @@ -67,7 +69,7 @@ function toCron(t: V1Task): { processAfter: string; recurrence: string | null } return null; } -function main(): void { +async function main(): Promise { const v1Path = process.argv[2]; if (!v1Path) { console.error('Usage: tsx setup/migrate-v2/tasks.ts '); @@ -104,6 +106,14 @@ function main(): void { let skipped = 0; let failed = 0; + // Mirrors db.ts: Discord platform_id needs API lookup to recover guildId. + let discordResolver: DiscordResolver | null = null; + const hasDiscord = activeTasks.some((t) => parseJid(t.chat_jid)?.channel_type === 'discord'); + if (hasDiscord) { + const env = readEnvFile(['DISCORD_BOT_TOKEN']); + discordResolver = await buildDiscordResolver(env.DISCORD_BOT_TOKEN ?? ''); + } + for (const t of activeTasks) { try { const ag = getAgentGroupByFolder(t.group_folder); @@ -112,7 +122,14 @@ function main(): void { const parsed = parseJid(t.chat_jid); if (!parsed) { skipped++; continue; } - const platformId = v2PlatformId(parsed.channel_type, t.chat_jid); + let platformId: string; + if (parsed.channel_type === 'discord') { + const resolved = discordResolver?.resolve(parsed.id) ?? null; + if (!resolved) { skipped++; continue; } + platformId = resolved; + } else { + platformId = v2PlatformId(parsed.channel_type, t.chat_jid); + } const mg = getMessagingGroupByPlatform(parsed.channel_type, platformId); if (!mg) { skipped++; continue; } @@ -155,4 +172,7 @@ function main(): void { console.log(`OK:active=${activeTasks.length},migrated=${migrated},skipped=${skipped},failed=${failed}`); } -main(); +main().catch((err) => { + console.error(`FAIL:${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); From 416c283dcbfdb8a4305e0ff2e1d7c6b448657a6f Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sat, 2 May 2026 14:50:21 +0300 Subject: [PATCH 110/144] fix(migrate-v2): bash 3.2 compatibility + reset coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit migrate-v2.sh Replace `declare -A STEP_RESULTS` with two parallel indexed arrays (STEP_NAMES + STEP_STATUSES) plus a `record_step` helper. macOS ships bash 3.2 which has no associative arrays — `declare -A` errored out silently and every `STEP_RESULTS["1a-env"]=...` triggered a fatal bash arithmetic error (interpreting "1a" as a number). Visible symptom: `steps: {}` in handoff.json. Latent symptom: phase 2c's install loop sometimes bailed mid-iteration before invoking the channel install script, leaving channel code uninstalled while reporting `overall_status: success`. migrate-v2-reset.sh Cover the gaps that left install side-effects in place between iterations: - Remove untracked adapter files in src/channels/ (mirror the pattern already used for container/skills/). - Restore tracked setup helpers that channel installs overwrite (setup/whatsapp-auth.ts, setup/pair-telegram.ts, setup/index.ts) and remove untracked ones they create (setup/groups.ts). - Restore package.json + pnpm-lock.yaml (channel installs add deps like @whiskeysockets/baileys). Setup/migrate-v2/* is intentionally not touched — that's where user WIP lives. Verified end-to-end: reset → migrate → all 9 steps reported in handoff.json with status "success", phase 2c install actually runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- migrate-v2-reset.sh | 45 ++++++++++++++++++++++++++++------ migrate-v2.sh | 59 +++++++++++++++++++++++++++------------------ 2 files changed, 73 insertions(+), 31 deletions(-) diff --git a/migrate-v2-reset.sh b/migrate-v2-reset.sh index b795745..cd817d9 100644 --- a/migrate-v2-reset.sh +++ b/migrate-v2-reset.sh @@ -6,17 +6,27 @@ # bash migrate-v2-reset.sh && bash migrate-v2.sh # # What it removes: -# - data/ (v2 DBs, session state) -# - logs/ (migration + setup logs) -# - .env (merged env keys) -# - groups/*/ (non-git group folders copied from v1) +# - data/ (v2 DBs, session state) +# - logs/ (migration + setup logs) +# - .env (merged env keys) +# - groups/*/ (non-git group folders copied from v1) +# - container/skills/*/ (untracked skill dirs copied from v1) +# - src/channels/*.ts (untracked adapters copied from channels branch) +# - setup/groups.ts (untracked, copied by channel install scripts) # -# What it restores: -# - groups/global/CLAUDE.md and groups/main/CLAUDE.md from git +# What it restores from git: +# - groups/ (CLAUDE.md files etc.) +# - container/skills/ (tracked container skills) +# - src/channels/ (tracked bridge / registry code) +# - setup/whatsapp-auth.ts (channel installs may overwrite) +# - setup/pair-telegram.ts (channel installs may overwrite) +# - setup/index.ts (channel installs append entries) +# - package.json + pnpm-lock.yaml (channel installs add deps) # # What it does NOT touch: -# - node_modules/ (expensive to reinstall, keep it) -# - The v1 install (read-only, never modified) +# - node_modules/ (expensive to reinstall, kept on purpose) +# - setup/migrate-v2/* (the migration scripts themselves, plus user WIP) +# - The v1 install (read-only, never modified) set -euo pipefail @@ -63,7 +73,26 @@ printf '%s Restored %s\n' "$(green '✓')" "container/skills/ from git" # Restore channel code (src/channels/) to git state git checkout -- src/channels/ 2>/dev/null || true +# Remove any untracked channel adapters copied in by install-*.sh +for f in src/channels/*.ts; do + [ -f "$f" ] || continue + if ! git ls-files --error-unmatch "$f" >/dev/null 2>&1; then + rm -f "$f" + fi +done printf '%s Restored %s\n' "$(green '✓')" "src/channels/ from git" +# Restore tracked setup helpers that channel installs overwrite, and +# remove the untracked ones they create. Don't blanket-clean setup/ +# because user WIP (setup/migrate-v2/*) lives there too. +git checkout -- setup/whatsapp-auth.ts setup/pair-telegram.ts setup/index.ts 2>/dev/null || true +rm -f setup/groups.ts +printf '%s Restored %s\n' "$(green '✓')" "setup/ install helpers" + +# Restore package.json + lockfile (channel installs add deps like +# @whiskeysockets/baileys). node_modules/ is intentionally kept. +git checkout -- package.json pnpm-lock.yaml 2>/dev/null || true +printf '%s Restored %s\n' "$(green '✓')" "package.json + pnpm-lock.yaml" + echo printf '%s\n\n' "$(dim 'Clean. Run: bash migrate-v2.sh')" diff --git a/migrate-v2.sh b/migrate-v2.sh index 38a0f0d..cacdffc 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -29,14 +29,25 @@ SERVICE_SWITCHED=false SELECTED_CHANNELS=() ABORTED_AT="" +# Per-step status tracking. Parallel indexed arrays so this works on +# bash 3.2 (macOS default) which has no associative arrays. +STEP_NAMES=() +STEP_STATUSES=() + +record_step() { + STEP_NAMES+=("$1") + STEP_STATUSES+=("$2") +} + # Write handoff.json on any exit so the skill can always read it write_handoff() { local handoff_dir="$LOGS_DIR/setup-migration" mkdir -p "$handoff_dir" local has_failures=false - for step_name in "${!STEP_RESULTS[@]}"; do - [ "${STEP_RESULTS[$step_name]}" = "failed" ] && has_failures=true + local i + for ((i=0; i<${#STEP_NAMES[@]}; i++)); do + [ "${STEP_STATUSES[$i]}" = "failed" ] && has_failures=true done local overall="success" @@ -44,8 +55,10 @@ write_handoff() { [ -n "$ABORTED_AT" ] && overall="failed" local steps_json="{" - for step_name in "${!STEP_RESULTS[@]}"; do - steps_json="${steps_json}\"${step_name}\": {\"status\": \"${STEP_RESULTS[$step_name]}\", \"log\": \"logs/migrate-steps/${step_name}.log\"}," + for ((i=0; i<${#STEP_NAMES[@]}; i++)); do + local n="${STEP_NAMES[$i]}" + local s="${STEP_STATUSES[$i]}" + steps_json="${steps_json}\"${n}\": {\"status\": \"${s}\", \"log\": \"logs/migrate-steps/${n}.log\"}," done steps_json="${steps_json%,}}" @@ -245,8 +258,8 @@ export NANOCLAW_V2_PATH="$PROJECT_ROOT" # ─── run_step helper ───────────────────────────────────────────────────── # Runs a TypeScript migration step, captures output, reports success/failure. -# Track step outcomes for handoff.json -declare -A STEP_RESULTS +# Step outcomes are tracked via record_step() into STEP_NAMES/STEP_STATUSES +# (defined above, near write_handoff). run_step() { local name=$1 label=$2 script=$3 @@ -258,7 +271,7 @@ run_step() { result=$(grep '^OK:' "$raw" | head -1 || true) step_ok "$label $(dim "$result")" log "$name: $result" - STEP_RESULTS[$name]="success" + record_step "$name" "success" # Surface partial errors (rows skipped due to parse/lookup failures) # even when the step exited successfully — they're easy to miss in the # raw log and have caused silent migrations before. @@ -276,7 +289,7 @@ run_step() { reason=$(grep '^SKIPPED:' "$raw" | head -1 | sed 's/^SKIPPED://') step_skip "$label $(dim "($reason)")" log "$name: skipped ($reason)" - STEP_RESULTS[$name]="skipped" + record_step "$name" "skipped" else step_fail "$label" echo @@ -285,7 +298,7 @@ run_step() { done echo log "$name: FAILED (see $raw)" - STEP_RESULTS[$name]="failed" + record_step "$name" "failed" fi } @@ -359,10 +372,10 @@ else STATUS_LINE=$(grep '^STATUS:' "$STEP_LOG" | head -1 | sed 's/^STATUS: *//') if [ "$STATUS_LINE" = "already-installed" ]; then step_skip "Install $ch $(dim "(already installed)")" - STEP_RESULTS[$STEP_NAME]="skipped" + record_step "$STEP_NAME" "skipped" else step_ok "Install $ch" - STEP_RESULTS[$STEP_NAME]="success" + record_step "$STEP_NAME" "success" fi log "install-$ch: $STATUS_LINE" else @@ -371,12 +384,12 @@ else echo " $(dim "$line")" done log "install-$ch: FAILED (see $STEP_LOG)" - STEP_RESULTS[$STEP_NAME]="failed" + record_step "$STEP_NAME" "failed" fi else step_skip "Install $ch $(dim "(no install script)")" log "install-$ch: no install script" - STEP_RESULTS[$STEP_NAME]="failed" + record_step "$STEP_NAME" "failed" fi done fi @@ -401,11 +414,11 @@ else if bash setup/install-docker.sh > "$DOCKER_LOG" 2>&1; then hash -r 2>/dev/null || true step_ok "Docker installed" - STEP_RESULTS["3a-docker"]="success" + record_step "3a-docker" "success" log "Docker: installed" else step_fail "Docker install failed $(dim "(see $DOCKER_LOG)")" - STEP_RESULTS["3a-docker"]="failed" + record_step "3a-docker" "failed" log "Docker: FAILED" fi fi @@ -426,16 +439,16 @@ elif command -v docker >/dev/null 2>&1; then if pnpm exec tsx setup/index.ts --step onecli > "$ONECLI_LOG" 2>"$ONECLI_ERR"; then step_ok "OneCLI ready" ONECLI_OK=true - STEP_RESULTS["3b-onecli"]="success" + record_step "3b-onecli" "success" log "OneCLI: installed/configured" else step_fail "OneCLI setup failed $(dim "(see $ONECLI_LOG)")" - STEP_RESULTS["3b-onecli"]="failed" + record_step "3b-onecli" "failed" log "OneCLI: FAILED" fi else step_fail "OneCLI needs Docker $(dim "(install Docker first)")" - STEP_RESULTS["3b-onecli"]="failed" + record_step "3b-onecli" "failed" log "OneCLI: skipped (no Docker)" fi @@ -449,11 +462,11 @@ elif [ "$ONECLI_OK" = "true" ]; then AUTH_ERR="$STEPS_DIR/3c-auth.err" if pnpm exec tsx setup/index.ts --step auth > "$AUTH_LOG" 2>"$AUTH_ERR"; then step_ok "Anthropic credential registered" - STEP_RESULTS["3c-auth"]="success" + record_step "3c-auth" "success" log "Anthropic credential: registered via auth step" else step_fail "Auth setup failed $(dim "(see $AUTH_LOG)")" - STEP_RESULTS["3c-auth"]="failed" + record_step "3c-auth" "failed" log "Anthropic credential: FAILED" fi else @@ -494,11 +507,11 @@ if command -v docker >/dev/null 2>&1; then BUILD_LOG="$STEPS_DIR/3e-container-build.log" if bash container/build.sh > "$BUILD_LOG" 2>&1; then step_ok "Container image built" - STEP_RESULTS["3e-build"]="success" + record_step "3e-build" "success" log "Container build: success" else step_fail "Container build failed" - STEP_RESULTS["3e-build"]="failed" + record_step "3e-build" "failed" tail -10 "$BUILD_LOG" 2>/dev/null | while IFS= read -r line; do echo " $(dim "$line")" done @@ -506,7 +519,7 @@ if command -v docker >/dev/null 2>&1; then fi else step_fail "Docker not available — cannot build container" - STEP_RESULTS["3e-build"]="failed" + record_step "3e-build" "failed" log "Container build: skipped (no Docker)" fi From 2a915e8af09adef67092b6f6c54b402b7054f74f Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sat, 2 May 2026 15:24:57 +0300 Subject: [PATCH 111/144] fix(migrate-v2): infer is_group from JID format v1 didn't track is_group separately; db.ts hardcoded `is_group: 1` for every messaging_group. v2 uses is_group=0 to collapse DM sub-thread sessions and to drive routing decisions, so getting it wrong is latent risk on otherwise-working installs. New helper inferIsGroup(channelType, platformId) lives in shared.ts so tasks.ts and any future migration step can reuse it. Inferred per channel: - whatsapp: `@g.us` is a group, anything else is a DM - telegram: negative chat IDs are groups, positive are DMs - everything else: default to 1 (least surprising for chats v1 chose to register, where DM auto-create paths weren't used) Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/migrate-v2/db.ts | 3 ++- setup/migrate-v2/shared.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/setup/migrate-v2/db.ts b/setup/migrate-v2/db.ts index fb15ab0..da2bab7 100644 --- a/setup/migrate-v2/db.ts +++ b/setup/migrate-v2/db.ts @@ -29,6 +29,7 @@ import { readEnvFile } from '../../src/env.js'; import { buildDiscordResolver, type DiscordResolver } from './discord-resolver.js'; import { generateId, + inferIsGroup, parseJid, triggerToEngage, v2PlatformId, @@ -148,7 +149,7 @@ async function main(): Promise { channel_type: channelType, platform_id: platformId, name: g.name || null, - is_group: 1, + is_group: inferIsGroup(channelType, platformId), unknown_sender_policy: 'public', created_at: createdAt, }); diff --git a/setup/migrate-v2/shared.ts b/setup/migrate-v2/shared.ts index ff219df..58c0b16 100644 --- a/setup/migrate-v2/shared.ts +++ b/setup/migrate-v2/shared.ts @@ -74,6 +74,27 @@ export function v2PlatformId(channelType: string, jid: string): string { return id.startsWith(`${channelType}:`) ? id : `${channelType}:${id}`; } +/** + * Infer messaging_groups.is_group from a v2 platform_id, given a channel type. + * + * v1 didn't track is_group, but most channels encode it in the JID/id format: + * - whatsapp: `@g.us` is a group, `@s.whatsapp.net` / `@lid` is a DM + * - telegram: negative chat IDs are groups, positive are DMs + * - everything else: default to 1 (group/channel) — least-surprising guess + * for chats v1 chose to register, where DM auto-create paths weren't used + */ +export function inferIsGroup(channelType: string, platformId: string): number { + if (channelType === 'whatsapp') { + return platformId.endsWith('@g.us') ? 1 : 0; + } + if (channelType === 'telegram') { + // platform_id is `telegram:` — negative chatId means group/channel. + const chatId = platformId.replace(/^telegram:/, ''); + return chatId.startsWith('-') ? 1 : 0; + } + return 1; +} + // ── Trigger mapping ───────────────────────────────────────────────────── /** From dca02f5453e97ccdfc47935a90b069596fcf6f1e Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sat, 2 May 2026 15:39:36 +0300 Subject: [PATCH 112/144] feat(migrate-v2): resolve WhatsApp LIDs from store/auth, alias DMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 stored every WhatsApp DM as `@s.whatsapp.net`. v2's WA adapter sometimes resolves the chat to `@lid` instead — when WhatsApp delivers via the LID protocol and Baileys hasn't yet learned a LID→phone mapping for that contact (cold cache after migration). The router then can't find the phone-keyed messaging_group and silently drops the message at router.ts:184. Baileys persists every LID↔phone pair it has ever learned to disk as `store/auth/lid-mapping-.json` (forward) and `lid-mapping-_reverse.json` (reverse). v1 will already have these populated for every contact it has talked to. New step 2d-whatsapp-lids parses the reverse files and writes paired LID-keyed `messaging_groups` + `messaging_group_agents` rows so both `@s.whatsapp.net` and `@lid` route to the same agent_group with the same engage rules. No Baileys boot, no WhatsApp connectivity required — pure filesystem read of files we've already copied via 2b-channel-auth. Step is no-op-on-skip if either store/auth or whatsapp DM rows are missing. Anything that slips through (a contact whose LID v1 never learned) falls back to the runtime approval flow once the WA adapter sets isMention=true on DMs — each unknown LID DM auto-creates an approval-required messaging_group and the owner gets a one-tap register prompt. Verified end-to-end on a 12-group v1 install: 3 DM rows aliased, inbound DM routed via the LID-keyed row. Co-Authored-By: Claude Opus 4.7 (1M context) --- migrate-v2.sh | 15 ++ setup/migrate-v2/whatsapp-resolve-lids.ts | 192 ++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 setup/migrate-v2/whatsapp-resolve-lids.ts diff --git a/migrate-v2.sh b/migrate-v2.sh index cacdffc..eb5a381 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -392,6 +392,21 @@ else record_step "$STEP_NAME" "failed" fi done + + # 2d. WhatsApp LID resolution. After whatsapp is installed (so Baileys + # is on disk) and auth files have been copied (so we can connect with + # the migrated identity), boot Baileys briefly to learn LID↔phone + # mappings during initial sync, then write paired LID-keyed + # messaging_groups. Best-effort: any failure degrades to runtime + # approval flow, which the WA adapter's isMention=true on DMs handles. + for ch in "${SELECTED_CHANNELS[@]}"; do + if [ "$ch" = "whatsapp" ]; then + run_step "2d-whatsapp-lids" \ + "Resolve WhatsApp LIDs for migrated DMs" \ + "setup/migrate-v2/whatsapp-resolve-lids.ts" + break + fi + done fi echo diff --git a/setup/migrate-v2/whatsapp-resolve-lids.ts b/setup/migrate-v2/whatsapp-resolve-lids.ts new file mode 100644 index 0000000..7a5eb8b --- /dev/null +++ b/setup/migrate-v2/whatsapp-resolve-lids.ts @@ -0,0 +1,192 @@ +/** + * migrate-v2 step: resolve WhatsApp LIDs for migrated DM messaging_groups. + * + * Why this exists + * ─────────────── + * v1 stored every WhatsApp DM as `@s.whatsapp.net`. v2's WA adapter + * sometimes resolves the chat to `@lid` instead — when WhatsApp + * delivers a message via the LID protocol and Baileys hasn't yet learned + * a LID→phone mapping for that contact (cold cache after migration). The + * router then can't find the phone-keyed messaging_group and silently + * drops the message at router.ts:184 — until the LID is learned (which + * happens lazily, message-by-message, via `chats.phoneNumberShare`). + * + * Baileys persists LID↔phone mappings to disk as + * `store/auth/lid-mapping-_reverse.json` (LID → phone) and + * `lid-mapping-.json` (phone → LID). v1 will already have populated + * these for every contact it talked to. This step parses the reverse + * files and writes paired LID-keyed `messaging_groups` + + * `messaging_group_agents` rows so both `@s.whatsapp.net` and + * `@lid` route to the same agent_group with the same engage rules. + * + * No Baileys boot, no network — pure filesystem read. If store/auth is + * missing or has no reverse mappings, exits 0 with a SKIPPED. Runtime + * fallback (WA adapter sets isMention=true on DMs → router auto-creates + * with `unknown_sender_policy=request_approval`) handles anything we + * miss. + * + * Usage: pnpm exec tsx setup/migrate-v2/whatsapp-resolve-lids.ts + */ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from '../../src/config.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 { generateId } from './shared.js'; + +interface RawMessagingGroup { + id: string; + channel_type: string; + platform_id: string; +} + +interface RawWiring { + id: string; + messaging_group_id: string; + agent_group_id: string; + engage_mode: string; + engage_pattern: string | null; + sender_scope: string; + ignored_message_policy: string; + session_mode: string; + priority: number; +} + +const REVERSE_FILE_RE = /^lid-mapping-(\d+)_reverse\.json$/; + +/** + * Read store/auth/lid-mapping-*_reverse.json into a Map. + * Returns an empty Map if the directory doesn't exist. + */ +function readReverseMappings(authDir: string): Map { + const out = new Map(); + if (!fs.existsSync(authDir)) return out; + for (const entry of fs.readdirSync(authDir)) { + const m = REVERSE_FILE_RE.exec(entry); + if (!m) continue; + const lidUser = m[1]; + try { + const raw = fs.readFileSync(path.join(authDir, entry), 'utf-8').trim(); + // The file content is a JSON-encoded string: `""` + const phoneUser = JSON.parse(raw); + if (typeof phoneUser !== 'string' || phoneUser.length === 0) continue; + out.set(lidUser, phoneUser); + } catch { + // Skip malformed entries — best-effort. + } + } + return out; +} + +function phoneUserOf(jid: string): string { + return jid.split('@')[0].split(':')[0]; +} + +function main(): void { + const authDir = path.join(process.cwd(), 'store', 'auth'); + const reverse = readReverseMappings(authDir); + + if (reverse.size === 0) { + console.log('SKIPPED:no lid-mapping-*_reverse.json files in store/auth'); + process.exit(0); + } + + // phoneUser → lidJid (the form we'll write to messaging_groups) + const phoneUserToLidJid = new Map(); + for (const [lidUser, phoneUser] of reverse) { + phoneUserToLidJid.set(phoneUser, `${lidUser}@lid`); + } + + const v2DbPath = path.join(DATA_DIR, 'v2.db'); + if (!fs.existsSync(v2DbPath)) { + console.error('FAIL:v2.db not found — run db step first'); + process.exit(1); + } + + const v2Db = initDb(v2DbPath); + runMigrations(v2Db); + + const phoneRows = v2Db + .prepare( + `SELECT id, channel_type, platform_id FROM messaging_groups + WHERE channel_type='whatsapp' AND platform_id LIKE '%@s.whatsapp.net'`, + ) + .all() as RawMessagingGroup[]; + + if (phoneRows.length === 0) { + console.log('SKIPPED:no whatsapp DM messaging_groups to resolve'); + v2Db.close(); + process.exit(0); + } + + // Pull existing wirings so each new alias gets the same agent_group + + // engage rules as the phone-keyed row. + const placeholders = phoneRows.map(() => '?').join(','); + const wiringRows = v2Db + .prepare(`SELECT * FROM messaging_group_agents WHERE messaging_group_id IN (${placeholders})`) + .all(...phoneRows.map((r) => r.id)) as RawWiring[]; + + const wiringsByMg = new Map(); + for (const w of wiringRows) { + const arr = wiringsByMg.get(w.messaging_group_id) ?? []; + arr.push(w); + wiringsByMg.set(w.messaging_group_id, arr); + } + + let resolved = 0; + let aliased = 0; + const createdAt = new Date().toISOString(); + + for (const row of phoneRows) { + const phoneUser = phoneUserOf(row.platform_id); + const lidJid = phoneUserToLidJid.get(phoneUser); + if (!lidJid) continue; + resolved++; + + let lidMg = getMessagingGroupByPlatform('whatsapp', lidJid); + if (!lidMg) { + createMessagingGroup({ + id: generateId('mg'), + channel_type: 'whatsapp', + platform_id: lidJid, + name: null, + is_group: 0, + unknown_sender_policy: 'public', + created_at: createdAt, + }); + lidMg = getMessagingGroupByPlatform('whatsapp', lidJid)!; + } + + const wirings = wiringsByMg.get(row.id) ?? []; + for (const w of wirings) { + if (getMessagingGroupAgentByPair(lidMg.id, w.agent_group_id)) continue; + createMessagingGroupAgent({ + id: generateId('mga'), + messaging_group_id: lidMg.id, + agent_group_id: w.agent_group_id, + engage_mode: w.engage_mode as 'pattern' | 'mention' | 'mention-sticky', + engage_pattern: w.engage_pattern, + sender_scope: w.sender_scope as 'all' | 'admins', + ignored_message_policy: w.ignored_message_policy as 'drop' | 'queue', + session_mode: w.session_mode as 'shared' | 'thread', + priority: w.priority, + created_at: createdAt, + }); + aliased++; + } + } + + v2Db.close(); + console.log( + `OK:reverse_mappings=${reverse.size},phone_dms=${phoneRows.length},lids_resolved=${resolved},aliased=${aliased}`, + ); +} + +main(); From 8439a180be0c16b09ab8f7a28e36f80e57181fa3 Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sat, 2 May 2026 18:22:50 +0300 Subject: [PATCH 113/144] docs(migrate-v2): collapsible README section + skill preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: replace the one-line v1 migration note with a collapsed
block. Quick Start stays compact for the common case (fresh install) while v1 users get the actual instructions. Calls out explicitly that the script must be run from a real terminal — not from inside a Claude session — so the channel-select / switchover prompts and the Node/pnpm/Docker bootstrap all work. migrate-from-v1 skill: add a Preflight section that aborts if logs/setup-migration/handoff.json is missing. Without this, invoking the skill before the script just leads Claude to start guessing / running shell commands. The new message redirects them to the script and tells them it'll hand back to Claude on completion. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/migrate-from-v1/SKILL.md | 16 ++++++++++++++++ README.md | 23 ++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.claude/skills/migrate-from-v1/SKILL.md b/.claude/skills/migrate-from-v1/SKILL.md index 52d8293..a6fc990 100644 --- a/.claude/skills/migrate-from-v1/SKILL.md +++ b/.claude/skills/migrate-from-v1/SKILL.md @@ -20,6 +20,22 @@ Your job is the parts that need human judgment: triage any failed steps, seed th Read `logs/setup-migration/handoff.json` first — it has `overall_status`, per-step results in `steps`, and a `followups` list. +## Preflight: was the script run? + +Before anything else, check that `logs/setup-migration/handoff.json` exists. If it doesn't, the user is invoking this skill before `migrate-v2.sh` ran. Stop and tell them, verbatim: + +> This skill finishes a migration that `migrate-v2.sh` started. Run that first, in your terminal — not from inside Claude: +> +> ```bash +> bash migrate-v2.sh +> ``` +> +> It needs interactive prompts (channel selection, service switchover) and runs Node/pnpm bootstrap, Docker, OneCLI setup, and a container build that don't fit inside a Claude session. When it finishes, it'll hand control back to Claude automatically — at which point this skill picks up. + +Do not attempt to run the script yourself, simulate its effects, or pick up the migration mid-stream. The deterministic side has dependencies on a real interactive shell. + +Once `handoff.json` exists, proceed to Phase 0. + ## Phase 0: Triage failed steps Check `handoff.json` → `overall_status`. If `"success"`, skip to Phase 1. diff --git a/README.md b/README.md index 1b485c9..69f9ea2 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,28 @@ 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. -**Coming from v1?** Run `bash migrate-v2.sh` instead of `nanoclaw.sh`. It finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), migrates state, installs channels, and hands off to Claude for owner setup and custom code porting. See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) and [docs/migration-dev.md](docs/migration-dev.md). +
+
Migrating from NanoClaw v1? + +Run from a fresh v2 checkout next to your v1 install: + +```bash +git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2 +cd nanoclaw-v2 +bash migrate-v2.sh +``` + +`migrate-v2.sh` finds your v1 install (sibling directory, or `NANOCLAW_V1_PATH=/path/to/nanoclaw`), migrates state into the v2 checkout, then `exec`s into Claude Code to finish the parts that need judgment (owner seeding, CLAUDE.local.md cleanup, fork-customisation replay). + +Run the script directly, not from inside a Claude session — the deterministic side needs interactive prompts and real shell I/O for Node/pnpm bootstrap, Docker, OneCLI, and the container build. + +**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including Baileys keystore + LID mappings for WhatsApp), builds the agent container. + +**What it doesn't:** flip the system service. Pick *"switch to v2"* at the prompt, or do it manually after testing — your v1 install is left untouched. + +See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different and [docs/migration-dev.md](docs/migration-dev.md) for development notes. + + ## Philosophy From 2617313f19cca785b037a2955935fdb4f5cc8983 Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sat, 2 May 2026 18:28:46 +0300 Subject: [PATCH 114/144] docs(migrate-from-v1): blockers-first + smoke test before deeper work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 used to be "triage every failed step before doing anything else", which front-loaded a bunch of fixes for things that don't actually block the user from proving v2 works. Restructure: - 0a — fix blockers only (1b/1d/2c/2d/3a/3b/3e). Defer non-blockers (1a, 1c, 1e, 2b, 3c) — most surface naturally in later phases. - 0b — smoke test: switch v1 → v2, send a real message, verify the routing chain in logs/nanoclaw.log. AskUserQuestion gates whether to continue. - Revert recipe (launchctl/systemctl) called out as always-available, not destructive — v1 process, data, and credentials are untouched. Up-front list of what the script handled now also mentions the WhatsApp LID resolution and Baileys keystore copy, so users see exactly what continuity they're getting. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/migrate-from-v1/SKILL.md | 107 ++++++++++++++++++++---- 1 file changed, 91 insertions(+), 16 deletions(-) diff --git a/.claude/skills/migrate-from-v1/SKILL.md b/.claude/skills/migrate-from-v1/SKILL.md index a6fc990..50c6afa 100644 --- a/.claude/skills/migrate-from-v1/SKILL.md +++ b/.claude/skills/migrate-from-v1/SKILL.md @@ -10,9 +10,10 @@ description: Finish migrating a NanoClaw v1 install into v2. Run after `bash mig - .env keys merged - v2 DB seeded (agent_groups, messaging_groups, wiring) - Group folders copied (v1 CLAUDE.md → v2 CLAUDE.local.md) -- Session data copied with conversation continuity +- Session data copied with conversation continuity (incl. Claude Code memory + JSONL transcripts) - Scheduled tasks ported -- Channel code installed +- Channel code installed and auth state copied (incl. WhatsApp Baileys keystore) +- WhatsApp LIDs resolved from `store/auth` and aliased into `messaging_groups` - Container skills copied - Container image built @@ -36,25 +37,99 @@ Do not attempt to run the script yourself, simulate its effects, or pick up the Once `handoff.json` exists, proceed to Phase 0. -## Phase 0: Triage failed steps +## Phase 0: Get v2 routing real messages -Check `handoff.json` → `overall_status`. If `"success"`, skip to Phase 1. +Goal: get from "the script finished" to "the user sent a message and v2 answered" as fast as possible — *before* spending tokens on CLAUDE.local.md cleanup, fork customisations, or anything that requires deeper engagement. v1 is paused, not touched; flipping back is a one-line restart. -If `"partial"`, walk `handoff.steps` — each has `status` and `log` (path to the raw log file). For each failed step: +### 0a — Fix blockers (only the blockers) -1. Read its log file at `handoff.step_logs_dir/.log`. -2. Explain what failed in one sentence. -3. Fix it if mechanical (re-run the step script, hand-write a DB insert, copy a missed file). The step scripts are at `setup/migrate-v2/.ts` and accept `` as the first argument. -4. Use `AskUserQuestion` when a judgment call is needed. +Walk `handoff.steps`. A step is **blocking** only if its failure prevents v2 from routing a single message. Treat these as blockers: -Common failures: -- **1b-db failed**: JID couldn't be parsed. Ask the user for the channel type, insert `agent_groups` + `messaging_groups` manually. -- **1d-sessions failed**: v2 DB wasn't seeded yet. Re-run after fixing 1b. -- **1e-tasks failed**: session doesn't exist yet. Re-run after fixing 1d. -- **2c-install-\ failed**: `git fetch origin channels` may have failed (network). Try again, or ask the user to run manually. -- **3e-container-build failed**: Docker issue. Read the build log, suggest fixes. +| Step | Why blocking | +|------|--------------| +| `1b-db` | No `messaging_groups` → router has nothing to match | +| `1d-sessions` | No session → no inbound DB to write into | +| `2c-install-` | No adapter for the channel the user wants to test | +| `2d-whatsapp-lids` | WhatsApp DMs may arrive as `@lid` and miss migrated phone-keyed rows | +| `3a-docker` / `3e-build` | No container image → agent can't run | +| `3b-onecli` | Anthropic credentials not injected → first agent call 401s | -After resolving all failures, proceed to Phase 1. +**Defer** these — they don't block a smoke test, and most surface naturally in later phases: + +- `1a-env`, `1c-groups`, `1e-tasks`, `2b-channel-auth`, `3c-auth` + +For each blocker: read `handoff.step_logs_dir/.log`, identify the cause, re-run the underlying script directly (`pnpm exec tsx setup/migrate-v2/.ts `) or hand-fix mechanically. Use `AskUserQuestion` for judgment calls. Don't simulate the script's work. + +Common blockers: +- **`1b-db` failed**: JID couldn't be parsed. Insert `agent_groups` + `messaging_groups` for the user's confirmed channel. +- **`2c-install-` failed**: `git fetch origin channels` issue. The user can run `bash setup/install-.sh` directly. +- **`3e-build` failed**: usually stale builder cache. `docker buildx prune -f && ./container/build.sh`. + +### 0b — Smoke test before any further migration work + +Tell the user, verbatim: + +> Before we touch CLAUDE.local.md or fork customisations, let's confirm v2 actually answers your real messages. **This is non-destructive — v1 is just paused, not touched.** v1 and v2 share your WhatsApp identity (we copied `store/auth/` over), so only one can be online at a time, but flipping back is instant. + +Find the v2 service unit (per-checkout hash): + +```bash +# macOS +launchctl list | grep nanoclaw +# Linux +systemctl --user list-units 'nanoclaw*' +``` + +Switch v1 → v2: + +```bash +# macOS +launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist +launchctl load ~/Library/LaunchAgents/com.nanoclaw-v2-.plist + +# Linux +systemctl --user stop nanoclaw +systemctl --user start nanoclaw-v2- +``` + +Tail the log and confirm clean boot: + +```bash +tail -f logs/nanoclaw.log +``` + +Watch for `NanoClaw running` plus `Channel adapter started` for each installed channel (and `Connected to ` for native adapters like WhatsApp). + +Ask the user to send a real test message — a DM to the bot, or a post in a known group from a non-bot account. A working route logs an inbound event → session resolution → container spawn → outbound delivery. + +`AskUserQuestion`: *"Did v2 respond? — Yes / No, here's what happened."* + +**If yes**: continue to Phase 1. + +**If no**: do not proceed. Read `logs/nanoclaw.log` + `logs/nanoclaw.error.log` and diagnose. Common patterns: +- WhatsApp DM with no routing chain in the log → check `SELECT platform_id FROM messaging_groups WHERE platform_id LIKE '%@lid'`. If empty, re-run `setup/migrate-v2/whatsapp-resolve-lids.ts`. +- Agent inside container fails on Anthropic 401 → OneCLI agents start in `selective` secret mode. `onecli agents set-secret-mode --id --mode all`. +- Channel disconnected silently → restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw-v2-`. + +Re-test before continuing. + +### Reverting (anytime — not just now) + +```bash +# macOS — back to v1 +launchctl unload ~/Library/LaunchAgents/com.nanoclaw-v2-.plist +launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist + +# Linux +systemctl --user stop nanoclaw-v2- +systemctl --user start nanoclaw +``` + +v1's process, data, credentials, and groups are untouched the whole time. Reverting is just a service restart. + +### Deferred non-blocker failures + +If you skipped non-blocker failures in 0a (`1a-env`, `1c-groups`, `1e-tasks`, `2b-channel-auth`, `3c-auth`), they still need fixing — most surface naturally in later phases (`1c-groups` ↔ Phase 2 CLAUDE.local.md cleanup, `1e-tasks` ↔ task verification). Re-run any that don't get covered before declaring the migration done. ## Phase 1: Owner and access From 2bc1279a12823ef8276fd383657aaee8101b060b Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sat, 2 May 2026 18:31:23 +0300 Subject: [PATCH 115/144] docs(migrate-from-v1): trim Phase 0 to intent only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous version spelled out launchctl/systemctl commands, log lines to grep for, diagnostic recipes — the agent reading this skill knows all of that. Keep only the parts that aren't obvious from the rest of the codebase: which steps are blocking vs deferred, the smoke-test ordering, and the non-destructive framing for the user. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/migrate-from-v1/SKILL.md | 94 +++---------------------- 1 file changed, 11 insertions(+), 83 deletions(-) diff --git a/.claude/skills/migrate-from-v1/SKILL.md b/.claude/skills/migrate-from-v1/SKILL.md index 50c6afa..0440d8a 100644 --- a/.claude/skills/migrate-from-v1/SKILL.md +++ b/.claude/skills/migrate-from-v1/SKILL.md @@ -39,97 +39,25 @@ Once `handoff.json` exists, proceed to Phase 0. ## Phase 0: Get v2 routing real messages -Goal: get from "the script finished" to "the user sent a message and v2 answered" as fast as possible — *before* spending tokens on CLAUDE.local.md cleanup, fork customisations, or anything that requires deeper engagement. v1 is paused, not touched; flipping back is a one-line restart. +Before any deeper migration work, prove v2 actually answers messages on the user's real channels. v1 is paused, not touched — flipping back is a service restart. -### 0a — Fix blockers (only the blockers) +### 0a — Fix blockers only -Walk `handoff.steps`. A step is **blocking** only if its failure prevents v2 from routing a single message. Treat these as blockers: +A step is **blocking** if its failure stops the bot from routing one message. Re-run or hand-fix only these; defer everything else to its later phase: -| Step | Why blocking | -|------|--------------| -| `1b-db` | No `messaging_groups` → router has nothing to match | -| `1d-sessions` | No session → no inbound DB to write into | -| `2c-install-` | No adapter for the channel the user wants to test | -| `2d-whatsapp-lids` | WhatsApp DMs may arrive as `@lid` and miss migrated phone-keyed rows | -| `3a-docker` / `3e-build` | No container image → agent can't run | -| `3b-onecli` | Anthropic credentials not injected → first agent call 401s | +| Blocking | Deferred | +|---|---| +| `1b-db`, `1d-sessions`, `2c-install-`, `2d-whatsapp-lids`, `3a-docker`, `3b-onecli`, `3e-build` | `1a-env`, `1c-groups`, `1e-tasks`, `2b-channel-auth`, `3c-auth` | -**Defer** these — they don't block a smoke test, and most surface naturally in later phases: +### 0b — Smoke test, then continue -- `1a-env`, `1c-groups`, `1e-tasks`, `2b-channel-auth`, `3c-auth` +Tell the user the switch is non-destructive (v1 is paused, not modified; reverting is one command). Help them stop v1's service unit and start v2's, tail the host log for a clean boot, and have them send a real test message. Use `AskUserQuestion` to confirm the bot responded. -For each blocker: read `handoff.step_logs_dir/.log`, identify the cause, re-run the underlying script directly (`pnpm exec tsx setup/migrate-v2/.ts `) or hand-fix mechanically. Use `AskUserQuestion` for judgment calls. Don't simulate the script's work. +If yes, continue to Phase 1. If no, diagnose from `logs/nanoclaw.log` and re-test — don't proceed to deeper work on a broken router. -Common blockers: -- **`1b-db` failed**: JID couldn't be parsed. Insert `agent_groups` + `messaging_groups` for the user's confirmed channel. -- **`2c-install-` failed**: `git fetch origin channels` issue. The user can run `bash setup/install-.sh` directly. -- **`3e-build` failed**: usually stale builder cache. `docker buildx prune -f && ./container/build.sh`. +### Deferred failures -### 0b — Smoke test before any further migration work - -Tell the user, verbatim: - -> Before we touch CLAUDE.local.md or fork customisations, let's confirm v2 actually answers your real messages. **This is non-destructive — v1 is just paused, not touched.** v1 and v2 share your WhatsApp identity (we copied `store/auth/` over), so only one can be online at a time, but flipping back is instant. - -Find the v2 service unit (per-checkout hash): - -```bash -# macOS -launchctl list | grep nanoclaw -# Linux -systemctl --user list-units 'nanoclaw*' -``` - -Switch v1 → v2: - -```bash -# macOS -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -launchctl load ~/Library/LaunchAgents/com.nanoclaw-v2-.plist - -# Linux -systemctl --user stop nanoclaw -systemctl --user start nanoclaw-v2- -``` - -Tail the log and confirm clean boot: - -```bash -tail -f logs/nanoclaw.log -``` - -Watch for `NanoClaw running` plus `Channel adapter started` for each installed channel (and `Connected to ` for native adapters like WhatsApp). - -Ask the user to send a real test message — a DM to the bot, or a post in a known group from a non-bot account. A working route logs an inbound event → session resolution → container spawn → outbound delivery. - -`AskUserQuestion`: *"Did v2 respond? — Yes / No, here's what happened."* - -**If yes**: continue to Phase 1. - -**If no**: do not proceed. Read `logs/nanoclaw.log` + `logs/nanoclaw.error.log` and diagnose. Common patterns: -- WhatsApp DM with no routing chain in the log → check `SELECT platform_id FROM messaging_groups WHERE platform_id LIKE '%@lid'`. If empty, re-run `setup/migrate-v2/whatsapp-resolve-lids.ts`. -- Agent inside container fails on Anthropic 401 → OneCLI agents start in `selective` secret mode. `onecli agents set-secret-mode --id --mode all`. -- Channel disconnected silently → restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw-v2-`. - -Re-test before continuing. - -### Reverting (anytime — not just now) - -```bash -# macOS — back to v1 -launchctl unload ~/Library/LaunchAgents/com.nanoclaw-v2-.plist -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist - -# Linux -systemctl --user stop nanoclaw-v2- -systemctl --user start nanoclaw -``` - -v1's process, data, credentials, and groups are untouched the whole time. Reverting is just a service restart. - -### Deferred non-blocker failures - -If you skipped non-blocker failures in 0a (`1a-env`, `1c-groups`, `1e-tasks`, `2b-channel-auth`, `3c-auth`), they still need fixing — most surface naturally in later phases (`1c-groups` ↔ Phase 2 CLAUDE.local.md cleanup, `1e-tasks` ↔ task verification). Re-run any that don't get covered before declaring the migration done. +Re-visit anything you skipped in 0a before declaring the migration done. Most surface naturally in later phases (`1c-groups` ↔ Phase 2, `1e-tasks` ↔ task verification). ## Phase 1: Owner and access From 8c1b209aeb8cdd2776fcd1afa92569d9f16629e2 Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sat, 2 May 2026 18:36:10 +0300 Subject: [PATCH 116/144] docs(migrate-from-v1): 2b-channel-auth and 3c-auth are blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2b-channel-auth: copies the Baileys keystore + channel-specific env keys. Without it WhatsApp can't connect — saw this firsthand when the original candidatePaths bug left env_keys=0,files=0. 3c-auth: registers Anthropic credentials in OneCLI. 3b installs the gateway; 3c puts the secret in the vault. Without 3c every agent request 401s regardless of 3b's status. 1c-groups stays deferred — agent runs on stock CLAUDE.md without it, but routing works. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/migrate-from-v1/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/migrate-from-v1/SKILL.md b/.claude/skills/migrate-from-v1/SKILL.md index 0440d8a..04b3aee 100644 --- a/.claude/skills/migrate-from-v1/SKILL.md +++ b/.claude/skills/migrate-from-v1/SKILL.md @@ -47,7 +47,7 @@ A step is **blocking** if its failure stops the bot from routing one message. Re | Blocking | Deferred | |---|---| -| `1b-db`, `1d-sessions`, `2c-install-`, `2d-whatsapp-lids`, `3a-docker`, `3b-onecli`, `3e-build` | `1a-env`, `1c-groups`, `1e-tasks`, `2b-channel-auth`, `3c-auth` | +| `1b-db`, `1d-sessions`, `2b-channel-auth`, `2c-install-`, `2d-whatsapp-lids`, `3a-docker`, `3b-onecli`, `3c-auth`, `3e-build` | `1a-env`, `1c-groups`, `1e-tasks` | ### 0b — Smoke test, then continue From 7922a19af73f4a2cd99bbf1ef01107756e8faada Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sat, 2 May 2026 18:40:07 +0300 Subject: [PATCH 117/144] docs(migrate-from-v1): drop the blocker/deferred table Trust the agent to figure out which failed steps actually stop routing. The rule is the goal ("can the bot route one message?"), not a hardcoded list. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/migrate-from-v1/SKILL.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.claude/skills/migrate-from-v1/SKILL.md b/.claude/skills/migrate-from-v1/SKILL.md index 04b3aee..e36a005 100644 --- a/.claude/skills/migrate-from-v1/SKILL.md +++ b/.claude/skills/migrate-from-v1/SKILL.md @@ -43,11 +43,7 @@ Before any deeper migration work, prove v2 actually answers messages on the user ### 0a — Fix blockers only -A step is **blocking** if its failure stops the bot from routing one message. Re-run or hand-fix only these; defer everything else to its later phase: - -| Blocking | Deferred | -|---|---| -| `1b-db`, `1d-sessions`, `2b-channel-auth`, `2c-install-`, `2d-whatsapp-lids`, `3a-docker`, `3b-onecli`, `3c-auth`, `3e-build` | `1a-env`, `1c-groups`, `1e-tasks` | +Walk `handoff.steps`. Fix only the failures that would stop the bot from routing one message; defer the rest to its later phase. ### 0b — Smoke test, then continue From 8181054bdbf675b71e6ff84f77f79d8dbe773988 Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sat, 2 May 2026 16:04:39 +0000 Subject: [PATCH 118/144] fix(migrate-v2): resolve Discord DMs as discord:@me: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver only enumerated guild channels, so any v1 install whose registered Discord chat was a DM (a common case for personal-bot installs) failed 1b-db with "not found in any guild" — leaving the migration without an agent_group or wiring, and the user with a bot that received messages but had nowhere to route them. Add an unresolved-channel classification pass: for any v1 channel id not found in a guild, GET /channels/ and emit discord:@me: when the type is DM (1) or GROUP_DM (3). Matches the runtime adapter's guild_id || "@me" encoding. Other types / 404 / 403 keep current skip-with-warning behavior. Caller passes the v1 channel id list (already on hand). Test coverage extends the existing mock-fetch pattern with DM, GROUP_DM, orphan, and dedupe cases. --- setup/migrate-v2/db.ts | 24 +++--- setup/migrate-v2/discord-resolver.test.ts | 92 +++++++++++++++++++++-- setup/migrate-v2/discord-resolver.ts | 90 +++++++++++++++++----- 3 files changed, 174 insertions(+), 32 deletions(-) diff --git a/setup/migrate-v2/db.ts b/setup/migrate-v2/db.ts index da2bab7..c2b5b48 100644 --- a/setup/migrate-v2/db.ts +++ b/setup/migrate-v2/db.ts @@ -82,21 +82,27 @@ async function main(): Promise { let skipped = 0; const errors: string[] = []; - // v1 stored Discord groups as `dc:` (no guildId). v2 needs - // `discord::`. If there are any Discord groups, use - // the bot token (carried forward by 1a-env) to look up each channel's - // guild via the Discord API. On any failure the resolver returns null - // for every channel and the affected groups skip with a clear warning. + // v1 stored Discord groups as `dc:` with no guild/DM signal. + // v2 needs either `discord::` (guild) or + // `discord:@me:` (DM / group DM). Use the v1 bot token to + // enumerate guilds + channels and to classify any leftover ids as DMs. + // On any failure the resolver returns null for every channel and the + // affected groups skip with a clear warning. let discordResolver: DiscordResolver | null = null; - const hasDiscord = v1Groups.some((g) => parseJid(g.jid)?.channel_type === 'discord'); - if (hasDiscord) { + const discordChannelIds = v1Groups + .map((g) => parseJid(g.jid)) + .filter((p): p is NonNullable => p?.channel_type === 'discord') + .map((p) => p.id); + if (discordChannelIds.length > 0) { const env = readEnvFile(['DISCORD_BOT_TOKEN']); - discordResolver = await buildDiscordResolver(env.DISCORD_BOT_TOKEN ?? ''); + discordResolver = await buildDiscordResolver(env.DISCORD_BOT_TOKEN ?? '', discordChannelIds); const stats = discordResolver.stats(); if (stats.reason) { console.log(`WARN:discord resolver disabled: ${stats.reason}`); } else { - console.log(`INFO:discord resolver: ${stats.guilds} guild(s), ${stats.channels} channel(s)`); + console.log( + `INFO:discord resolver: ${stats.guilds} guild(s), ${stats.channels} guild channel(s), ${stats.dms} DM(s)`, + ); } } diff --git a/setup/migrate-v2/discord-resolver.test.ts b/setup/migrate-v2/discord-resolver.test.ts index 31e63f7..e74fc40 100644 --- a/setup/migrate-v2/discord-resolver.test.ts +++ b/setup/migrate-v2/discord-resolver.test.ts @@ -20,7 +20,7 @@ function mockFetch(handlers: Record): typeof fetch { describe('buildDiscordResolver', () => { it('returns empty resolver when token is missing', async () => { const r = await buildDiscordResolver(''); - expect(r.stats()).toMatchObject({ guilds: 0, channels: 0 }); + expect(r.stats()).toMatchObject({ guilds: 0, channels: 0, dms: 0 }); expect(r.stats().reason).toMatch(/no DISCORD_BOT_TOKEN/); expect(r.resolve('any')).toBeNull(); }); @@ -40,9 +40,9 @@ describe('buildDiscordResolver', () => { ], }); - const r = await buildDiscordResolver('valid-token', fetchImpl); + const r = await buildDiscordResolver('valid-token', [], fetchImpl); - expect(r.stats()).toEqual({ guilds: 2, channels: 3 }); + expect(r.stats()).toEqual({ guilds: 2, channels: 3, dms: 0 }); expect(r.resolve('c1')).toBe('discord:g1:c1'); expect(r.resolve('c2')).toBe('discord:g1:c2'); expect(r.resolve('c3')).toBe('discord:g2:c3'); @@ -58,7 +58,7 @@ describe('buildDiscordResolver', () => { }, }); - const r = await buildDiscordResolver('bad-token', fetchImpl); + const r = await buildDiscordResolver('bad-token', [], fetchImpl); expect(r.stats().guilds).toBe(0); expect(r.stats().reason).toMatch(/401/); expect(r.resolve('c1')).toBeNull(); @@ -79,7 +79,7 @@ describe('buildDiscordResolver', () => { }); const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const r = await buildDiscordResolver('valid-token', fetchImpl); + const r = await buildDiscordResolver('valid-token', [], fetchImpl); errSpy.mockRestore(); expect(r.resolve('c1')).toBe('discord:g1:c1'); @@ -105,11 +105,91 @@ describe('buildDiscordResolver', () => { return new Response(JSON.stringify([{ id: `c-${gid}` }]), { status: 200 }); }) as unknown as typeof fetch; - const r = await buildDiscordResolver('valid-token', fetchImpl); + const r = await buildDiscordResolver('valid-token', [], fetchImpl); expect(r.stats().guilds).toBe(201); expect(r.stats().channels).toBe(201); expect(r.resolve('c-g0')).toBe('discord:g0:c-g0'); expect(r.resolve('c-g200')).toBe('discord:g200:c-g200'); }); + + it('classifies unresolved ids as DMs and emits discord:@me:', async () => { + const fetchImpl = mockFetch({ + 'https://discord.com/api/v10/users/@me/guilds': [{ id: 'g1', name: 'G1' }], + 'https://discord.com/api/v10/guilds/g1/channels': [{ id: 'guild-chan' }], + // dmId is a 1:1 DM (type=1) + 'https://discord.com/api/v10/channels/dmId': { id: 'dmId', type: 1 }, + // groupDmId is a multi-recipient DM (type=3) + 'https://discord.com/api/v10/channels/groupDmId': { id: 'groupDmId', type: 3 }, + }); + + const r = await buildDiscordResolver( + 'valid-token', + ['guild-chan', 'dmId', 'groupDmId'], + fetchImpl, + ); + + expect(r.stats()).toEqual({ guilds: 1, channels: 1, dms: 2 }); + expect(r.resolve('guild-chan')).toBe('discord:g1:guild-chan'); + expect(r.resolve('dmId')).toBe('discord:@me:dmId'); + expect(r.resolve('groupDmId')).toBe('discord:@me:groupDmId'); + }); + + it('leaves ids unresolved when classify returns 404 or non-DM type', async () => { + const fetchImpl = mockFetch({ + 'https://discord.com/api/v10/users/@me/guilds': [], + // 404 — bot has no access (typical when bot was kicked from the guild) + 'https://discord.com/api/v10/channels/orphanId': { + status: 404, + statusText: 'Not Found', + body: '{"message":"Unknown Channel","code":10003}', + }, + // type=0 — guild text channel in a guild we no longer enumerate (shouldn't happen, + // but the fallback is conservative: only emit @me for type 1/3) + 'https://discord.com/api/v10/channels/leftoverGuildChan': { + id: 'leftoverGuildChan', + type: 0, + }, + }); + + const r = await buildDiscordResolver( + 'valid-token', + ['orphanId', 'leftoverGuildChan'], + fetchImpl, + ); + + expect(r.stats()).toEqual({ guilds: 0, channels: 0, dms: 0 }); + expect(r.resolve('orphanId')).toBeNull(); + expect(r.resolve('leftoverGuildChan')).toBeNull(); + }); + + it('skips classify for ids already found in a guild and dedupes input', async () => { + let dmCallCount = 0; + const fetchImpl = vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes('/users/@me/guilds')) { + return new Response(JSON.stringify([{ id: 'g1', name: 'G1' }]), { status: 200 }); + } + if (url.includes('/guilds/g1/channels')) { + return new Response(JSON.stringify([{ id: 'guild-chan' }]), { status: 200 }); + } + if (url.includes('/channels/dmId')) { + dmCallCount++; + return new Response(JSON.stringify({ id: 'dmId', type: 1 }), { status: 200 }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch; + + // 'guild-chan' is in the guild map (skip classify); 'dmId' appears twice + // in the input (classify exactly once). + const r = await buildDiscordResolver( + 'valid-token', + ['guild-chan', 'dmId', 'dmId'], + fetchImpl, + ); + + expect(dmCallCount).toBe(1); + expect(r.resolve('guild-chan')).toBe('discord:g1:guild-chan'); + expect(r.resolve('dmId')).toBe('discord:@me:dmId'); + }); }); diff --git a/setup/migrate-v2/discord-resolver.ts b/setup/migrate-v2/discord-resolver.ts index 17b9a9f..ecc1d5e 100644 --- a/setup/migrate-v2/discord-resolver.ts +++ b/setup/migrate-v2/discord-resolver.ts @@ -1,11 +1,18 @@ /** - * Discord channel → guild resolver for the v1 → v2 migration. + * Discord channel → platform_id resolver for the v1 → v2 migration. * - * v1 stored Discord groups as `dc:` — only the channel id, not - * the guild id. v2's `@chat-adapter/discord` encodes `platform_id` as - * `discord::`, so we can't reconstruct it from v1 data - * alone. Instead, we use the v1 bot token (carried forward by 1a-env) to - * query the Discord API and build a channelId → guildId map. + * v1 stored Discord groups as `dc:` — only the channel id, with + * no signal for guild vs. DM. v2's `@chat-adapter/discord` encodes + * `platform_id` as either `discord::` (guild channel) + * or `discord:@me:` (DM / group DM) — see `guild_id || "@me"` + * in the runtime adapter. We can't reconstruct that from v1 data alone, so + * we use the v1 bot token (carried forward by 1a-env) to query Discord: + * 1. Enumerate every guild the bot is in and every channel in those + * guilds → channelId → guildId map. + * 2. For any v1 channel id NOT in that map, classify via `GET + * /channels/` — DM (type=1) and GROUP_DM (type=3) get + * `discord:@me:`. Anything else returns null and the caller + * skips with a warning. * * Network calls are best-effort: on auth failure or network error, the * resolver returns null for every channel and the caller falls back to @@ -14,6 +21,11 @@ const DISCORD_API = 'https://discord.com/api/v10'; +// Discord channel types we care about. See: +// https://discord.com/developers/docs/resources/channel#channel-object-channel-types +const CHANNEL_TYPE_DM = 1; +const CHANNEL_TYPE_GROUP_DM = 3; + interface Guild { id: string; name: string; @@ -24,18 +36,27 @@ interface Channel { name?: string; } +interface ChannelInfo { + id: string; + type: number; +} + export interface DiscordResolver { - /** Returns `discord::` or null if the channel isn't visible to the bot. */ + /** + * Returns the v2 `platform_id` for a v1 channel id, or null if the bot + * can't see it. Format is `discord::` for guild + * channels and `discord:@me:` for DMs / group DMs. + */ resolve(channelId: string): string | null; - /** Diagnostic info — guild count and total channel count discovered. */ - stats(): { guilds: number; channels: number; reason?: string }; + /** Diagnostic info — guild count, channel count, DM count, optional disable reason. */ + stats(): { guilds: number; channels: number; dms: number; reason?: string }; } /** A no-op resolver that returns null for every lookup with a stored reason. */ function emptyResolver(reason: string): DiscordResolver { return { resolve: () => null, - stats: () => ({ guilds: 0, channels: 0, reason }), + stats: () => ({ guilds: 0, channels: 0, dms: 0, reason }), }; } @@ -57,14 +78,20 @@ async function getJson(url: string, token: string, fetchImpl: FetchFn): Promi /** * Build a Discord resolver by enumerating every guild the bot is in and - * every channel in those guilds. Returns an empty resolver on any error. + * every channel in those guilds, then classifying any `unresolvedChannelIds` + * that didn't show up in a guild via `GET /channels/` (so DMs and + * group DMs can be encoded as `discord:@me:`). * - * Costs: 1 + N HTTP calls (N = guild count). Discord's global rate limit - * is 50 req/s; even installs with hundreds of guilds finish in under a - * second of network time. + * Returns an empty resolver on any error during guild enumeration. + * + * Costs: 1 + N + K HTTP calls — N = guild count (enumerated channels per + * guild), K = unresolved-channel classification calls. Discord's global + * rate limit is 50 req/s; even installs with hundreds of guilds finish in + * under a second of network time. */ export async function buildDiscordResolver( token: string, + unresolvedChannelIds: string[] = [], fetchImpl: FetchFn = fetch, ): Promise { if (!token) return emptyResolver('no DISCORD_BOT_TOKEN in .env'); @@ -109,12 +136,41 @@ export async function buildDiscordResolver( } } + // Classify any v1 channel ids that didn't surface in a guild — they're + // most likely DMs (type=1) or group DMs (type=3). Anything else (404, + // 403, type=0 in a guild the bot left) stays unresolved so the caller's + // existing skip-with-warning path fires. + const dmChannels = new Set(); + const seen = new Set(); + for (const channelId of unresolvedChannelIds) { + if (channelToGuild.has(channelId)) continue; + if (seen.has(channelId)) continue; + seen.add(channelId); + try { + const ch = await getJson( + `${DISCORD_API}/channels/${channelId}`, + token, + fetchImpl, + ); + if (ch.type === CHANNEL_TYPE_DM || ch.type === CHANNEL_TYPE_GROUP_DM) { + dmChannels.add(channelId); + } + } catch { + // Channel not visible to the bot — leave it unresolved. + } + } + return { resolve(channelId: string): string | null { const guildId = channelToGuild.get(channelId); - if (!guildId) return null; - return `discord:${guildId}:${channelId}`; + if (guildId) return `discord:${guildId}:${channelId}`; + if (dmChannels.has(channelId)) return `discord:@me:${channelId}`; + return null; }, - stats: () => ({ guilds: guilds.length, channels: channelToGuild.size }), + stats: () => ({ + guilds: guilds.length, + channels: channelToGuild.size, + dms: dmChannels.size, + }), }; } From 7dbedad9bde26650ae2bec60f84c989ab770bc52 Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sat, 2 May 2026 16:06:45 +0000 Subject: [PATCH 119/144] fix(migrate-v2): skip symlinks in group copyTree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fs.copyFileSync follows symlinks, so a single broken/dangling link in v1 (e.g. .claude-shared.md → /app/CLAUDE.md, a container-side path that doesn't resolve on the host) crashed the alphabetical traversal with ENOENT — preventing later folders, including the actual registered group, from being copied. Check entry.isSymbolicLink() and skip with a one-line log. v2 uses composed CLAUDE.md fragments, so v1's container-path symlinks have no v2 meaning and don't need to be carried forward. --- setup/migrate-v2/groups.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/setup/migrate-v2/groups.ts b/setup/migrate-v2/groups.ts index beb88be..d1e9ed0 100644 --- a/setup/migrate-v2/groups.ts +++ b/setup/migrate-v2/groups.ts @@ -18,7 +18,16 @@ import Database from 'better-sqlite3'; const SKIP_NAMES = new Set(['CLAUDE.md', 'logs', '.git', '.DS_Store', 'node_modules']); -/** Copy a directory tree, skipping SKIP_NAMES. Never overwrites existing files. */ +/** + * Copy a directory tree, skipping SKIP_NAMES. Never overwrites existing files. + * + * Symlinks are skipped, not followed: v1 group folders sometimes contain + * container-side paths like `.claude-shared.md → /app/CLAUDE.md` that + * don't resolve on the host. Following them with `fs.copyFileSync` would + * crash ENOENT on a broken target and abort the rest of the traversal. + * v2 uses composed CLAUDE.md fragments anyway — these v1 symlinks have no + * v2 meaning and don't need to be carried forward. + */ function copyTree(src: string, dst: string): number { let written = 0; if (!fs.existsSync(src)) return 0; @@ -29,6 +38,10 @@ function copyTree(src: string, dst: string): number { const s = path.join(src, entry.name); const d = path.join(dst, entry.name); + if (entry.isSymbolicLink()) { + console.log(`SKIP:symlink ${path.relative(process.cwd(), s)}`); + continue; + } if (entry.isDirectory()) { written += copyTree(s, d); continue; From 3b5e5a24f43b50acd5280f4e873335511b5e76fc Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sat, 2 May 2026 16:07:43 +0000 Subject: [PATCH 120/144] fix(migrate-v2): reset auto-created messaging_group policy on re-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If 1b-db is re-run after the v2 service has already started (e.g. recovering from an earlier failure), the messaging_group it would otherwise create may already exist — auto-created by the runtime router on the first inbound message, with the router's default unknown_sender_policy ('request_approval'), not the migration's intent ('public'). The previous reuse path skipped creation but never updated the policy, so re-runs left the bot hanging every message waiting for an approver that wasn't seeded yet. When reusing an existing row that has zero wired agent_groups (signal of a router auto-create), reset the policy to 'public'. Once any wiring exists, the user has had a chance to tighten via the skill — leave it. Also adds a CHANGELOG entry covering this and the two sibling fixes (Discord DM resolution, symlink skip in copyTree). --- CHANGELOG.md | 1 + setup/migrate-v2/db.ts | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e297d3a..2ec9fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ For detailed release notes, see the [full changelog on the documentation site](h ## [Unreleased] - **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md` → `CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md). +- **Migration fixes.** `1b-db` now resolves Discord DMs as `discord:@me:` (previously skipped any v1 chat that wasn't a guild channel — a blocker for personal-bot installs). `1c-groups` skips symlinks instead of following them (a single broken `.claude-shared.md → /app/CLAUDE.md` no longer aborts the whole copy). When `1b-db` reuses an auto-created `messaging_group` with no wired agents, its `unknown_sender_policy` is now reconciled to the migration's `public` default. ## [2.0.0] - 2026-04-22 diff --git a/setup/migrate-v2/db.ts b/setup/migrate-v2/db.ts index c2b5b48..ada4764 100644 --- a/setup/migrate-v2/db.ts +++ b/setup/migrate-v2/db.ts @@ -22,7 +22,9 @@ import { createMessagingGroup, createMessagingGroupAgent, getMessagingGroupAgentByPair, + getMessagingGroupAgents, getMessagingGroupByPlatform, + updateMessagingGroup, } from '../../src/db/messaging-groups.js'; import { runMigrations } from '../../src/db/migrations/index.js'; import { readEnvFile } from '../../src/env.js'; @@ -147,7 +149,15 @@ async function main(): Promise { ag = getAgentGroupByFolder(g.folder)!; } - // messaging_group — one per (channel_type, platform_id) + // messaging_group — one per (channel_type, platform_id). + // + // If the row already exists *and* has zero wired agent_groups, it + // was almost certainly auto-created by the runtime router on an + // inbound message (which uses 'request_approval' or similar — not + // the migration's 'public'). Reset its policy to match what the + // migration would have set if it had created the row first. Once + // any wiring exists, the user has had a chance to tighten the + // policy via the skill — leave it alone. let mg = getMessagingGroupByPlatform(channelType, platformId); if (!mg) { createMessagingGroup({ @@ -160,6 +170,12 @@ async function main(): Promise { created_at: createdAt, }); mg = getMessagingGroupByPlatform(channelType, platformId)!; + } else if ( + mg.unknown_sender_policy !== 'public' && + getMessagingGroupAgents(mg.id).length === 0 + ) { + updateMessagingGroup(mg.id, { unknown_sender_policy: 'public' }); + mg = getMessagingGroupByPlatform(channelType, platformId)!; } // messaging_group_agents — wire them From 82216b536dbf70e18e3af8e0a9370610a71e1189 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 2 May 2026 21:19:23 +0300 Subject: [PATCH 121/144] Add /add-deltachat skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skill files only — copied from PR #2192 (channels branch). Source adapter (src/channels/deltachat.ts) lives on the channels branch and is installed by the skill. Co-Authored-By: Axel McLaren Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-deltachat/REMOVE.md | 62 ++++++ .claude/skills/add-deltachat/SKILL.md | 254 +++++++++++++++++++++++++ .claude/skills/add-deltachat/VERIFY.md | 54 ++++++ 3 files changed, 370 insertions(+) create mode 100644 .claude/skills/add-deltachat/REMOVE.md create mode 100644 .claude/skills/add-deltachat/SKILL.md create mode 100644 .claude/skills/add-deltachat/VERIFY.md diff --git a/.claude/skills/add-deltachat/REMOVE.md b/.claude/skills/add-deltachat/REMOVE.md new file mode 100644 index 0000000..7cb2d31 --- /dev/null +++ b/.claude/skills/add-deltachat/REMOVE.md @@ -0,0 +1,62 @@ +# Remove DeltaChat + +## 1. Disable the adapter + +Comment out the import in `src/channels/index.ts`: + +```typescript +// import './deltachat.js'; +``` + +## 2. Remove credentials + +Remove the `DC_*` lines from `.env`: + +```bash +DC_EMAIL +DC_PASSWORD +DC_IMAP_HOST +DC_IMAP_PORT +DC_SMTP_HOST +DC_SMTP_PORT +``` + +## 3. Rebuild and restart + +```bash +pnpm run build + +# Linux +systemctl --user restart nanoclaw + +# macOS +launchctl kickstart -k gui/$(id -u)/com.nanoclaw +``` + +## 4. Remove account data (optional) + +To fully remove all account data including DeltaChat encryption keys: + +```bash +rm -rf dc-account/ +``` + +> **Warning:** This deletes the Autocrypt keys. Contacts who have verified your bot's key will need to re-verify if the same email address is re-used with a new account. + +To keep the account for later reinstall, leave `dc-account/` intact. + +## 5. Remove the package (optional) + +```bash +pnpm remove @deltachat/stdio-rpc-server +``` + +## Verification + +After removal, confirm the adapter is no longer starting: + +```bash +grep "deltachat" logs/nanoclaw.log | tail -5 +``` + +Expected: no `Channel adapter started` entry after the last restart. diff --git a/.claude/skills/add-deltachat/SKILL.md b/.claude/skills/add-deltachat/SKILL.md new file mode 100644 index 0000000..45aa416 --- /dev/null +++ b/.claude/skills/add-deltachat/SKILL.md @@ -0,0 +1,254 @@ +--- +name: add-deltachat +description: Add DeltaChat channel integration via @deltachat/stdio-rpc-server. Native adapter — no Chat SDK bridge. Email-based messaging with end-to-end encryption. +--- + +# Add DeltaChat Channel + +The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directly — pure Node.js against the DeltaChat core library. Messages are delivered over email with Autocrypt/OpenPGP encryption. + +## Install + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/deltachat.ts` exists +- `src/channels/index.ts` contains `import './deltachat.js';` +- `@deltachat/stdio-rpc-server` 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/deltachat.ts > src/channels/deltachat.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if already present): + +```typescript +import './deltachat.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @deltachat/stdio-rpc-server@2.49.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Account Setup + +A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one. + +**Default security modes:** IMAP uses SSL/TLS (port 993), SMTP uses STARTTLS (port 587). Both are configurable via `.env` — see Credentials below. + +To find the correct hostnames for a domain: + +```bash +node -e "require('dns').resolveMx('example.com', (e,r) => console.log(r))" +``` + +Most providers publish their IMAP/SMTP hostnames in their help docs under "manual setup" or "IMAP access." + +## Credentials + +Add to `.env`: + +```bash +DC_EMAIL=bot@example.com +DC_PASSWORD=your-app-password +DC_IMAP_HOST=imap.example.com +DC_IMAP_PORT=993 +DC_IMAP_SECURITY=1 # 1=SSL/TLS (default), 2=STARTTLS, 3=plain +DC_SMTP_HOST=smtp.example.com +DC_SMTP_PORT=587 +DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain +``` + +Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account. + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Optional settings + +The following are read from the process environment (not `.env`). To override them, add `Environment=` lines to the systemd service unit or your launchd plist: + +| Variable | Default | Description | +|----------|---------|-------------| +| `DC_ACCOUNT_DIR` | `dc-account` | Directory for DeltaChat account data (IMAP state, keys, blobs) | +| `DC_DISPLAY_NAME` | `NanoClaw` | Bot display name shown in DeltaChat | +| `DC_AVATAR_PATH` | _(none)_ | Absolute path to avatar image; set at startup only | + +The `/set-avatar` command (send an image with that caption) is the easiest way to set the avatar at runtime without modifying the service file. Only users with `owner` or global `admin` role can use it. + +### Restart + +```bash +# Linux +systemctl --user restart nanoclaw + +# macOS +launchctl kickstart -k gui/$(id -u)/com.nanoclaw +``` + +On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`). + +## Wiring + +### DMs + +**DeltaChat contacts cannot be added by email alone** — to start a chat, the user must open the bot's invite link in their DeltaChat app or scan its QR code. This triggers the SecureJoin handshake. + +#### Step 1 — Get the invite link + +After the service starts, the adapter logs the invite URL and writes a QR SVG: + +```bash +grep "invite link" logs/nanoclaw.log | tail -1 +# url field contains the https://i.delta.chat/... invite link +# also written to dc-account/invite-qr.svg (or $DC_ACCOUNT_DIR/invite-qr.svg) +``` + +The invite URL is stable (tied to the bot's email and encryption keys) so it stays valid across restarts. + +#### Step 2 — Add the bot in DeltaChat + +Two options for the user to connect: + +- **Link**: Copy the `https://i.delta.chat/...` URL and open it on the device running DeltaChat. The app recognises it and shows a "Start chat" prompt. +- **QR code**: Open `dc-account/invite-qr.svg` in a browser or image viewer, display it on screen, and scan it from the DeltaChat app using the QR-scan button on the new-chat screen. + +After accepting, DeltaChat exchanges keys and creates the chat automatically. + +#### Step 3 — Wire the chat to an agent + +Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID: + +```bash +sqlite3 data/v2.db \ + "SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5" +``` + +Then run `/init-first-agent` — it creates the agent group, grants the user owner access, and wires the messaging group in one step: + +```bash +pnpm exec tsx scripts/init-first-agent.ts \ + --channel deltachat \ + --user-id deltachat:user@example.com \ + --platform-id \ + --display-name "Your Name" +``` + +### Groups + +Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group. + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaChat DM (see Wiring above), or `/manage-channels` to wire this channel to an existing agent group. + +## Channel Info + +- **type**: `deltachat` +- **terminology**: DeltaChat calls them "chats" (1:1 DMs) and "groups" +- **supports-threads**: no — DeltaChat has no thread model +- **platform-id-format**: numeric chat ID as a string (e.g. `"12"`) — the DeltaChat core's internal chat identifier +- **user-id-format**: `deltachat:{email}` — the contact's email address +- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above +- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat +- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode + +### Features + +- File attachments — inbound and outbound; inbound waits up to 30 seconds for large-message download to complete +- Invite link logged on every startup — URL + QR SVG written to `dc-account/invite-qr.svg`; see Wiring for the bootstrap flow +- `/set-avatar` — send an image with this caption to change the bot's DeltaChat avatar (admin/owner only) +- Connectivity watchdog — restarts IO if IMAP goes quiet for 20 minutes or connectivity drops below threshold for two consecutive 5-minute checks +- Network nudge — `maybeNetwork()` called every 10 minutes to recover from prolonged idle + +Not supported: DeltaChat reactions, message editing/deletion, read receipts. + +### Connectivity model + +`isConnected()` returns `true` when the internal connectivity value is ≥ 3000: + +| Range | Meaning | +|-------|---------| +| 1000–1999 | Not connected | +| 2000–2999 | Connecting | +| 3000–3999 | Working (IMAP fetching) | +| ≥ 4000 | Fully connected (IMAP IDLE) | + +## Troubleshooting + +### Adapter not starting — credentials missing + +```bash +grep "Channel credentials missing" logs/nanoclaw.log | grep deltachat +``` + +All six required vars (`DC_EMAIL`, `DC_PASSWORD`, `DC_IMAP_HOST`, `DC_IMAP_PORT`, `DC_SMTP_HOST`, `DC_SMTP_PORT`) must be present in `.env`. + +### Account configure fails + +```bash +grep "DeltaChat" logs/nanoclaw.log | tail -20 +``` + +Common causes: +- Wrong IMAP/SMTP hostnames — double-check provider docs +- App password not generated — Gmail and some others require this when 2FA is enabled +- Port/security mismatch — defaults are port 993 + SSL/TLS for IMAP and port 587 + STARTTLS for SMTP; override with `DC_IMAP_PORT`/`DC_IMAP_SECURITY` or `DC_SMTP_PORT`/`DC_SMTP_SECURITY` in `.env` + +### Provider uses SMTP port 465 (SSL/TLS) instead of 587 + +Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart. + +### Messages not arriving + +1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log` +2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log` +3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat +4. Verify the messaging group is wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"` + +### Stale lock file after crash + +```bash +rm -f dc-account/accounts.lock +systemctl --user restart nanoclaw +``` + +### Bot not responding after restart + +The account is already configured — IO restarts automatically on service start. If the RPC subprocess is stuck, restart the service. Check for errors: + +```bash +grep "DeltaChat" logs/nanoclaw.error.log | tail -20 +``` + +### Messages received but agent not responding + +The messaging group exists but may not be wired to an agent group. Run: + +```bash +sqlite3 data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'" +``` + +If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`. diff --git a/.claude/skills/add-deltachat/VERIFY.md b/.claude/skills/add-deltachat/VERIFY.md new file mode 100644 index 0000000..839fa85 --- /dev/null +++ b/.claude/skills/add-deltachat/VERIFY.md @@ -0,0 +1,54 @@ +# Verify DeltaChat + +## 1. Check the adapter started + +```bash +grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1 +``` + +Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }` + +## 2. Check IMAP/SMTP connectivity + +Replace with your provider's hostnames from `.env`: + +```bash +DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2) +DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2) + +bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked" +bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked" +``` + +## 3. End-to-end message test + +1. Open DeltaChat on your device +2. Add the bot email address as a contact +3. Send a message +4. The bot should respond within a few seconds + +If nothing arrives, check: + +```bash +grep "DeltaChat" logs/nanoclaw.log | tail -20 +grep "DeltaChat" logs/nanoclaw.error.log | tail -10 +``` + +## 4. Check messaging group was created + +```bash +sqlite3 data/v2.db \ + "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5" +``` + +If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`. + +## 5. Verify user access + +If the message arrived but the agent didn't respond, the sender may not have access: + +```bash +sqlite3 data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'" +``` + +Grant access as shown in the SKILL.md "Grant user access" section. From eba5b78006a615a52faf3dbf7b575246943204a3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 2 May 2026 18:23:39 +0000 Subject: [PATCH 122/144] chore: bump version to 2.0.26 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d3ccf04..427cec5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.25", + "version": "2.0.26", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 953264e0d333b8a457ab64c36d0e01a232435a72 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 2 May 2026 18:37:43 +0000 Subject: [PATCH 123/144] chore: bump version to 2.0.27 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 427cec5..446bc1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.26", + "version": "2.0.27", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 58fc5728db57b16ae55f0488ca4dd28485fa92f0 Mon Sep 17 00:00:00 2001 From: javexed <7049888+javexed@users.noreply.github.com> Date: Sun, 3 May 2026 01:03:38 -0400 Subject: [PATCH 124/144] =?UTF-8?q?feat(setup):=20add=20"Other=E2=80=A6"?= =?UTF-8?q?=20option=20to=20channel=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-time setup picker only listed seven channels with bash installers. Users wanting to install one of the other channels (matrix, github, linear, webex, etc.) had no entry point from the picker and had to know to run /add- from Claude Code afterwards. Add an "Other…" option that prompts for a free-text name, normalizes it (accepts "matrix", "add-matrix", or "/add-matrix"), and prints a hint telling the user to run /add- from Claude Code after setup finishes. The verify step's "What's left" panel already covers the empty-channels case, so the user is not left thinking the channel was wired. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 0095321..b57672f 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -60,7 +60,7 @@ import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); -type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip'; +type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'other' | 'skip'; async function main(): Promise { // Make sure ~/.local/bin is on PATH for every child process we spawn. @@ -441,7 +441,7 @@ async function main(): Promise { if (!skip.has('channel')) { channelChoice = await askChannelChoice(); - if (channelChoice !== 'skip') { + if (channelChoice !== 'skip' && channelChoice !== 'other') { await resolveDisplayName(); } if (channelChoice === 'telegram') { @@ -458,6 +458,8 @@ async function main(): Promise { await runSlackChannel(displayName!); } else if (channelChoice === 'imessage') { await runIMessageChannel(displayName!); + } else if (channelChoice === 'other') { + await askOtherChannelName(); } else { p.log.info( brandBody( @@ -1076,6 +1078,7 @@ async function askChannelChoice(): Promise { hint: 'needs public URL', }, { value: 'teams', label: 'Yes, connect Microsoft Teams', hint: 'complex setup' }, + { value: 'other', label: 'Other…', hint: 'install via /add- after setup' }, { value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" }, ], }), @@ -1085,6 +1088,26 @@ async function askChannelChoice(): Promise { return choice; } +async function askOtherChannelName(): Promise { + const answer = ensureAnswer( + await p.text({ + message: 'Which channel would you like to install?', + placeholder: 'e.g. matrix, github, linear, webex', + }), + ); + const name = (answer as string).trim().toLowerCase().replace(/^\/?(add-)?/, ''); + setupLog.userInput('other_channel', name); + phEmit('channel_other_named', { channel: name }); + p.log.info( + brandBody( + wrapForGutter( + `No bash installer for ${k.bold(name)} — open Claude Code after setup and run ${k.bold(`/add-${name}`)} to install it.`, + 4, + ), + ), + ); +} + // ─── interactive / env helpers ───────────────────────────────────────── function ensureLocalBinOnPath(): void { From 0e9dadfaeef387bbfe8c2b97f2eeecbe03ec94e1 Mon Sep 17 00:00:00 2001 From: Ziv Daniel <5122000+ziv-daniel@users.noreply.github.com> Date: Sun, 3 May 2026 15:40:46 +0300 Subject: [PATCH 125/144] fix: accept media-only messages with empty text in onNewMessage /./ requires at least one character and silently drops messages with no text (e.g. Telegram photo/video/file sent without a caption). Switching to /[\s\S]*/ matches the empty string too, so media-only messages now reach the router and then the agent. --- 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 18ab2cb..52c92ba 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -253,12 +253,12 @@ 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 every - // unsubscribed thread the bot can see. The router short-circuits via + // with `/[\s\S]*/` lets the router see every plain message (including + // media-only messages with empty text) on every unsubscribed thread the // 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) => { + chat.onNewMessage(/[\s\S]*/, async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false, true)); }); From e34380656c4c086f7c9a2ddceed560b62197000c Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Sun, 3 May 2026 12:38:30 +0000 Subject: [PATCH 126/144] feat(setup): headless-aware Claude sign-in pre-message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-message printed by setup/register-claude-token.sh used to say "A browser window will open for you to sign in with your Claude account." Accurate on a laptop or desktop, but a lie on headless devices (Pi, SSH'd-into Linux server, CI) where the browser auto-open never lands and the user actually has to copy the URL `claude setup-token` prints to another device. Add a small bash isHeadless check (mirrors `isHeadless()` in setup/platform.ts: Linux without DISPLAY / WAYLAND_DISPLAY) and swap the heredoc accordingly: - Headless: "A sign-in link will appear for you to sign in with your Claude account. When you finish, we'll save the token to your OneCLI vault automatically." - GUI: existing "A browser window will open…" copy, unchanged. The trailing "Press Enter to continue, or edit the command first." line and the actual `claude setup-token` invocation are unchanged — only the leading sentence flips. --- setup/register-claude-token.sh | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/setup/register-claude-token.sh b/setup/register-claude-token.sh index e0adfc6..324b1be 100755 --- a/setup/register-claude-token.sh +++ b/setup/register-claude-token.sh @@ -51,13 +51,34 @@ command -v script >/dev/null \ tmpfile=$(mktemp -t claude-setup-token.XXXXXX) trap 'rm -f "$tmpfile"' EXIT -cat <<'EOF' +# Detect headless. Mirrors `isHeadless()` in setup/platform.ts: on Linux +# with neither DISPLAY nor WAYLAND_DISPLAY set, no graphical session +# exists, so `claude setup-token` won't be able to auto-open a browser +# and the user will need to copy the printed sign-in URL by hand. The +# pre-message copy below is swapped accordingly so we don't promise a +# browser pop that will never happen. +is_headless=0 +if [ "$(uname -s)" = "Linux" ] && [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ]; then + is_headless=1 +fi + +if [ "$is_headless" = "1" ]; then + cat <<'EOF' +A sign-in link will appear for you to sign in with your Claude account. +When you finish, we'll save the token to your OneCLI vault automatically. + +Press Enter to continue, or edit the command first. + +EOF +else + 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. Press Enter to continue, or edit the command first. EOF +fi cmd="claude setup-token" if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then From 7fc68a100814b21bb2adc45e43a3e021dd4927f6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 3 May 2026 14:04:59 +0000 Subject: [PATCH 127/144] chore: bump version to 2.0.28 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 446bc1b..f305bec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.27", + "version": "2.0.28", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From e432467066c01b8bc9524957ba7af260fdacf9ff Mon Sep 17 00:00:00 2001 From: Gabi Simons <263580637+gabi-simons@users.noreply.github.com> Date: Sun, 3 May 2026 14:46:18 +0000 Subject: [PATCH 128/144] fix: update /update-nanoclaw skill for v2 architecture The skill was written for v1 and missed several v2 changes: container rebuild after merge, dependency install for both pnpm and bun lockfiles, container typecheck, channel/provider branch update awareness, and platform-aware service restart instructions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/update-nanoclaw/SKILL.md | 63 ++++++++++++++++++++----- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index aebe96e..de17b5a 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -17,8 +17,9 @@ Run `/update-nanoclaw` in Claude Code. **Preview**: runs `git log` and `git diff` against the merge base to show upstream changes since your last sync. Groups changed files into categories: - **Skills** (`.claude/skills/`): unlikely to conflict unless you edited an upstream skill -- **Source** (`src/`): may conflict if you modified the same files -- **Build/config** (`package.json`, `tsconfig*.json`, `container/`): review needed +- **Host source** (`src/`): may conflict if you modified the same files +- **Container** (`container/`): triggers container rebuild +- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install **Update paths** (you pick one): - `merge` (default): `git merge upstream/`. Resolves all conflicts in one pass. @@ -30,7 +31,7 @@ Run `/update-nanoclaw` in Claude Code. **Conflict resolution**: opens only conflicted files, resolves the conflict markers, keeps your local customizations intact. -**Validation**: runs `pnpm run build` and `pnpm test`. +**Validation**: runs `pnpm run build` and `pnpm test`. If container files changed, also runs the container typecheck and `./container/build.sh`. **Breaking changes check**: after validation, reads CHANGELOG.md for any `[BREAKING]` entries introduced by the update. If found, shows each breaking change and offers to run the recommended skill to migrate. @@ -108,9 +109,10 @@ Show file-level impact from upstream: Bucket the upstream changed files: - **Skills** (`.claude/skills/`): unlikely to conflict unless the user edited an upstream skill -- **Source** (`src/`): may conflict if user modified the same files -- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`, `container/`, `launchd/`): review needed -- **Other**: docs, tests, misc +- **Host source** (`src/`): may conflict if user modified the same files +- **Container** (`container/`): triggers container rebuild (+ typecheck if `agent-runner/src/` changed) +- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install +- **Other**: docs, tests, setup scripts, misc **Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push. @@ -173,11 +175,29 @@ If it gets messy (more than 3 rounds of conflicts): - `git rebase --abort` - Recommend merge instead. +# Step 4.5: Install dependencies (if lockfiles changed) +Check if the merge changed any lockfiles or package manifests: +- `git diff ..HEAD --name-only | grep -E '^(pnpm-lock\.yaml|package\.json)$'` + - If matched: `pnpm install` +- `git diff ..HEAD --name-only | grep -E '^container/agent-runner/(bun\.lock|package\.json)$'` + - If matched: `cd container/agent-runner && bun install` + +Skip this step if neither lockfile changed. + # Step 5: Validation -Run: +Check which areas changed to determine what to validate: +- `CHANGED_FILES=$(git diff --name-only ..HEAD)` + +**Host build** (always): - `pnpm run build` - `pnpm test` (do not fail the flow if tests are not configured) +**Container typecheck** (only if `container/agent-runner/src/` files are in CHANGED_FILES): +- `pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit` + +**Container image rebuild** (only if any `container/` files are in CHANGED_FILES): +- `./container/build.sh` + If build fails: - Show the error. - Only fix issues clearly caused by the merge (missing imports, type mismatches from merged code). @@ -209,8 +229,10 @@ If one or more `[BREAKING]` lines are found: - For each skill the user selects, invoke it using the Skill tool. - After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check). -# Step 7: Check for skill updates -After the summary, check if skills are distributed as branches in this repo: +# Step 7: Check for skill and channel/provider updates + +## 7a: Skill branches +Check if skills are distributed as branches in this repo: - `git branch -r --list 'upstream/skill/*'` If any `upstream/skill/*` branches exist: @@ -218,7 +240,21 @@ If any `upstream/skill/*` branches exist: - Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates") - Option 2: "No, skip" (description: "You can run /update-skills later any time") - If user selects yes, invoke `/update-skills` using the Skill tool. -- After the skill completes (or if user selected no), proceed to Step 8. + +## 7b: Channel and provider updates +Detect installed channels by reading `src/channels/index.ts` and collecting all `import './.js';` lines (excluding `cli`). For providers, check `src/providers/index.ts` the same way. + +If any channels/providers are installed AND `upstream/channels` or `upstream/providers` branches exist: +- List the installed channels/providers. +- Use AskUserQuestion to ask: "Would you like to update your installed channels/providers? Re-running `/add-` is safe — it only updates code files, credentials and wiring are untouched." + - One option per installed channel/provider (e.g., "Update Slack (/add-slack)") + - "Skip — I'll update them later" + - Set `multiSelect: true` +- For each selected option, invoke the corresponding `/add-` or `/add-` skill. + +If no channels/providers are installed, skip silently. + +Proceed to Step 8. # Step 8: Summary + rollback instructions Show: @@ -232,9 +268,10 @@ Show: Tell the user: - To rollback: `git reset --hard ` - Backup branch also exists: `backup/pre-update--` -- Restart the service to apply changes: - - If using launchd: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` - - If running manually: restart `pnpm run dev` +- Restart the service to apply changes. Detect platform with `uname -s`: + - **macOS (Darwin)**: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` + - **Linux**: `systemctl --user restart nanoclaw` + - **Manual**: restart `pnpm run dev` ## Diagnostics From cf783385e7e5fc880dec27e32c5030ba582d103f Mon Sep 17 00:00:00 2001 From: Gabi Simons <263580637+gabi-simons@users.noreply.github.com> Date: Sun, 3 May 2026 15:45:18 +0000 Subject: [PATCH 129/144] fix: handle missing bun on host and dynamic systemd service name Container typecheck and bun install gracefully skip when bun isn't installed on the host. Linux service restart now detects the actual systemd service name instead of hardcoding 'nanoclaw'. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/update-nanoclaw/SKILL.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index de17b5a..2dec81b 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -180,7 +180,8 @@ Check if the merge changed any lockfiles or package manifests: - `git diff ..HEAD --name-only | grep -E '^(pnpm-lock\.yaml|package\.json)$'` - If matched: `pnpm install` - `git diff ..HEAD --name-only | grep -E '^container/agent-runner/(bun\.lock|package\.json)$'` - - If matched: `cd container/agent-runner && bun install` + - If matched AND `command -v bun` succeeds: `cd container/agent-runner && bun install` + - If bun is not installed on the host, skip — container deps will be installed during `./container/build.sh` Skip this step if neither lockfile changed. @@ -192,8 +193,9 @@ Check which areas changed to determine what to validate: - `pnpm run build` - `pnpm test` (do not fail the flow if tests are not configured) -**Container typecheck** (only if `container/agent-runner/src/` files are in CHANGED_FILES): -- `pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit` +**Container typecheck** (only if `container/agent-runner/src/` files are in CHANGED_FILES AND bun types are available): +- Check: `pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit` +- If this fails because bun types are missing (`Cannot find type definition file for 'bun'`), skip with a note — type errors will surface at container runtime instead **Container image rebuild** (only if any `container/` files are in CHANGED_FILES): - `./container/build.sh` @@ -270,8 +272,8 @@ Tell the user: - Backup branch also exists: `backup/pre-update--` - Restart the service to apply changes. Detect platform with `uname -s`: - **macOS (Darwin)**: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` - - **Linux**: `systemctl --user restart nanoclaw` - - **Manual**: restart `pnpm run dev` + - **Linux**: detect the service name with `systemctl --user list-units --type=service | grep nanoclaw | awk '{print $1}'`, then `systemctl --user restart ` + - **Manual** (no service found): restart `pnpm run dev` ## Diagnostics From 5dc54194abdeec33f0c7e6e054e5b98e3b9cacff Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 3 May 2026 09:16:13 -0700 Subject: [PATCH 130/144] Recognize ANTHROPIC_AUTH_TOKEN in setup verification The credential proxy already reads ANTHROPIC_AUTH_TOKEN (credential-proxy.ts line 33) and uses it for OAuth-mode authentication, but setup/verify.ts did not include it in its credential-detection regex. Users with ANTHROPIC_AUTH_TOKEN in .env saw 'CREDENTIALS: missing' even though their credentials were valid at runtime. Add ANTHROPIC_AUTH_TOKEN to the regex and add a matching test case. Closes gh-853 --- setup/environment.test.ts | 13 ++++++++++--- setup/verify.ts | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/setup/environment.test.ts b/setup/environment.test.ts index 7765693..93f61c3 100644 --- a/setup/environment.test.ts +++ b/setup/environment.test.ts @@ -84,21 +84,28 @@ describe('credentials detection', () => { const content = 'SOME_KEY=value\nANTHROPIC_API_KEY=sk-ant-test123\nOTHER=foo'; const hasCredentials = - /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content); + /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content); expect(hasCredentials).toBe(true); }); it('detects CLAUDE_CODE_OAUTH_TOKEN in env content', () => { const content = 'CLAUDE_CODE_OAUTH_TOKEN=token123'; const hasCredentials = - /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content); + /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content); + expect(hasCredentials).toBe(true); + }); + + it('detects ANTHROPIC_AUTH_TOKEN in env content', () => { + const content = 'ANTHROPIC_AUTH_TOKEN=token123\nANTHROPIC_BASE_URL=http://localhost:8080'; + const hasCredentials = + /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content); expect(hasCredentials).toBe(true); }); it('returns false when no credentials', () => { const content = 'ASSISTANT_NAME="Andy"\nOTHER=foo'; const hasCredentials = - /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content); + /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content); expect(hasCredentials).toBe(false); }); }); diff --git a/setup/verify.ts b/setup/verify.ts index de1160c..683398e 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -139,7 +139,7 @@ export async function run(_args: string[]): Promise { const envFile = path.join(projectRoot, '.env'); if (fs.existsSync(envFile)) { const envContent = fs.readFileSync(envFile, 'utf-8'); - if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ONECLI_URL)=/m.test(envContent)) { + if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(envContent)) { credentials = 'configured'; } } From 6a05e41afe620c5b8a2bdef5a75f90426ab16a38 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 3 May 2026 14:47:52 +0300 Subject: [PATCH 131/144] fix: require interactive terminal for migrate-v2.sh The migration script has interactive prompts and streams progress output that gets collapsed when run via Claude Code's Bash tool. Add a TTY guard that exits early with instructions to use the ! prefix instead. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 ++-- migrate-v2.sh | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e65515a..3104b32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,8 @@ This is NanoClaw v2, a ground-up rewrite with breaking changes throughout. It ca **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. +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 run the migration script yourself — it requires an interactive terminal and must be run by the user with the `!` prefix. If you are a fresh install (you ran `git clone`, not `git pull`) and there are no conflicts, ignore this banner and continue below. diff --git a/migrate-v2.sh b/migrate-v2.sh index eb5a381..ac9f4e0 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -5,6 +5,9 @@ # Run from the v2 directory: # bash migrate-v2.sh # +# From Claude Code, use the ! prefix so it runs in your terminal: +# ! bash migrate-v2.sh +# # Finds v1 automatically (sibling directory, or $NANOCLAW_V1_PATH). # Installs prerequisites (Node, pnpm, deps) via the existing setup.sh # bootstrap, then runs the migration steps. @@ -17,6 +20,19 @@ set -uo pipefail PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$PROJECT_ROOT" +# This script has interactive prompts (channel selection, service switchover) +# and streams progress output — it must run in a real terminal, not inside +# a tool subprocess (e.g. Claude Code's Bash tool, which collapses output). +if ! [ -t 0 ] || ! [ -t 1 ]; then + echo "This script requires an interactive terminal." + echo "" + echo "If you're in Claude Code, run it directly with the ! prefix:" + echo " ! bash migrate-v2.sh" + echo "" + echo "Or run it in a separate terminal session." + exit 1 +fi + LOGS_DIR="$PROJECT_ROOT/logs" STEPS_DIR="$LOGS_DIR/migrate-steps" MIGRATE_LOG="$LOGS_DIR/migrate-v2.log" From d88b0807e60dd63b787432fc37288a0d962264ab Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 3 May 2026 13:02:34 +0000 Subject: [PATCH 132/144] fix: retire legacy v1 service file after migration switchover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After migration keeps v2, the old unslugged `nanoclaw.service` (or `com.nanoclaw.plist`) was only disabled — the unit file stayed on disk. A `systemctl --user restart nanoclaw` would start v1 instead of v2. Now the migration removes the old file and symlinks it to the v2 unit, so the legacy name transparently starts v2. Handles systemd (Linux/WSL) and launchd (macOS). Idempotent — skips if the symlink already exists. Co-Authored-By: Claude Opus 4.6 --- migrate-v2.sh | 51 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/migrate-v2.sh b/migrate-v2.sh index ac9f4e0..aedbf0f 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -563,6 +563,51 @@ echo echo "$(bold 'Service switchover')" echo +# Retire the legacy v1 service file and alias it to the v2 unit. +# Called after the user confirms "keep v2", or when v1 wasn't running. +# Idempotent — safe to call multiple times. +retire_v1_service() { + if [ -z "$V2_SERVICE" ]; then + return + fi + + if [ "$PLATFORM_SERVICE" = "systemd" ]; then + local unit_dir="$HOME/.config/systemd/user" + local v1_file="$unit_dir/${V1_SERVICE}.service" + local v2_file="${V2_SERVICE}.service" + + # Already a correct symlink — nothing to do + if [ -L "$v1_file" ] && [ "$(readlink "$v1_file")" = "$v2_file" ]; then + return + fi + + # Only retire if the file exists (as a regular file or stale symlink) + if [ -f "$v1_file" ] || [ -L "$v1_file" ]; then + systemctl --user stop "$V1_SERVICE" 2>/dev/null || true + systemctl --user disable "$V1_SERVICE" 2>/dev/null || true + rm -f "$v1_file" + ln -s "$v2_file" "$v1_file" + systemctl --user daemon-reload 2>/dev/null || true + step_ok "Aliased $V1_SERVICE → $V2_SERVICE" + fi + + elif [ "$PLATFORM_SERVICE" = "launchd" ]; then + local v1_plist="$HOME/Library/LaunchAgents/${V1_SERVICE}.plist" + local v2_plist="${V2_SERVICE}.plist" + + if [ -L "$v1_plist" ] && [ "$(readlink "$v1_plist")" = "$v2_plist" ]; then + return + fi + + if [ -f "$v1_plist" ] || [ -L "$v1_plist" ]; then + launchctl unload "$v1_plist" 2>/dev/null || true + rm -f "$v1_plist" + ln -s "$v2_plist" "$v1_plist" + step_ok "Aliased $V1_SERVICE → $V2_SERVICE" + fi + fi +} + # Detect platform and service names V1_SERVICE="" V2_SERVICE="" @@ -651,16 +696,14 @@ if [ "$V1_RUNNING" = "true" ]; then SERVICE_SWITCHED=false else step_ok "Keeping v2 service" - # Disable v1 from auto-starting - if [ "$PLATFORM_SERVICE" = "systemd" ]; then - systemctl --user disable "$V1_SERVICE" 2>/dev/null || true - fi + retire_v1_service fi else step_skip "Service switchover skipped" fi else step_skip "v1 service not running — nothing to switch" + retire_v1_service fi echo From 58e4df44e24bb05e051001e68cd4c6c0ed14a361 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 3 May 2026 16:23:52 +0300 Subject: [PATCH 133/144] fix: add hint to channel multiselect in migration Co-Authored-By: Claude Opus 4.6 --- setup/migrate-v2/select-channels.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup/migrate-v2/select-channels.ts b/setup/migrate-v2/select-channels.ts index eecf1ab..a2c8b21 100644 --- a/setup/migrate-v2/select-channels.ts +++ b/setup/migrate-v2/select-channels.ts @@ -12,6 +12,7 @@ import fs from 'fs'; import * as p from '@clack/prompts'; +import { styleText } from 'node:util'; const CHANNELS = [ { value: 'telegram', label: 'Telegram' }, @@ -47,7 +48,7 @@ async function main(): Promise { } const selected = await p.multiselect({ - message: 'Which channels do you want to set up?', + message: 'Which channels do you want to set up?\n' + styleText('dim', ' space to select, enter to confirm') + '\n', options: CHANNELS, required: false, }); From 6daa1a3ffe5d04d3cc6a9483305e8b9c4590b66a Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 3 May 2026 17:09:31 +0300 Subject: [PATCH 134/144] fix: preserve v1 service file for rollback instead of symlinking The previous approach deleted the v1 unit file and symlinked it to v2, making rollback impossible. Now we just disable v1 and leave the file on disk so users can switch back with a single command. Also adds rollback instructions to the migration summary output. Co-Authored-By: Claude Opus 4.6 --- migrate-v2.sh | 51 ++++++++++++++++++--------------------------------- 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/migrate-v2.sh b/migrate-v2.sh index aedbf0f..dec497f 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -563,47 +563,22 @@ echo echo "$(bold 'Service switchover')" echo -# Retire the legacy v1 service file and alias it to the v2 unit. -# Called after the user confirms "keep v2", or when v1 wasn't running. +# Disable the v1 service so it doesn't auto-start, but leave the unit file +# on disk so the user can rollback with: systemctl --user start nanoclaw # Idempotent — safe to call multiple times. -retire_v1_service() { - if [ -z "$V2_SERVICE" ]; then - return - fi - +disable_v1_service() { if [ "$PLATFORM_SERVICE" = "systemd" ]; then - local unit_dir="$HOME/.config/systemd/user" - local v1_file="$unit_dir/${V1_SERVICE}.service" - local v2_file="${V2_SERVICE}.service" - - # Already a correct symlink — nothing to do - if [ -L "$v1_file" ] && [ "$(readlink "$v1_file")" = "$v2_file" ]; then - return - fi - - # Only retire if the file exists (as a regular file or stale symlink) + local v1_file="$HOME/.config/systemd/user/${V1_SERVICE}.service" if [ -f "$v1_file" ] || [ -L "$v1_file" ]; then systemctl --user stop "$V1_SERVICE" 2>/dev/null || true systemctl --user disable "$V1_SERVICE" 2>/dev/null || true - rm -f "$v1_file" - ln -s "$v2_file" "$v1_file" - systemctl --user daemon-reload 2>/dev/null || true - step_ok "Aliased $V1_SERVICE → $V2_SERVICE" + step_ok "Disabled $V1_SERVICE (unit file kept for rollback)" fi - elif [ "$PLATFORM_SERVICE" = "launchd" ]; then local v1_plist="$HOME/Library/LaunchAgents/${V1_SERVICE}.plist" - local v2_plist="${V2_SERVICE}.plist" - - if [ -L "$v1_plist" ] && [ "$(readlink "$v1_plist")" = "$v2_plist" ]; then - return - fi - if [ -f "$v1_plist" ] || [ -L "$v1_plist" ]; then launchctl unload "$v1_plist" 2>/dev/null || true - rm -f "$v1_plist" - ln -s "$v2_plist" "$v1_plist" - step_ok "Aliased $V1_SERVICE → $V2_SERVICE" + step_ok "Unloaded $V1_SERVICE (plist kept for rollback)" fi fi } @@ -696,14 +671,14 @@ if [ "$V1_RUNNING" = "true" ]; then SERVICE_SWITCHED=false else step_ok "Keeping v2 service" - retire_v1_service + disable_v1_service fi else step_skip "Service switchover skipped" fi else step_skip "v1 service not running — nothing to switch" - retire_v1_service + disable_v1_service fi echo @@ -735,6 +710,16 @@ echo " $(green '✓') Channels installed: ${SELECTED_CHANNELS[*]}" fi echo " $(green '✓') Container skills copied" echo " $(green '✓') Container image built" +if [ "$SERVICE_SWITCHED" = "true" ] && [ -n "$V2_SERVICE" ]; then +echo " $(green '✓') Service switched to v2 $(dim "($V2_SERVICE)")" +echo +echo " $(bold 'Rollback to v1:')" +if [ "$PLATFORM_SERVICE" = "systemd" ]; then +echo " $(dim '$') systemctl --user stop $V2_SERVICE && systemctl --user start $V1_SERVICE" +elif [ "$PLATFORM_SERVICE" = "launchd" ]; then +echo " $(dim '$') launchctl unload ~/Library/LaunchAgents/${V2_SERVICE}.plist && launchctl load ~/Library/LaunchAgents/${V1_SERVICE}.plist" +fi +fi echo echo " $(bold 'What still needs a human:')" if [ "$ONECLI_OK" = "false" ]; then From 5deccc44eaa47fedf1a1c483f1864909116101b4 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 3 May 2026 17:26:46 +0300 Subject: [PATCH 135/144] fix: direct users to exit Claude Code for migration instead of using ! prefix Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 ++-- migrate-v2.sh | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3104b32..c17001b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,8 @@ This is NanoClaw v2, a ground-up rewrite with breaking changes throughout. It ca **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 run the migration script yourself — it requires an interactive terminal and must be run by the user with the `!` prefix. +2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Exit Claude Code (or open a separate terminal) and run `bash migrate-v2.sh` from the shell."* +3. Wait for the user to confirm before doing anything else. Do not run the migration script yourself — it requires an interactive terminal and cannot be run from within Claude Code. If you are a fresh install (you ran `git clone`, not `git pull`) and there are no conflicts, ignore this banner and continue below. diff --git a/migrate-v2.sh b/migrate-v2.sh index dec497f..f06a548 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -5,8 +5,7 @@ # Run from the v2 directory: # bash migrate-v2.sh # -# From Claude Code, use the ! prefix so it runs in your terminal: -# ! bash migrate-v2.sh +# If you're in Claude Code, exit first or open a separate terminal. # # Finds v1 automatically (sibling directory, or $NANOCLAW_V1_PATH). # Installs prerequisites (Node, pnpm, deps) via the existing setup.sh @@ -26,10 +25,10 @@ cd "$PROJECT_ROOT" if ! [ -t 0 ] || ! [ -t 1 ]; then echo "This script requires an interactive terminal." echo "" - echo "If you're in Claude Code, run it directly with the ! prefix:" - echo " ! bash migrate-v2.sh" + echo "If you're in Claude Code, exit first or open a separate terminal," + echo "then run:" + echo " bash migrate-v2.sh" echo "" - echo "Or run it in a separate terminal session." exit 1 fi From 37d6335ebc2ead019a8e0d9675d786011bd98772 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 3 May 2026 20:12:37 +0000 Subject: [PATCH 136/144] fix(setup): clean up legacy OneCLI containers before installer runs The OneCLI installer (curl onecli.sh/install | sh) doesn't pass --remove-orphans to docker compose up. After the upstream service rename (app -> onecli), the legacy onecli-app-1 container keeps :10254 bound and crashes the new bring-up. This breaks /migrate-v2.sh on any host that has a pre-rename OneCLI installed. Workaround: before invoking the installer, remove containers in the "onecli" compose project whose service name isn't in the v2 set ({onecli, postgres}). Label-keyed and no-op on fresh installs. Filed upstream; remove this once the installer adds --remove-orphans. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/onecli.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/setup/onecli.ts b/setup/onecli.ts index fbf76a9..8f758bb 100644 --- a/setup/onecli.ts +++ b/setup/onecli.ts @@ -115,9 +115,43 @@ function installOnecliCliOnly(): { stdout: string; ok: boolean } { return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok }; } +// Remove containers in the "onecli" compose project whose service name isn't +// in the v2 set. Pre-v2 OneCLI used service "app" (container onecli-app-1); +// v2 uses "onecli". Compose flags the old container as an orphan but won't +// stop it without --remove-orphans, leaving port 10254 bound and crashing +// the new bring-up. Filed upstream; this is the downstream workaround. +function removeLegacyOnecliContainers(): string { + const out: string[] = []; + let list = ''; + try { + list = execSync( + `docker ps -a --filter "label=com.docker.compose.project=onecli" --format '{{.Names}}|{{.Label "com.docker.compose.service"}}'`, + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }, + ).trim(); + } catch { + return ''; + } + if (!list) return ''; + const v2Services = new Set(['onecli', 'postgres']); + for (const line of list.split('\n')) { + const [name, service] = line.split('|'); + if (!name || !service || v2Services.has(service)) continue; + out.push(`Removing legacy OneCLI container: ${name} (service=${service})`); + try { + execSync(`docker rm -f ${JSON.stringify(name)}`, { stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (err) { + out.push(` rm failed (continuing): ${(err as Error).message}`); + } + } + return out.join('\n'); +} + function installOnecli(): { stdout: string; ok: boolean } { let stdout = ''; + const cleanup = removeLegacyOnecliContainers(); + if (cleanup) stdout += cleanup + '\n'; + // Gateway install (docker-compose based, no rate-limit concerns). const gw = runInstall('curl -fsSL onecli.sh/install | sh'); stdout += gw.stdout; From f68f6da406fe7637ef4b764fe58d86bcf8cb2da8 Mon Sep 17 00:00:00 2001 From: Alex Mashkovtsev Date: Mon, 4 May 2026 16:49:53 +0800 Subject: [PATCH 137/144] fix(agent-runner): derive MCP allowedTools from registered mcpServers Claude Code 2.1.116+ treats SDK `allowedTools` as a hard whitelist: servers whose namespace isnt listed are filtered out before the agent ever sees them, regardless of `permissionMode: bypassPermissions` or any `permissions.allow` in settings. The static TOOL_ALLOWLIST only contained `mcp__nanoclaw__*`, so any MCP wired via add_mcp_server (or directly in container.json) was silently dropped. Derive `mcp____*` entries at the SDK call site from the already-aggregated `this.mcpServers` map, mirroring the SDKs own sanitization rule (chars outside [A-Za-z0-9_-] become _). Prior diagnosis by @jsboige in #2028 (withdrawn, not upstreamed). --- .../agent-runner/src/providers/claude.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index c9478b8..6c30cc2 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -34,7 +34,11 @@ const SDK_DISALLOWED_TOOLS = [ 'ExitWorktree', ]; -// Tool allowlist for NanoClaw agent containers +// Tool allowlist for NanoClaw agent containers. MCP-tool entries are derived +// at the call site from the registered `mcpServers` map so that any server +// added via `add_mcp_server` (or wired in container.json directly) is +// reachable to the agent — without this, the SDK's allowedTools filter +// silently drops every MCP namespace not listed here. const TOOL_ALLOWLIST = [ 'Bash', 'Read', @@ -54,9 +58,15 @@ const TOOL_ALLOWLIST = [ 'ToolSearch', 'Skill', 'NotebookEdit', - 'mcp__nanoclaw__*', ]; +// MCP server names are sanitized by the SDK when forming tool prefixes: +// any character outside [A-Za-z0-9_-] becomes '_'. Mirror that here so our +// allowlist patterns match what the SDK actually exposes. +function mcpAllowPattern(serverName: string): string { + return `mcp__${serverName.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`; +} + interface SDKUserMessage { type: 'user'; message: { role: 'user'; content: string }; @@ -277,7 +287,10 @@ export class ClaudeProvider implements AgentProvider { resume: input.continuation, pathToClaudeCodeExecutable: '/pnpm/claude', systemPrompt: instructions ? { type: 'preset' as const, preset: 'claude_code' as const, append: instructions } : undefined, - allowedTools: TOOL_ALLOWLIST, + allowedTools: [ + ...TOOL_ALLOWLIST, + ...Object.keys(this.mcpServers).map(mcpAllowPattern), + ], disallowedTools: SDK_DISALLOWED_TOOLS, env: this.env, permissionMode: 'bypassPermissions', From 34c3e90156b64e932948119815c2a9a658b3d5ed Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 4 May 2026 09:01:43 +0000 Subject: [PATCH 138/144] feat(setup): clarify @BotFather is Telegram's official bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of the Telegram channel's BotFather instructions used to read: 1. Open Telegram and message @BotFather Two small UX issues with that: - "BotFather" reads slightly sketchy without context — a first-time user has no way to know it's the official, sanctioned account rather than an impersonator. - Typing the username from memory leaves room for picking a typo'd impostor account (Telegram has many @BotF4ther / @BotFAther / etc. look-alikes). Update the line so the official-bot framing is part of the instruction itself: 1. Open Telegram and message @BotFather — Telegram's official bot for creating and managing bots One-line change in the existing note() body. No new dependencies, no asset churn, no other behavior change. --- setup/channels/telegram.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 41ee407..bf474f2 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -149,7 +149,7 @@ async function collectTelegramToken(): Promise { "Your assistant talks to you through a Telegram bot you create.", "Here's how:", '', - ' 1. Open Telegram and message @BotFather', + " 1. Open Telegram and message @BotFather — Telegram's official bot for creating and managing bots", ' 2. Send /newbot and follow the prompts', ' 3. Copy the token it gives you (it looks like :)', '', From b33f6654fdece99c5b11e06fc160917f1ac6fb61 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 4 May 2026 09:23:43 +0000 Subject: [PATCH 139/144] fix(setup): use fmtDuration in the container-build spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup/lib/windowed-runner.ts was the one place on main still printing elapsed time as raw seconds (`(170s)`) instead of using the minute-aware `fmtDuration` helper from #2108. Two spots — the live spinner suffix that ticks during the build, and the success/error completion suffix — both now go through `fmtDuration`, so anything past 60 seconds renders as `Xm Ys` (e.g. `2m 50s`) like the rest of the setup flow. The miss happened because a separate PR (closed) was supposed to remove the timer entirely from this file, so #2108 deliberately skipped it. With that other PR closed, applying `fmtDuration` here is the consistent fix. Pure formatting change. The helper itself is unchanged from #2108; behavior under 60s is identical (`Xs`); behavior past 60s now matches everywhere else. --- setup/lib/windowed-runner.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/setup/lib/windowed-runner.ts b/setup/lib/windowed-runner.ts index 6f165a4..87c971e 100644 --- a/setup/lib/windowed-runner.ts +++ b/setup/lib/windowed-runner.ts @@ -23,7 +23,7 @@ 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 { brandBody, fitToWidth } from './theme.js'; +import { brandBody, fitToWidth, fmtDuration } from './theme.js'; const WINDOW_SIZE = 3; const SPINNER_FRAMES = ['◒', '◐', '◓', '◑']; @@ -85,9 +85,8 @@ async function runUnderWindow( 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 suffix = ` (${fmtDuration(Date.now() - start)})`; const header = fitToWidth(labels.running, suffix); out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`); @@ -164,8 +163,7 @@ async function runUnderWindow( out.write(SHOW_CURSOR); process.off('exit', restoreCursorOnExit); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; if (result.ok) { const isSkipped = result.terminal?.fields.STATUS === 'skipped'; const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; From 9e4feb08001c8901aa41ddf2ce1ec7e0b47331df Mon Sep 17 00:00:00 2001 From: koshkoshinsk Date: Mon, 4 May 2026 12:54:54 +0000 Subject: [PATCH 140/144] feat(setup): warn when host is below recommended hardware specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-flight check in nanoclaw.sh that detects available RAM and free disk on the project-root partition (Linux + macOS) before the bootstrap spinner runs. Below 3700 MB RAM or 20 GB free disk, surfaces a "likely cannot run" warning with a Try-anyway prompt defaulting to abort. The 3700 MB floor sits below 4 GB because "4 GB" VMs typically report 3700–3900 MB after kernel reserves (Hetzner CX21 ≈ 3814, AWS t3.medium ≈ 3800). Cheaper to fail here than to wait through pnpm install on a host that can't run the agent container. Diagnostic events fire on continue/abort. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/nanoclaw.sh b/nanoclaw.sh index 82d445a..c2b2614 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -137,6 +137,69 @@ write_header # NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark. cat "$PROJECT_ROOT/assets/setup-splash.txt" +# ─── pre-flight: minimum hardware specs ──────────────────────────────── +# NanoClaw runs an agent container per session. Below these thresholds the +# host + container + agent will struggle (OOM under load, image + session +# DBs filling the disk). Soft warn — `df` only sees the partition that +# $PROJECT_ROOT lives on, which can underreport on hosts with separate +# /home or /var mounts, so the user can override. + +# RAM floor is set below 4 GB because "4 GB" VMs typically report 3700–3900 MB +# after kernel reserves (e.g. Hetzner CX21 ≈ 3814, AWS t3.medium ≈ 3800). +MIN_MEM_MB=3700 +MIN_DISK_GB=20 + +detect_mem_mb() { + case "$(uname -s)" in + Linux) + awk '/^MemTotal:/ {printf "%d", $2 / 1024}' /proc/meminfo 2>/dev/null + ;; + Darwin) + local bytes + bytes=$(sysctl -n hw.memsize 2>/dev/null || echo 0) + echo $(( bytes / 1024 / 1024 )) + ;; + esac +} + +detect_disk_gb() { + # -P: POSIX format (no line-wrapping); -k: 1024-byte blocks. Avail is col 4. + df -Pk "$PROJECT_ROOT" 2>/dev/null \ + | awk 'NR==2 { printf "%d", $4 / 1024 / 1024 }' +} + +MEM_MB=$(detect_mem_mb) +DISK_GB=$(detect_disk_gb) +: "${MEM_MB:=0}" +: "${DISK_GB:=0}" + +LOW_MEM=false; LOW_DISK=false +[ "$MEM_MB" -gt 0 ] && [ "$MEM_MB" -lt "$MIN_MEM_MB" ] && LOW_MEM=true +[ "$DISK_GB" -gt 0 ] && [ "$DISK_GB" -lt "$MIN_DISK_GB" ] && LOW_DISK=true + +if [ "$LOW_MEM" = true ] || [ "$LOW_DISK" = true ]; then + printf ' %s\n' "$(red 'Warning: this machine likely cannot run NanoClaw.')" + printf ' %s\n' "$(dim 'NanoClaw recommends a 4 GB+ machine with 20 GB+ free disk. Below this,')" + printf ' %s\n' "$(dim 'the host + agent container will run out of memory or disk under most')" + printf ' %s\n' "$(dim 'workloads. A stronger machine is strongly recommended.')" + [ "$LOW_MEM" = true ] && printf ' %s\n' "$(dim " · Detected RAM: ${MEM_MB} MB")" + [ "$LOW_DISK" = true ] && printf ' %s\n' "$(dim " · Free disk on $PROJECT_ROOT: ${DISK_GB} GB")" + printf '\n' + read -r -p " $(bold 'Try anyway?') [y/N] " SPECS_ANS Date: Sat, 2 May 2026 07:48:41 -0700 Subject: [PATCH 141/144] Add namespacedPlatformId exclusion for DeltaChat (cherry picked from commit 5987fdc189f60b5afa76fd08c3d01ccc8a7a3a43) --- src/platform-id.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/platform-id.ts b/src/platform-id.ts index 1c49325..dfd5568 100644 --- a/src/platform-id.ts +++ b/src/platform-id.ts @@ -9,15 +9,17 @@ * 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. + * Native adapters (Signal, WhatsApp, iMessage, DeltaChat) 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. DeltaChat emits numeric chat IDs + * ('12'). 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; + if (channel === 'deltachat') return raw; return `${channel}:${raw}`; } From 251b31cd7847bf4c1faf610b1f1c954d64931852 Mon Sep 17 00:00:00 2001 From: koshkoshinsk Date: Mon, 4 May 2026 14:27:07 +0000 Subject: [PATCH 142/144] feat(setup): warn when running on a Google Compute Engine VM NanoClaw is known to not run reliably on GCE instances. Detect via DMI during pre-flight (between the spec check and root warning) and let the user abort before sinking time into bootstrap. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/nanoclaw.sh b/nanoclaw.sh index c2b2614..c17966e 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -200,6 +200,33 @@ if [ "$LOW_MEM" = true ] || [ "$LOW_DISK" = true ]; then esac fi +# ─── pre-flight: Google Cloud VM warning (Linux) ────────────────────── +# NanoClaw is known to not run reliably on Google Compute Engine instances. +# Warn early — before the root check or bootstrap spinner — so users can +# switch providers before sinking time into setup. Detection uses DMI +# (no network round-trip), which on GCE reports "Google" / "Google +# Compute Engine". +if [ "$(uname -s)" = "Linux" ] \ + && { grep -qi 'Google' /sys/class/dmi/id/product_name 2>/dev/null \ + || grep -qi 'Google' /sys/class/dmi/id/sys_vendor 2>/dev/null; }; then + printf ' %s\n' "$(red 'Warning: Google Cloud VM detected.')" + printf ' %s\n' "$(dim 'Google blocks sudo commands, so NanoClaw is unlikely to run successfully on this VM.')" + printf ' %s\n\n' "$(dim 'If you want to run NanoClaw successfully, switch to a different provider (Hetzner, Hostinger, exe.dev and others..).')" + read -r -p " $(bold 'Try anyway?') [y/N] " GCE_ANS Date: Mon, 4 May 2026 15:31:09 +0000 Subject: [PATCH 143/144] chore: bump version to 2.0.29 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f305bec..032c7f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.28", + "version": "2.0.29", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 1404f7feb632fca83dcd0cfe81a09d5be7763dc1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 May 2026 15:32:34 +0000 Subject: [PATCH 144/144] chore: bump version to 2.0.30 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 032c7f8..f92ed88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.29", + "version": "2.0.30", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0",