mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ae66624eb | |||
| 7e86f6c642 | |||
| f97cd4442a | |||
| d2e264a969 |
@@ -1,243 +0,0 @@
|
||||
---
|
||||
name: add-atomic-chat-tool
|
||||
description: Add Atomic Chat MCP server so the container agent can call local models served by the Atomic Chat desktop app via its OpenAI-compatible API.
|
||||
---
|
||||
|
||||
# Add Atomic Chat Integration
|
||||
|
||||
This skill adds a stdio-based MCP server that exposes models running in the local [Atomic Chat](https://github.com/AtomicBot-ai/Atomic-Chat) desktop app as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by Atomic Chat on `http://127.0.0.1:1337/v1` (OpenAI-compatible).
|
||||
|
||||
Tools exposed:
|
||||
- `atomic_chat_list_models` — list models currently available in Atomic Chat (`GET /v1/models`)
|
||||
- `atomic_chat_generate` — send a prompt to a specified model and return the response (`POST /v1/chat/completions`)
|
||||
|
||||
Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library.
|
||||
|
||||
The skill ships the MCP server source in this folder and copies it into the agent-runner tree at install time, then wires it up with small edits to `index.ts`, `providers/claude.ts`, and `container-runner.ts`. No branch merge — all edits are additive and idempotent.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Check if `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure).
|
||||
|
||||
### Check prerequisites
|
||||
|
||||
Verify Atomic Chat is installed and its local API server is running. On the host:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:1337/v1/models | head
|
||||
```
|
||||
|
||||
If the request fails:
|
||||
|
||||
1. Install Atomic Chat from the [latest release](https://github.com/AtomicBot-ai/Atomic-Chat/releases) (macOS only for now — `atomic-chat.dmg`).
|
||||
2. Open the app.
|
||||
3. Open **Settings → Local API Server** and make sure it's enabled on port `1337`.
|
||||
4. Go to the **Hub** (or **Models**) tab and download at least one model (e.g. Llama 3.2 3B, Qwen 2.5 Coder 7B).
|
||||
5. Load the model once by sending any message in Atomic Chat's UI to warm it up.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Copy the MCP server source
|
||||
|
||||
```bash
|
||||
cp .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts
|
||||
```
|
||||
|
||||
### Register the MCP server in the agent-runner
|
||||
|
||||
Edit `container/agent-runner/src/index.ts`. Find the `mcpServers` object that currently looks like this:
|
||||
|
||||
```ts
|
||||
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
|
||||
nanoclaw: {
|
||||
command: 'bun',
|
||||
args: ['run', mcpServerPath],
|
||||
env: {},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Add an `atomic_chat` entry alongside `nanoclaw`:
|
||||
|
||||
```ts
|
||||
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
|
||||
nanoclaw: {
|
||||
command: 'bun',
|
||||
args: ['run', mcpServerPath],
|
||||
env: {},
|
||||
},
|
||||
atomic_chat: {
|
||||
command: 'bun',
|
||||
args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')],
|
||||
env: {
|
||||
...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}),
|
||||
...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Add the tool glob to the allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in the `TOOL_ALLOWLIST` array and add `'mcp__atomic_chat__*',` on the following line:
|
||||
|
||||
```ts
|
||||
'mcp__nanoclaw__*',
|
||||
'mcp__atomic_chat__*',
|
||||
];
|
||||
```
|
||||
|
||||
### Forward host env vars into the container
|
||||
|
||||
Edit `src/container-runner.ts` in `buildContainerArgs`. Find the `TZ` env line:
|
||||
|
||||
```ts
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
```
|
||||
|
||||
Add ATOMIC_CHAT forwarding right after it:
|
||||
|
||||
```ts
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
|
||||
// Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337).
|
||||
if (process.env.ATOMIC_CHAT_HOST) {
|
||||
args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`);
|
||||
}
|
||||
if (process.env.ATOMIC_CHAT_API_KEY) {
|
||||
args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Surface `[ATOMIC]` log lines at info level
|
||||
|
||||
In the same file, find the stderr logger:
|
||||
|
||||
```ts
|
||||
container.stderr?.on('data', (data) => {
|
||||
for (const line of data.toString().trim().split('\n')) {
|
||||
if (line) log.debug(line, { container: agentGroup.folder });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```ts
|
||||
container.stderr?.on('data', (data) => {
|
||||
for (const line of data.toString().trim().split('\n')) {
|
||||
if (!line) continue;
|
||||
if (line.includes('[ATOMIC]')) {
|
||||
log.info(line, { container: agentGroup.folder });
|
||||
} else {
|
||||
log.debug(line, { container: agentGroup.folder });
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Add env-var stubs to `.env.example`
|
||||
|
||||
Append to `.env.example`:
|
||||
|
||||
```bash
|
||||
# Atomic Chat MCP tool (.claude/skills/add-atomic-chat-tool)
|
||||
# Override the host where Atomic Chat exposes its OpenAI-compatible API.
|
||||
# Default: http://host.docker.internal:1337 (with fallback to localhost)
|
||||
# ATOMIC_CHAT_HOST=http://host.docker.internal:1337
|
||||
|
||||
# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth.
|
||||
# ATOMIC_CHAT_API_KEY=
|
||||
```
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
All three must be clean before proceeding.
|
||||
|
||||
## Phase 3: Configure
|
||||
|
||||
### Set Atomic Chat host (optional)
|
||||
|
||||
By default, the MCP server connects to `http://host.docker.internal:1337` (Docker Desktop) with a fallback to `localhost`. To use a custom host, add to `.env`:
|
||||
|
||||
```bash
|
||||
ATOMIC_CHAT_HOST=http://your-atomic-chat-host:1337
|
||||
```
|
||||
|
||||
### Set API key (optional)
|
||||
|
||||
Atomic Chat does **not require authentication** when running locally — leave this unset. Only set it if you've put Atomic Chat behind a reverse proxy that enforces auth:
|
||||
|
||||
```bash
|
||||
ATOMIC_CHAT_API_KEY=sk-...
|
||||
```
|
||||
|
||||
### Restart the service
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
### Test inference
|
||||
|
||||
Tell the user:
|
||||
|
||||
> Send a message like: "use atomic chat to tell me the capital of France"
|
||||
>
|
||||
> The agent should use `atomic_chat_list_models` to find available models, then `atomic_chat_generate` to get a response.
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log | grep -i atomic
|
||||
```
|
||||
|
||||
Look for:
|
||||
- `[ATOMIC] Listing models...` — list request started
|
||||
- `[ATOMIC] Found N models` — models discovered
|
||||
- `[ATOMIC] >>> Generating with <model>` — generation started
|
||||
- `[ATOMIC] <<< Done: <model> | Xs | N tokens | M chars` — generation completed
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent says "Atomic Chat is not installed" or tries to run a CLI
|
||||
|
||||
The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means:
|
||||
1. The MCP server wasn't copied — check `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists
|
||||
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers`
|
||||
3. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST`
|
||||
4. The container wasn't rebuilt — run `./container/build.sh`
|
||||
|
||||
### "Failed to connect to Atomic Chat"
|
||||
|
||||
1. Verify the host API is reachable: `curl http://127.0.0.1:1337/v1/models`
|
||||
2. Confirm the Local API Server is enabled in Atomic Chat's settings
|
||||
3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:1337/v1/models`
|
||||
4. If using a custom host, check `ATOMIC_CHAT_HOST` in `.env`
|
||||
|
||||
### `model not found` / 404 on generate
|
||||
|
||||
The model ID passed to `atomic_chat_generate` must exactly match one of the IDs returned by `atomic_chat_list_models`. Ask the agent to list models first, then pick one from that list.
|
||||
|
||||
### Slow first response
|
||||
|
||||
Atomic Chat lazy-loads models into memory on first use. The initial call may take longer while the model warms up. Subsequent calls against the same model are fast.
|
||||
|
||||
### Agent doesn't use Atomic Chat tools
|
||||
|
||||
The agent may not know about the tools. Try being explicit: "use the atomic_chat_generate tool with llama3.2-3b-instruct to answer: ..."
|
||||
|
||||
### Context window or output size issues
|
||||
|
||||
Atomic Chat respects each model's native context length. If you hit limits, pass `max_tokens` explicitly when calling `atomic_chat_generate`, or switch to a model with a larger context window in the Atomic Chat UI.
|
||||
@@ -1,229 +0,0 @@
|
||||
/**
|
||||
* Atomic Chat MCP Server for NanoClaw
|
||||
* Exposes local Atomic Chat models (OpenAI-compatible, /v1) as tools for the container agent.
|
||||
* Uses host.docker.internal to reach the host's Atomic Chat desktop app from Docker.
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ATOMIC_CHAT_HOST =
|
||||
process.env.ATOMIC_CHAT_HOST || 'http://host.docker.internal:1337';
|
||||
const ATOMIC_CHAT_API_KEY = process.env.ATOMIC_CHAT_API_KEY || '';
|
||||
const ATOMIC_CHAT_STATUS_FILE = '/workspace/ipc/atomic_chat_status.json';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[ATOMIC] ${msg}`);
|
||||
}
|
||||
|
||||
function writeStatus(status: string, detail?: string): void {
|
||||
try {
|
||||
const data = { status, detail, timestamp: new Date().toISOString() };
|
||||
const tmpPath = `${ATOMIC_CHAT_STATUS_FILE}.tmp`;
|
||||
fs.mkdirSync(path.dirname(ATOMIC_CHAT_STATUS_FILE), { recursive: true });
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(data));
|
||||
fs.renameSync(tmpPath, ATOMIC_CHAT_STATUS_FILE);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
async function atomicFetch(
|
||||
apiPath: string,
|
||||
options?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const url = `${ATOMIC_CHAT_HOST}${apiPath}`;
|
||||
const headers: Record<string, string> = {
|
||||
...((options?.headers as Record<string, string>) || {}),
|
||||
};
|
||||
if (ATOMIC_CHAT_API_KEY) {
|
||||
headers.Authorization = `Bearer ${ATOMIC_CHAT_API_KEY}`;
|
||||
}
|
||||
const finalOptions: RequestInit = { ...options, headers };
|
||||
try {
|
||||
return await fetch(url, finalOptions);
|
||||
} catch (err) {
|
||||
// Fallback to localhost if host.docker.internal fails
|
||||
if (ATOMIC_CHAT_HOST.includes('host.docker.internal')) {
|
||||
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
|
||||
return await fetch(fallbackUrl, finalOptions);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'atomic_chat',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'atomic_chat_list_models',
|
||||
'List all models available in the local Atomic Chat desktop app. Use this to see which models are loaded before calling atomic_chat_generate.',
|
||||
{},
|
||||
async () => {
|
||||
log('Listing models...');
|
||||
writeStatus('listing', 'Listing available models');
|
||||
try {
|
||||
const res = await atomicFetch('/v1/models');
|
||||
if (!res.ok) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Atomic Chat API error: ${res.status} ${res.statusText}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
data?: Array<{ id: string; owned_by?: string }>;
|
||||
};
|
||||
const models = data.data || [];
|
||||
|
||||
if (models.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'No models available. Open Atomic Chat on the host and download a model from the Hub.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const list = models
|
||||
.map((m) => `- ${m.id}${m.owned_by ? ` (${m.owned_by})` : ''}`)
|
||||
.join('\n');
|
||||
|
||||
log(`Found ${models.length} models`);
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Available models:\n${list}` },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to connect to Atomic Chat at ${ATOMIC_CHAT_HOST}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'atomic_chat_generate',
|
||||
'Send a prompt to a local Atomic Chat model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use atomic_chat_list_models first to see available models.',
|
||||
{
|
||||
model: z
|
||||
.string()
|
||||
.describe(
|
||||
'The model ID as returned by atomic_chat_list_models (e.g. "llama3.2-3b-instruct")',
|
||||
),
|
||||
prompt: z.string().describe('The prompt to send to the model'),
|
||||
system: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional system prompt to set model behavior'),
|
||||
temperature: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Sampling temperature (0.0–2.0). Defaults to model default.'),
|
||||
max_tokens: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Maximum number of tokens to generate in the response.'),
|
||||
},
|
||||
async (args) => {
|
||||
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
|
||||
writeStatus('generating', `Generating with ${args.model}`);
|
||||
try {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
if (args.system) {
|
||||
messages.push({ role: 'system', content: args.system });
|
||||
}
|
||||
messages.push({ role: 'user', content: args.prompt });
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: args.model,
|
||||
messages,
|
||||
stream: false,
|
||||
};
|
||||
if (args.temperature !== undefined) body.temperature = args.temperature;
|
||||
if (args.max_tokens !== undefined) body.max_tokens = args.max_tokens;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const res = await atomicFetch('/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Atomic Chat error (${res.status}): ${errorText}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const response = data.choices?.[0]?.message?.content ?? '';
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
const completionTokens = data.usage?.completion_tokens;
|
||||
|
||||
const meta = `\n\n[${args.model} | ${elapsedSec}s${
|
||||
completionTokens !== undefined ? ` | ${completionTokens} tokens` : ''
|
||||
}]`;
|
||||
|
||||
log(
|
||||
`<<< Done: ${args.model} | ${elapsedSec}s | ${
|
||||
completionTokens ?? '?'
|
||||
} tokens | ${response.length} chars`,
|
||||
);
|
||||
writeStatus(
|
||||
'done',
|
||||
`${args.model} | ${elapsedSec}s | ${completionTokens ?? '?'} tokens`,
|
||||
);
|
||||
|
||||
return { content: [{ type: 'text' as const, text: response + meta }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to call Atomic Chat: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
@@ -1,161 +0,0 @@
|
||||
---
|
||||
name: add-codex
|
||||
description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
|
||||
---
|
||||
|
||||
# Codex agent provider
|
||||
|
||||
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`).
|
||||
|
||||
Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image.
|
||||
|
||||
The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight
|
||||
|
||||
If all of the following are already present, skip to **Configuration**:
|
||||
|
||||
- `src/providers/codex.ts`
|
||||
- `container/agent-runner/src/providers/codex.ts`
|
||||
- `container/agent-runner/src/providers/codex-app-server.ts`
|
||||
- `container/agent-runner/src/providers/codex.factory.test.ts`
|
||||
- `import './codex.js';` line in `src/providers/index.ts`
|
||||
- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts`
|
||||
- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile`
|
||||
|
||||
Missing pieces — continue below. All steps are idempotent; re-running is safe.
|
||||
|
||||
### 1. Fetch the providers branch
|
||||
|
||||
```bash
|
||||
git fetch origin providers
|
||||
```
|
||||
|
||||
### 2. Copy the Codex source files
|
||||
|
||||
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
|
||||
|
||||
```bash
|
||||
git show origin/providers:src/providers/codex.ts > src/providers/codex.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration imports
|
||||
|
||||
Each barrel gets one line — alphabetical placement keeps diffs small.
|
||||
|
||||
`src/providers/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
`container/agent-runner/src/providers/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
### 4. Add the Codex CLI to the container Dockerfile
|
||||
|
||||
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
|
||||
|
||||
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`:
|
||||
|
||||
```dockerfile
|
||||
ARG CODEX_VERSION=0.124.0
|
||||
```
|
||||
|
||||
**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@openai/codex@${CODEX_VERSION}"
|
||||
```
|
||||
|
||||
Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`.
|
||||
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build # host
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
|
||||
./container/build.sh # agent image
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup.
|
||||
|
||||
### Option A — ChatGPT subscription (recommended for individuals)
|
||||
|
||||
On the host (not inside the container), run Codex's OAuth login:
|
||||
|
||||
```bash
|
||||
codex login
|
||||
```
|
||||
|
||||
This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched.
|
||||
|
||||
No `.env` variables required for this mode.
|
||||
|
||||
### Option B — API key (recommended for CI or API billing)
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=sk-...
|
||||
CODEX_MODEL=gpt-5.4-mini
|
||||
```
|
||||
|
||||
The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription.
|
||||
|
||||
### Option C — BYO OpenAI-compatible endpoint (experimental)
|
||||
|
||||
Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc.
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=...
|
||||
OPENAI_BASE_URL=https://api.groq.com/openai/v1
|
||||
CODEX_MODEL=llama-3.3-70b-versatile
|
||||
```
|
||||
|
||||
Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration.
|
||||
|
||||
**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing.
|
||||
|
||||
### Per group / per session
|
||||
|
||||
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
|
||||
|
||||
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
|
||||
|
||||
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions.
|
||||
- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config.
|
||||
- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error.
|
||||
- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode).
|
||||
- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped.
|
||||
- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
grep -q "./codex.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK"
|
||||
grep -q "./codex.js" src/providers/index.ts && echo "host barrel: OK"
|
||||
grep -q "@openai/codex@" container/Dockerfile && echo "Dockerfile install: OK"
|
||||
cd container/agent-runner && bun test src/providers/codex.factory.test.ts && cd -
|
||||
```
|
||||
|
||||
After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. Successful round-trip looks like:
|
||||
|
||||
- `init` event with a stable thread ID as continuation
|
||||
- One or more `activity` / `progress` events during the turn
|
||||
- `result` event with the model's reply
|
||||
|
||||
If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm.
|
||||
@@ -1,62 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,254 +0,0 @@
|
||||
---
|
||||
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
|
||||
pnpm exec tsx scripts/q.ts 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 <platform_id from above> \
|
||||
--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: `pnpm exec tsx scripts/q.ts 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
|
||||
pnpm exec tsx scripts/q.ts 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`.
|
||||
@@ -1,54 +0,0 @@
|
||||
# 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
|
||||
pnpm exec tsx scripts/q.ts 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
|
||||
pnpm exec tsx scripts/q.ts 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.
|
||||
@@ -44,7 +44,7 @@ import './discord.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/discord@4.27.0
|
||||
pnpm install @chat-adapter/discord@4.26.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -241,7 +241,7 @@ grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "
|
||||
### No response from agent
|
||||
|
||||
1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
||||
2. Messaging group wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"`
|
||||
2. Messaging group wired: `sqlite3 data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"`
|
||||
3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20`
|
||||
|
||||
If no messaging group row exists, run the `register` command above.
|
||||
@@ -292,5 +292,5 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
|
||||
# Remove the NanoClaw block from your Emacs config
|
||||
# Optionally clean up the messaging group:
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
|
||||
sqlite3 data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
|
||||
```
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
---
|
||||
name: add-gcal-tool
|
||||
description: Add Google Calendar as an MCP tool (list calendars, list/search/create events, free/busy queries) using OneCLI-managed OAuth. Multi-calendar and multi-account supported. Mirrors /add-gmail-tool's stub pattern — no raw credentials ever reach the container; OneCLI injects real tokens at request time.
|
||||
---
|
||||
|
||||
# Add Google Calendar Tool (OneCLI-native)
|
||||
|
||||
This skill wires [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `calendar.googleapis.com` / `oauth2.googleapis.com` and swaps the bearer for the real OAuth token from its vault.
|
||||
|
||||
**Why this package (and not gongrzhe's):** `@gongrzhe/server-calendar-autoauth-mcp` only supports the `primary` calendar and exposes 5 tools (no `list_calendars`). `@cocal/google-calendar-mcp` explicitly supports multi-calendar and multi-account, and is actively maintained.
|
||||
|
||||
Tools exposed (surfaced as `mcp__calendar__<name>`, exact set depends on version — run `tools/list` against the MCP server to enumerate): `list-calendars`, `list-events`, `search-events`, `create-event`, `update-event`, `delete-event`, `get-event`, `list-colors`, `get-freebusy`, `get-current-time`, plus multi-account management tools.
|
||||
|
||||
**Why this pattern:** v2's invariant is that containers never receive raw API keys (CHANGELOG 2.0.0). Same stub pattern `/add-gmail-tool` uses. This skill is deliberately a sibling, not a combined "Google Workspace" skill — installs independently and removes cleanly.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Verify OneCLI has Google Calendar connected
|
||||
|
||||
```bash
|
||||
onecli apps get --provider google-calendar
|
||||
```
|
||||
|
||||
Expected: `"connection": { "status": "connected" }` with scopes including `calendar.readonly` and `calendar.events`.
|
||||
|
||||
If not connected, tell the user:
|
||||
|
||||
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Google Calendar, and click Connect. Sign in with the Google account the agent should act as. `calendar.readonly` + `calendar.events` are the minimum useful scopes.
|
||||
|
||||
### Verify stub credentials exist
|
||||
|
||||
The stub lives at `~/.calendar-mcp/` by convention (shared with `/add-gmail-tool`'s sibling). cocal doesn't default to this path (it uses `~/.config/google-calendar-mcp/tokens.json`) — we override via env vars below so it reads our stubs instead.
|
||||
|
||||
```bash
|
||||
ls -la ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json 2>&1
|
||||
```
|
||||
|
||||
If both exist with `onecli-managed`:
|
||||
|
||||
```bash
|
||||
grep -l onecli-managed ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json
|
||||
```
|
||||
|
||||
...skip to Phase 2. If either file has real credentials (no `onecli-managed`), **STOP** — back up and delete before proceeding.
|
||||
|
||||
If absent, write them:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.calendar-mcp
|
||||
cat > ~/.calendar-mcp/gcp-oauth.keys.json <<'EOF'
|
||||
{
|
||||
"installed": {
|
||||
"client_id": "onecli-managed.apps.googleusercontent.com",
|
||||
"client_secret": "onecli-managed",
|
||||
"redirect_uris": ["http://localhost:3000/oauth2callback"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cat > ~/.calendar-mcp/credentials.json <<'EOF'
|
||||
{
|
||||
"access_token": "onecli-managed",
|
||||
"refresh_token": "onecli-managed",
|
||||
"token_type": "Bearer",
|
||||
"expiry_date": 99999999999999,
|
||||
"scope": "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events"
|
||||
}
|
||||
EOF
|
||||
chmod 600 ~/.calendar-mcp/*.json
|
||||
```
|
||||
|
||||
### Verify mount allowlist covers the path
|
||||
|
||||
```bash
|
||||
cat ~/.config/nanoclaw/mount-allowlist.json
|
||||
```
|
||||
|
||||
`~/.calendar-mcp` must sit under an `allowedRoots` entry.
|
||||
|
||||
### Check agent secret-mode
|
||||
|
||||
For each target agent group, confirm OneCLI will inject the Google Calendar token:
|
||||
|
||||
```bash
|
||||
onecli agents list
|
||||
```
|
||||
|
||||
`secretMode: all` is sufficient. If `selective`, explicitly assign the Calendar secret.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
grep -q 'CALENDAR_MCP_VERSION' container/Dockerfile && \
|
||||
grep -q "mcp__calendar__\*" container/agent-runner/src/providers/claude.ts && \
|
||||
echo "ALREADY APPLIED — skip to Phase 3"
|
||||
```
|
||||
|
||||
### Add MCP server to Dockerfile
|
||||
|
||||
Edit `container/Dockerfile`. Find the pinned-version ARG block and add:
|
||||
|
||||
```dockerfile
|
||||
ARG CALENDAR_MCP_VERSION=2.6.1
|
||||
```
|
||||
|
||||
If `/add-gmail-tool` has already been applied, the pnpm global-install block already exists with its `zod-to-json-schema@3.22.5` pin. Just append the calendar package — **the calendar-mcp uses `zod@4.x` and does NOT need that pin**, but it's harmless to share the block:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g \
|
||||
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
|
||||
"@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}" \
|
||||
"zod-to-json-schema@3.22.5"
|
||||
```
|
||||
|
||||
If `/add-gmail-tool` hasn't been applied, install Calendar standalone:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
|
||||
```
|
||||
|
||||
### Add tools to allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present).
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
## Phase 3: Wire Per-Agent-Group
|
||||
|
||||
For each agent group, merge into `groups/<folder>/container.json`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"calendar": {
|
||||
"command": "google-calendar-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"GOOGLE_OAUTH_CREDENTIALS": "/workspace/extra/.calendar-mcp/gcp-oauth.keys.json",
|
||||
"GOOGLE_CALENDAR_MCP_TOKEN_PATH": "/workspace/extra/.calendar-mcp/credentials.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalMounts": [
|
||||
{
|
||||
"hostPath": "/home/<user>/.calendar-mcp",
|
||||
"containerPath": ".calendar-mcp",
|
||||
"readonly": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Substitute `<user>` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
|
||||
|
||||
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, **merge, don't replace** — both entries coexist in `mcpServers` and `additionalMounts`.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
Kill any existing agent containers so they respawn with the new mcpServers config:
|
||||
|
||||
```bash
|
||||
docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill
|
||||
```
|
||||
|
||||
## Phase 5: Verify
|
||||
|
||||
### Test from a wired agent
|
||||
|
||||
> Send: **"list my calendars"** or **"what's on my work calendar next Monday?"**.
|
||||
>
|
||||
> First call takes 2–3s while the MCP server starts and OneCLI does the token exchange.
|
||||
|
||||
### Check logs if the tool isn't working
|
||||
|
||||
```bash
|
||||
tail -100 logs/nanoclaw.log | grep -iE 'calendar|mcp'
|
||||
```
|
||||
|
||||
Common signals:
|
||||
- `command not found: google-calendar-mcp` → image not rebuilt.
|
||||
- `ENOENT ...credentials.json` → mount missing. Check the mount allowlist.
|
||||
- `401 Unauthorized` from `*.googleapis.com` → OneCLI isn't injecting; verify agent's secret mode and that Google Calendar is connected.
|
||||
- Agent says "I don't have calendar tools" → `mcp__calendar__*` missing from `TOOL_ALLOWLIST`, or image cache stale (`./container/build.sh` again).
|
||||
|
||||
## Removal
|
||||
|
||||
1. Delete `"calendar"` from `mcpServers` and the `.calendar-mcp` mount from `additionalMounts` in each group's `container.json`.
|
||||
2. Remove `'mcp__calendar__*'` from `TOOL_ALLOWLIST`.
|
||||
3. Remove `CALENDAR_MCP_VERSION` ARG and the calendar package from the Dockerfile install block.
|
||||
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
|
||||
5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`.
|
||||
|
||||
## Credits & references
|
||||
|
||||
- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar.
|
||||
- **Why not gongrzhe:** earlier versions of this skill used `@gongrzhe/server-calendar-autoauth-mcp@1.0.2` which only supports the primary calendar with 5 event-level tools. The cocal server supersedes it.
|
||||
- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism.
|
||||
@@ -44,7 +44,7 @@ import './gchat.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat@4.27.0
|
||||
pnpm install @chat-adapter/gchat@4.26.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -48,7 +48,7 @@ import './github.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/github@4.27.0
|
||||
pnpm install @chat-adapter/github@4.26.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
---
|
||||
name: add-gmail-tool
|
||||
description: Add Gmail as an MCP tool (read, search, send, label, draft) using OneCLI-managed OAuth. The agent gets Gmail tools in every enabled group; OneCLI injects real tokens at request time so no raw credentials are ever in the container or on disk in usable form.
|
||||
---
|
||||
|
||||
# Add Gmail Tool (OneCLI-native)
|
||||
|
||||
This skill wires the [`@gongrzhe/server-gmail-autoauth-mcp`](https://www.npmjs.com/package/@gongrzhe/server-gmail-autoauth-mcp) stdio MCP server into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `gmail.googleapis.com` and injects the real OAuth bearer from its vault.
|
||||
|
||||
Tools exposed (from `gmail-mcp@1.1.11`, surfaced to the agent as `mcp__gmail__<name>`): `search_emails`, `read_email`, `send_email`, `draft_email`, `delete_email`, `modify_email`, `batch_modify_emails`, `batch_delete_emails`, `download_attachment`, `list_email_labels`, `create_label`, `update_label`, `delete_label`, `get_or_create_label`, `list_filters`, `get_filter`, `create_filter`, `create_filter_from_template`, `delete_filter`.
|
||||
|
||||
**Why this pattern:** v2's invariant is that containers never receive raw API keys — OneCLI is the sole credential path (see CHANGELOG v2.0.0). The stub-file pattern satisfies this: the container sees `"onecli-managed"` placeholders, the gateway swaps them in flight.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Verify OneCLI has Gmail connected
|
||||
|
||||
```bash
|
||||
onecli apps get --provider gmail
|
||||
```
|
||||
|
||||
Expected: `"connection": { "status": "connected" }` with scopes including `gmail.readonly`, `gmail.modify`, `gmail.send`.
|
||||
|
||||
If not connected, tell the user:
|
||||
|
||||
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Gmail, and click Connect. Sign in with the Google account you want the agent to act as.
|
||||
|
||||
### Verify stub credentials exist
|
||||
|
||||
```bash
|
||||
ls -la ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json 2>&1
|
||||
```
|
||||
|
||||
If both exist and contain `"onecli-managed"`:
|
||||
|
||||
```bash
|
||||
grep -l onecli-managed ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
|
||||
```
|
||||
|
||||
...skip to Phase 2.
|
||||
|
||||
If either file exists but does **not** contain `onecli-managed`, **STOP** and tell the user — these are real OAuth credentials from a previous non-OneCLI install. Back them up, then delete before proceeding. The OneCLI migration normally handles this; if it didn't, something is wrong.
|
||||
|
||||
If both files are absent, write them now:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.gmail-mcp
|
||||
cat > ~/.gmail-mcp/gcp-oauth.keys.json <<'EOF'
|
||||
{
|
||||
"installed": {
|
||||
"client_id": "onecli-managed.apps.googleusercontent.com",
|
||||
"client_secret": "onecli-managed",
|
||||
"redirect_uris": ["http://localhost:3000/oauth2callback"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cat > ~/.gmail-mcp/credentials.json <<'EOF'
|
||||
{
|
||||
"access_token": "onecli-managed",
|
||||
"refresh_token": "onecli-managed",
|
||||
"token_type": "Bearer",
|
||||
"expiry_date": 99999999999999,
|
||||
"scope": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send"
|
||||
}
|
||||
EOF
|
||||
chmod 600 ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
|
||||
```
|
||||
|
||||
### Verify mount allowlist covers the path
|
||||
|
||||
```bash
|
||||
cat ~/.config/nanoclaw/mount-allowlist.json
|
||||
```
|
||||
|
||||
`~/.gmail-mcp` must sit under an `allowedRoots` entry (e.g. `/home/<user>`). If it doesn't, tell the user to run `/manage-mounts` first or add their home directory.
|
||||
|
||||
### Check agent secret-mode
|
||||
|
||||
For each target agent group, confirm OneCLI will inject Gmail secrets into its container. Find the OneCLI agent ID that matches the group's `agentGroupId`:
|
||||
|
||||
```bash
|
||||
onecli agents list
|
||||
```
|
||||
|
||||
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets using the safe merge pattern (`set-secrets` replaces the entire list — always read first):
|
||||
|
||||
```bash
|
||||
GMAIL_IDS=$(onecli secrets list | jq -r '[.data[] | select(.name | test("(?i)gmail")) | .id] | join(",")')
|
||||
CURRENT=$(onecli agents secrets --id <agent-id> | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,$GMAIL_IDS" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids "$MERGED"
|
||||
onecli agents secrets --id <agent-id>
|
||||
```
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \
|
||||
grep -q "mcp__gmail__\*" container/agent-runner/src/providers/claude.ts && \
|
||||
echo "ALREADY APPLIED — skip to Phase 3"
|
||||
```
|
||||
|
||||
### Add MCP server to Dockerfile
|
||||
|
||||
Edit `container/Dockerfile`. Find the pinned-version ARG block:
|
||||
|
||||
```dockerfile
|
||||
ARG CLAUDE_CODE_VERSION=2.1.116
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=latest
|
||||
ARG BUN_VERSION=1.3.12
|
||||
```
|
||||
|
||||
Add a new line:
|
||||
|
||||
```dockerfile
|
||||
ARG GMAIL_MCP_VERSION=1.1.11
|
||||
```
|
||||
|
||||
Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block after it, before `# ---- Entrypoint`:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g \
|
||||
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
|
||||
"zod-to-json-schema@3.22.5"
|
||||
```
|
||||
|
||||
Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates trunk installs, and CLAUDE.md requires a fixed ARG version for all Node CLIs installed into the image.
|
||||
|
||||
**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`.
|
||||
|
||||
### Add tools to allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it.
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cached on rebuild).
|
||||
|
||||
## Phase 3: Wire Per-Agent-Group
|
||||
|
||||
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), edit `groups/<folder>/container.json` to add the mount and MCP server.
|
||||
|
||||
Merge these into the group's `container.json`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"gmail": {
|
||||
"command": "gmail-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"GMAIL_OAUTH_PATH": "/workspace/extra/.gmail-mcp/gcp-oauth.keys.json",
|
||||
"GMAIL_CREDENTIALS_PATH": "/workspace/extra/.gmail-mcp/credentials.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalMounts": [
|
||||
{
|
||||
"hostPath": "/home/<user>/.gmail-mcp",
|
||||
"containerPath": ".gmail-mcp",
|
||||
"readonly": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Substitute `<user>` with the host user's home (use `echo $HOME`, don't assume `~` will expand — `container-runner.ts` does expand `~` via `expandPath`, but an explicit absolute path is clearer and matches what `/manage-mounts` writes).
|
||||
|
||||
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
## Phase 5: Verify
|
||||
|
||||
### Test from the wired agent
|
||||
|
||||
Tell the user:
|
||||
|
||||
> In your `<agent-name>` chat, send: **"list my gmail labels"** or **"search my inbox for invoices from last month"**.
|
||||
>
|
||||
> The agent should use `mcp__gmail__list_labels` / `mcp__gmail__search`. The first call may take a second or two while the MCP server starts and OneCLI does the token exchange.
|
||||
|
||||
### Check logs if the tool isn't working
|
||||
|
||||
```bash
|
||||
tail -100 logs/nanoclaw.log logs/nanoclaw.error.log | grep -iE 'gmail|mcp'
|
||||
# Per-container logs — session-scoped:
|
||||
ls data/v2-sessions/*/stderr.log | head
|
||||
```
|
||||
|
||||
Common signals:
|
||||
- `command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile).
|
||||
- `ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`.
|
||||
- `401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id <agent-id>`) and that the Gmail app is connected (`onecli apps get --provider gmail`).
|
||||
- Agent says "I don't have Gmail tools" → `mcp__gmail__*` wasn't added to `TOOL_ALLOWLIST`, or the agent-runner wasn't rebuilt (image cache — run `./container/build.sh` again with `--no-cache` if suspicious).
|
||||
|
||||
## Removal
|
||||
|
||||
1. Delete the `"gmail"` entry from `mcpServers` and the `.gmail-mcp` entry from `additionalMounts` in each group's `container.json`.
|
||||
2. Remove `'mcp__gmail__*'` from `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`.
|
||||
3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`.
|
||||
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
|
||||
5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs.
|
||||
6. (Optional) Disconnect Gmail in OneCLI: `onecli apps disconnect --provider gmail`.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes.
|
||||
- **Scopes are set at OAuth connect time.** If the agent needs scopes beyond what's currently connected (e.g. the user later wants `calendar.readonly` for combined email/calendar workflows), disconnect and reconnect Gmail in the OneCLI web UI with the expanded scope set.
|
||||
- **This is tool-only.** Inbound email as a channel (emails trigger the agent) is a separate piece of work — it needs a `src/channels/gmail.ts` adapter that polls the inbox and routes to a messaging group. The pre-v2 qwibitai skill had this; it has not been ported to v2's channel architecture as of v2.0.0.
|
||||
|
||||
## Credits & references
|
||||
|
||||
- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed.
|
||||
- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`.
|
||||
- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md).
|
||||
- **Addresses:** [issue #1500](https://github.com/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
|
||||
- **Related PRs:** [#1810](https://github.com/qwibitai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
|
||||
@@ -71,11 +71,38 @@ AskUserQuestion: "Want periodic wiki health checks?"
|
||||
2. **Monthly**
|
||||
3. **Skip** — lint manually
|
||||
|
||||
If yes, ask the agent to schedule the lint task using the `schedule_task` MCP tool in conversation.
|
||||
|
||||
## Step 6: Restart
|
||||
If yes, create a NanoClaw scheduled task that runs in the wiki group. This is NOT a Claude Code cron job — it's a NanoClaw group task that runs in the agent container. Insert it into the SQLite database:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx -e "
|
||||
const Database = require('better-sqlite3');
|
||||
const { CronExpressionParser } = require('cron-parser');
|
||||
const db = new Database('store/messages.db');
|
||||
const interval = CronExpressionParser.parse('<cron-expr>', { tz: process.env.TZ || 'UTC' });
|
||||
const nextRun = interval.next().toISOString();
|
||||
db.prepare('INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(
|
||||
'wiki-lint',
|
||||
'<group_folder>',
|
||||
'<chat_jid>',
|
||||
'Run a wiki lint pass per the wiki container skill. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings and offer to fix issues.',
|
||||
'cron',
|
||||
'<cron-expr>',
|
||||
'group',
|
||||
nextRun,
|
||||
'active',
|
||||
new Date().toISOString()
|
||||
);
|
||||
db.close();
|
||||
"
|
||||
```
|
||||
|
||||
Use the group's `folder` and `chat_jid` from the registered groups table. Cron expressions: `0 10 * * 0` (weekly Sunday 10am) or `0 10 1 * *` (monthly 1st at 10am).
|
||||
|
||||
## Step 6: Build and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
./container/build.sh
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
@@ -87,7 +87,7 @@ Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler n
|
||||
### 5. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.27.0
|
||||
pnpm install @chat-adapter/linear@4.26.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
---
|
||||
name: add-mnemon
|
||||
description: Add persistent graph-based memory via mnemon. Agents recall past context before responding and remember insights after each turn.
|
||||
---
|
||||
|
||||
# Add Mnemon — Persistent Memory
|
||||
|
||||
Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container image. On each container start, `mnemon setup` registers Claude Code hooks that surface relevant memory before the agent responds and store new insights after each turn. Memory is written to the per-agent-group `.claude/` mount and survives container restarts.
|
||||
|
||||
## Provider Compatibility
|
||||
|
||||
**mnemon hooks only work with `--target claude-code`.** If the agent group uses `AGENT_PROVIDER=opencode`, hooks registered by `mnemon setup` will never fire — OpenCode spawns its own process and doesn't invoke the `claude` CLI at all.
|
||||
|
||||
Check your provider:
|
||||
|
||||
```bash
|
||||
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
|
||||
```
|
||||
|
||||
- `AGENT_PROVIDER=claude` (default) — fully compatible, proceed with both Phase 2 steps.
|
||||
- `AGENT_PROVIDER=opencode` — use **Phase 2 (OpenCode path)** instead of the standard entrypoint step.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
grep -q 'MNEMON_VERSION' container/Dockerfile && echo "Already applied" || echo "Not applied"
|
||||
```
|
||||
|
||||
If already applied, skip to Phase 3 (Verify).
|
||||
|
||||
### Check latest mnemon version
|
||||
|
||||
```bash
|
||||
curl -fsSL https://api.github.com/repos/mnemon-dev/mnemon/releases/latest | grep '"tag_name"'
|
||||
```
|
||||
|
||||
Note the version (e.g. `v0.1.1`) — use it as `MNEMON_VERSION` in the next step.
|
||||
|
||||
## Phase 2: Apply Changes (Claude Code path)
|
||||
|
||||
### 1. Dockerfile — install mnemon binary
|
||||
|
||||
Add after the AWS CLI block, before the Bun runtime section:
|
||||
|
||||
```dockerfile
|
||||
# ---- mnemon — persistent agent memory ----------------------------------------
|
||||
ARG MNEMON_VERSION=0.1.1
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin mnemon && \
|
||||
chmod +x /usr/local/bin/mnemon
|
||||
|
||||
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
|
||||
```
|
||||
|
||||
`MNEMON_DATA_DIR` points into the per-agent-group `.claude/` mount so memory persists across container restarts. No extra volume mounts needed.
|
||||
|
||||
### 2. Entrypoint — run mnemon setup on each container start
|
||||
|
||||
`mnemon setup` is idempotent. Edit `container/entrypoint.sh` to run it right after `set -e`, before the `cat` that captures stdin:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# NanoClaw agent container entrypoint.
|
||||
#
|
||||
# ...existing header comment...
|
||||
|
||||
set -e
|
||||
|
||||
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
|
||||
|
||||
cat > /tmp/input.json
|
||||
|
||||
exec bun run /app/src/index.ts < /tmp/input.json
|
||||
```
|
||||
|
||||
`>/dev/stderr 2>&1` routes all mnemon output to stderr (docker logs) so it doesn't interfere with the JSON stdin handshake between host and agent-runner.
|
||||
|
||||
### 3. Rebuild and smoke-test the image
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
|
||||
```
|
||||
|
||||
## Phase 3: Restart and Verify
|
||||
|
||||
### Restart the service
|
||||
|
||||
```bash
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
### Confirm mnemon hooks are registered
|
||||
|
||||
After the next container starts, check that setup ran:
|
||||
|
||||
```bash
|
||||
docker logs $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) 2>&1 | grep -i mnemon
|
||||
```
|
||||
|
||||
Then inspect the hooks inside the running container:
|
||||
|
||||
```bash
|
||||
docker exec $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
|
||||
cat /home/node/.claude/settings.json | grep -A5 mnemon
|
||||
```
|
||||
|
||||
### Test memory recall
|
||||
|
||||
Have a conversation with the agent, then start a new session and reference something from the earlier one. Mnemon should surface the relevant context automatically without you restating it.
|
||||
|
||||
## Phase 2 (OpenCode path) — context injection
|
||||
|
||||
mnemon hooks don't fire under OpenCode. Instead, the agent-runner injects mnemon context directly into every prompt via `wrapPromptWithContext()` in `container/agent-runner/src/providers/opencode.ts`. This is already implemented in NanoClaw — no code changes needed if you're on current `ester`/`main`.
|
||||
|
||||
**How it works:** On each prompt, `readMnemonContext()` checks for `MNEMON_DATA_DIR` (set by the Dockerfile `ENV`). If the env var is present, it reads `$MNEMON_DATA_DIR/prompt/guide.md` (mnemon's custom prompt guide, written by `mnemon setup`) or falls back to an inline guide. The content is prepended as a `<system>` block, instructing the agent to run `mnemon recall` at the start of relevant tasks and `mnemon remember` after key decisions.
|
||||
|
||||
**What this means for the agent:** The agent (running inside OpenCode) can call `mnemon recall`, `mnemon remember`, `mnemon link`, and `mnemon status` via its bash tool. mnemon writes its graph to `$MNEMON_DATA_DIR`, which is in the per-agent-group `.claude/` mount — so memory persists across container restarts.
|
||||
|
||||
**Applying:** Only the Dockerfile step from Phase 2 is needed for OpenCode agents. Skip `container/entrypoint.sh` entirely.
|
||||
|
||||
```dockerfile
|
||||
ARG MNEMON_VERSION=0.1.1
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin mnemon && \
|
||||
chmod +x /usr/local/bin/mnemon
|
||||
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
|
||||
```
|
||||
|
||||
Then rebuild: `./container/build.sh`
|
||||
|
||||
### Verify (OpenCode)
|
||||
|
||||
Start a session and ask the agent to run `mnemon status`. It should report empty graphs (no error) on first run.
|
||||
|
||||
```bash
|
||||
# Also confirm the binary is present in the image:
|
||||
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
|
||||
```
|
||||
|
||||
## Memory Storage
|
||||
|
||||
Mnemon writes to `/home/node/.claude/mnemon/` inside the container, which maps to the per-agent-group `.claude/` directory on the host. To find the exact host path:
|
||||
|
||||
```bash
|
||||
docker inspect $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
|
||||
--format '{{range .Mounts}}{{if eq .Destination "/home/node/.claude"}}{{.Source}}{{end}}{{end}}'
|
||||
```
|
||||
|
||||
To reset all memory for an agent, stop the container and delete the `mnemon/` subdirectory from that host path.
|
||||
|
||||
## Migration Guide Update
|
||||
|
||||
If you are using `/migrate-nanoclaw`, add these entries to `.nanoclaw-migrations/05-dockerfile.md`:
|
||||
|
||||
**Dockerfile — after AWS CLI, before Bun runtime:**
|
||||
```dockerfile
|
||||
ARG MNEMON_VERSION=0.1.1
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin mnemon && \
|
||||
chmod +x /usr/local/bin/mnemon
|
||||
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
|
||||
```
|
||||
|
||||
**`container/entrypoint.sh` — add after `set -e`:**
|
||||
```bash
|
||||
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `mnemon: command not found` in container
|
||||
|
||||
The image wasn't rebuilt after adding the Dockerfile layer. Run `./container/build.sh` and restart.
|
||||
|
||||
### Memory not persisting across restarts
|
||||
|
||||
Verify `MNEMON_DATA_DIR` resolves to a mounted path (not an in-container ephemeral directory):
|
||||
|
||||
```bash
|
||||
docker exec <container> sh -c 'ls -la $MNEMON_DATA_DIR'
|
||||
```
|
||||
|
||||
If the directory is empty after conversations, the mount is missing or the path is wrong. Check the host mount with the `docker inspect` command above.
|
||||
|
||||
### Agent not using past memory
|
||||
|
||||
`mnemon setup` writes hooks into `/home/node/.claude/settings.json`. Verify:
|
||||
|
||||
```bash
|
||||
docker exec <container> cat /home/node/.claude/settings.json
|
||||
```
|
||||
|
||||
If the hooks are absent, `mnemon setup` may have failed silently. Check container startup logs for errors from mnemon.
|
||||
|
||||
### Setup fails at container start
|
||||
|
||||
Run setup manually inside a running container to see the full error:
|
||||
|
||||
```bash
|
||||
docker exec -it <container> mnemon setup --target claude-code --yes --global
|
||||
```
|
||||
@@ -76,7 +76,7 @@ Then rebuild the container image: `./container/build.sh`
|
||||
|
||||
Ask the user (plain text, not AskUserQuestion):
|
||||
|
||||
1. **Which agent group?** List available groups: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT folder, name FROM agent_groups;"`
|
||||
1. **Which agent group?** List available groups: `sqlite3 data/v2.db "SELECT folder, name FROM agent_groups;"`
|
||||
2. **Which Ollama model?** List available: `curl -s http://localhost:11434/api/tags | grep '"name"'`
|
||||
3. **Block Anthropic API?** Recommended yes — prevents accidental spend if config drifts.
|
||||
|
||||
@@ -111,7 +111,7 @@ Read the agent group's shared Claude settings:
|
||||
|
||||
```bash
|
||||
# Find the agent group ID
|
||||
AG_ID=$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
|
||||
AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
|
||||
SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json
|
||||
```
|
||||
|
||||
|
||||
@@ -132,16 +132,13 @@ Credentials: register provider API keys in OneCLI with the matching `--host-patt
|
||||
|
||||
After adding a secret, **grant the agent access** — agents in `selective` mode only receive secrets they've been explicitly assigned:
|
||||
|
||||
Use the safe merge pattern — `set-secrets` replaces the entire list, so always read first:
|
||||
|
||||
```bash
|
||||
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
|
||||
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
|
||||
onecli agents secrets --id "$AGENT_ID"
|
||||
# Find the agent id and secret id, then:
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids <existing-ids>,<new-secret-id>
|
||||
```
|
||||
|
||||
Always include existing secret IDs in the list — `set-secrets` replaces, not appends.
|
||||
|
||||
#### Example: DeepSeek
|
||||
|
||||
```env
|
||||
@@ -211,7 +208,7 @@ onecli secrets create --name "OpenCode Zen" --type generic \
|
||||
|
||||
### Per group / per session
|
||||
|
||||
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
|
||||
Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group).
|
||||
|
||||
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ Look for: `Parallel AI MCP servers configured`
|
||||
- Check agent-runner logs for "Parallel AI MCP servers configured" message
|
||||
|
||||
**Task polling not working:**
|
||||
- Verify scheduled task was created: `pnpm exec tsx scripts/q.ts store/messages.db "SELECT * FROM scheduled_tasks"`
|
||||
- Verify scheduled task was created: `sqlite3 store/messages.db "SELECT * FROM scheduled_tasks"`
|
||||
- Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"`
|
||||
- Ensure task prompt includes proper Parallel MCP tool names
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Remove Signal
|
||||
|
||||
1. Comment out `import './signal.js'` in `src/channels/index.ts`
|
||||
2. Remove `SIGNAL_ACCOUNT` (and any other `SIGNAL_*` vars) from `.env`
|
||||
3. Rebuild and restart
|
||||
|
||||
If you also want to unlink the Signal account from `signal-cli`:
|
||||
|
||||
```bash
|
||||
signal-cli -a +1YOURNUMBER removeDevice --deviceId <id>
|
||||
```
|
||||
|
||||
(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.)
|
||||
@@ -1,323 +0,0 @@
|
||||
---
|
||||
name: add-signal
|
||||
description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge.
|
||||
---
|
||||
|
||||
# Add Signal Channel
|
||||
|
||||
Adds Signal messaging support via a native adapter that 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
|
||||
|
||||
### Java
|
||||
|
||||
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
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/signal.ts` and `src/channels/signal.test.ts` both exist
|
||||
- `src/channels/index.ts` contains `import './signal.js';`
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and tests
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/signal.ts > src/channels/signal.ts
|
||||
git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './signal.js';
|
||||
```
|
||||
|
||||
### 4. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
No npm packages to install — the adapter uses only Node.js builtins.
|
||||
|
||||
## Credentials
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
SIGNAL_ACCOUNT=+1YOURNUMBER
|
||||
```
|
||||
|
||||
### Optional settings
|
||||
|
||||
```bash
|
||||
# TCP daemon host and port (default: 127.0.0.1:7583)
|
||||
SIGNAL_TCP_HOST=127.0.0.1
|
||||
SIGNAL_TCP_PORT=7583
|
||||
|
||||
# Path to the signal-cli binary (default: resolved on PATH)
|
||||
SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
|
||||
|
||||
# Whether NanoClaw manages the daemon lifecycle (default: true).
|
||||
# Set to false if you run signal-cli daemon externally.
|
||||
SIGNAL_MANAGE_DAEMON=true
|
||||
|
||||
# signal-cli data directory (default: ~/.local/share/signal-cli)
|
||||
SIGNAL_DATA_DIR=~/.local/share/signal-cli
|
||||
```
|
||||
|
||||
**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Restart
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## 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
|
||||
pnpm exec tsx scripts/q.ts 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")
|
||||
pnpm exec tsx scripts/q.ts 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")
|
||||
pnpm exec tsx scripts/q.ts 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.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `signal`
|
||||
- **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 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 matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops
|
||||
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`
|
||||
- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx
|
||||
|
||||
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Daemon not reachable
|
||||
|
||||
```bash
|
||||
grep "Signal" logs/nanoclaw.log | tail
|
||||
```
|
||||
|
||||
If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`:
|
||||
- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`)
|
||||
- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting
|
||||
|
||||
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`.
|
||||
|
||||
### Bot not responding
|
||||
|
||||
1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1`
|
||||
2. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"`
|
||||
3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
||||
4. **Check for duplicate service instances** — if `logs/nanoclaw.error.log` shows `No adapter for channel type channelType="signal"` despite the adapter starting, two NanoClaw processes are racing. See the `/debug` skill section "No adapter for channel type / Messages silently lost" for the full fix.
|
||||
|
||||
### Messages delivered but never arrive (null platformMsgId)
|
||||
|
||||
Signal responses show `platformMsgId=undefined` in the main log. This means the delivery poll ran but found no adapter — likely a duplicate service instance issue (see above). Affected messages cannot be retried; the user must resend.
|
||||
|
||||
### Lost connection mid-session
|
||||
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Verify Signal
|
||||
|
||||
Send a message to your own Signal number (Note to Self) from another device, or have someone send your linked number a DM. The bot should respond within a few seconds.
|
||||
|
||||
If nothing happens, tail `logs/nanoclaw.log` for `Signal channel connected` and `Signal message received`.
|
||||
@@ -44,7 +44,7 @@ import './slack.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/slack@4.27.0
|
||||
pnpm install @chat-adapter/slack@4.26.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
@@ -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`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
|
||||
- `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
|
||||
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
|
||||
5. Go to **Basic Information** and copy the **Signing Secret**
|
||||
|
||||
@@ -76,13 +76,7 @@ pnpm run build
|
||||
10. Under **Subscribe to bot events**, add:
|
||||
- `message.channels`, `message.groups`, `message.im`, `app_mention`
|
||||
11. Click **Save Changes**
|
||||
|
||||
### 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
|
||||
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
|
||||
|
||||
### Configure environment
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import './teams.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/teams@4.27.0
|
||||
pnpm install @chat-adapter/teams@4.26.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -58,7 +58,7 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
|
||||
### 5. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/telegram@4.27.0
|
||||
pnpm install @chat-adapter/telegram@4.26.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
@@ -90,12 +90,12 @@ onecli secrets list | grep -i vercel
|
||||
OneCLI uses selective secret mode — secrets must be explicitly assigned to each agent. Get the Vercel secret ID from the output above, then assign it to every agent:
|
||||
|
||||
```bash
|
||||
# set-secrets replaces the entire list — read and merge for each agent.
|
||||
VERCEL_SECRET_ID=$(onecli secrets list | jq -r '.data[] | select(.name | test("(?i)vercel")) | .id' | head -1)
|
||||
for agent in $(onecli agents list | jq -r '.data[].id'); do
|
||||
CURRENT=$(onecli agents secrets --id "$agent" | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,$VERCEL_SECRET_ID" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id "$agent" --secret-ids "$MERGED"
|
||||
# For each agent, add the Vercel secret to its assigned secrets list.
|
||||
# First get current assignments, then set them with the new secret appended.
|
||||
VERCEL_SECRET_ID=$(onecli secrets list 2>/dev/null | grep -B2 "Vercel" | grep '"id"' | head -1 | sed 's/.*"id": "//;s/".*//')
|
||||
for agent in $(onecli agents list 2>/dev/null | grep '"id"' | sed 's/.*"id": "//;s/".*//'); do
|
||||
CURRENT=$(onecli agents secrets --id "$agent" 2>/dev/null | grep '"' | grep -v hint | grep -v data | sed 's/.*"//;s/".*//' | tr '\n' ',' | sed 's/,$//')
|
||||
onecli agents set-secrets --id "$agent" --secret-ids "${CURRENT:+$CURRENT,}$VERCEL_SECRET_ID"
|
||||
done
|
||||
```
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import './whatsapp-cloud.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/whatsapp@4.27.0
|
||||
pnpm install @chat-adapter/whatsapp@4.26.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -57,7 +57,7 @@ groups: () => import('./groups.js'),
|
||||
### 5. Install the adapter packages (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
@@ -200,7 +200,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
|
||||
- **type**: `whatsapp`
|
||||
- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members.
|
||||
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
|
||||
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `sqlite3 data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
|
||||
- **supports-threads**: no
|
||||
- **typical-use**: Interactive chat — direct messages or small groups
|
||||
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
|
||||
@@ -256,7 +256,7 @@ systemctl --user start nanoclaw
|
||||
|
||||
1. Auth exists: `test -f store/auth/creds.json`
|
||||
2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1`
|
||||
3. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
|
||||
3. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
|
||||
4. Service running: `systemctl --user status nanoclaw`
|
||||
|
||||
### "conflict" disconnection
|
||||
|
||||
@@ -57,50 +57,7 @@ Debug level shows:
|
||||
|
||||
## Common Issues
|
||||
|
||||
### 1. "No adapter for channel type" / Messages silently lost (null platformMsgId)
|
||||
|
||||
**Symptom:** The bot stops replying. `logs/nanoclaw.error.log` shows repeated:
|
||||
```
|
||||
WARN No adapter for channel type channelType="telegram"
|
||||
WARN No adapter for channel type channelType="signal"
|
||||
```
|
||||
The main log shows "Message delivered" entries with `platformMsgId=undefined` — meaning the delivery poll ran, found no adapter, and permanently marked the message as delivered without sending it.
|
||||
|
||||
**Root cause: two NanoClaw service instances running simultaneously.**
|
||||
|
||||
When a second service instance (often `nanoclaw-v2-<id>.service` running alongside `nanoclaw.service`) is active with a stale binary, it has no channel adapters registered. Its delivery poll races against the working instance and wins — permanently marking outbound messages as delivered without ever sending them.
|
||||
|
||||
**Diagnosis:**
|
||||
```bash
|
||||
# Check for duplicate running instances
|
||||
ps aux | grep 'nanoclaw/dist/index.js' | grep -v grep
|
||||
|
||||
# Check which services are active
|
||||
systemctl --user list-units 'nanoclaw*' --all
|
||||
|
||||
# Confirm channel adapters registered by the current process
|
||||
grep "Channel adapter started" logs/nanoclaw.log | tail -10
|
||||
```
|
||||
|
||||
**Fix:**
|
||||
1. Identify which service has the correct binary and EnvironmentFile (the one showing `signal`, `telegram`, `cli` all started in the log).
|
||||
2. Stop and disable the stale duplicate service:
|
||||
```bash
|
||||
systemctl --user stop nanoclaw.service # or whichever is the old one
|
||||
systemctl --user disable nanoclaw.service
|
||||
```
|
||||
3. If the remaining service unit is missing `EnvironmentFile`, add it:
|
||||
```bash
|
||||
# Edit the service unit — add this line under [Service]:
|
||||
# EnvironmentFile=/home/[user]/nanoclaw/.env
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user restart nanoclaw-v2-<id>.service
|
||||
```
|
||||
4. Verify only one instance runs: `ps aux | grep nanoclaw/dist/index.js | grep -v grep`
|
||||
|
||||
**Note:** Messages that were marked delivered with a null `platform_message_id` cannot be automatically retried — they are permanently lost. The user must resend their message.
|
||||
|
||||
### 2. "Claude Code process exited with code 1"
|
||||
### 1. "Claude Code process exited with code 1"
|
||||
|
||||
**Check the container log file** in `groups/{folder}/logs/container-*.log`
|
||||
|
||||
@@ -322,7 +279,7 @@ rm -rf data/sessions/
|
||||
rm -rf data/sessions/{groupFolder}/.claude/
|
||||
|
||||
# Also clear the session ID from NanoClaw's tracking (stored in SQLite)
|
||||
pnpm exec tsx scripts/q.ts store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'"
|
||||
sqlite3 store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'"
|
||||
```
|
||||
|
||||
To verify session resumption is working, check the logs for the same session ID across messages:
|
||||
|
||||
@@ -54,7 +54,7 @@ Tell the user:
|
||||
Wait for the user's confirmation. Then look up the most recent DM messaging groups:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
|
||||
sqlite3 data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
Show the top rows to the user and confirm which `platform_id` is theirs (usually the most recent). Record as `PLATFORM_ID`. If none appeared, check `logs/nanoclaw.log` for `unknown_sender` drops — the adapter might be rejecting inbound due to connection or permission issues.
|
||||
@@ -103,7 +103,7 @@ Wait for the user's reply. If they confirm receipt, the skill is done.
|
||||
|
||||
If they say it didn't arrive, then diagnose using the DB directly (no waiting loops required — the message either delivered or it didn't):
|
||||
|
||||
- `pnpm exec tsx scripts/q.ts data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
|
||||
- `sqlite3 data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
|
||||
- `grep -E 'Unauthorized channel destination|container.*exited|error' logs/nanoclaw.log | tail -20` — look for ACL rejections or container crashes.
|
||||
- `ls data/v2-sessions/<agent-group-id>/sessions/*/outbound.db` — confirm the session exists.
|
||||
|
||||
|
||||
@@ -259,41 +259,6 @@ Tell the user:
|
||||
- To manage secrets: `onecli secrets list`, or open ${ONECLI_URL}
|
||||
- To add rate limits or policies: `onecli rules create --help`
|
||||
|
||||
## Granting secrets to agents (safe merge)
|
||||
|
||||
`set-secrets` **replaces** the agent's entire secret list — it never appends. Always read the current list first and merge before calling it. This pattern is canonical across all skills that assign secrets:
|
||||
|
||||
```bash
|
||||
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
|
||||
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
|
||||
onecli agents secrets --id "$AGENT_ID"
|
||||
```
|
||||
|
||||
- `<agentGroupId>` — the `agentGroupId` field in `groups/<folder>/container.json`
|
||||
- `<new-secret-id>` — the `id` from `onecli secrets list`
|
||||
- Multiple new secrets: append them comma-separated before the `printf` step
|
||||
|
||||
### git over HTTPS
|
||||
|
||||
OneCLI's proxy injects credentials proactively — `injections_applied=1` appears in `docker logs onecli` even when git sends no auth header. However, OneCLI sets `SSL_CERT_FILE` for Node/Python/Deno but not `GIT_SSL_CAINFO`. Without it, git rejects the OneCLI MITM certificate.
|
||||
|
||||
**Auth format matters**: GitHub's git smart HTTP protocol (`github.com`) requires `Basic` auth, not `Bearer`. GitHub's REST API (`api.github.com`) accepts `Bearer`. These must be configured as separate secrets with different formats — see `/add-github` for the full setup.
|
||||
|
||||
If an agent uses `git` or `gh`, add to `data/v2-sessions/<agent-group-id>/.claude-shared/settings.json`:
|
||||
|
||||
```json
|
||||
"GIT_SSL_CAINFO": "/tmp/onecli-combined-ca.pem",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": "credential.helper",
|
||||
"GIT_CONFIG_VALUE_0": "",
|
||||
"GH_TOKEN": "ghp_onecli_proxy_replaces_this"
|
||||
```
|
||||
|
||||
**Debugging injection**: `docker logs onecli 2>&1 | grep "github.com"` shows every request with `injections_applied=N` and the HTTP status. If `injections_applied=1` but status is still 401, the injected credential value is wrong or uses the wrong auth format for that endpoint.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf ${ONECLI_URL}/health`. Start it with `onecli start` if needed.
|
||||
|
||||
@@ -11,22 +11,7 @@ Privilege is a **user-level** concept, not a channel-level one (see `src/db/user
|
||||
|
||||
## Assess Current State
|
||||
|
||||
Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`).
|
||||
|
||||
Run each via the in-tree wrapper — the host setup deliberately ships no `sqlite3` CLI:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "<query>"
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups;
|
||||
SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups;
|
||||
SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents;
|
||||
SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC;
|
||||
```
|
||||
|
||||
Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
|
||||
Read the central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, and `user_roles` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
|
||||
|
||||
Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**.
|
||||
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
---
|
||||
name: migrate-from-v1
|
||||
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".
|
||||
---
|
||||
|
||||
# Finish v1 → v2 migration
|
||||
|
||||
`bash migrate-v2.sh` already ran the deterministic migration. It handled:
|
||||
|
||||
- .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 (incl. Claude Code memory + JSONL transcripts)
|
||||
- Scheduled tasks ported
|
||||
- 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
|
||||
|
||||
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.
|
||||
|
||||
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: Get v2 routing real messages
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Deferred failures
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
**User ID format**: always `<channel_type>:<platform_handle>`. Each channel populates this differently:
|
||||
- **Telegram**: `telegram:<numeric_user_id>` (e.g. `telegram:6037840640`)
|
||||
- **Discord**: `discord:<snowflake_user_id>` (e.g. `discord:123456789012345678`)
|
||||
- **WhatsApp**: `whatsapp:<phone>@s.whatsapp.net` (e.g. `whatsapp:14155551234@s.whatsapp.net`)
|
||||
- **Slack**: `slack:<user_id>` (e.g. `slack:U04ABCDEF`)
|
||||
- **Others**: `<channel_type>:<platform_id>`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Query `users` table: `SELECT id, kind, display_name FROM users`.
|
||||
2. If exactly one user exists, confirm: `AskUserQuestion`: "Is `<display_name>` (`<id>`) 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 ('<user_id>', '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';
|
||||
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);
|
||||
```
|
||||
|
||||
### Access policy
|
||||
|
||||
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.
|
||||
|
||||
Present the options via `AskUserQuestion`:
|
||||
|
||||
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.
|
||||
|
||||
If the user picks option 2 or 3, seed the known users from v1's message history. The v1 database is at `<handoff.v1_path>/store/messages.db`. It has a `messages` table with `sender` and `sender_name` columns. For each group:
|
||||
|
||||
```sql
|
||||
-- v1: unique senders per chat (excluding bot messages)
|
||||
SELECT DISTINCT sender, sender_name
|
||||
FROM messages
|
||||
WHERE chat_jid = '<v1_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-v2/shared.ts`) and combining: `<channel_type>:<sender>`.
|
||||
|
||||
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.
|
||||
|
||||
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 = '<chosen_policy>'
|
||||
WHERE id IN (SELECT id FROM messaging_groups WHERE channel_type IN (<migrated_channels>))
|
||||
```
|
||||
|
||||
## 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 <v1_path>
|
||||
git remote -v
|
||||
git log --oneline <upstream>/main..HEAD 2>/dev/null
|
||||
```
|
||||
|
||||
If no commits ahead of upstream: stock v1, skip this phase.
|
||||
|
||||
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-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.
|
||||
|
||||
## Principles
|
||||
|
||||
- **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.
|
||||
|
||||
## Setup steps you can run
|
||||
|
||||
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 <name>
|
||||
```
|
||||
|
||||
| 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-<date>.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-*
|
||||
```
|
||||
@@ -17,9 +17,8 @@ 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
|
||||
- **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
|
||||
- **Source** (`src/`): may conflict if you modified the same files
|
||||
- **Build/config** (`package.json`, `tsconfig*.json`, `container/`): review needed
|
||||
|
||||
**Update paths** (you pick one):
|
||||
- `merge` (default): `git merge upstream/<branch>`. Resolves all conflicts in one pass.
|
||||
@@ -31,7 +30,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`. If container files changed, also runs the container typecheck and `./container/build.sh`.
|
||||
**Validation**: runs `pnpm run build` and `pnpm test`.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -109,10 +108,9 @@ Show file-level impact from upstream:
|
||||
|
||||
Bucket the upstream changed files:
|
||||
- **Skills** (`.claude/skills/`): unlikely to conflict unless the user edited an upstream skill
|
||||
- **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
|
||||
- **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
|
||||
|
||||
**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.
|
||||
|
||||
@@ -175,31 +173,11 @@ 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 <backup-tag-from-step-1>..HEAD --name-only | grep -E '^(pnpm-lock\.yaml|package\.json)$'`
|
||||
- If matched: `pnpm install`
|
||||
- `git diff <backup-tag-from-step-1>..HEAD --name-only | grep -E '^container/agent-runner/(bun\.lock|package\.json)$'`
|
||||
- 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.
|
||||
|
||||
# Step 5: Validation
|
||||
Check which areas changed to determine what to validate:
|
||||
- `CHANGED_FILES=$(git diff --name-only <backup-tag-from-step-1>..HEAD)`
|
||||
|
||||
**Host build** (always):
|
||||
Run:
|
||||
- `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 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`
|
||||
|
||||
If build fails:
|
||||
- Show the error.
|
||||
- Only fix issues clearly caused by the merge (missing imports, type mismatches from merged code).
|
||||
@@ -231,10 +209,8 @@ 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 and channel/provider updates
|
||||
|
||||
## 7a: Skill branches
|
||||
Check if skills are distributed as branches in this repo:
|
||||
# Step 7: Check for skill updates
|
||||
After the summary, check if skills are distributed as branches in this repo:
|
||||
- `git branch -r --list 'upstream/skill/*'`
|
||||
|
||||
If any `upstream/skill/*` branches exist:
|
||||
@@ -242,21 +218,7 @@ 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.
|
||||
|
||||
## 7b: Channel and provider updates
|
||||
Detect installed channels by reading `src/channels/index.ts` and collecting all `import './<name>.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-<name>` 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-<channel>` or `/add-<provider>` skill.
|
||||
|
||||
If no channels/providers are installed, skip silently.
|
||||
|
||||
Proceed to Step 8.
|
||||
- After the skill completes (or if user selected no), proceed to Step 8.
|
||||
|
||||
# Step 8: Summary + rollback instructions
|
||||
Show:
|
||||
@@ -270,10 +232,9 @@ Show:
|
||||
Tell the user:
|
||||
- To rollback: `git reset --hard <backup-tag-from-step-1>`
|
||||
- Backup branch also exists: `backup/pre-update-<HASH>-<TIMESTAMP>`
|
||||
- Restart the service to apply changes. Detect platform with `uname -s`:
|
||||
- **macOS (Darwin)**: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
- **Linux**: detect the service name with `systemctl --user list-units --type=service | grep nanoclaw | awk '{print $1}'`, then `systemctl --user restart <detected-name>`
|
||||
- **Manual** (no service found): restart `pnpm run dev`
|
||||
- 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`
|
||||
|
||||
|
||||
## Diagnostics
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
name: Label PR
|
||||
|
||||
# SECURITY: this workflow runs with write access to the base repo on fork PRs,
|
||||
# because `pull_request_target` executes in the context of the base branch.
|
||||
# Keep it metadata-only — do NOT add actions/checkout or any step that
|
||||
# executes PR-supplied content (install scripts, build commands, etc.).
|
||||
# See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [opened, edited]
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -4,11 +4,6 @@ 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.** 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:<id>` (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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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. 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.
|
||||
2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Run `bash migrate-v2.sh` to migrate instead."*
|
||||
3. Wait for the user to confirm before doing anything else. Do not attempt the migration yourself.
|
||||
|
||||
If you are a fresh install (you ran `git clone`, not `git pull`) and there are no conflicts, ignore this banner and continue below.
|
||||
|
||||
@@ -53,8 +53,6 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f
|
||||
|
||||
`data/v2.db` holds everything that isn't per-session: users, user_roles, agent_groups, messaging_groups, wiring, pending_approvals, user_dms, chat_sdk_* (for the Chat SDK bridge), schema_version. Migrations live at `src/db/migrations/`.
|
||||
|
||||
For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than the `sqlite3` CLI: `pnpm exec tsx scripts/q.ts <db> "<sql>"`. The host setup intentionally avoids depending on the `sqlite3` binary (`setup/verify.ts:5`); the wrapper goes through the `better-sqlite3` dep that setup already installs and verifies. Default-output format matches `sqlite3 -list` (pipe-separated, no header) so existing skill text reads identically.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
@@ -79,7 +77,6 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `container/skills/` | Container skills mounted into every agent session |
|
||||
| `groups/<folder>/` | 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) |
|
||||
| `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)
|
||||
|
||||
@@ -160,17 +157,6 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
||||
|
||||
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, and the pre-submission checklist.
|
||||
|
||||
## PR Hygiene
|
||||
|
||||
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. Installation-specific files (group files, .claude/settings.json, local configs) should not be included.
|
||||
|
||||
## Development
|
||||
|
||||
Run commands directly — don't tell the user to run them.
|
||||
@@ -200,17 +186,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart
|
||||
systemctl --user start|stop|restart nanoclaw
|
||||
```
|
||||
|
||||
## 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/<agent-group>/<session>/` — `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.
|
||||
Host logs: `logs/nanoclaw.log` (normal) and `logs/nanoclaw.error.log` (errors only — some delivery/approval failures only show up here).
|
||||
|
||||
## Supply Chain Security (pnpm)
|
||||
|
||||
@@ -235,8 +211,6 @@ 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 |
|
||||
| [docs/migration-dev.md](docs/migration-dev.md) | Migration development guide — testing, debugging, dev loop |
|
||||
|
||||
## Container Build Cache
|
||||
|
||||
|
||||
+1
-2
@@ -123,8 +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 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:
|
||||
3. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
|
||||
|
||||
| Checkbox | Label |
|
||||
|----------|-------|
|
||||
|
||||
@@ -16,7 +16,6 @@ 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
|
||||
- [cschmidt](https://github.com/cschmidt) — Carl Schmidt
|
||||
- [leonalfredbot-ship-it](https://github.com/leonalfredbot-ship-it) — Alfred-the-buttler
|
||||
- [moktamd](https://github.com/moktamd)
|
||||
|
||||
@@ -26,36 +26,11 @@ NanoClaw provides that same core functionality, but in a codebase small enough t
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash nanoclaw.sh
|
||||
git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw && 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.
|
||||
|
||||
<details>
|
||||
<summary><strong>Migrating from NanoClaw v1?</strong></summary>
|
||||
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
## 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.
|
||||
@@ -215,5 +190,3 @@ See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release hist
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=47894bd5-353b-42fe-bb97-74144e6df0bf" />
|
||||
|
||||
+103
-63
@@ -8,56 +8,92 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nanoclaw.dev">nanoclaw.dev</a> •
|
||||
<a href="https://docs.nanoclaw.dev">ドキュメント</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
> **注意:** この日本語訳は v1 時点のもので、最新の v2 アーキテクチャは反映されていません。最新の内容は [README.md](README.md) をご覧ください。
|
||||
|
||||
---
|
||||
|
||||
<h2 align="center">🐳 Dockerサンドボックスで動作</h2>
|
||||
<p align="center">各エージェントはマイクロVM内の独立したコンテナで実行されます。<br>ハイパーバイザーレベルの分離。ミリ秒で起動。複雑なセットアップ不要。</p>
|
||||
|
||||
**macOS (Apple Silicon)**
|
||||
```bash
|
||||
curl -fsSL https://nanoclaw.dev/install-docker-sandboxes.sh | bash
|
||||
```
|
||||
|
||||
**Windows (WSL)**
|
||||
```bash
|
||||
curl -fsSL https://nanoclaw.dev/install-docker-sandboxes-windows.sh | bash
|
||||
```
|
||||
|
||||
> 現在、macOS(Apple Silicon)とWindows(x86)に対応しています。Linux対応は近日公開予定。
|
||||
|
||||
<p align="center"><a href="https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes">発表記事を読む →</a> · <a href="docs/docker-sandboxes.md">手動セットアップガイド →</a></p>
|
||||
|
||||
---
|
||||
|
||||
## NanoClawを作った理由
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw)は素晴らしいプロジェクトですが、自分が理解しきれない複雑なソフトウェアに生活へのフルアクセスを与えたまま安心して眠れるとは思えませんでした。OpenClawは約50万行のコード、53の設定ファイル、70以上の依存関係を持っています。セキュリティはアプリケーションレベル(許可リスト、ペアリングコード)であり、真のOSレベルの分離ではありません。すべてが共有メモリを持つ1つのNodeプロセスで動作します。
|
||||
[OpenClaw](https://github.com/openclaw/openclaw)は素晴らしいプロジェクトですが、理解しきれない複雑なソフトウェアに自分の生活へのフルアクセスを与えたまま安心して眠れるとは思えませんでした。OpenClawは約50万行のコード、53の設定ファイル、70以上の依存関係を持っています。セキュリティはアプリケーションレベル(許可リスト、ペアリングコード)であり、真のOS レベルの分離ではありません。すべてが共有メモリを持つ1つのNodeプロセスで動作します。
|
||||
|
||||
NanoClawは同じコア機能を提供しますが、理解できる規模のコードベースで実現しています。1つのプロセスと少数のファイル。Claudeエージェントは単なるパーミッションチェックの背後ではなく、ファイルシステム分離された独自のLinuxコンテナで実行されます。
|
||||
NanoClawは同じコア機能を提供しますが、理解できる規模のコードベースで実現しています:1つのプロセスと少数のファイル。Claudeエージェントは単なるパーミッションチェックの背後ではなく、ファイルシステム分離された独自のLinuxコンテナで実行されます。
|
||||
|
||||
## クイックスタート
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash nanoclaw.sh
|
||||
gh repo fork qwibitai/nanoclaw --clone
|
||||
cd nanoclaw
|
||||
claude
|
||||
```
|
||||
|
||||
`nanoclaw.sh`は、まっさらなマシンから、メッセージを送れる名前付きエージェントが動く状態までを一気通貫で案内します。NodeやpnpmやDockerが無ければインストールし、AnthropicクレデンシャルをOneCLIに登録し、エージェントコンテナをビルドし、最初のチャネル(Telegram、Discord、WhatsApp、またはローカルCLI)とペアリングします。途中でステップが失敗すれば自動的にClaude Codeが呼び出され、原因を診断して中断箇所から再開します。
|
||||
<details>
|
||||
<summary>GitHub CLIなしの場合</summary>
|
||||
|
||||
1. GitHub上で[qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw)をフォーク(Forkボタンをクリック)
|
||||
2. `git clone https://github.com/<あなたのユーザー名>/nanoclaw.git`
|
||||
3. `cd nanoclaw`
|
||||
4. `claude`
|
||||
|
||||
</details>
|
||||
|
||||
その後、`/setup`を実行します。Claude Codeがすべてを処理します:依存関係、認証、コンテナセットアップ、サービス設定。
|
||||
|
||||
> **注意:** `/`で始まるコマンド(`/setup`、`/add-whatsapp`など)は[Claude Codeスキル](https://code.claude.com/docs/en/skills)です。通常のターミナルではなく、`claude` CLIプロンプト内で入力してください。Claude Codeをインストールしていない場合は、[claude.com/product/claude-code](https://claude.com/product/claude-code)から入手してください。
|
||||
|
||||
## 設計思想
|
||||
|
||||
**理解できる規模。** 1つのプロセス、少数のソースファイル、マイクロサービスなし。NanoClawのコードベース全体を把握したいなら、Claude Codeに説明を求めれば十分です。
|
||||
**理解できる規模。** 1つのプロセス、少数のソースファイル、マイクロサービスなし。NanoClawのコードベース全体を理解したい場合は、Claude Codeに説明を求めるだけです。
|
||||
|
||||
**分離によるセキュリティ。** エージェントはLinuxコンテナで実行され、明示的にマウントされたものだけが見えます。コマンドはホストではなくコンテナ内で実行されるため、Bashアクセスも安全です。
|
||||
**分離によるセキュリティ。** エージェントはLinuxコンテナ(macOSではApple Container、またはDocker)で実行され、明示的にマウントされたものだけが見えます。コマンドはホストではなくコンテナ内で実行されるため、Bashアクセスは安全です。
|
||||
|
||||
**個人ユーザー向け。** NanoClawはモノリシックなフレームワークではなく、各ユーザーのニーズに正確にフィットするソフトウェアです。肥大化するのではなく、オーダーメイドであるよう設計されています。自分のフォークを作り、Claude Codeにニーズに合わせて変更させます。
|
||||
**個人ユーザー向け。** NanoClawはモノリシックなフレームワークではなく、各ユーザーのニーズに正確にフィットするソフトウェアです。肥大化するのではなく、オーダーメイドになるよう設計されています。自分のフォークを作成し、Claude Codeにニーズに合わせて変更させます。
|
||||
|
||||
**カスタマイズ=コード変更。** 設定の肥大化はありません。動作を変えたいならコードを変える。コードベースは変更しても安全な規模です。
|
||||
**カスタマイズ=コード変更。** 設定ファイルの肥大化なし。動作を変えたい?コードを変更するだけ。コードベースは変更しても安全な規模です。
|
||||
|
||||
**AIネイティブ、設計としてハイブリッド。** インストールとオンボーディングは最適化されたスクリプトのパスで、速く決定的です。判断が必要なところ(インストール失敗、対話的な決定、カスタマイズ)では、制御はシームレスにClaude Codeへ渡されます。セットアップ以降も、監視ダッシュボードやデバッグUIは用意しません。問題をチャットで説明すれば、Claude Codeが処理します。
|
||||
**AIネイティブ。**
|
||||
- インストールウィザードなし — Claude Codeがセットアップを案内。
|
||||
- モニタリングダッシュボードなし — Claudeに状況を聞くだけ。
|
||||
- デバッグツールなし — 問題を説明すればClaudeが修正。
|
||||
|
||||
**機能ではなくスキル。** トランクにはレジストリとインフラのみを同梱し、個別のチャネルアダプターや代替プロバイダーは含めません。チャネル(Discord、Slack、Telegram、WhatsAppなど)は長期運用される`channels`ブランチに、代替プロバイダー(OpenCode、Ollama)は`providers`ブランチに置かれます。`/add-telegram`や`/add-opencode`などを実行すると、スキルが必要なモジュールだけを正確にフォークへコピーします。要求していない機能は一切入りません。
|
||||
**機能追加ではなくスキル。** コードベースに機能(例:Telegram対応)を追加する代わりに、コントリビューターは`/add-telegram`のような[Claude Codeスキル](https://code.claude.com/docs/en/skills)を提出し、あなたのフォークを変換します。あなたが必要なものだけを正確に実行するクリーンなコードが手に入ります。
|
||||
|
||||
**最高のハーネス、最高のモデル。** NanoClawはAnthropic公式のClaude Agent SDK経由でネイティブにClaude Codeを使用します。最新のClaudeモデルとClaude Codeの全ツールセット(自分のNanoClawフォークを変更・拡張する能力を含む)が手に入ります。他プロバイダーはドロップイン・オプションです。OpenAIのCodex(ChatGPTサブスクリプションまたはAPIキー)向けには`/add-codex`、OpenCode経由のOpenRouter、Google、DeepSeekなどには`/add-opencode`、ローカルのオープンウェイトモデルには`/add-ollama-provider`。プロバイダーはエージェントグループごとに設定可能です。
|
||||
**最高のハーネス、最高のモデル。** NanoClawはClaude Agent SDK上で動作します。つまり、Claude Codeを直接実行しているということです。Claude Codeは高い能力を持ち、そのコーディングと問題解決能力によってNanoClawを変更・拡張し、各ユーザーに合わせてカスタマイズできます。
|
||||
|
||||
## サポート機能
|
||||
|
||||
- **マルチチャネルメッセージング** — WhatsApp、Telegram、Discord、Slack、Microsoft Teams、iMessage、Matrix、Google Chat、Webex、Linear、GitHub、WeChat、Resend経由のメール。`/add-<channel>`スキルでオンデマンドにインストール。1つでも複数でも同時に実行可能。
|
||||
- **柔軟な分離モデル** — チャネルごとに専用エージェントを割り当てて完全プライバシーを確保することも、複数チャネルで1つのエージェントを共有して会話は分離しつつメモリを統一することも、複数チャネルを1つの共有セッションにまとめて会話を横断させることもできます。`/manage-channels`でチャネル単位に選択。[docs/isolation-model.md](docs/isolation-model.md)参照。
|
||||
- **エージェントごとのワークスペース** — 各エージェントグループは独自の`CLAUDE.md`、独自のメモリ、独自のコンテナ、そしてあなたが許可したマウントのみを持ちます。明示的に配線しない限り、境界を越えるものはありません。
|
||||
- **スケジュールタスク** — Claudeを実行し、結果を返信できる定期ジョブ。
|
||||
- **Webアクセス** — Webからの検索とコンテンツ取得。
|
||||
- **コンテナ分離** — エージェントはDockerでサンドボックス化されます(macOS/Linux/WSL2)。[Docker Sandboxes](docs/docker-sandboxes.md)によるマイクロVM分離や、macOSネイティブのオプトインとしてApple Containerも選択可能です。
|
||||
- **クレデンシャルのセキュリティ** — エージェントは生のAPIキーを保持しません。アウトバウンドリクエストは[OneCLI Agent Vault](https://github.com/onecli/onecli)を経由し、リクエスト時に認証情報を注入して、エージェントごとのポリシーとレート制限を適用します。
|
||||
- **マルチチャネルメッセージング** - WhatsApp、Telegram、Discord、Slack、Gmailからアシスタントと会話。`/add-whatsapp`や`/add-telegram`などのスキルでチャネルを追加。1つでも複数でも同時に実行可能。
|
||||
- **グループごとの分離コンテキスト** - 各グループは独自の`CLAUDE.md`メモリ、分離されたファイルシステムを持ち、そのファイルシステムのみがマウントされた専用コンテナサンドボックスで実行。
|
||||
- **メインチャネル** - 管理制御用のプライベートチャネル(セルフチャット)。各グループは完全に分離。
|
||||
- **スケジュールタスク** - Claudeを実行し、メッセージを返せる定期ジョブ。
|
||||
- **Webアクセス** - Webからのコンテンツ検索・取得。
|
||||
- **コンテナ分離** - エージェントは[Dockerサンドボックス](https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes)(マイクロVM分離)、Apple Container(macOS)、またはDocker(macOS/Linux)でサンドボックス化。
|
||||
- **エージェントスウォーム** - 複雑なタスクで協力する専門エージェントチームを起動。
|
||||
- **オプション連携** - Gmail(`/add-gmail`)などをスキルで追加。
|
||||
|
||||
## 使い方
|
||||
|
||||
@@ -69,7 +105,7 @@ bash nanoclaw.sh
|
||||
@Andy 毎週月曜の朝8時に、Hacker NewsとTechCrunchからAI関連のニュースをまとめてブリーフィングを送って
|
||||
```
|
||||
|
||||
所有または管理しているチャネルからは、グループやタスクを管理できます:
|
||||
メインチャネル(セルフチャット)から、グループやタスクを管理できます:
|
||||
```
|
||||
@Andy 全グループのスケジュールタスクを一覧表示して
|
||||
@Andy 月曜のブリーフィングタスクを一時停止して
|
||||
@@ -78,14 +114,14 @@ bash nanoclaw.sh
|
||||
|
||||
## カスタマイズ
|
||||
|
||||
NanoClawは設定ファイルを使いません。変更したいときは、Claude Codeにやりたいことを伝えるだけです:
|
||||
NanoClawは設定ファイルを使いません。変更するには、Claude Codeに伝えるだけです:
|
||||
|
||||
- 「トリガーワードを@Bobに変更して」
|
||||
- 「今後はレスポンスをもっと短く直接的にして」
|
||||
- 「おはようと言ったらカスタム挨拶を追加して」
|
||||
- 「会話の要約を毎週保存して」
|
||||
|
||||
または`/customize`を実行すればガイド付きで変更できます。
|
||||
または`/customize`を実行してガイド付きの変更を行えます。
|
||||
|
||||
コードベースは十分に小さいため、Claudeが安全に変更できます。
|
||||
|
||||
@@ -93,101 +129,105 @@ NanoClawは設定ファイルを使いません。変更したいときは、Cla
|
||||
|
||||
**機能を追加するのではなく、スキルを追加してください。**
|
||||
|
||||
新しいチャネルやエージェントプロバイダーを追加したい場合、トランクには追加しないでください。新しいチャネルアダプターは`channels`ブランチに、新しいエージェントプロバイダーは`providers`ブランチに追加します。ユーザーはそれぞれのフォークで`/add-<name>`スキルを実行し、スキルが必要なモジュールを標準パスへコピーし、登録を配線し、依存関係をピン留めします。
|
||||
Telegram対応を追加したい場合、コアコードベースにTelegramを追加するPRを作成しないでください。代わりに、NanoClawをフォークし、ブランチでコード変更を行い、PRを開いてください。あなたのPRから`skill/telegram`ブランチを作成し、他のユーザーが自分のフォークにマージできるようにします。
|
||||
|
||||
こうすることでトランクは純粋なレジストリ/インフラのまま保たれ、どのフォークもスリムなままです。ユーザーは求めたチャネルとプロバイダーだけを受け取り、それ以外は入りません。
|
||||
ユーザーは自分のフォークで`/add-telegram`を実行するだけで、あらゆるユースケースに対応しようとする肥大化したシステムではなく、必要なものだけを正確に実行するクリーンなコードが手に入ります。
|
||||
|
||||
### RFS(スキル募集)
|
||||
|
||||
私たちが見たいスキル:
|
||||
私たちが求めているスキル:
|
||||
|
||||
**コミュニケーションチャネル**
|
||||
- `/add-signal` — Signalをチャネルとして追加
|
||||
- `/add-signal` - Signalをチャネルとして追加
|
||||
|
||||
**セッション管理**
|
||||
- `/clear` - 会話をコンパクト化する`/clear`コマンドの追加(同一セッション内で重要な情報を保持しながらコンテキストを要約)。Claude Agent SDKを通じてプログラム的にコンパクト化をトリガーする方法の解明が必要。
|
||||
|
||||
## 必要条件
|
||||
|
||||
- macOSまたはLinux(WindowsはWSL2経由)
|
||||
- Node.js 20以上とpnpm 10以上(インストーラーが未インストールなら両方をインストールします)
|
||||
- [Docker Desktop](https://docker.com/products/docker-desktop)(macOS/Windows)または Docker Engine(Linux)
|
||||
- [Claude Code](https://claude.ai/download)(`/customize`、`/debug`、セットアップ時のエラー復旧、全ての`/add-<channel>`スキルで使用)
|
||||
- macOSまたはLinux
|
||||
- Node.js 20以上
|
||||
- [Claude Code](https://claude.ai/download)
|
||||
- [Apple Container](https://github.com/apple/container)(macOS)または[Docker](https://docker.com/products/docker-desktop)(macOS/Linux)
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
```
|
||||
メッセージングアプリ → ホストプロセス(ルーター) → inbound.db → コンテナ(Bun、Claude Agent SDK) → outbound.db → ホストプロセス(配信) → メッセージングアプリ
|
||||
チャネル --> SQLite --> ポーリングループ --> コンテナ(Claude Agent SDK) --> レスポンス
|
||||
```
|
||||
|
||||
単一のNodeホストがセッションごとのエージェントコンテナをオーケストレーションします。メッセージが到着すると、ホストはエンティティモデル(ユーザー → メッセージンググループ → エージェントグループ → セッション)に沿ってルーティングし、セッションの`inbound.db`に書き込み、コンテナを起こします。コンテナ内部のagent-runnerは`inbound.db`をポーリングしてClaudeを実行し、レスポンスを`outbound.db`に書き込みます。ホストは`outbound.db`をポーリングし、チャネルアダプターを通じて配信します。
|
||||
単一のNode.jsプロセス。チャネルはスキルで追加され、起動時に自己登録します — オーケストレーターは認証情報が存在するチャネルを接続します。エージェントはファイルシステム分離された独立したLinuxコンテナで実行されます。マウントされたディレクトリのみアクセス可能。グループごとのメッセージキューと同時実行制御。ファイルシステム経由のIPC。
|
||||
|
||||
セッションごとに2つのSQLiteファイル、各ファイルにライターは1つだけ — クロスマウントの競合なし、IPCなし、stdinパイプなし。チャネルと代替プロバイダーは起動時に自己登録します。トランクはレジストリとChat SDKブリッジを同梱し、アダプター本体はフォークごとにスキルでインストールされます。
|
||||
|
||||
詳しいアーキテクチャ説明は[docs/architecture.md](docs/architecture.md)を、3階層の分離モデルについては[docs/isolation-model.md](docs/isolation-model.md)を参照してください。
|
||||
詳細なアーキテクチャについては、[docs/SPEC.md](docs/SPEC.md)を参照してください。
|
||||
|
||||
主要ファイル:
|
||||
- `src/index.ts` — エントリーポイント:DB初期化、チャネルアダプター、配信ポーリング、sweep
|
||||
- `src/router.ts` — インバウンドルーティング:メッセージンググループ → エージェントグループ → セッション → `inbound.db`
|
||||
- `src/delivery.ts` — `outbound.db`をポーリングし、アダプター経由で配信、システムアクションを処理
|
||||
- `src/host-sweep.ts` — 60秒ごとのsweep:ストール検出、期限到来メッセージの起動、繰り返し
|
||||
- `src/session-manager.ts` — セッションの解決、`inbound.db`と`outbound.db`のオープン
|
||||
- `src/container-runner.ts` — エージェントグループごとのコンテナ起動、OneCLIによるクレデンシャル注入
|
||||
- `src/db/` — セントラルDB(ユーザー、ロール、エージェントグループ、メッセージンググループ、配線、マイグレーション)
|
||||
- `src/channels/` — チャネルアダプターのインフラ(アダプターは`/add-<channel>`スキルでインストール)
|
||||
- `src/providers/` — ホスト側プロバイダー設定(`claude`はバンドル、その他はスキル経由)
|
||||
- `container/agent-runner/` — Bun製agent-runner:ポーリングループ、MCPツール、プロバイダー抽象化
|
||||
- `groups/<folder>/` — エージェントグループごとのファイルシステム(`CLAUDE.md`、スキル、コンテナ設定)
|
||||
- `src/index.ts` - オーケストレーター:状態、メッセージループ、エージェント呼び出し
|
||||
- `src/channels/registry.ts` - チャネルレジストリ(起動時の自己登録)
|
||||
- `src/ipc.ts` - IPCウォッチャーとタスク処理
|
||||
- `src/router.ts` - メッセージフォーマットとアウトバウンドルーティング
|
||||
- `src/group-queue.ts` - グローバル同時実行制限付きのグループごとのキュー
|
||||
- `src/container-runner.ts` - ストリーミングエージェントコンテナの起動
|
||||
- `src/task-scheduler.ts` - スケジュールタスクの実行
|
||||
- `src/db.ts` - SQLite操作(メッセージ、グループ、セッション、状態)
|
||||
- `groups/*/CLAUDE.md` - グループごとのメモリ
|
||||
|
||||
## FAQ
|
||||
|
||||
**なぜDockerなのか?**
|
||||
|
||||
Dockerはクロスプラットフォーム対応(macOS、Linux、WSL2経由のWindows)と成熟したエコシステムを提供します。macOSでは、`/convert-to-apple-container`でオプションとしてApple Containerに切り替え、より軽量なネイティブランタイムを使えます。さらに強い分離が必要なら、[Docker Sandboxes](docs/docker-sandboxes.md)が各コンテナをマイクロVM内で動作させます。
|
||||
Dockerはクロスプラットフォーム対応(macOS、Linux、さらにWSL2経由のWindows)と成熟したエコシステムを提供します。macOSでは、`/convert-to-apple-container`でオプションとしてApple Containerに切り替え、より軽量なネイティブランタイムを使用できます。
|
||||
|
||||
**LinuxやWindowsで実行できますか?**
|
||||
**Linuxで実行できますか?**
|
||||
|
||||
はい。Dockerがデフォルトのランタイムで、macOS、Linux、Windows(WSL2経由)で動作します。`bash nanoclaw.sh`を実行するだけです。
|
||||
はい。DockerがデフォルトのランタイムでmacOSとLinuxの両方で動作します。`/setup`を実行するだけです。
|
||||
|
||||
**セキュリティは大丈夫ですか?**
|
||||
|
||||
エージェントはアプリケーションレベルのパーミッションチェックではなく、コンテナ内で実行されます。明示的にマウントされたディレクトリのみアクセス可能です。クレデンシャルはコンテナに渡されず、アウトバウンドAPIリクエストは[OneCLI Agent Vault](https://github.com/onecli/onecli)を経由し、プロキシレベルで認証を注入し、レートリミットやアクセスポリシーをサポートします。実行するものはレビューすべきですが、コードベースは実際にレビュー可能な規模です。完全なセキュリティモデルについては[セキュリティドキュメント](https://docs.nanoclaw.dev/concepts/security)を参照してください。
|
||||
エージェントはアプリケーションレベルのパーミッションチェックの背後ではなく、コンテナで実行されます。明示的にマウントされたディレクトリのみアクセスできます。実行するものをレビューすべきですが、コードベースは十分に小さいため実際にレビュー可能です。完全なセキュリティモデルについては[docs/SECURITY.md](docs/SECURITY.md)を参照してください。
|
||||
|
||||
**なぜ設定ファイルがないのか?**
|
||||
|
||||
設定の肥大化を避けたいからです。すべてのユーザーがNanoClawをカスタマイズし、汎用的なシステムを設定するのではなくコードが自分の望み通りに動くようにすべきです。設定ファイルが欲しければClaudeに追加するよう伝えれば実現できます。
|
||||
設定の肥大化を避けたいからです。すべてのユーザーがNanoClawをカスタマイズし、汎用的なシステムを設定するのではなく、コードが必要なことを正確に実行するようにすべきです。設定ファイルが欲しい場合は、Claudeに追加するよう伝えることができます。
|
||||
|
||||
**サードパーティやオープンソースモデルを使えますか?**
|
||||
|
||||
はい。推奨される方法は`/add-opencode`(OpenCode設定経由でOpenRouter、OpenAI、Google、DeepSeekなど)か`/add-ollama-provider`(Ollama経由でローカルのオープンウェイトモデル)です。どちらもエージェントグループごとに設定可能なので、同じインストール内で異なるエージェントが異なるバックエンドで動作できます。
|
||||
|
||||
一時的な実験用には、Claude API互換のエンドポイントも`.env`で利用できます:
|
||||
はい。NanoClawはClaude API互換のモデルエンドポイントに対応しています。`.env`ファイルで以下の環境変数を設定してください:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
|
||||
ANTHROPIC_AUTH_TOKEN=your-token-here
|
||||
```
|
||||
|
||||
以下が使用可能です:
|
||||
- [Ollama](https://ollama.ai)とAPIプロキシ経由のローカルモデル
|
||||
- [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai)等でホストされたオープンソースモデル
|
||||
- Anthropic互換APIのカスタムモデルデプロイメント
|
||||
|
||||
注意:最高の互換性のため、モデルはAnthropic APIフォーマットに対応している必要があります。
|
||||
|
||||
**問題のデバッグ方法は?**
|
||||
|
||||
Claude Codeに聞いてください。「スケジューラーが動いていないのはなぜ?」「最近のログには何がある?」「このメッセージに返信がなかったのはなぜ?」これがNanoClawの基盤となるAIネイティブなアプローチです。
|
||||
|
||||
**セットアップがうまくいかない場合は?**
|
||||
|
||||
ステップが失敗した場合、`nanoclaw.sh`は診断と再開のためにClaude Codeへ制御を渡します。それでも解決しなければ、`claude`を実行して`/debug`を呼び出してください。他のユーザーにも影響しそうな問題をClaudeが特定した場合は、該当のセットアップステップまたはスキルにPRを送ってください。
|
||||
問題がある場合、セットアップ中にClaudeが動的に修正を試みます。それでもうまくいかない場合は、`claude`を実行してから`/debug`を実行してください。Claudeが他のユーザーにも影響する可能性のある問題を見つけた場合は、セットアップのSKILL.mdを修正するPRを開いてください。
|
||||
|
||||
**どのような変更がコードベースに受け入れられますか?**
|
||||
|
||||
ベース設定に受け入れられるのは、セキュリティ修正、バグ修正、明確な改善のみです。それだけです。
|
||||
セキュリティ修正、バグ修正、明確な改善のみが基本設定に受け入れられます。それだけです。
|
||||
|
||||
それ以外(新機能、OS互換性、ハードウェアサポート、拡張など)は、`channels`または`providers`ブランチのスキルとしてコントリビュートしてください。
|
||||
それ以外のすべて(新機能、OS互換性、ハードウェアサポート、機能拡張)はスキルとしてコントリビューションすべきです。
|
||||
|
||||
これにより、ベースシステムを最小限に保ち、全ユーザーが不要な機能を継承することなく自分のインストールをカスタマイズできます。
|
||||
これにより、基本システムを最小限に保ち、すべてのユーザーが不要な機能を継承することなく、自分のインストールをカスタマイズできます。
|
||||
|
||||
## コミュニティ
|
||||
|
||||
質問やアイデアがありますか?[Discordに参加](https://discord.gg/VDdww8qS42)してください。
|
||||
質問やアイデアは?[Discordに参加](https://discord.gg/VDdww8qS42)してください。
|
||||
|
||||
## 変更履歴
|
||||
|
||||
破壊的変更については[CHANGELOG.md](CHANGELOG.md)を、完全なリリース履歴はドキュメントサイトの[full release history](https://docs.nanoclaw.dev/changelog)を参照してください。
|
||||
破壊的変更と移行ノートについては[CHANGELOG.md](CHANGELOG.md)を参照してください。
|
||||
|
||||
## ライセンス
|
||||
|
||||
|
||||
+88
-78
@@ -3,87 +3,93 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
一个将智能体安全运行在独立容器中的 AI 助手。轻量、易于理解,并可根据您的需求完全定制。
|
||||
NanoClaw —— 您的专属 Claude 助手,在容器中安全运行。它轻巧易懂,并能根据您的个人需求灵活定制。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nanoclaw.dev">nanoclaw.dev</a> •
|
||||
<a href="https://docs.nanoclaw.dev">文档</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
> **注意:** 此中文翻译对应 v1 版本,已不反映最新的 v2 架构。请参考 [README.md](README.md) 获取最新内容。
|
||||
|
||||
## 我为什么创建 NanoClaw
|
||||
通过 Claude Code,NanoClaw 可以动态重写自身代码,根据您的需求定制功能。
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解、却能访问我个人隐私的复杂软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(白名单、配对码),而非真正的操作系统级隔离。所有东西都在一个共享内存的 Node 进程中运行。
|
||||
**新功能:** 首个支持 [Agent Swarms(智能体集群)](https://code.claude.com/docs/en/agent-teams) 的 AI 助手。可轻松组建智能体团队,在您的聊天中高效协作。
|
||||
|
||||
NanoClaw 用一个您能轻松理解的代码库提供了同样的核心功能:一个进程,少数几个文件。Claude 智能体运行在具有文件系统隔离的独立 Linux 容器中,而不是仅靠权限检查。
|
||||
## 我为什么创建这个项目
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解却能访问我个人隐私的软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(通过白名单、配对码实现),而非操作系统级别的隔离。所有东西都在一个共享内存的 Node 进程中运行。
|
||||
|
||||
NanoClaw 用一个您能快速理解的代码库,为您提供了同样的核心功能。只有一个进程,少数几个文件。智能体(Agent)运行在具有文件系统隔离的真实 Linux 容器中,而不是依赖于权限检查。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash nanoclaw.sh
|
||||
git clone https://github.com/qwibitai/nanoclaw.git
|
||||
cd nanoclaw
|
||||
claude
|
||||
```
|
||||
|
||||
`nanoclaw.sh` 会把您从一台全新机器一直带到一个可以直接发消息的命名智能体。它会在缺失时安装 Node、pnpm 和 Docker,向 OneCLI 注册您的 Anthropic 凭据,构建智能体容器,并配对您的第一个渠道(Telegram、Discord、WhatsApp 或本地 CLI)。如果某一步失败,会自动调用 Claude Code 进行诊断并从中断处继续。
|
||||
然后运行 `/setup`。Claude Code 会处理一切:依赖安装、身份验证、容器设置、服务配置。
|
||||
|
||||
> **注意:** 以 `/` 开头的命令(如 `/setup`、`/add-whatsapp`)是 [Claude Code 技能](https://code.claude.com/docs/en/skills)。请在 `claude` CLI 提示符中输入,而非在普通终端中。
|
||||
|
||||
## 设计哲学
|
||||
|
||||
**小到可以理解。** 单一进程,少量源文件,无微服务。如果您想了解完整的 NanoClaw 代码库,直接让 Claude Code 给您讲一遍就行。
|
||||
**小巧易懂:** 单一进程,少量源文件。无微服务、无消息队列、无复杂抽象层。让 Claude Code 引导您轻松上手。
|
||||
|
||||
**通过隔离实现安全。** 智能体运行在 Linux 容器中,只能看到明确挂载的内容。Bash 访问是安全的,因为命令在容器内执行,而不是在您的宿主机上。
|
||||
**通过隔离保障安全:** 智能体运行在 Linux 容器(在 macOS 上是 Apple Container,或 Docker)中。它们只能看到被明确挂载的内容。即便通过 Bash 访问也十分安全,因为所有命令都在容器内执行,不会直接操作您的宿主机。
|
||||
|
||||
**为个人用户打造。** NanoClaw 不是一个单体框架,而是能精确匹配每个用户需求的软件。它被设计成量身定制的,而不是臃肿膨胀。您创建自己的 fork,让 Claude Code 按您的需求修改它。
|
||||
**为单一用户打造:** 这不是一个框架,是一个完全符合您个人需求的、可工作的软件。您可以 Fork 本项目,然后让 Claude Code 根据您的精确需求进行修改和适配。
|
||||
|
||||
**定制 = 修改代码。** 没有配置膨胀。想要不同的行为?改代码。代码库小到改动是安全的。
|
||||
**定制即代码修改:** 没有繁杂的配置文件。想要不同的行为?直接修改代码。代码库足够小,这样做是安全的。
|
||||
|
||||
**AI 原生,混合式设计。** 安装与上手流程走的是经过优化的脚本路径,快速且确定。当某一步需要判断(安装失败、引导决策、定制化)时,控制权会无缝地交给 Claude Code。安装之后也不提供监控仪表盘或调试 UI:您在聊天中描述问题,Claude Code 来处理。
|
||||
**AI 原生:** 无安装向导(由 Claude Code 指导安装)。无需监控仪表盘,直接询问 Claude 即可了解系统状况。无调试工具(描述问题,Claude 会修复它)。
|
||||
|
||||
**技能优于功能。** 主干只发布注册表和基础设施,不包含具体的渠道适配器或替代智能体提供者。各个渠道(Discord、Slack、Telegram、WhatsApp……)放在长期存在的 `channels` 分支上;替代提供者(OpenCode、Ollama)放在 `providers` 分支上。您运行 `/add-telegram`、`/add-opencode` 等,技能会把您所需要的模块精确地复制到您的 fork 里。不会出现您没要求的功能。
|
||||
**技能(Skills)优于功能(Features):** 贡献者不应该向代码库添加新功能(例如支持 Telegram)。相反,他们应该贡献像 `/add-telegram` 这样的 [Claude Code 技能](https://code.claude.com/docs/en/skills),这些技能可以改造您的 fork。最终,您得到的是只做您需要事情的整洁代码。
|
||||
|
||||
**最强的 harness,最强的模型。** NanoClaw 通过 Anthropic 官方的 Claude Agent SDK 原生使用 Claude Code,所以您能用上最新的 Claude 模型以及 Claude Code 的完整工具集——包括修改和扩展自己的 NanoClaw fork 的能力。其他提供者是可插拔选项:`/add-codex` 对应 OpenAI 的 Codex(ChatGPT 订阅或 API key),`/add-opencode` 通过 OpenCode 接入 OpenRouter、Google、DeepSeek 等,`/add-ollama-provider` 用于本地开源权重模型。提供者可按智能体组单独配置。
|
||||
**最好的工具套件,最好的模型:** 本项目运行在 Claude Agent SDK 之上,这意味着您直接运行的就是 Claude Code。Claude Code 高度强大,其编码和问题解决能力使其能够修改和扩展 NanoClaw,为每个用户量身定制。
|
||||
|
||||
## 功能支持
|
||||
|
||||
- **多渠道消息** — WhatsApp、Telegram、Discord、Slack、Microsoft Teams、iMessage、Matrix、Google Chat、Webex、Linear、GitHub、WeChat,以及通过 Resend 的邮件。按需通过 `/add-<channel>` 技能安装。可同时运行一个或多个。
|
||||
- **灵活的隔离模式** — 可为每个渠道配一个独立智能体以获得完全隐私,也可让一个智能体在多个渠道上共享、统一记忆但会话独立,或者把多个渠道合并到一个共享会话里,让一场对话横跨多个入口。通过 `/manage-channels` 按渠道选择。详见 [docs/isolation-model.md](docs/isolation-model.md)。
|
||||
- **每个智能体的独立工作区** — 每个智能体组都有自己的 `CLAUDE.md`、自己的记忆、自己的容器,以及您允许的挂载点。除非您明确接线,否则不会有东西越过边界。
|
||||
- **计划任务** — 运行 Claude 的周期性作业,可以给您回发消息。
|
||||
- **网络访问** — 搜索和抓取网页内容。
|
||||
- **容器隔离** — 智能体在 Docker(macOS/Linux/WSL2)中沙箱化运行,可选 [Docker Sandboxes](docs/docker-sandboxes.md) 的微虚拟机隔离,或在 macOS 上选用 Apple Container 作为原生运行时。
|
||||
- **凭据安全** — 智能体不持有原始 API key。出站请求经由 [OneCLI 的 Agent Vault](https://github.com/onecli/onecli),在请求时注入凭据,并按每个智能体执行策略和速率限制。
|
||||
- **多渠道消息** - 通过 WhatsApp、Telegram、Discord、Slack 或 Gmail 与您的助手对话。使用 `/add-whatsapp` 或 `/add-telegram` 等技能添加渠道,可同时运行一个或多个。
|
||||
- **隔离的群组上下文** - 每个群组都拥有独立的 `CLAUDE.md` 记忆和隔离的文件系统。它们在各自的容器沙箱中运行,且仅挂载所需的文件系统。
|
||||
- **主频道** - 您的私有频道(self-chat),用于管理控制;其他所有群组都完全隔离
|
||||
- **计划任务** - 运行 Claude 的周期性作业,并可以给您回发消息
|
||||
- **网络访问** - 搜索和抓取网页内容
|
||||
- **容器隔离** - 智能体在 Apple Container (macOS) 或 Docker (macOS/Linux) 的沙箱中运行
|
||||
- **智能体集群(Agent Swarms)** - 启动多个专业智能体团队,协作完成复杂任务(首个支持此功能的个人 AI 助手)
|
||||
- **可选集成** - 通过技能添加 Gmail (`/add-gmail`) 等更多功能
|
||||
|
||||
## 使用方法
|
||||
|
||||
用触发词(默认为 `@Andy`)与您的助手对话:
|
||||
使用触发词(默认为 `@Andy`)与您的助手对话:
|
||||
|
||||
```
|
||||
@Andy 每个工作日早上 9 点给我发一份销售渠道概览(可以访问我的 Obsidian vault 文件夹)
|
||||
@Andy 每周五回顾过去一周的 git 历史,如果与 README 有出入就更新它
|
||||
@Andy 每周一早上 8 点,从 Hacker News 和 TechCrunch 收集 AI 相关资讯,给我发一份简报
|
||||
@Andy 每周一到周五早上9点,给我发一份销售渠道的概览(需要访问我的 Obsidian vault 文件夹)
|
||||
@Andy 每周五回顾过去一周的 git 历史,如果与 README 有出入,就更新它
|
||||
@Andy 每周一早上8点,从 Hacker News 和 TechCrunch 收集关于 AI 发展的资讯,然后发给我一份简报
|
||||
```
|
||||
|
||||
在您拥有或管理的渠道里,还可以管理群组和任务:
|
||||
在主频道(您的self-chat)中,可以管理群组和任务:
|
||||
```
|
||||
@Andy 列出所有群组里的计划任务
|
||||
@Andy 列出所有群组的计划任务
|
||||
@Andy 暂停周一简报任务
|
||||
@Andy 加入"家庭聊天"群组
|
||||
```
|
||||
|
||||
## 定制
|
||||
|
||||
NanoClaw 不用配置文件。想改就直接告诉 Claude Code:
|
||||
没有需要学习的配置文件。直接告诉 Claude Code 您想要什么:
|
||||
|
||||
- "把触发词改成 @Bob"
|
||||
- "以后回答请更简短、更直接"
|
||||
- "我说早上好的时候加一个自定义问候"
|
||||
- "每周保存一次会话摘要"
|
||||
- "记住以后回答要更简短直接"
|
||||
- "当我说早上好的时候,加一个自定义的问候"
|
||||
- "每周存储一次对话摘要"
|
||||
|
||||
或者运行 `/customize` 进行引导式修改。
|
||||
|
||||
@@ -91,103 +97,107 @@ NanoClaw 不用配置文件。想改就直接告诉 Claude Code:
|
||||
|
||||
## 贡献
|
||||
|
||||
**不要加功能,要加技能。**
|
||||
**不要添加功能,而是添加技能。**
|
||||
|
||||
如果您想添加新的渠道或智能体提供者,不要把它加到主干上。新的渠道适配器进入 `channels` 分支;新的智能体提供者进入 `providers` 分支。用户在自己的 fork 上运行 `/add-<name>` 技能,由技能把相关模块复制到标准路径、接好注册、固定依赖版本。
|
||||
如果您想添加 Telegram 支持,不要创建一个 PR 同时添加 Telegram 和 WhatsApp。而是贡献一个技能文件 (`.claude/skills/add-telegram/SKILL.md`),教 Claude Code 如何改造一个 NanoClaw 安装以使用 Telegram。
|
||||
|
||||
这样主干始终保持为纯粹的注册表和基础设施,每个 fork 也都保持精简——用户只获得他们要求的渠道和提供者,其它什么也不会混进来。
|
||||
然后用户在自己的 fork 上运行 `/add-telegram`,就能得到只做他们需要事情的整洁代码,而不是一个试图支持所有用例的臃肿系统。
|
||||
|
||||
### RFS(技能征集)
|
||||
### RFS (技能征集)
|
||||
|
||||
我们希望看到的技能:
|
||||
|
||||
**通信渠道**
|
||||
- `/add-signal` — 添加 Signal 作为渠道
|
||||
- `/add-signal` - 添加 Signal 作为渠道
|
||||
|
||||
**会话管理**
|
||||
- `/clear` - 添加一个 `/clear` 命令,用于压缩会话(在同一会话中总结上下文,同时保留关键信息)。这需要研究如何通过 Claude Agent SDK 以编程方式触发压缩。
|
||||
|
||||
## 系统要求
|
||||
|
||||
- macOS 或 Linux(Windows 通过 WSL2)
|
||||
- Node.js 20+ 和 pnpm 10+(安装脚本会在缺失时自动安装)
|
||||
- [Docker Desktop](https://docker.com/products/docker-desktop)(macOS/Windows)或 Docker Engine(Linux)
|
||||
- [Claude Code](https://claude.ai/download),用于 `/customize`、`/debug`、安装过程中的错误恢复以及所有 `/add-<channel>` 技能
|
||||
- macOS 或 Linux
|
||||
- Node.js 20+
|
||||
- [Claude Code](https://claude.ai/download)
|
||||
- [Apple Container](https://github.com/apple/container) (macOS) 或 [Docker](https://docker.com/products/docker-desktop) (macOS/Linux)
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
消息应用 → 主机进程(路由器) → inbound.db → 容器(Bun、Claude Agent SDK) → outbound.db → 主机进程(投递) → 消息应用
|
||||
渠道 --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) --> 响应
|
||||
```
|
||||
|
||||
单一 Node 主机编排每个会话的智能体容器。当一条消息到来时,主机按实体模型(用户 → 消息组 → 智能体组 → 会话)进行路由,写入该会话的 `inbound.db`,并唤醒容器。容器内部的 agent-runner 轮询 `inbound.db`,调用 Claude,并把响应写入 `outbound.db`。主机轮询 `outbound.db`,通过渠道适配器投递回去。
|
||||
单一 Node.js 进程。渠道通过技能添加,启动时自注册 — 编排器连接具有凭据的渠道。智能体在具有文件系统隔离的 Linux 容器中执行。每个群组的消息队列带有并发控制。通过文件系统进行 IPC。
|
||||
|
||||
每个会话两个 SQLite 文件,每个文件只有一个写入者——没有跨挂载的锁争用,没有 IPC,没有 stdin 管道。渠道和替代提供者在启动时自注册;主干提供注册表和 Chat SDK 桥接,而适配器本身在每个 fork 里通过技能安装。
|
||||
|
||||
完整架构说明见 [docs/architecture.md](docs/architecture.md);三级隔离模型见 [docs/isolation-model.md](docs/isolation-model.md)。
|
||||
完整架构详情请见 [docs/SPEC.md](docs/SPEC.md)。
|
||||
|
||||
关键文件:
|
||||
- `src/index.ts` — 入口:数据库初始化、渠道适配器、投递轮询、sweep
|
||||
- `src/router.ts` — 入站路由:消息组 → 智能体组 → 会话 → `inbound.db`
|
||||
- `src/delivery.ts` — 轮询 `outbound.db`,通过适配器投递,处理系统动作
|
||||
- `src/host-sweep.ts` — 60 秒 sweep:失效检测、到期消息唤醒、循环任务
|
||||
- `src/session-manager.ts` — 解析会话,打开 `inbound.db` / `outbound.db`
|
||||
- `src/container-runner.ts` — 为每个智能体组启动容器,OneCLI 凭据注入
|
||||
- `src/db/` — 中心数据库(用户、角色、智能体组、消息组、接线、迁移)
|
||||
- `src/channels/` — 渠道适配器基础设施(适配器通过 `/add-<channel>` 技能安装)
|
||||
- `src/providers/` — 主机侧提供者配置(`claude` 内置,其他通过技能安装)
|
||||
- `container/agent-runner/` — Bun 版 agent-runner:轮询循环、MCP 工具、提供者抽象
|
||||
- `groups/<folder>/` — 每个智能体组的文件系统(`CLAUDE.md`、技能、容器配置)
|
||||
- `src/index.ts` - 编排器:状态管理、消息循环、智能体调用
|
||||
- `src/channels/registry.ts` - 渠道注册表(启动时自注册)
|
||||
- `src/ipc.ts` - IPC 监听与任务处理
|
||||
- `src/router.ts` - 消息格式化与出站路由
|
||||
- `src/group-queue.ts` - 带全局并发限制的群组队列
|
||||
- `src/container-runner.ts` - 生成流式智能体容器
|
||||
- `src/task-scheduler.ts` - 运行计划任务
|
||||
- `src/db.ts` - SQLite 操作(消息、群组、会话、状态)
|
||||
- `groups/*/CLAUDE.md` - 各群组的记忆
|
||||
|
||||
## FAQ
|
||||
|
||||
**为什么用 Docker?**
|
||||
**为什么是 Docker?**
|
||||
|
||||
Docker 提供跨平台支持(macOS、Linux、Windows via WSL2)和成熟的生态。在 macOS 上,您可以选择通过 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量的原生运行时。如需更强隔离,[Docker Sandboxes](docs/docker-sandboxes.md) 会把每个容器放到一台微虚拟机里运行。
|
||||
Docker 提供跨平台支持(macOS 和 Linux)和成熟的生态系统。在 macOS 上,您可以选择通过运行 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量级的原生运行时体验。
|
||||
|
||||
**我可以在 Linux 或 Windows 上运行吗?**
|
||||
**我可以在 Linux 上运行吗?**
|
||||
|
||||
可以。Docker 是默认运行时,可在 macOS、Linux 以及 Windows(通过 WSL2)上工作。运行 `bash nanoclaw.sh` 就行。
|
||||
可以。Docker 是默认的容器运行时,在 macOS 和 Linux 上都可以使用。只需运行 `/setup`。
|
||||
|
||||
**这个项目安全吗?**
|
||||
|
||||
智能体运行在容器里,而不是躲在应用级权限检查之后。它们只能访问明确挂载的目录。凭据不会进入容器——出站 API 请求通过 [OneCLI 的 Agent Vault](https://github.com/onecli/onecli) 在代理层注入认证,并支持速率限制和访问策略。您仍然应该审查自己要运行的代码,但代码库小到您真的能做到。完整的安全模型见 [安全文档](https://docs.nanoclaw.dev/concepts/security)。
|
||||
智能体在容器中运行,而不是在应用级别的权限检查之后。它们只能访问被明确挂载的目录。您仍然应该审查您运行的代码,但这个代码库小到您真的可以做到。完整的安全模型请见 [docs/SECURITY.md](docs/SECURITY.md)。
|
||||
|
||||
**为什么没有配置文件?**
|
||||
|
||||
我们不想让配置泛滥。每位用户都应该定制 NanoClaw,让代码精确地做他们想要的事,而不是去配置一个通用系统。如果您更喜欢有配置文件,可以让 Claude 给您加。
|
||||
我们不希望配置泛滥。每个用户都应该定制它,让代码完全符合他们的需求,而不是去配置一个通用的系统。如果您喜欢用配置文件,告诉 Claude 让它加上。
|
||||
|
||||
**我可以使用第三方或开源模型吗?**
|
||||
|
||||
可以。推荐做法是 `/add-opencode`(通过 OpenCode 配置接入 OpenRouter、OpenAI、Google、DeepSeek 等)或 `/add-ollama-provider`(通过 Ollama 使用本地开源权重模型)。两者都可以按智能体组单独配置,所以同一套安装里不同的智能体可以运行在不同的后端上。
|
||||
|
||||
对于一次性实验,任何 Claude API 兼容的端点也可以通过 `.env` 使用:
|
||||
可以。NanoClaw 支持任何 API 兼容的模型端点。在 `.env` 文件中设置以下环境变量:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
|
||||
ANTHROPIC_AUTH_TOKEN=your-token-here
|
||||
```
|
||||
|
||||
这使您能够使用:
|
||||
- 通过 [Ollama](https://ollama.ai) 配合 API 代理运行的本地模型
|
||||
- 托管在 [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai) 等平台上的开源模型
|
||||
- 兼容 Anthropic API 格式的自定义模型部署
|
||||
|
||||
注意:为获得最佳兼容性,模型需支持 Anthropic API 格式。
|
||||
|
||||
**我该如何调试问题?**
|
||||
|
||||
问 Claude Code。"为什么计划任务没运行?""最近的日志里有什么?""为什么这条消息没有得到回复?"这就是 NanoClaw 底层的 AI 原生方式。
|
||||
问 Claude Code。"为什么计划任务没有运行?" "最近的日志里有什么?" "为什么这条消息没有得到回应?" 这就是 AI 原生的方法。
|
||||
|
||||
**为什么安装对我不成功?**
|
||||
**为什么我的安装不成功?**
|
||||
|
||||
如果某一步失败,`nanoclaw.sh` 会把控制权交给 Claude Code 进行诊断并从中断处继续。如果还是没解决,运行 `claude`,然后 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请对相关的安装步骤或技能提 PR。
|
||||
如果遇到问题,安装过程中 Claude 会尝试动态修复。如果问题仍然存在,运行 `claude`,然后运行 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请开一个 PR 来修改 setup SKILL.md。
|
||||
|
||||
**什么样的更改会被接受进代码库?**
|
||||
**什么样的代码更改会被接受?**
|
||||
|
||||
进入基础配置的只会是:安全修复、bug 修复、明显的改进。仅此而已。
|
||||
安全修复、bug 修复,以及对基础配置的明确改进。仅此而已。
|
||||
|
||||
其他一切(新能力、操作系统兼容、硬件支持、增强)都应作为技能贡献到 `channels` 或 `providers` 分支。
|
||||
其他一切(新功能、操作系统兼容性、硬件支持、增强功能)都应该作为技能来贡献。
|
||||
|
||||
这样基础系统保持最小化,每位用户都可以定制自己的安装,而不必继承他们不想要的功能。
|
||||
这使得基础系统保持最小化,并让每个用户可以定制他们的安装,而无需继承他们不想要的功能。
|
||||
|
||||
## 社区
|
||||
|
||||
有问题或想法?欢迎[加入 Discord](https://discord.gg/VDdww8qS42)。
|
||||
有任何疑问或建议?欢迎[加入 Discord 社区](https://discord.gg/VDdww8qS42)与我们交流。
|
||||
|
||||
## 更新日志
|
||||
|
||||
破坏性变更见 [CHANGELOG.md](CHANGELOG.md),完整发布历史见文档站的 [full release history](https://docs.nanoclaw.dev/changelog)。
|
||||
破坏性变更和迁移说明请见 [CHANGELOG.md](CHANGELOG.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⣄⠘⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⡆⢸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m [2m[38;2;43;183;206m°[39m[22m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⠀⠀⠀⢀⣤⣤⠀⢀⣄⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⣴⡿⡋⠀⠀⠀⠀⠀⢤⣾⣿⢛⢿⣏⠀⠀⠀⢰⣟⣽⡏⠀⣸⡿⣧[39m
|
||||
[2m[38;2;43;183;206mo[39m[22m [38;2;43;183;206m⠀⠀⢀⣾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠘⠈⣧⣀⣿⣧⠀⠀⣿⣼⣿⣇⣾⠋⢠⣿[39m
|
||||
[38;2;43;183;206m⠀⠀⣾⢃⠀⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⠀⣿⢻⣉⠉⠙⠠⣼⠇[39m
|
||||
[38;2;43;183;206m⠀⣼⡏⠃⠀⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋⠀[39m [2m[38;2;43;183;206mo[39m[22m
|
||||
[38;2;43;183;206m⢠⣿⡃⠀⠀⠀⠀⠀⠈⠀⠀⠉⠙⠁⠀⠀⠀⠐⣿⣿⣟⠁⣿⣿⠟⠋⠀⠀⠀[39m
|
||||
[2m[38;2;43;183;206m°[39m[22m [38;2;43;183;206m⢸⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⢸⣿⣿⣷⣤⣤⠀⣀⢀⠀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m [38;2;43;183;206mO[39m
|
||||
[38;2;43;183;206m⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠀⠀⠩⢻⣆⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⠀⠀⣿⡆⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠀⠠⣤⣿⡇⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206mo[39m [38;2;43;183;206m⠀⠀⠀⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋⠀⠀⠀⠀⠀[39m
|
||||
|
||||
[1m _ _ [22m[38;2;43;183;206m[1m ___ _ [22m[39m
|
||||
[1m| \| |__ _ _ _ ___ [22m[38;2;43;183;206m[1m / __| |__ ___ __ __[22m[39m
|
||||
[1m| .` / _` | ' \/ _ \[22m[38;2;43;183;206m[1m| (__| / _` \ V V /[22m[39m
|
||||
[1m|_|\_\__,_|_||_\___/[22m[38;2;43;183;206m[1m \___|_\__,_|\_/\_/ [22m[39m
|
||||
|
||||
[2mSmall.[22m
|
||||
[2mRuns on your machine.[22m
|
||||
[2mYours to modify.[22m
|
||||
|
||||
[38;2;5;62;165m════════════════════════════════════════[39m
|
||||
@@ -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=52.2.1
|
||||
ARG VERCEL_VERSION=latest
|
||||
ARG BUN_VERSION=1.3.12
|
||||
|
||||
# ---- System dependencies -----------------------------------------------------
|
||||
@@ -91,13 +91,7 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||
# the SDK fails at spawn time with "native binary not found".
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
# Pin pnpm to match the host (package.json packageManager). pnpm 11 stopped
|
||||
# honoring `only-built-dependencies[]=` in .npmrc for global installs, which
|
||||
# silently skips claude-code's native-binary postinstall and agent-browser's
|
||||
# bin chmod — the agent then crashes at runtime with "native binary not
|
||||
# installed". Keep this in lockstep with package.json's `packageManager`.
|
||||
ARG PNPM_VERSION=10.33.0
|
||||
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||
RUN corepack enable
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* PreCompact hook script — outputs custom compaction instructions to stdout.
|
||||
*
|
||||
* Claude Code captures the stdout of PreCompact shell hooks and passes it
|
||||
* as `customInstructions` to the compaction prompt. This ensures the
|
||||
* compaction summary preserves message routing context that the agent needs
|
||||
* to correctly address responses.
|
||||
*
|
||||
* Invoked by the PreCompact hook in .claude-shared/settings.json:
|
||||
* "command": "bun /app/src/compact-instructions.ts"
|
||||
*/
|
||||
import { getAllDestinations } from './destinations.js';
|
||||
|
||||
const destinations = getAllDestinations();
|
||||
const names = destinations.map((d) => d.name);
|
||||
|
||||
const instructions = [
|
||||
'Preserve the following in the compaction summary:',
|
||||
'',
|
||||
'1. For recent messages, keep the full XML structure including all attributes:',
|
||||
' - <message from="..." sender="..." time="..."> for chat messages',
|
||||
' - <task from="..." time="..."> for scheduled tasks',
|
||||
' - <webhook from="..." source="..." event="..."> for webhooks',
|
||||
' The message content can be summarized if long, but the XML tags and attributes must remain.',
|
||||
'',
|
||||
'2. Preserve the chronological message/reply sequence of recent exchanges.',
|
||||
' The agent needs to see: who said what, in what order, and from which destination.',
|
||||
'',
|
||||
'3. The `from` attribute identifies which destination sent the message.',
|
||||
' The agent MUST wrap all responses in <message to="name">...</message> blocks.',
|
||||
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}`,
|
||||
];
|
||||
|
||||
console.log(instructions.join('\n'));
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Per-batch context the poll loop publishes for downstream consumers
|
||||
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
|
||||
*
|
||||
* Today the only field is `inReplyTo` — the id of the first inbound
|
||||
* message in the batch the agent is currently processing. MCP tools like
|
||||
* `send_message` and `send_file` read this and stamp it onto the outbound
|
||||
* row so the host's a2a return-path routing can correlate replies back to
|
||||
* the originating session.
|
||||
*
|
||||
* This is module-level state on purpose: the agent-runner is single-process
|
||||
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
|
||||
* before invoking the provider and `clearCurrentInReplyTo` after the batch
|
||||
* completes (or errors out).
|
||||
*/
|
||||
let currentInReplyTo: string | null = null;
|
||||
|
||||
export function setCurrentInReplyTo(id: string | null): void {
|
||||
currentInReplyTo = id;
|
||||
}
|
||||
|
||||
export function clearCurrentInReplyTo(): void {
|
||||
currentInReplyTo = null;
|
||||
}
|
||||
|
||||
export function getCurrentInReplyTo(): string | null {
|
||||
return currentInReplyTo;
|
||||
}
|
||||
|
||||
@@ -27,46 +27,12 @@ const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat';
|
||||
let _inbound: Database | null = null;
|
||||
let _outbound: Database | null = null;
|
||||
let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH;
|
||||
let _testMode = false;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// In test mode return a thin wrapper over the in-memory singleton.
|
||||
// Callers do try/finally { db.close() } — the wrapper no-ops close()
|
||||
// so the singleton survives for the rest of the test.
|
||||
if (_testMode && _inbound) {
|
||||
const db = _inbound;
|
||||
return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as 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.
|
||||
*/
|
||||
/** Inbound DB — container opens read-only (host is the sole writer). */
|
||||
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;
|
||||
}
|
||||
@@ -178,7 +144,6 @@ export function clearStaleProcessingAcks(): void {
|
||||
|
||||
/** For tests — creates in-memory DBs with the session schemas. */
|
||||
export function initTestSessionDb(): { inbound: Database; outbound: Database } {
|
||||
_testMode = true;
|
||||
_inbound = new Database(':memory:');
|
||||
_inbound.exec('PRAGMA foreign_keys = ON');
|
||||
_inbound.exec(`
|
||||
@@ -255,7 +220,6 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
|
||||
export function closeSessionDb(): void {
|
||||
_inbound?.close();
|
||||
_inbound = null;
|
||||
_testMode = false;
|
||||
_outbound?.close();
|
||||
_outbound = null;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* processing_ack. The host reads processing_ack to sync message lifecycle.
|
||||
*/
|
||||
import { getConfig } from '../config.js';
|
||||
import { openInboundDb, getOutboundDb } from './connection.js';
|
||||
import { getInboundDb, getOutboundDb } from './connection.js';
|
||||
|
||||
export interface MessageInRow {
|
||||
id: string;
|
||||
@@ -50,35 +50,31 @@ function getMaxMessagesPerPrompt(): number {
|
||||
* trigger=1 separately (see src/db/session-db.ts).
|
||||
*/
|
||||
export function getPendingMessages(): MessageInRow[] {
|
||||
const inbound = openInboundDb();
|
||||
const inbound = getInboundDb();
|
||||
const outbound = getOutboundDb();
|
||||
|
||||
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[];
|
||||
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();
|
||||
} finally {
|
||||
inbound.close();
|
||||
}
|
||||
// Reverse: we fetched DESC to take the most recent N, but the agent
|
||||
// should see them in chronological order (oldest first).
|
||||
return pending.filter((m) => !ackedIds.has(m.id)).reverse();
|
||||
}
|
||||
|
||||
/** Mark messages as processing — writes to processing_ack in outbound.db. */
|
||||
@@ -116,12 +112,7 @@ export function markFailed(id: string): void {
|
||||
|
||||
/** Get a message by ID (read from inbound.db). */
|
||||
export function getMessageIn(id: string): MessageInRow | undefined {
|
||||
const inbound = openInboundDb();
|
||||
try {
|
||||
return inbound.prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined;
|
||||
} finally {
|
||||
inbound.close();
|
||||
}
|
||||
return getInboundDb().prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,23 +120,19 @@ 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 = openInboundDb();
|
||||
const inbound = getInboundDb();
|
||||
const outbound = getOutboundDb();
|
||||
|
||||
try {
|
||||
const response = inbound
|
||||
.prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?")
|
||||
.get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined;
|
||||
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;
|
||||
} finally {
|
||||
inbound.close();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getOutboundDb, initTestSessionDb } from './connection.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
getContinuation,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
} from './session-state.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
});
|
||||
|
||||
function seedLegacy(value: string): void {
|
||||
getOutboundDb()
|
||||
.prepare('INSERT INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||
.run('sdk_session_id', value, new Date().toISOString());
|
||||
}
|
||||
|
||||
describe('session-state — per-provider continuations', () => {
|
||||
test('set/get round-trip, case-insensitive provider key', () => {
|
||||
setContinuation('claude', 'claude-conv-1');
|
||||
expect(getContinuation('claude')).toBe('claude-conv-1');
|
||||
expect(getContinuation('Claude')).toBe('claude-conv-1');
|
||||
expect(getContinuation('CLAUDE')).toBe('claude-conv-1');
|
||||
});
|
||||
|
||||
test('providers are isolated — switching reads the right slot', () => {
|
||||
setContinuation('claude', 'claude-conv-1');
|
||||
setContinuation('codex', 'codex-thread-xyz');
|
||||
|
||||
expect(getContinuation('claude')).toBe('claude-conv-1');
|
||||
expect(getContinuation('codex')).toBe('codex-thread-xyz');
|
||||
});
|
||||
|
||||
test('clearContinuation only affects the specified provider', () => {
|
||||
setContinuation('claude', 'keep-me');
|
||||
setContinuation('codex', 'drop-me');
|
||||
|
||||
clearContinuation('codex');
|
||||
|
||||
expect(getContinuation('claude')).toBe('keep-me');
|
||||
expect(getContinuation('codex')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unknown provider returns undefined', () => {
|
||||
expect(getContinuation('never-used')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('session-state — legacy migration', () => {
|
||||
test('adopts legacy value into current provider when current is empty', () => {
|
||||
seedLegacy('old-session-id');
|
||||
|
||||
const adopted = migrateLegacyContinuation('claude');
|
||||
|
||||
expect(adopted).toBe('old-session-id');
|
||||
expect(getContinuation('claude')).toBe('old-session-id');
|
||||
});
|
||||
|
||||
test('always deletes legacy row regardless of migration outcome', () => {
|
||||
seedLegacy('old-session-id');
|
||||
setContinuation('claude', 'existing');
|
||||
|
||||
migrateLegacyContinuation('claude');
|
||||
|
||||
// After migration the legacy key must be gone, whether or not it was adopted.
|
||||
// A subsequent migration for a different provider must not see it.
|
||||
const resultAfterSecondCall = migrateLegacyContinuation('codex');
|
||||
expect(resultAfterSecondCall).toBeUndefined();
|
||||
});
|
||||
|
||||
test('prefers existing current-provider slot over legacy', () => {
|
||||
seedLegacy('legacy-value');
|
||||
setContinuation('claude', 'claude-value');
|
||||
|
||||
const result = migrateLegacyContinuation('claude');
|
||||
|
||||
expect(result).toBe('claude-value');
|
||||
expect(getContinuation('claude')).toBe('claude-value');
|
||||
});
|
||||
|
||||
test('no legacy row — returns current provider value (possibly undefined)', () => {
|
||||
expect(migrateLegacyContinuation('claude')).toBeUndefined();
|
||||
|
||||
setContinuation('codex', 'codex-value');
|
||||
expect(migrateLegacyContinuation('codex')).toBe('codex-value');
|
||||
});
|
||||
|
||||
test('migration is idempotent on a second call (legacy already gone)', () => {
|
||||
seedLegacy('once');
|
||||
|
||||
const first = migrateLegacyContinuation('claude');
|
||||
expect(first).toBe('once');
|
||||
|
||||
const second = migrateLegacyContinuation('claude');
|
||||
expect(second).toBe('once');
|
||||
});
|
||||
});
|
||||
@@ -2,20 +2,12 @@
|
||||
* Persistent key/value state for the container. Lives in outbound.db
|
||||
* (container-owned, already scoped per channel/thread).
|
||||
*
|
||||
* Primary use: remember each provider's opaque continuation id so the
|
||||
* agent's conversation resumes across container restarts. Keyed per
|
||||
* provider because continuations are provider-private — a Claude
|
||||
* conversation id means nothing to Codex and vice versa. Switching
|
||||
* providers is therefore lossless: each provider's last thread stays
|
||||
* on file and resumes cleanly if the user flips back.
|
||||
* Primary use: remember the SDK session ID so the agent's conversation
|
||||
* resumes across container restarts. Cleared by /clear.
|
||||
*/
|
||||
import { getOutboundDb } from './connection.js';
|
||||
|
||||
const LEGACY_KEY = 'sdk_session_id';
|
||||
|
||||
function continuationKey(providerName: string): string {
|
||||
return `continuation:${providerName.toLowerCase()}`;
|
||||
}
|
||||
const SDK_SESSION_KEY = 'sdk_session_id';
|
||||
|
||||
function getValue(key: string): string | undefined {
|
||||
const row = getOutboundDb()
|
||||
@@ -26,7 +18,9 @@ function getValue(key: string): string | undefined {
|
||||
|
||||
function setValue(key: string, value: string): void {
|
||||
getOutboundDb()
|
||||
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||
.prepare(
|
||||
'INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)',
|
||||
)
|
||||
.run(key, value, new Date().toISOString());
|
||||
}
|
||||
|
||||
@@ -34,46 +28,14 @@ function deleteValue(key: string): void {
|
||||
getOutboundDb().prepare('DELETE FROM session_state WHERE key = ?').run(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration of the pre-per-provider continuation row.
|
||||
*
|
||||
* Before this was keyed per provider, continuations lived under the
|
||||
* single key `sdk_session_id`. On container start, if that legacy row
|
||||
* exists and the current provider has no continuation of its own, adopt
|
||||
* the legacy value into the current provider's slot (best-guess — the
|
||||
* legacy row was written by whatever provider ran last). The legacy row
|
||||
* is always deleted so future provider flips never re-read a stale id
|
||||
* through the wrong lens.
|
||||
*
|
||||
* Returns the continuation the caller should use at startup (either the
|
||||
* current provider's existing value, the adopted legacy value, or
|
||||
* undefined).
|
||||
*/
|
||||
export function migrateLegacyContinuation(providerName: string): string | undefined {
|
||||
const legacy = getValue(LEGACY_KEY);
|
||||
const currentKey = continuationKey(providerName);
|
||||
const current = getValue(currentKey);
|
||||
|
||||
if (legacy === undefined) return current;
|
||||
|
||||
// Always drop the legacy row so no future provider reads it.
|
||||
deleteValue(LEGACY_KEY);
|
||||
|
||||
// Prefer the current provider's own slot if one already exists.
|
||||
if (current !== undefined) return current;
|
||||
|
||||
setValue(currentKey, legacy);
|
||||
return legacy;
|
||||
export function getStoredSessionId(): string | undefined {
|
||||
return getValue(SDK_SESSION_KEY);
|
||||
}
|
||||
|
||||
export function getContinuation(providerName: string): string | undefined {
|
||||
return getValue(continuationKey(providerName));
|
||||
export function setStoredSessionId(sessionId: string): void {
|
||||
setValue(SDK_SESSION_KEY, sessionId);
|
||||
}
|
||||
|
||||
export function setContinuation(providerName: string, id: string): void {
|
||||
setValue(continuationKey(providerName), id);
|
||||
}
|
||||
|
||||
export function clearContinuation(providerName: string): void {
|
||||
deleteValue(continuationKey(providerName));
|
||||
export function clearStoredSessionId(): void {
|
||||
deleteValue(SDK_SESSION_KEY);
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
|
||||
import { closeSessionDb, getInboundDb, initTestSessionDb } from './db/connection.js';
|
||||
import { buildSystemPromptAddendum } from './destinations.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
function seedDestination(name: string, displayName: string, channelType: string, platformId: string): void {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES (?, ?, 'channel', ?, ?, NULL)`,
|
||||
)
|
||||
.run(name, displayName, channelType, platformId);
|
||||
}
|
||||
|
||||
describe('buildSystemPromptAddendum — multi-destination routing guidance', () => {
|
||||
it('includes default-routing nudge when there are >1 destinations', () => {
|
||||
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
|
||||
seedDestination('whatsapp-mg-17780', 'whatsapp-mg-17780', 'whatsapp', 'phone-2@s.whatsapp.net');
|
||||
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('Default routing');
|
||||
expect(prompt).toContain('from="name"');
|
||||
expect(prompt).toContain('`casa`');
|
||||
expect(prompt).toContain('`whatsapp-mg-17780`');
|
||||
});
|
||||
|
||||
it('requires explicit wrapping even for a single destination', () => {
|
||||
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
|
||||
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('Every response must be wrapped');
|
||||
expect(prompt).toContain('<message to="name">');
|
||||
expect(prompt).toContain('`casa`');
|
||||
});
|
||||
|
||||
it('handles the no-destination case without crashing', () => {
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('no configured destinations');
|
||||
expect(prompt).not.toContain('Default routing');
|
||||
});
|
||||
|
||||
it('includes default-routing and wrapping instructions for single destination', () => {
|
||||
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
|
||||
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('Every response must be wrapped');
|
||||
expect(prompt).toContain('<message to="name">');
|
||||
expect(prompt).toContain('Default routing');
|
||||
expect(prompt).toContain('`casa`');
|
||||
});
|
||||
});
|
||||
@@ -102,28 +102,32 @@ function buildDestinationsSection(): string {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const lines = ['## Sending messages', ''];
|
||||
// Single-destination shortcut: the agent just writes its response normally.
|
||||
if (all.length === 1) {
|
||||
const d = all[0];
|
||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||
lines.push(`Your destination is \`${d.name}\`${label}.`);
|
||||
} else {
|
||||
lines.push('You can send messages to the following destinations:', '');
|
||||
for (const d of all) {
|
||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||
lines.push(`- \`${d.name}\`${label}`);
|
||||
}
|
||||
return [
|
||||
'## Sending messages',
|
||||
'',
|
||||
`Your messages are delivered to \`${d.name}\`${label}. Just write your response directly — no special wrapping needed.`,
|
||||
'',
|
||||
'To mark something as scratchpad (logged but not sent), wrap it in `<internal>...</internal>`.',
|
||||
'',
|
||||
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', ''];
|
||||
for (const d of all) {
|
||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||
lines.push(`- \`${d.name}\`${label}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('**Every response must be wrapped** in a `<message to="name">...</message>` block.');
|
||||
lines.push('To send a message, wrap it in a `<message to="name">...</message>` block.');
|
||||
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
|
||||
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
|
||||
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'**Default routing**: when replying to an incoming message, address the same destination the message came `from` — every inbound `<message>` tag carries a `from="name"` attribute that names the origin destination. Only address a different destination when the request itself asks you to (e.g., "tell Laura that…").',
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.',
|
||||
);
|
||||
|
||||
@@ -66,18 +66,6 @@ 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;
|
||||
@@ -177,49 +165,40 @@ function formatSingleChat(msg: MessageInRow): string {
|
||||
const replyPrefix = formatReplyContext(content.replyTo);
|
||||
const attachmentsSuffix = formatAttachments(content.attachments);
|
||||
|
||||
const fromAttr = originAttr(msg);
|
||||
// Look up the destination name for the origin (reverse map lookup).
|
||||
// If not found, fall back to a raw channel:platform_id marker so nothing
|
||||
// gets silently dropped — this should only happen if the destination was
|
||||
// removed between when the message was received and when it's being processed.
|
||||
const fromDest = findByRouting(msg.channel_type, msg.platform_id);
|
||||
const fromAttr = fromDest
|
||||
? ` from="${escapeXml(fromDest.name)}"`
|
||||
: msg.channel_type || msg.platform_id
|
||||
? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`
|
||||
: '';
|
||||
|
||||
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${escapeXml(time)}"${replyAttr}>${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ` from="destination_name"` attribute string from a message's routing
|
||||
* fields. Shared by all formatters so the agent always knows where a message
|
||||
* originated — critical for explicit addressing.
|
||||
*/
|
||||
function originAttr(msg: MessageInRow): string {
|
||||
const fromDest = findByRouting(msg.channel_type, msg.platform_id);
|
||||
if (fromDest) return ` from="${escapeXml(fromDest.name)}"`;
|
||||
if (msg.channel_type || msg.platform_id) {
|
||||
return ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatTaskMessage(msg: MessageInRow): string {
|
||||
const content = parseContent(msg.content);
|
||||
const from = originAttr(msg);
|
||||
const time = formatLocalTime(msg.timestamp, TIMEZONE);
|
||||
const parts: string[] = [];
|
||||
const parts = ['[SCHEDULED TASK]'];
|
||||
if (content.scriptOutput) {
|
||||
parts.push('Script output:', JSON.stringify(content.scriptOutput, null, 2), '');
|
||||
parts.push('', 'Script output:', JSON.stringify(content.scriptOutput, null, 2));
|
||||
}
|
||||
parts.push('Instructions:', content.prompt || '');
|
||||
return `<task${from} time="${escapeXml(time)}">${parts.join('\n')}</task>`;
|
||||
parts.push('', 'Instructions:', content.prompt || '');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function formatWebhookMessage(msg: MessageInRow): string {
|
||||
const content = parseContent(msg.content);
|
||||
const source = content.source || 'unknown';
|
||||
const event = content.event || 'unknown';
|
||||
const from = originAttr(msg);
|
||||
return `<webhook${from} source="${escapeXml(source)}" event="${escapeXml(event)}">${JSON.stringify(content.payload || content, null, 2)}</webhook>`;
|
||||
return `[WEBHOOK: ${source}/${event}]\n\n${JSON.stringify(content.payload || content, null, 2)}`;
|
||||
}
|
||||
|
||||
function formatSystemMessage(msg: MessageInRow): string {
|
||||
const content = parseContent(msg.content);
|
||||
const from = originAttr(msg);
|
||||
return `<system_response${from} action="${escapeXml(content.action || 'unknown')}" status="${escapeXml(content.status || 'unknown')}">${JSON.stringify(content.result || null)}</system_response>`;
|
||||
return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,7 +95,6 @@ async function main(): Promise<void> {
|
||||
|
||||
await runPollLoop({
|
||||
provider,
|
||||
providerName,
|
||||
cwd: CWD,
|
||||
systemContext: { instructions },
|
||||
});
|
||||
|
||||
@@ -74,163 +74,6 @@ describe('poll loop integration', () => {
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('should resolve thread_id per-destination, not from global routing', async () => {
|
||||
// Seed a second destination
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`,
|
||||
)
|
||||
.run();
|
||||
|
||||
// Insert messages from each destination with distinct thread IDs
|
||||
insertMessage('m-discord', { sender: 'Alice', text: 'from discord' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread-1' });
|
||||
insertMessage('m-slack', { sender: 'Bob', text: 'from slack' }, { platformId: 'chan-2', channelType: 'slack', threadId: 'slack-thread-99' });
|
||||
|
||||
// Agent replies to both destinations
|
||||
const provider = new MockProvider({}, () =>
|
||||
'<message to="discord-test">reply-d</message><message to="slack-test">reply-s</message>',
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length >= 2, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
const discordOut = out.find((m) => m.platform_id === 'chan-1');
|
||||
const slackOut = out.find((m) => m.platform_id === 'chan-2');
|
||||
|
||||
expect(discordOut).toBeDefined();
|
||||
expect(discordOut!.thread_id).toBe('discord-thread-1');
|
||||
expect(discordOut!.in_reply_to).toBe('m-discord');
|
||||
|
||||
expect(slackOut).toBeDefined();
|
||||
expect(slackOut!.thread_id).toBe('slack-thread-99');
|
||||
expect(slackOut!.in_reply_to).toBe('m-slack');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('bare text produces no outbound messages (scratchpad only)', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'hello' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
// Agent responds with bare text — no <message to="..."> wrapping
|
||||
const provider = new MockProvider({}, () => 'I am thinking about this...');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
// Wait long enough for the poll loop to process
|
||||
await sleep(1000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('unknown destination is dropped, valid destination is sent', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new MockProvider(
|
||||
{},
|
||||
() => '<message to="nonexistent">dropped</message><message to="discord-test">delivered</message>',
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
// Only the valid destination should produce output
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toBe('delivered');
|
||||
expect(out[0].platform_id).toBe('chan-1');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('multiple <message> blocks each produce an outbound message', async () => {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`,
|
||||
)
|
||||
.run();
|
||||
|
||||
insertMessage('m1', { sender: 'Alice', text: 'broadcast' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new MockProvider(
|
||||
{},
|
||||
() => '<message to="discord-test">for discord</message><message to="slack-test">for slack</message>',
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length >= 2, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(2);
|
||||
const discord = out.find((m) => m.platform_id === 'chan-1');
|
||||
const slack = out.find((m) => m.platform_id === 'chan-2');
|
||||
expect(discord).toBeDefined();
|
||||
expect(JSON.parse(discord!.content).text).toBe('for discord');
|
||||
expect(slack).toBeDefined();
|
||||
expect(JSON.parse(slack!.content).text).toBe('for slack');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('sends null thread_id when no prior inbound from destination', async () => {
|
||||
// Seed a second destination that has NO inbound messages
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('slack-new', 'Slack New', 'channel', 'slack', 'chan-new', NULL)`,
|
||||
)
|
||||
.run();
|
||||
|
||||
// Only insert a message from discord — slack-new has never sent anything
|
||||
insertMessage('m1', { sender: 'Alice', text: 'tell slack' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread' });
|
||||
|
||||
const provider = new MockProvider({}, () => '<message to="slack-new">hello slack</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].platform_id).toBe('chan-new');
|
||||
expect(out[0].thread_id).toBeNull();
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('resolves most recent thread_id when destination has multiple inbound messages', async () => {
|
||||
// Two messages from same destination, different threads
|
||||
insertMessage('m-old', { sender: 'Alice', text: 'old' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-old' });
|
||||
insertMessage('m-new', { sender: 'Alice', text: 'new' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-new' });
|
||||
|
||||
const provider = new MockProvider({}, () => '<message to="discord-test">reply</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].thread_id).toBe('thread-new');
|
||||
expect(out[0].in_reply_to).toBe('m-new');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('should process messages arriving after loop starts', async () => {
|
||||
const provider = new MockProvider({}, () => '<message to="discord-test">Processed</message>');
|
||||
const controller = new AbortController();
|
||||
@@ -248,167 +91,13 @@ describe('poll loop integration', () => {
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('internal tags between message blocks are stripped from scratchpad', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new MockProvider(
|
||||
{},
|
||||
() => '<internal>thinking about this...</internal><message to="discord-test">answer</message><internal>done thinking</internal>',
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toBe('answer');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('handles mixed task + chat batch with correct origin metadata', async () => {
|
||||
// Seed destination for routing lookup
|
||||
insertMessage('m-chat', { sender: 'Alice', text: 'check this' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
// Task with same routing — simulates a scheduled task in a channel session
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
|
||||
VALUES ('t-task', 'task', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
|
||||
)
|
||||
.run(JSON.stringify({ prompt: 'daily check' }));
|
||||
|
||||
const provider = new MockProvider({}, () => '<message to="discord-test">done</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].platform_id).toBe('chan-1');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('should inject destination reminder after a compacted event', async () => {
|
||||
// Two destinations — required for the reminder to fire (single-destination
|
||||
// groups have a fallback path that works without <message to="…"> wrapping).
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('discord-second', 'Discord Second', 'channel', 'discord', 'chan-2', NULL)`,
|
||||
)
|
||||
.run();
|
||||
|
||||
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new CompactingProvider();
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
|
||||
controller.abort();
|
||||
|
||||
expect(provider.pushes.length).toBeGreaterThanOrEqual(1);
|
||||
const reminder = provider.pushes.find((p) => p.includes('Context was just compacted'));
|
||||
expect(reminder).toBeDefined();
|
||||
expect(reminder).toContain('2 destinations');
|
||||
expect(reminder).toContain('discord-test');
|
||||
expect(reminder).toContain('discord-second');
|
||||
expect(reminder).toContain('<message to="name">');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('should NOT inject destination reminder with a single destination', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new CompactingProvider();
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
|
||||
controller.abort();
|
||||
|
||||
// Only the original prompt push (if any) — no reminder, since beforeEach
|
||||
// seeds exactly one destination.
|
||||
const reminders = provider.pushes.filter((p) => p.includes('Context was just compacted'));
|
||||
expect(reminders).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Provider that emits a single compacted event mid-stream, then returns a
|
||||
* result. Captures every push() call so tests can assert on the injected
|
||||
* reminder content.
|
||||
*/
|
||||
class CompactingProvider {
|
||||
readonly supportsNativeSlashCommands = false;
|
||||
readonly pushes: string[] = [];
|
||||
|
||||
isSessionInvalid(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
query(_input: { prompt: string; cwd: string }) {
|
||||
const pushes = this.pushes;
|
||||
let ended = false;
|
||||
let aborted = false;
|
||||
let resolveWaiter: (() => void) | null = null;
|
||||
|
||||
async function* events() {
|
||||
yield { type: 'activity' as const };
|
||||
yield { type: 'init' as const, continuation: 'compaction-test-session' };
|
||||
yield { type: 'activity' as const };
|
||||
yield { type: 'compacted' as const, text: 'Context compacted (50,000 tokens compacted).' };
|
||||
|
||||
// Wait for poll-loop to push the reminder (or end / abort)
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveWaiter = resolve;
|
||||
// Belt-and-braces: don't hang forever if the reminder never arrives
|
||||
setTimeout(resolve, 200);
|
||||
});
|
||||
|
||||
yield { type: 'activity' as const };
|
||||
yield { type: 'result' as const, text: '<message to="discord-test">ack</message>' };
|
||||
while (!ended && !aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveWaiter = resolve;
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
push(message: string) {
|
||||
pushes.push(message);
|
||||
resolveWaiter?.();
|
||||
},
|
||||
end() {
|
||||
ended = true;
|
||||
resolveWaiter?.();
|
||||
},
|
||||
abort() {
|
||||
aborted = true;
|
||||
resolveWaiter?.();
|
||||
},
|
||||
events: events(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: run poll loop until aborted or timeout
|
||||
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
|
||||
return Promise.race([
|
||||
runPollLoop({
|
||||
provider,
|
||||
providerName: 'mock',
|
||||
cwd: '/tmp',
|
||||
}),
|
||||
new Promise<void>((_, reject) => {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Tests for the core MCP tools' interaction with the per-batch routing
|
||||
* context. The agent-runner sets a current `inReplyTo` at the top of each
|
||||
* batch in poll-loop, and outbound writes from MCP tools (send_message,
|
||||
* send_file) must pick it up so a2a return-path routing on the host can
|
||||
* correlate replies back to the originating session.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
|
||||
import { getUndeliveredMessages } from '../db/messages-out.js';
|
||||
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
|
||||
import { sendMessage } from './core.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
// Seed a peer agent destination
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('peer', 'Peer', 'agent', NULL, NULL, 'ag-peer')`,
|
||||
)
|
||||
.run();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearCurrentInReplyTo();
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||
it('stamps current batch in_reply_to on outbound rows', async () => {
|
||||
setCurrentInReplyTo('inbound-msg-1');
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].in_reply_to).toBe('inbound-msg-1');
|
||||
});
|
||||
|
||||
it('writes null when no batch is active', async () => {
|
||||
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].in_reply_to).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,6 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getCurrentInReplyTo } from '../current-batch.js';
|
||||
import { findByName, getAllDestinations } from '../destinations.js';
|
||||
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
|
||||
import { getSessionRouting } from '../db/session-routing.js';
|
||||
@@ -51,7 +50,9 @@ function destinationList(): string {
|
||||
*/
|
||||
function resolveRouting(
|
||||
to: string | undefined,
|
||||
): { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string } | { error: string } {
|
||||
):
|
||||
| { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string }
|
||||
| { error: string } {
|
||||
if (!to) {
|
||||
// Default: reply to whatever thread/channel this session is bound to.
|
||||
const session = getSessionRouting();
|
||||
@@ -81,7 +82,9 @@ function resolveRouting(
|
||||
// preserve the thread_id so replies land in the correct thread.
|
||||
const session = getSessionRouting();
|
||||
const threadId =
|
||||
session.channel_type === dest.channelType && session.platform_id === dest.platformId ? session.thread_id : null;
|
||||
session.channel_type === dest.channelType && session.platform_id === dest.platformId
|
||||
? session.thread_id
|
||||
: null;
|
||||
return {
|
||||
channel_type: dest.channelType!,
|
||||
platform_id: dest.platformId!,
|
||||
@@ -95,14 +98,12 @@ function resolveRouting(
|
||||
export const sendMessage: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'send_message',
|
||||
description: 'Send a message to a named destination. If you have only one destination, you can omit `to`.',
|
||||
description:
|
||||
'Send a message to a named destination. If you have only one destination, you can omit `to`.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
to: {
|
||||
type: 'string',
|
||||
description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.',
|
||||
},
|
||||
to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.' },
|
||||
text: { type: 'string', description: 'Message content' },
|
||||
},
|
||||
required: ['text'],
|
||||
@@ -118,7 +119,6 @@ export const sendMessage: McpToolDefinition = {
|
||||
const id = generateId();
|
||||
const seq = writeMessageOut({
|
||||
id,
|
||||
in_reply_to: getCurrentInReplyTo(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platform_id,
|
||||
channel_type: routing.channel_type,
|
||||
@@ -165,7 +165,6 @@ export const sendFile: McpToolDefinition = {
|
||||
|
||||
writeMessageOut({
|
||||
id,
|
||||
in_reply_to: getCurrentInReplyTo(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platform_id,
|
||||
channel_type: routing.channel_type,
|
||||
|
||||
@@ -89,9 +89,6 @@ export const scheduleTask: McpToolDefinition = {
|
||||
script,
|
||||
processAfter,
|
||||
recurrence,
|
||||
platformId: r.platform_id,
|
||||
channelType: r.channel_type,
|
||||
threadId: r.thread_id,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('formatter', () => {
|
||||
insertMessage('m1', 'task', { prompt: 'Review open PRs' });
|
||||
const messages = getPendingMessages();
|
||||
const prompt = formatMessages(messages);
|
||||
expect(prompt).toContain('<task');
|
||||
expect(prompt).toContain('[SCHEDULED TASK]');
|
||||
expect(prompt).toContain('Review open PRs');
|
||||
});
|
||||
|
||||
@@ -55,17 +55,15 @@ describe('formatter', () => {
|
||||
insertMessage('m1', 'webhook', { source: 'github', event: 'push', payload: { ref: 'main' } });
|
||||
const messages = getPendingMessages();
|
||||
const prompt = formatMessages(messages);
|
||||
expect(prompt).toContain('<webhook');
|
||||
expect(prompt).toContain('source="github"');
|
||||
expect(prompt).toContain('event="push"');
|
||||
expect(prompt).toContain('[WEBHOOK: github/push]');
|
||||
});
|
||||
|
||||
it('should format system messages', () => {
|
||||
insertMessage('m1', 'system', { action: 'register_group', status: 'success', result: { id: 'ag-1' } });
|
||||
const messages = getPendingMessages();
|
||||
const prompt = formatMessages(messages);
|
||||
expect(prompt).toContain('<system_response');
|
||||
expect(prompt).toContain('action="register_group"');
|
||||
expect(prompt).toContain('[SYSTEM RESPONSE]');
|
||||
expect(prompt).toContain('register_group');
|
||||
});
|
||||
|
||||
it('should handle mixed kinds', () => {
|
||||
@@ -74,7 +72,7 @@ describe('formatter', () => {
|
||||
const messages = getPendingMessages();
|
||||
const prompt = formatMessages(messages);
|
||||
expect(prompt).toContain('sender="John"');
|
||||
expect(prompt).toContain('<system_response');
|
||||
expect(prompt).toContain('[SYSTEM RESPONSE]');
|
||||
});
|
||||
|
||||
it('should escape XML in content', () => {
|
||||
@@ -149,76 +147,6 @@ describe('routing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('origin metadata (from= attribute)', () => {
|
||||
function seedDestination(name: string, channelType: string, platformId: string): void {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES (?, ?, 'channel', ?, ?, NULL)`,
|
||||
)
|
||||
.run(name, name, channelType, platformId);
|
||||
}
|
||||
|
||||
function insertWithRouting(id: string, kind: string, content: object, channelType: string | null, platformId: string | null): void {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
|
||||
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
|
||||
)
|
||||
.run(id, kind, platformId, channelType, JSON.stringify(content));
|
||||
}
|
||||
|
||||
it('chat message includes from= when destination matches', () => {
|
||||
seedDestination('discord-main', 'discord', 'chan-1');
|
||||
insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'discord', 'chan-1');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('from="discord-main"');
|
||||
});
|
||||
|
||||
it('chat message falls back to raw routing when no destination matches', () => {
|
||||
insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'telegram', 'chat-999');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('from="unknown:telegram:chat-999"');
|
||||
});
|
||||
|
||||
it('chat message omits from= when routing is null', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' });
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).not.toContain('from=');
|
||||
});
|
||||
|
||||
it('task message includes from= when destination matches', () => {
|
||||
seedDestination('slack-ops', 'slack', 'C-OPS');
|
||||
insertWithRouting('t1', 'task', { prompt: 'check status' }, 'slack', 'C-OPS');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('<task');
|
||||
expect(prompt).toContain('from="slack-ops"');
|
||||
});
|
||||
|
||||
it('task message omits from= when routing is null', () => {
|
||||
insertMessage('t1', 'task', { prompt: 'check status' });
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('<task');
|
||||
expect(prompt).not.toContain('from=');
|
||||
});
|
||||
|
||||
it('webhook message includes from= when destination matches', () => {
|
||||
seedDestination('github-ch', 'github', 'repo-1');
|
||||
insertWithRouting('w1', 'webhook', { source: 'github', event: 'push', payload: {} }, 'github', 'repo-1');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('<webhook');
|
||||
expect(prompt).toContain('from="github-ch"');
|
||||
});
|
||||
|
||||
it('system message includes from= when destination matches', () => {
|
||||
seedDestination('discord-main', 'discord', 'chan-1');
|
||||
insertWithRouting('s1', 'system', { action: 'test', status: 'ok', result: null }, 'discord', 'chan-1');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('<system_response');
|
||||
expect(prompt).toContain('from="discord-main"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mock provider', () => {
|
||||
it('should produce init + result events', async () => {
|
||||
const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`);
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
|
||||
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
|
||||
import {
|
||||
formatMessages,
|
||||
extractRouting,
|
||||
categorizeMessage,
|
||||
isClearCommand,
|
||||
isRunnerCommand,
|
||||
stripInternalTags,
|
||||
type RoutingContext,
|
||||
} from './formatter.js';
|
||||
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js';
|
||||
import { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
@@ -28,12 +19,6 @@ function generateId(): string {
|
||||
|
||||
export interface PollLoopConfig {
|
||||
provider: AgentProvider;
|
||||
/**
|
||||
* Name of the provider (e.g. "claude", "codex", "opencode"). Used to key
|
||||
* the stored continuation per-provider so flipping providers doesn't
|
||||
* resurrect a stale id from a different backend.
|
||||
*/
|
||||
providerName: string;
|
||||
cwd: string;
|
||||
systemContext?: {
|
||||
instructions?: string;
|
||||
@@ -54,9 +39,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// Resume the agent's prior session from a previous container run if one
|
||||
// was persisted. The continuation is opaque to the poll-loop — the
|
||||
// provider decides how to use it (Claude resumes a .jsonl transcript,
|
||||
// other providers may reload a thread ID, etc.). Keyed per-provider so
|
||||
// a Codex thread id never gets handed to Claude or vice versa.
|
||||
let continuation: string | undefined = migrateLegacyContinuation(config.providerName);
|
||||
// other providers may reload a thread ID, etc.).
|
||||
let continuation: string | undefined = getStoredSessionId();
|
||||
|
||||
if (continuation) {
|
||||
log(`Resuming agent session ${continuation}`);
|
||||
@@ -110,7 +94,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) {
|
||||
log('Clearing session (resetting continuation)');
|
||||
continuation = undefined;
|
||||
clearContinuation(config.providerName);
|
||||
clearStoredSessionId();
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'chat',
|
||||
@@ -175,14 +159,11 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// Process the query while concurrently polling for new messages
|
||||
const skippedSet = new Set(skipped);
|
||||
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
|
||||
// Publish the batch's in_reply_to so MCP tools (send_message, send_file)
|
||||
// can stamp it on outbound rows — needed for a2a return-path routing.
|
||||
setCurrentInReplyTo(routing.inReplyTo);
|
||||
try {
|
||||
const result = await processQuery(query, routing, processingIds, config.providerName);
|
||||
const result = await processQuery(query, routing, processingIds);
|
||||
if (result.continuation && result.continuation !== continuation) {
|
||||
continuation = result.continuation;
|
||||
setContinuation(config.providerName, continuation);
|
||||
setStoredSessionId(continuation);
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -194,7 +175,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
if (continuation && config.provider.isSessionInvalid(err)) {
|
||||
log(`Stale session detected (${continuation}) — clearing for next retry`);
|
||||
continuation = undefined;
|
||||
clearContinuation(config.providerName);
|
||||
clearStoredSessionId();
|
||||
}
|
||||
|
||||
// Write error response so the user knows something went wrong
|
||||
@@ -206,8 +187,6 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: `Error: ${errMsg}` }),
|
||||
});
|
||||
} finally {
|
||||
clearCurrentInReplyTo();
|
||||
}
|
||||
|
||||
// Ensure completed even if processQuery ended without a result event
|
||||
@@ -259,96 +238,41 @@ async function processQuery(
|
||||
query: AgentQuery,
|
||||
routing: RoutingContext,
|
||||
initialBatchIds: string[],
|
||||
providerName: string,
|
||||
): Promise<QueryResult> {
|
||||
let queryContinuation: string | undefined;
|
||||
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 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.
|
||||
// We do NOT force-end the stream on silence — keeping the query open is
|
||||
// strictly cheaper than close+reopen (no cold prompt cache, no reconnect).
|
||||
// Stream liveness is decided host-side via the heartbeat file + processing
|
||||
// claim age (see src/host-sweep.ts); if something is truly stuck, the host
|
||||
// will kill the container and messages get reset to pending.
|
||||
let pollInFlight = false;
|
||||
let endedForCommand = false;
|
||||
const pollHandle = setInterval(() => {
|
||||
if (done || pollInFlight || endedForCommand) return;
|
||||
pollInFlight = true;
|
||||
if (done) return;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const pending = getPendingMessages();
|
||||
// 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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
const prompt = formatMessages(newMessages);
|
||||
log(`Pushing ${newMessages.length} follow-up message(s) into active query`);
|
||||
query.push(prompt);
|
||||
|
||||
// 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 = pending.filter((m) => m.kind !== 'system');
|
||||
if (newMessages.length === 0) return;
|
||||
|
||||
const newIds = newMessages.map((m) => m.id);
|
||||
markProcessing(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;
|
||||
// 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;
|
||||
}
|
||||
})();
|
||||
markCompleted(newIds);
|
||||
}
|
||||
}, ACTIVE_POLL_INTERVAL_MS);
|
||||
|
||||
try {
|
||||
@@ -364,7 +288,7 @@ async function processQuery(
|
||||
// container died between `init` and `result`, the SDK session was
|
||||
// effectively orphaned and the next message started a blank
|
||||
// Claude session with no prior context.
|
||||
setContinuation(providerName, event.continuation);
|
||||
setStoredSessionId(event.continuation);
|
||||
} else if (event.type === 'result') {
|
||||
// A result — with or without text — means the turn is done. Mark
|
||||
// the initial batch completed now so the host sweep doesn't see
|
||||
@@ -376,23 +300,6 @@ async function processQuery(
|
||||
if (event.text) {
|
||||
dispatchResultText(event.text, routing);
|
||||
}
|
||||
} else if (event.type === 'compacted') {
|
||||
// The SDK auto-compacted the conversation. After compaction the
|
||||
// model often drops the learned `<message to="…">` wrapping
|
||||
// discipline (the destinations are still in the system prompt,
|
||||
// but the behavioral pattern is summarized away). Inject a
|
||||
// reminder back into the live query so the next turn re-anchors
|
||||
// on the destination model. Only do this when there's >1
|
||||
// destination — single-destination groups have a fallback that
|
||||
// works without wrapping. See qwibitai/nanoclaw#2325.
|
||||
const destinations = getAllDestinations();
|
||||
if (destinations.length > 1) {
|
||||
const names = destinations.map((d) => d.name).join(', ');
|
||||
query.push(
|
||||
`[system] Context was just compacted. Reminder: you have ${destinations.length} destinations (${names}). ` +
|
||||
`Use <message to="name"> blocks to address them. Bare text goes to the scratchpad fallback only.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -412,26 +319,25 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
||||
log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`);
|
||||
break;
|
||||
case 'error':
|
||||
log(
|
||||
`Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`,
|
||||
);
|
||||
log(`Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`);
|
||||
break;
|
||||
case 'progress':
|
||||
log(`Progress: ${event.message}`);
|
||||
break;
|
||||
case 'compacted':
|
||||
log(`Compacted: ${event.text}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the agent's final text for <message to="name">...</message> blocks
|
||||
* and dispatch each one to its resolved destination. Text outside of blocks
|
||||
* (including <internal>...</internal>) is scratchpad — logged but not sent.
|
||||
* (including <internal>...</internal>) is normally scratchpad — logged but
|
||||
* not sent.
|
||||
*
|
||||
* The agent must always wrap output in <message to="name">...</message>
|
||||
* blocks, even with a single destination. Bare text is scratchpad only.
|
||||
* Single-destination shortcut: if the agent has exactly one configured
|
||||
* destination AND the output contains zero <message> blocks, the entire
|
||||
* cleaned text (with <internal> tags stripped) is sent to that destination.
|
||||
* This preserves the simple case of one user on one channel — the agent
|
||||
* doesn't need to know about wrapping syntax at all.
|
||||
*/
|
||||
function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
|
||||
@@ -464,6 +370,30 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||
|
||||
const scratchpad = stripInternalTags(scratchpadParts.join(''));
|
||||
|
||||
// Single-destination shortcut: the agent wrote plain text — send to
|
||||
// the session's originating channel (from session_routing) if available,
|
||||
// otherwise fall back to the single destination.
|
||||
if (sent === 0 && scratchpad) {
|
||||
if (routing.channelType && routing.platformId) {
|
||||
// Reply to the channel/thread the message came from
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
in_reply_to: routing.inReplyTo,
|
||||
kind: 'chat',
|
||||
platform_id: routing.platformId,
|
||||
channel_type: routing.channelType,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: scratchpad }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const all = getAllDestinations();
|
||||
if (all.length === 1) {
|
||||
sendToDestination(all[0], scratchpad, routing);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (scratchpad) {
|
||||
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
|
||||
}
|
||||
@@ -476,46 +406,20 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
|
||||
const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
|
||||
const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
|
||||
// Resolve thread_id per-destination from the most recent inbound message
|
||||
// that came from this same channel+platform. In agent-shared sessions,
|
||||
// different destinations have different thread contexts — using a single
|
||||
// routing.threadId would stamp one channel's thread onto another.
|
||||
const destRouting = resolveDestinationThread(channelType, platformId);
|
||||
// Inherit thread_id from the inbound routing context so replies land in the
|
||||
// same thread the conversation is in. For non-threaded adapters the router
|
||||
// strips thread_id at ingest, so this will already be null.
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo,
|
||||
in_reply_to: routing.inReplyTo,
|
||||
kind: 'chat',
|
||||
platform_id: platformId,
|
||||
channel_type: channelType,
|
||||
thread_id: destRouting?.threadId ?? null,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: body }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the thread_id and message id from the most recent inbound message
|
||||
* matching the given channel+platform. Returns null if no match found.
|
||||
*/
|
||||
function resolveDestinationThread(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
): { threadId: string | null; inReplyTo: string | null } | null {
|
||||
try {
|
||||
const db = getInboundDb();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT thread_id, id FROM messages_in
|
||||
WHERE channel_type = ? AND platform_id = ?
|
||||
ORDER BY seq DESC LIMIT 1`,
|
||||
)
|
||||
.get(channelType, platformId) as { thread_id: string | null; id: string } | undefined;
|
||||
if (row) return { threadId: row.thread_id, inReplyTo: row.id };
|
||||
} catch (err) {
|
||||
log(`resolveDestinationThread error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -34,11 +34,7 @@ const SDK_DISALLOWED_TOOLS = [
|
||||
'ExitWorktree',
|
||||
];
|
||||
|
||||
// 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.
|
||||
// Tool allowlist for NanoClaw agent containers
|
||||
const TOOL_ALLOWLIST = [
|
||||
'Bash',
|
||||
'Read',
|
||||
@@ -58,15 +54,9 @@ 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 };
|
||||
@@ -236,12 +226,8 @@ 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 = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000';
|
||||
const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000';
|
||||
|
||||
/**
|
||||
* Stale-session detection. Matches Claude Code's error text when a
|
||||
@@ -287,10 +273,7 @@ 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,
|
||||
...Object.keys(this.mcpServers).map(mcpAllowPattern),
|
||||
],
|
||||
allowedTools: TOOL_ALLOWLIST,
|
||||
disallowedTools: SDK_DISALLOWED_TOOLS,
|
||||
env: this.env,
|
||||
permissionMode: 'bypassPermissions',
|
||||
@@ -329,7 +312,7 @@ export class ClaudeProvider implements AgentProvider {
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
||||
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
|
||||
const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : '';
|
||||
yield { type: 'compacted', text: `Context compacted${detail}.` };
|
||||
yield { type: 'result', text: `Context compacted${detail}.` };
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
|
||||
const tn = message as { summary?: string };
|
||||
yield { type: 'progress', message: tn.summary || 'Task notification' };
|
||||
|
||||
@@ -79,12 +79,4 @@ export type ProviderEvent =
|
||||
* event (tool call, thinking, partial message, anything) so the
|
||||
* poll-loop's idle timer stays honest during long tool runs.
|
||||
*/
|
||||
| { type: 'activity' }
|
||||
/**
|
||||
* The provider's underlying SDK auto-compacted the conversation context.
|
||||
* The poll-loop reacts by injecting a destination reminder back into
|
||||
* the live query so the agent doesn't drop `<message to="…">` wrapping
|
||||
* after compaction. Distinct from `result` so it doesn't mark the turn
|
||||
* completed or get dispatched as a chat message. See qwibitai/nanoclaw#2325.
|
||||
*/
|
||||
| { type: 'compacted'; text: string };
|
||||
| { type: 'activity' };
|
||||
|
||||
+1
-7
@@ -9,15 +9,9 @@
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Derive the image name from the project root so two NanoClaw installs on the
|
||||
# same host don't overwrite each other's `nanoclaw-agent:latest` tag. Matches
|
||||
# setup/lib/install-slug.sh + src/install-slug.ts.
|
||||
# shellcheck source=../setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
IMAGE_NAME="$(container_image_base)"
|
||||
IMAGE_NAME="nanoclaw-agent"
|
||||
TAG="${1:-latest}"
|
||||
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}"
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# 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-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
|
||||
```
|
||||
|
||||
## 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:<details>`, `SKIPPED:<reason>`, 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/<ag_id>/.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-<hash>` — find it: `systemctl --user list-units 'nanoclaw*'`
|
||||
2. Manually stop: `systemctl --user stop <unit> && systemctl --user disable <unit>`
|
||||
3. Restart v1: `systemctl --user start nanoclaw`
|
||||
|
||||
### Step logs
|
||||
|
||||
Each step writes raw output to `logs/migrate-steps/<step>.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`.
|
||||
@@ -1,172 +0,0 @@
|
||||
# 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 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.
|
||||
|
||||
---
|
||||
|
||||
## 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 = <the regex>`
|
||||
- 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-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.
|
||||
|
||||
---
|
||||
|
||||
## 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/<session_id>/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/<session_id>/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-<channel>` skill:
|
||||
1. `git fetch origin channels`
|
||||
2. `git show channels:src/channels/<name>.ts > src/channels/<name>.ts`
|
||||
3. Appends `import './<name>.js';` to `src/channels/index.ts`
|
||||
4. `pnpm install @chat-adapter/<name>@<pinned>`
|
||||
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-<channel>.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` step has a per-channel registry (`setup/migrate-v2/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 = "<channel_type>:<handle>", 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/<folder>/CLAUDE.md` and optional `logs/`. `CLAUDE.md` was a plain instruction file, group-specific.
|
||||
|
||||
**v2:** each group still lives at `groups/<folder>/`, 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/<folder>/.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
|
||||
|
||||
- `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.
|
||||
@@ -1,98 +0,0 @@
|
||||
#!/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)
|
||||
# - 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 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, kept on purpose)
|
||||
# - setup/migrate-v2/* (the migration scripts themselves, plus user WIP)
|
||||
# - 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
|
||||
# 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')"
|
||||
-742
@@ -1,742 +0,0 @@
|
||||
#!/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
|
||||
#
|
||||
# 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
|
||||
# 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"
|
||||
|
||||
# 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, exit first or open a separate terminal,"
|
||||
echo "then run:"
|
||||
echo " bash migrate-v2.sh"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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=""
|
||||
|
||||
# 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
|
||||
local i
|
||||
for ((i=0; i<${#STEP_NAMES[@]}; i++)); do
|
||||
[ "${STEP_STATUSES[$i]}" = "failed" ] && has_failures=true
|
||||
done
|
||||
|
||||
local overall="success"
|
||||
$has_failures && overall="partial"
|
||||
[ -n "$ABORTED_AT" ] && overall="failed"
|
||||
|
||||
local steps_json="{"
|
||||
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%,}}"
|
||||
|
||||
cat > "$handoff_dir/handoff.json" <<HANDOFF_EOF
|
||||
{
|
||||
"version": 1,
|
||||
"started_at": "$(ts_utc)",
|
||||
"v1_path": "$V1_PATH",
|
||||
"v1_version": "$V1_VERSION",
|
||||
"overall_status": "$overall",
|
||||
"aborted_at": "$ABORTED_AT",
|
||||
"source": "migrate-v2.sh",
|
||||
"channels_installed": [$(printf '"%s",' "${SELECTED_CHANNELS[@]}" 2>/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.
|
||||
# Uses the in-tree wrapper instead of the sqlite3 CLI: setup.sh (run via
|
||||
# phase 0a above) installs Node + better-sqlite3 but NOT the sqlite3 CLI,
|
||||
# and #2191 documented how a missing CLI here used to surface as a
|
||||
# misleading "registered_groups missing" abort.
|
||||
TABLES=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT name FROM sqlite_master WHERE type='table'" 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=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0)
|
||||
TASK_COUNT=$(pnpm exec tsx scripts/q.ts "$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.
|
||||
|
||||
# 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
|
||||
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"
|
||||
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.
|
||||
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://')
|
||||
step_skip "$label $(dim "($reason)")"
|
||||
log "$name: skipped ($reason)"
|
||||
record_step "$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)"
|
||||
record_step "$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"
|
||||
STEP_NAME="2c-install-${ch}"
|
||||
if [ -f "$INSTALL_SCRIPT" ]; then
|
||||
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)")"
|
||||
record_step "$STEP_NAME" "skipped"
|
||||
else
|
||||
step_ok "Install $ch"
|
||||
record_step "$STEP_NAME" "success"
|
||||
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)"
|
||||
record_step "$STEP_NAME" "failed"
|
||||
fi
|
||||
else
|
||||
step_skip "Install $ch $(dim "(no install script)")"
|
||||
log "install-$ch: no install script"
|
||||
record_step "$STEP_NAME" "failed"
|
||||
fi
|
||||
done
|
||||
|
||||
# 2d. (Removed) WhatsApp LID resolution was previously needed because the
|
||||
# v6 adapter couldn't reliably translate LID→phone JIDs, so the migration
|
||||
# pre-created dual messaging_groups rows. With Baileys v7, the adapter
|
||||
# resolves LIDs via extractAddressingContext + signalRepository.lidMapping
|
||||
# on every inbound message, so dual rows are unnecessary and were causing
|
||||
# split sessions.
|
||||
fi
|
||||
|
||||
echo
|
||||
step_ok "Phase 2 complete"
|
||||
echo
|
||||
|
||||
# ─── phase 3: infrastructure ────────────────────────────────────────────
|
||||
|
||||
echo "$(bold 'Phase 3: Infrastructure')"
|
||||
echo
|
||||
|
||||
# 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"
|
||||
record_step "3a-docker" "success"
|
||||
log "Docker: installed"
|
||||
else
|
||||
step_fail "Docker install failed $(dim "(see $DOCKER_LOG)")"
|
||||
record_step "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}"
|
||||
|
||||
if curl -sf "${ONECLI_URL_CHECK}/api/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"
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
step_info "Setting up OneCLI…"
|
||||
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
|
||||
record_step "3b-onecli" "success"
|
||||
log "OneCLI: installed/configured"
|
||||
else
|
||||
step_fail "OneCLI setup failed $(dim "(see $ONECLI_LOG)")"
|
||||
record_step "3b-onecli" "failed"
|
||||
log "OneCLI: FAILED"
|
||||
fi
|
||||
else
|
||||
step_fail "OneCLI needs Docker $(dim "(install Docker first)")"
|
||||
record_step "3b-onecli" "failed"
|
||||
log "OneCLI: skipped (no Docker)"
|
||||
fi
|
||||
|
||||
# 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/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"
|
||||
record_step "3c-auth" "success"
|
||||
log "Anthropic credential: registered via auth step"
|
||||
else
|
||||
step_fail "Auth setup failed $(dim "(see $AUTH_LOG)")"
|
||||
record_step "3c-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
|
||||
|
||||
# 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"
|
||||
record_step "3e-build" "success"
|
||||
log "Container build: success"
|
||||
else
|
||||
step_fail "Container build failed"
|
||||
record_step "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_fail "Docker not available — cannot build container"
|
||||
record_step "3e-build" "failed"
|
||||
log "Container build: skipped (no Docker)"
|
||||
fi
|
||||
|
||||
echo
|
||||
step_ok "Phase 3 complete"
|
||||
echo
|
||||
|
||||
# ─── service switchover ─────────────────────────────────────────────────
|
||||
|
||||
echo "$(bold 'Service switchover')"
|
||||
echo
|
||||
|
||||
# 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.
|
||||
disable_v1_service() {
|
||||
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
|
||||
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
|
||||
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"
|
||||
if [ -f "$v1_plist" ] || [ -L "$v1_plist" ]; then
|
||||
launchctl unload "$v1_plist" 2>/dev/null || true
|
||||
step_ok "Unloaded $V1_SERVICE (plist kept for rollback)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 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_service
|
||||
fi
|
||||
else
|
||||
step_skip "Service switchover skipped"
|
||||
fi
|
||||
else
|
||||
step_skip "v1 service not running — nothing to switch"
|
||||
disable_v1_service
|
||||
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"
|
||||
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
|
||||
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
|
||||
+9
-131
@@ -129,123 +129,10 @@ rm -f "$PROGRESS_LOG"
|
||||
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
||||
write_header
|
||||
|
||||
# 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: minimum hardware specs ────────────────────────────────
|
||||
# NanoClaw runs an agent container per session. Below this threshold the
|
||||
# host + container + agent will struggle (OOM under load). Soft warn — 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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
MEM_MB=$(detect_mem_mb)
|
||||
: "${MEM_MB:=0}"
|
||||
|
||||
LOW_MEM=false
|
||||
[ "$MEM_MB" -gt 0 ] && [ "$MEM_MB" -lt "$MIN_MEM_MB" ] && LOW_MEM=true
|
||||
|
||||
if [ "$LOW_MEM" = true ]; then
|
||||
printf ' %s\n' "$(red 'Warning: this machine likely cannot run NanoClaw.')"
|
||||
printf ' %s\n' "$(dim 'NanoClaw recommends a 4 GB+ RAM machine. Below this, the host + agent')"
|
||||
printf ' %s\n' "$(dim 'container will run out of memory under most workloads. A stronger')"
|
||||
printf ' %s\n' "$(dim 'machine is strongly recommended.')"
|
||||
printf ' %s\n' "$(dim " · Detected RAM: ${MEM_MB} MB")"
|
||||
printf '\n'
|
||||
read -r -p " $(bold 'Try anyway?') [y/N] " SPECS_ANS </dev/tty
|
||||
|
||||
case "${SPECS_ANS:-N}" in
|
||||
[Yy]*)
|
||||
ph_event setup_low_specs_continued mem_mb="$MEM_MB" low_mem="$LOW_MEM"
|
||||
printf '\n'
|
||||
;;
|
||||
*)
|
||||
ph_event setup_low_specs_aborted mem_mb="$MEM_MB" low_mem="$LOW_MEM"
|
||||
printf '\n %s\n\n' "$(dim 'Aborted. Re-run after upgrading the host.')"
|
||||
exit 1
|
||||
;;
|
||||
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 </dev/tty
|
||||
|
||||
case "${GCE_ANS:-N}" in
|
||||
[Yy]*)
|
||||
ph_event setup_gce_continued
|
||||
printf '\n'
|
||||
;;
|
||||
*)
|
||||
ph_event setup_gce_aborted
|
||||
printf '\n %s\n\n' "$(dim 'Aborted. Re-run on a non-GCE host to continue.')"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ─── 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' "$(bold '1)') $(dim 'Show me instructions for creating a new Linux user')"
|
||||
printf ' %s\n\n' "$(bold '2)') $(dim 'Continue setting up NanoClaw as root user (not recommended)')"
|
||||
read -r -p " $(bold 'Choose [1/2]: ')" ROOT_ANS </dev/tty
|
||||
|
||||
case "${ROOT_ANS:-1}" in
|
||||
2)
|
||||
ph_event setup_root_continued
|
||||
printf '\n'
|
||||
;;
|
||||
*)
|
||||
ph_event setup_root_aborted
|
||||
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')"
|
||||
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
|
||||
fi
|
||||
# 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')"
|
||||
|
||||
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
|
||||
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
|
||||
@@ -301,6 +188,9 @@ 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 "NanoClaw is small and runs entirely on your machine. Yours to modify.")"
|
||||
spinner_start "$BOOTSTRAP_LABEL"
|
||||
|
||||
# Run in the background so we can tick elapsed time. Capture exit code via
|
||||
@@ -332,7 +222,7 @@ rm -f "$BOOTSTRAP_EXIT_FILE"
|
||||
BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START ))
|
||||
|
||||
if [ "$BOOTSTRAP_RC" -eq 0 ]; then
|
||||
spinner_success "Basics ready" "$BOOTSTRAP_DUR"
|
||||
spinner_success "Basics installed" "$BOOTSTRAP_DUR"
|
||||
write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
|
||||
else
|
||||
spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR"
|
||||
@@ -355,19 +245,7 @@ fi
|
||||
# wipe it.
|
||||
export NANOCLAW_BOOTSTRAPPED=1
|
||||
|
||||
# setup.sh may have just installed pnpm via npm into a prefix that's not on
|
||||
# our PATH (custom `npm config set prefix`, or the default prefix missing
|
||||
# from the shell's login PATH). Its PATH mutation doesn't propagate back
|
||||
# to us — so replay the same lookup here before the exec.
|
||||
if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
|
||||
NPM_PREFIX="$(npm config get prefix 2>/dev/null)"
|
||||
if [ -n "$NPM_PREFIX" ] && [ -x "$NPM_PREFIX/bin/pnpm" ]; then
|
||||
export PATH="$NPM_PREFIX/bin:$PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --silent suppresses pnpm's `> nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts`
|
||||
# preamble so the flow continues visually from "Basics installed" straight
|
||||
# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly.
|
||||
# `-- "$@"` forwards any flags (e.g. --onecli-api-host) to setup:auto.
|
||||
exec pnpm --silent run setup:auto -- "$@"
|
||||
exec pnpm --silent run setup:auto
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.40",
|
||||
"version": "2.0.0",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
@@ -24,7 +24,6 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/core": "^1.2.0",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@onecli-sh/sdk": "^0.3.1",
|
||||
"better-sqlite3": "11.10.0",
|
||||
|
||||
Generated
-3
@@ -8,9 +8,6 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@clack/core':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
'@clack/prompts':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="147k tokens, 74% of context window">
|
||||
<title>147k tokens, 74% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="43.8k tokens, 22% of context window">
|
||||
<title>43.8k tokens, 22% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r">
|
||||
<rect width="90" height="20" rx="3" fill="#fff"/>
|
||||
<rect width="97" height="20" rx="3" fill="#fff"/>
|
||||
</clipPath>
|
||||
<a xlink:href="https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens">
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="52" height="20" fill="#555"/>
|
||||
<rect x="52" width="38" height="20" fill="#e05d44"/>
|
||||
<rect width="90" height="20" fill="url(#s)"/>
|
||||
<rect x="52" width="45" height="20" fill="#4c1"/>
|
||||
<rect width="97" height="20" fill="url(#s)"/>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">147k</text>
|
||||
<text x="71" y="14">147k</text>
|
||||
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">43.8k</text>
|
||||
<text x="74" y="14">43.8k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Delete the scratch CLI agent created during setup's ping-pong test.
|
||||
*
|
||||
* 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/<folder>/ 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 <folder-name>
|
||||
*/
|
||||
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 <folder-name>');
|
||||
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);
|
||||
}
|
||||
|
||||
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/<folder>/ directory.
|
||||
const groupDir = path.join(process.cwd(), 'groups', args.folder);
|
||||
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}).`);
|
||||
@@ -41,13 +41,11 @@ 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];
|
||||
@@ -57,9 +55,6 @@ function parseArgs(argv: string[]): Args {
|
||||
} else if (key === '--agent-name') {
|
||||
agentName = val;
|
||||
i++;
|
||||
} else if (key === '--folder') {
|
||||
folder = val;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +67,6 @@ function parseArgs(argv: string[]): Args {
|
||||
return {
|
||||
displayName,
|
||||
agentName: agentName?.trim() || displayName,
|
||||
folder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,7 +95,7 @@ async function main(): Promise<void> {
|
||||
const promotedToOwner = false;
|
||||
|
||||
// 2. Agent group + filesystem.
|
||||
const folder = args.folder || `cli-with-${normalizeName(args.displayName)}`;
|
||||
const folder = `cli-with-${normalizeName(args.displayName)}`;
|
||||
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
|
||||
if (!ag) {
|
||||
const agId = generateId('ag');
|
||||
|
||||
@@ -48,7 +48,6 @@ import { addMember } from '../src/modules/permissions/db/agent-group-members.js'
|
||||
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
|
||||
import { upsertUser } from '../src/modules/permissions/db/users.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
||||
|
||||
type Role = 'owner' | 'admin' | 'member';
|
||||
@@ -138,6 +137,16 @@ function namespacedUserId(channel: string, raw: string): string {
|
||||
return raw.includes(':') ? raw : `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
function namespacedPlatformId(channel: string, raw: string): string {
|
||||
if (raw.startsWith(`${channel}:`)) return raw;
|
||||
// Adapters using native JID format (WhatsApp: <phone>@s.whatsapp.net,
|
||||
// <groupId>@g.us) store platform_id without a channel prefix. The '@' is
|
||||
// the discriminator — telegram/discord platform_ids don't contain it
|
||||
// except after a channel prefix, which is already handled above.
|
||||
if (raw.includes('@')) return raw;
|
||||
return `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
/**
|
||||
* Smoke tests for the q.ts sqlite-CLI replacement wrapper.
|
||||
*
|
||||
* Verifies the two modes (SELECT prints rows in sqlite3 default "list"
|
||||
* format; mutation runs via db.exec) and a few edge cases that real
|
||||
* skill invocations rely on.
|
||||
*/
|
||||
|
||||
const Q = path.resolve(__dirname, 'q.ts');
|
||||
|
||||
describe('scripts/q.ts', () => {
|
||||
let tempDir: string;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'q-test-'));
|
||||
dbPath = path.join(tempDir, 'test.db');
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE t (id INTEGER, name TEXT, note TEXT);
|
||||
INSERT INTO t (id, name, note) VALUES (1, 'alice', 'hi'), (2, 'bob', NULL);
|
||||
`);
|
||||
db.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function run(sql: string): { stdout: string; stderr: string; status: number } {
|
||||
const r = spawnSync('pnpm', ['exec', 'tsx', Q, dbPath, sql], {
|
||||
encoding: 'utf-8',
|
||||
cwd: path.resolve(__dirname, '..'),
|
||||
});
|
||||
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
|
||||
}
|
||||
|
||||
it('SELECT prints pipe-separated rows in default order', () => {
|
||||
const r = run('SELECT id, name FROM t ORDER BY id');
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout.trim()).toBe('1|alice\n2|bob');
|
||||
});
|
||||
|
||||
it('SELECT renders NULL as empty string (matches sqlite3 default mode)', () => {
|
||||
const r = run('SELECT id, note FROM t ORDER BY id');
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout.trim()).toBe('1|hi\n2|');
|
||||
});
|
||||
|
||||
it('SELECT with no rows prints nothing', () => {
|
||||
const r = run("SELECT id FROM t WHERE name = 'nobody'");
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
});
|
||||
|
||||
it('INSERT runs via db.exec and persists', () => {
|
||||
const r = run("INSERT INTO t (id, name) VALUES (3, 'carol')");
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db.prepare('SELECT name FROM t WHERE id = 3').get() as { name: string };
|
||||
db.close();
|
||||
expect(row.name).toBe('carol');
|
||||
});
|
||||
|
||||
it('compound mutation statements execute together', () => {
|
||||
const r = run("DELETE FROM t WHERE id = 1; INSERT INTO t (id, name) VALUES (9, 'zed');");
|
||||
expect(r.status).toBe(0);
|
||||
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const ids = (db.prepare('SELECT id FROM t ORDER BY id').all() as { id: number }[]).map(
|
||||
(r) => r.id,
|
||||
);
|
||||
db.close();
|
||||
expect(ids).toEqual([2, 9]);
|
||||
});
|
||||
|
||||
it('WITH...DELETE is treated as a mutation, not a query', () => {
|
||||
const r = run("WITH stale AS (SELECT id FROM t WHERE name = 'alice') DELETE FROM t WHERE id IN (SELECT id FROM stale)");
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const rows = db.prepare('SELECT name FROM t').all() as { name: string }[];
|
||||
db.close();
|
||||
expect(rows).toEqual([{ name: 'bob' }]);
|
||||
});
|
||||
|
||||
it('exits 2 with usage when args are missing', () => {
|
||||
const r = spawnSync('pnpm', ['exec', 'tsx', Q], {
|
||||
encoding: 'utf-8',
|
||||
cwd: path.resolve(__dirname, '..'),
|
||||
});
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toMatch(/Usage/);
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* scripts/q.ts — sqlite3 CLI replacement for skill SQL invocations.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/q.ts <db-path> "<sql>"
|
||||
*
|
||||
* Uses better-sqlite3's stmt.reader property to distinguish queries
|
||||
* (SELECT / WITH...SELECT) from mutations. Queries print rows in
|
||||
* sqlite3 CLI default ("list") format — pipe-separated, no header —
|
||||
* so existing skill text reads identically. Mutations run via
|
||||
* stmt.run() (single statement) or db.exec() (compound).
|
||||
*
|
||||
* Why this exists: setup/verify.ts:5 codifies that NanoClaw avoids
|
||||
* depending on the sqlite3 CLI binary; setup never installs or probes
|
||||
* for it. Skills that shell out to `sqlite3` therefore fail on hosts
|
||||
* where it isn't preinstalled (common on fresh Ubuntu — see #2191).
|
||||
* This wrapper preserves the skill-text shape (path then SQL string)
|
||||
* while routing through the better-sqlite3 dep that setup already
|
||||
* installs and verifies.
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const [, , dbPath, sql] = process.argv;
|
||||
|
||||
if (!dbPath || sql === undefined) {
|
||||
console.error('Usage: pnpm exec tsx scripts/q.ts <db-path> "<sql>"');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
if (stmt.reader) {
|
||||
const rows = stmt.all() as Record<string, unknown>[];
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
Object.values(row)
|
||||
.map((v) => (v === null ? '' : String(v)))
|
||||
.join('|'),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
stmt.run();
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
// better-sqlite3 throws on compound statements ("contains more than
|
||||
// one statement"). Compound SQL in skills is always mutations
|
||||
// (e.g. "DELETE ...; INSERT ...;"), so fall back to db.exec().
|
||||
if (e instanceof Error && /more than one statement/i.test(e.message)) {
|
||||
db.exec(sql);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -120,20 +120,6 @@ install_deps() {
|
||||
|| true
|
||||
fi
|
||||
|
||||
# `npm install -g` writes to npm's global prefix, which isn't always on the
|
||||
# shell PATH — common on macOS where the user has `npm config set prefix
|
||||
# ~/.npm-global` to avoid sudo, or on Linux where /usr/local/bin isn't in
|
||||
# PATH. Discover the prefix and prepend its bin dir so `command -v pnpm`
|
||||
# sees the new install.
|
||||
if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
|
||||
local npm_prefix
|
||||
npm_prefix=$(npm config get prefix 2>/dev/null)
|
||||
if [ -n "$npm_prefix" ] && [ -x "$npm_prefix/bin/pnpm" ]; then
|
||||
export PATH="$npm_prefix/bin:$PATH"
|
||||
log "Prepended npm prefix bin to PATH: $npm_prefix/bin"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
log "pnpm not on PATH after corepack + npm fallback"
|
||||
return
|
||||
|
||||
+6
-14
@@ -16,13 +16,7 @@ cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-discord/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/discord@4.26.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
CHANNELS_BRANCH="origin/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
@@ -60,8 +54,8 @@ ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
git fetch origin channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch origin channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -110,15 +104,13 @@ mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
systemctl --user restart nanoclaw >&2 2>/dev/null \
|
||||
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the iMessage adapter, persist mode/creds to .env + data/env/env,
|
||||
# and restart the service. Non-interactive — the Full Disk Access walkthrough
|
||||
# (local mode) and Photon URL/key prompts (remote mode) live in
|
||||
# setup/channels/imessage.ts. Creds come in via env vars:
|
||||
# IMESSAGE_LOCAL 'true' | 'false' (required)
|
||||
# IMESSAGE_ENABLED 'true' (required when IMESSAGE_LOCAL=true)
|
||||
# IMESSAGE_SERVER_URL (required when IMESSAGE_LOCAL=false)
|
||||
# IMESSAGE_API_KEY (required when IMESSAGE_LOCAL=false)
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_IMESSAGE) at the end.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-imessage/SKILL.md.
|
||||
ADAPTER_VERSION="chat-adapter-imessage@0.1.1"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
local mode=${IMESSAGE_LOCAL:-}
|
||||
echo "=== NANOCLAW SETUP: ADD_IMESSAGE ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$mode" ] && echo "MODE: $([ "$mode" = "true" ] && echo local || echo remote)"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-imessage] $*" >&2; }
|
||||
|
||||
# Validate creds based on mode.
|
||||
if [ -z "${IMESSAGE_LOCAL:-}" ]; then
|
||||
emit_status failed "IMESSAGE_LOCAL env var not set (expected true|false)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${IMESSAGE_LOCAL}" = "true" ]; then
|
||||
if [ -z "${IMESSAGE_ENABLED:-}" ]; then
|
||||
emit_status failed "IMESSAGE_ENABLED env var not set for local mode"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
emit_status failed "local mode requires macOS"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [ -z "${IMESSAGE_SERVER_URL:-}" ]; then
|
||||
emit_status failed "IMESSAGE_SERVER_URL env var not set for remote mode"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${IMESSAGE_API_KEY:-}" ]; then
|
||||
emit_status failed "IMESSAGE_API_KEY env var not set for remote mode"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/imessage.ts ] && return 0
|
||||
! grep -q "^import './imessage.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/imessage.ts" > src/channels/imessage.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './imessage.js';" src/channels/index.ts; then
|
||||
echo "import './imessage.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
|
||||
remove_env() {
|
||||
local key=$1
|
||||
if grep -q "^${key}=" .env 2>/dev/null; then
|
||||
grep -v "^${key}=" .env > .env.tmp && mv .env.tmp .env
|
||||
fi
|
||||
}
|
||||
|
||||
# Write the canonical keys for the chosen mode, strip the opposite mode's
|
||||
# keys so stale values can't confuse the adapter's factory.
|
||||
upsert_env IMESSAGE_LOCAL "$IMESSAGE_LOCAL"
|
||||
if [ "$IMESSAGE_LOCAL" = "true" ]; then
|
||||
upsert_env IMESSAGE_ENABLED "$IMESSAGE_ENABLED"
|
||||
remove_env IMESSAGE_SERVER_URL
|
||||
remove_env IMESSAGE_API_KEY
|
||||
else
|
||||
upsert_env IMESSAGE_SERVER_URL "$IMESSAGE_SERVER_URL"
|
||||
upsert_env IMESSAGE_API_KEY "$IMESSAGE_API_KEY"
|
||||
remove_env IMESSAGE_ENABLED
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the creds…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the adapter a moment to open chat.db (local) or handshake with
|
||||
# Photon (remote) before emitting success.
|
||||
sleep 3
|
||||
|
||||
emit_status success
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Signal adapter in an already-running NanoClaw checkout.
|
||||
# Non-interactive — the operator-facing "install signal-cli" + QR scan
|
||||
# live in setup/channels/signal.ts. This script only:
|
||||
#
|
||||
# 1. Fetches src/channels/signal.ts + signal.test.ts from the channels
|
||||
# branch.
|
||||
# 2. Appends the self-registration import to src/channels/index.ts.
|
||||
# 3. Installs qrcode (for setup-flow QR rendering — adapter itself has
|
||||
# no npm deps).
|
||||
# 4. Builds.
|
||||
#
|
||||
# SIGNAL_ACCOUNT is persisted separately by the driver once signal-cli
|
||||
# link has produced a number; that keeps this script idempotent and
|
||||
# re-runnable without re-auth.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_SIGNAL) at the end. All
|
||||
# chatty progress goes to stderr so setup:auto's raw-log capture sees
|
||||
# the full story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-signal/SKILL.md.
|
||||
QRCODE_VERSION="qrcode@1.5.4"
|
||||
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
|
||||
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_SIGNAL ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-signal] $*" >&2; }
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/signal.ts ] && return 0
|
||||
! grep -q "^import './signal.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter files from ${CHANNELS_BRANCH}…"
|
||||
for f in \
|
||||
src/channels/signal.ts \
|
||||
src/channels/signal.test.ts
|
||||
do
|
||||
git show "${CHANNELS_BRANCH}:$f" > "$f" || {
|
||||
emit_status failed "git show ${CHANNELS_BRANCH}:$f failed"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
if ! grep -q "^import './signal.js';" src/channels/index.ts; then
|
||||
echo "import './signal.js';" >> src/channels/index.ts
|
||||
fi
|
||||
fi
|
||||
|
||||
# qrcode is needed by setup/signal-auth.ts to render the linking URL as a
|
||||
# terminal QR. Install idempotently — if it's already present (e.g. from a
|
||||
# prior WhatsApp install) pnpm is a no-op.
|
||||
if ! node -e "require.resolve('qrcode')" >/dev/null 2>&1; then
|
||||
log "Installing ${QRCODE_VERSION}…"
|
||||
pnpm install "${QRCODE_VERSION}" "${QRCODE_TYPES_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${QRCODE_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
emit_status success
|
||||
@@ -1,125 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET to
|
||||
# .env + data/env/env, and restart the service. Non-interactive — the
|
||||
# operator-facing app creation walkthrough + credential paste live in
|
||||
# setup/channels/slack.ts. Credentials come in via env vars:
|
||||
# SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the full
|
||||
# story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-slack/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/slack@4.26.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_SLACK ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-slack] $*" >&2; }
|
||||
|
||||
if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
|
||||
emit_status failed "SLACK_BOT_TOKEN env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
emit_status failed "SLACK_SIGNING_SECRET env var not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/slack.ts ] && return 0
|
||||
! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './slack.js';" src/channels/index.ts; then
|
||||
echo "import './slack.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
# Persist credentials. auto.ts validates via auth.test before this point, so
|
||||
# bad values here would be an internal bug rather than operator input.
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the Slack adapter a moment to finish starting the webhook listener
|
||||
# before emitting success.
|
||||
sleep 3
|
||||
|
||||
emit_status success
|
||||
+6
-14
@@ -19,13 +19,7 @@ cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-teams/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/teams@4.26.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
CHANNELS_BRANCH="origin/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
@@ -67,8 +61,8 @@ ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
git fetch origin channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch origin channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -119,15 +113,13 @@ mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
systemctl --user restart nanoclaw >&2 2>/dev/null \
|
||||
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
+6
-14
@@ -16,13 +16,7 @@ cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/telegram@4.26.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
CHANNELS_BRANCH="origin/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
@@ -59,8 +53,8 @@ ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
git fetch origin channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch origin channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -144,15 +138,13 @@ cp .env data/env/env
|
||||
# non-interactive install.
|
||||
|
||||
log "Restarting service so the new adapter picks up the token…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
systemctl --user restart nanoclaw >&2 2>/dev/null \
|
||||
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
Regular → Executable
+4
-10
@@ -16,17 +16,11 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-whatsapp/SKILL.md.
|
||||
BAILEYS_VERSION="@whiskeysockets/baileys@7.0.0-rc.9"
|
||||
BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16"
|
||||
QRCODE_VERSION="qrcode@1.5.4"
|
||||
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
|
||||
PINO_VERSION="pino@9.6.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
CHANNELS_BRANCH="origin/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
@@ -53,8 +47,8 @@ ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
git fetch origin channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch origin channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
+161
-694
File diff suppressed because it is too large
Load Diff
+29
-112
@@ -27,13 +27,9 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
@@ -49,17 +45,10 @@ interface AppInfo {
|
||||
owner: { id: string; username: string } | null;
|
||||
}
|
||||
|
||||
export async function runDiscordChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const choice = await askHasBotToken();
|
||||
if (choice === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
const hasBot = choice === 'yes';
|
||||
if (!hasBot) {
|
||||
export async function runDiscordChannel(displayName: string): Promise<void> {
|
||||
if (!(await askHasBotToken())) {
|
||||
await walkThroughBotCreation();
|
||||
}
|
||||
// Even users who said "yes" often can't find the token on demand — the
|
||||
// Dev Portal resets it if you don't store it, and people forget which
|
||||
// app it belongs to. A quick reminder before the paste prompt is cheap.
|
||||
showTokenLocationReminder(hasBot);
|
||||
|
||||
const token = await collectDiscordToken();
|
||||
const botUsername = await validateDiscordToken(token);
|
||||
@@ -67,13 +56,6 @@ export async function runDiscordChannel(displayName: string): Promise<ChannelFlo
|
||||
|
||||
const ownerUserId = await resolveOwnerUserId(app.owner);
|
||||
|
||||
// Before inviting: do they have a server to invite into? Walkthrough if
|
||||
// not — a fresh Discord account without a server makes the invite page a
|
||||
// dead end.
|
||||
if (!(await askHasDiscordServer())) {
|
||||
await walkThroughServerCreation();
|
||||
}
|
||||
|
||||
await promptInviteBot(app.applicationId, botUsername);
|
||||
|
||||
const install = await runQuietChild(
|
||||
@@ -145,23 +127,22 @@ export async function runDiscordChannel(displayName: string): Promise<ChannelFlo
|
||||
}
|
||||
}
|
||||
|
||||
async function askHasBotToken(): Promise<'yes' | 'no' | 'back'> {
|
||||
async function askHasBotToken(): Promise<boolean> {
|
||||
const answer = ensureAnswer(
|
||||
await brightSelect({
|
||||
await p.select({
|
||||
message: 'Do you already have a Discord bot?',
|
||||
options: [
|
||||
{ value: 'yes', label: 'Yes, I have a bot token ready' },
|
||||
{ value: 'no', label: "No, walk me through creating one" },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
return answer as 'yes' | 'no' | 'back';
|
||||
return answer === 'yes';
|
||||
}
|
||||
|
||||
async function walkThroughBotCreation(): Promise<void> {
|
||||
const url = 'https://discord.com/developers/applications';
|
||||
note(
|
||||
p.note(
|
||||
[
|
||||
"You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.",
|
||||
'',
|
||||
@@ -169,8 +150,9 @@ async function walkThroughBotCreation(): Promise<void> {
|
||||
' 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),
|
||||
].filter((line): line is string => line !== null).join('\n'),
|
||||
'',
|
||||
k.dim(url),
|
||||
].join('\n'),
|
||||
'Create a Discord bot',
|
||||
);
|
||||
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
|
||||
@@ -183,82 +165,10 @@ async function walkThroughBotCreation(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
function showTokenLocationReminder(hasExistingBot: boolean): void {
|
||||
// If we just walked them through creating a bot, they're staring at the
|
||||
// token. If they came in with an existing one, they may still need a nudge
|
||||
// to find it — tokens in the Dev Portal aren't visible after first reveal,
|
||||
// and "Reset Token" issues a new one.
|
||||
if (hasExistingBot) {
|
||||
note(
|
||||
[
|
||||
"Where to find your bot token:",
|
||||
'',
|
||||
' 1. discord.com/developers/applications → pick your app',
|
||||
' 2. "Bot" tab → "Reset Token" (the old one stops working)',
|
||||
' 3. Copy the new token',
|
||||
].join('\n'),
|
||||
'Reminder',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function askHasDiscordServer(): Promise<boolean> {
|
||||
const answer = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'Do you have a Discord server you can add the bot to?',
|
||||
options: [
|
||||
{ value: 'yes', label: 'Yes, I have a server' },
|
||||
{ value: 'no', label: "No, walk me through creating one" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('discord_has_server', String(answer));
|
||||
return answer === 'yes';
|
||||
}
|
||||
|
||||
async function walkThroughServerCreation(): Promise<void> {
|
||||
// Discord doesn't have a stable deep-link for "create server" so we open
|
||||
// the web client and rely on the + button being visible. The steps below
|
||||
// are the same whether they're in the desktop app or the browser.
|
||||
const url = 'https://discord.com/channels/@me';
|
||||
note(
|
||||
[
|
||||
"A Discord server is just a private space for you and the bot. Free and takes 30 seconds.",
|
||||
'',
|
||||
' 1. In Discord, click the "+" at the bottom of the server list',
|
||||
' 2. Choose "Create My Own" → "For me and my friends"',
|
||||
' 3. Give it any name (e.g. "NanoClaw")',
|
||||
formatNoteLink(url),
|
||||
].filter((line): line is string => line !== null).join('\n'),
|
||||
'Create a Discord server',
|
||||
);
|
||||
await confirmThenOpen(url, 'Press Enter to open Discord');
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "Server created?",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectDiscordToken(): Promise<string> {
|
||||
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?`,
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('discord_token', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -292,8 +202,9 @@ async function validateDiscordToken(token: string): Promise<string> {
|
||||
username?: string;
|
||||
message?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (res.ok && data.username) {
|
||||
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
setupLog.step('discord-validate', 'success', Date.now() - start, {
|
||||
BOT_USERNAME: data.username,
|
||||
BOT_ID: data.id ?? '',
|
||||
@@ -311,7 +222,8 @@ async function validateDiscordToken(token: string): Promise<string> {
|
||||
'Copy the token again from the Developer Portal and retry setup.',
|
||||
);
|
||||
} catch (err) {
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
@@ -339,6 +251,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
||||
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 +264,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
||||
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
|
||||
);
|
||||
}
|
||||
s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
// owner is populated for solo applications; team-owned apps return a
|
||||
// team object instead and we'll fall back to a manual user-id prompt.
|
||||
const owner =
|
||||
@@ -369,7 +282,8 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
||||
owner,
|
||||
};
|
||||
} catch (err) {
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
@@ -398,14 +312,14 @@ async function resolveOwnerUserId(
|
||||
}
|
||||
} else {
|
||||
p.log.info(
|
||||
brandBody("Your bot is owned by a Developer Team, so we need your Discord user ID directly."),
|
||||
"Your bot is owned by a Developer Team, so we need your Discord user ID directly.",
|
||||
);
|
||||
}
|
||||
return await promptForUserIdWithDevMode();
|
||||
}
|
||||
|
||||
async function promptForUserIdWithDevMode(): Promise<string> {
|
||||
note(
|
||||
p.note(
|
||||
[
|
||||
"To get your Discord user ID:",
|
||||
'',
|
||||
@@ -443,14 +357,15 @@ async function promptInviteBot(
|
||||
`&scope=bot` +
|
||||
`&permissions=${INVITE_PERMISSIONS}`;
|
||||
|
||||
note(
|
||||
p.note(
|
||||
[
|
||||
`@${botUsername} needs to share a server with you before it can DM you.`,
|
||||
'',
|
||||
' 1. Pick any server you\'re in (a personal one is fine)',
|
||||
' 2. Click "Authorize"',
|
||||
formatNoteLink(url),
|
||||
].filter((line): line is string => line !== null).join('\n'),
|
||||
'',
|
||||
k.dim(url),
|
||||
].join('\n'),
|
||||
'Add bot to a server',
|
||||
);
|
||||
await confirmThenOpen(url, 'Press Enter to open the invite page');
|
||||
@@ -477,6 +392,7 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
||||
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);
|
||||
@@ -489,13 +405,14 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
||||
'Make sure the bot is in a server you\'re also in, then retry setup.',
|
||||
);
|
||||
}
|
||||
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
|
||||
DM_CHANNEL_ID: data.id,
|
||||
});
|
||||
return data.id;
|
||||
} catch (err) {
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
@@ -516,7 +433,7 @@ async function resolveAgentName(): Promise<string> {
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
message: 'What should your assistant be called?',
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
/**
|
||||
* iMessage channel flow for setup:auto.
|
||||
*
|
||||
* `runIMessageChannel(displayName)` covers both deployment modes:
|
||||
*
|
||||
* Local (macOS): the bot runs on this Mac and talks via the signed-in
|
||||
* iMessage account. Reading chat.db needs Full Disk Access granted to
|
||||
* the Node binary — we open the directory for them so they can drag
|
||||
* the `node` file into System Settings.
|
||||
*
|
||||
* Remote (Photon API): the bot talks to a separate server (Photon)
|
||||
* that owns an iMessage account on another Mac. Used when this host
|
||||
* is Linux, or when the operator wants to keep their daily-driver
|
||||
* Mac's chat history out of the loop.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Pick mode (auto-defaults to local on macOS, remote elsewhere)
|
||||
* 2. Local: FDA walkthrough (open node bin directory, wait for ack)
|
||||
* Remote: prompt for Photon server URL + API key
|
||||
* 3. Ask for the phone or email the operator messages from — this is
|
||||
* the platform-id for first-agent wiring
|
||||
* 4. Install the adapter (setup/add-imessage.sh, non-interactive)
|
||||
* 5. Wire the agent via scripts/init-first-agent.ts — the welcome
|
||||
* iMessage goes out through the normal delivery path
|
||||
*
|
||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
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';
|
||||
|
||||
type Mode = 'local' | 'remote';
|
||||
|
||||
interface RemoteCreds {
|
||||
serverUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export async function runIMessageChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const isMac = os.platform() === 'darwin';
|
||||
|
||||
const mode = await askMode(isMac);
|
||||
if (mode === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
let remoteCreds: RemoteCreds | null = null;
|
||||
|
||||
if (mode === 'local') {
|
||||
if (!isMac) {
|
||||
await fail(
|
||||
'imessage',
|
||||
"Local iMessage mode only works on macOS.",
|
||||
'Choose remote mode (Photon API) on Linux/WSL, or run setup from your Mac.',
|
||||
);
|
||||
}
|
||||
await walkThroughFullDiskAccess();
|
||||
} else {
|
||||
remoteCreds = await collectRemoteCreds();
|
||||
}
|
||||
|
||||
const handle = await askOperatorHandle();
|
||||
|
||||
const install = await runQuietChild(
|
||||
'imessage-install',
|
||||
'bash',
|
||||
['setup/add-imessage.sh'],
|
||||
{
|
||||
running:
|
||||
mode === 'local'
|
||||
? "Connecting the iMessage adapter to this Mac…"
|
||||
: `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}…`,
|
||||
done: 'iMessage adapter installed.',
|
||||
},
|
||||
{
|
||||
env:
|
||||
mode === 'local'
|
||||
? { IMESSAGE_LOCAL: 'true', IMESSAGE_ENABLED: 'true' }
|
||||
: {
|
||||
IMESSAGE_LOCAL: 'false',
|
||||
IMESSAGE_SERVER_URL: remoteCreds!.serverUrl,
|
||||
IMESSAGE_API_KEY: remoteCreds!.apiKey,
|
||||
},
|
||||
extraFields: { MODE: mode },
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'imessage-install',
|
||||
"Couldn't install the iMessage adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const role = await askOperatorRole('iMessage');
|
||||
setupLog.userInput('imessage_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'imessage',
|
||||
'--user-id', handle,
|
||||
'--platform-id', handle,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to iMessage…`,
|
||||
done: `${agentName} is ready. Check iMessage for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'imessage',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: handle,
|
||||
MODE: mode,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'Double-check Full Disk Access (local mode) or Photon credentials (remote), then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function askMode(isMac: boolean): Promise<Mode | 'back'> {
|
||||
const baseOptions = isMac
|
||||
? [
|
||||
{
|
||||
value: 'local' as const,
|
||||
label: 'Local (this Mac)',
|
||||
hint: "uses this machine's iMessage account",
|
||||
},
|
||||
{
|
||||
value: 'remote' as const,
|
||||
label: 'Remote (Photon API)',
|
||||
hint: 'the bot lives on another server',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
value: 'remote' as const,
|
||||
label: 'Remote (Photon API)',
|
||||
hint: 'only option off macOS',
|
||||
},
|
||||
];
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<Mode | 'back'>({
|
||||
message: 'How should iMessage run?',
|
||||
initialValue: isMac ? 'local' : 'remote',
|
||||
options: [
|
||||
...baseOptions,
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (choice !== 'back') setupLog.userInput('imessage_mode', String(choice));
|
||||
return choice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant Full Disk Access to the Node binary the host runs under — without
|
||||
* it, the adapter can't read chat.db and inbound messages never arrive.
|
||||
* Opening the containing directory in Finder makes the drag-and-drop
|
||||
* target obvious; falling back to printing the path keeps us working in
|
||||
* SSH/headless contexts where `open` is a no-op.
|
||||
*/
|
||||
async function walkThroughFullDiskAccess(): Promise<void> {
|
||||
let nodePath = process.execPath;
|
||||
try {
|
||||
// `which node` picks up the user's shell-resolved node, which may differ
|
||||
// from process.execPath (e.g. they launched setup under a different
|
||||
// Node via `nvm`). If it succeeds and is resolvable, prefer it.
|
||||
const which = execSync('which node', { encoding: 'utf-8' }).trim();
|
||||
if (which) nodePath = which;
|
||||
} catch {
|
||||
// fall back to process.execPath
|
||||
}
|
||||
const nodeDir = path.dirname(nodePath);
|
||||
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`iMessage needs Full Disk Access granted to the Node binary:`,
|
||||
'',
|
||||
` ${nodePath}`,
|
||||
'',
|
||||
' 1. System Settings → Privacy & Security → Full Disk Access',
|
||||
` 2. Click +, then drag the "node" file from the Finder window`,
|
||||
' we just opened for you',
|
||||
' 3. Toggle it on, then come back here',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Grant Full Disk Access',
|
||||
);
|
||||
|
||||
try {
|
||||
execSync(`open "${nodeDir}"`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// No Finder (SSH/headless) — user sees the path in the note above.
|
||||
}
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "Granted Full Disk Access?",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('imessage_fda_confirmed', 'true');
|
||||
}
|
||||
|
||||
async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||
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?`,
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('imessage_remote_creds', 'reused-existing');
|
||||
return { serverUrl: existingUrl, apiKey: existingKey };
|
||||
}
|
||||
}
|
||||
|
||||
note(
|
||||
[
|
||||
"Photon is a separate service that owns an iMessage account and",
|
||||
"exposes it over HTTP. NanoClaw will talk to it via its API.",
|
||||
'',
|
||||
' 1. Set up a Photon server: https://photon.im',
|
||||
' 2. Copy the server URL and API key from your Photon dashboard',
|
||||
].join('\n'),
|
||||
'Remote iMessage via Photon',
|
||||
);
|
||||
|
||||
const urlAnswer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Photon server URL',
|
||||
placeholder: 'https://photon.example.com',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'URL is required';
|
||||
if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://';
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const serverUrl = (urlAnswer as string).trim();
|
||||
|
||||
const keyAnswer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Photon API key',
|
||||
clearOnError: true,
|
||||
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
|
||||
}),
|
||||
);
|
||||
const apiKey = (keyAnswer as string).trim();
|
||||
|
||||
setupLog.userInput('imessage_server_url', serverUrl);
|
||||
setupLog.userInput(
|
||||
'imessage_api_key',
|
||||
`${apiKey.slice(0, 4)}…${apiKey.slice(-4)}`,
|
||||
);
|
||||
return { serverUrl, apiKey };
|
||||
}
|
||||
|
||||
async function askOperatorHandle(): Promise<string> {
|
||||
note(
|
||||
[
|
||||
"What phone number or email do you iMessage with?",
|
||||
"That's where your assistant will send its welcome message.",
|
||||
'',
|
||||
k.dim(' • Phone: start with + and your country code, no spaces or dashes'),
|
||||
k.dim(' Example: +14155551234 (country code 1, then 4155551234)'),
|
||||
k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'),
|
||||
].join('\n'),
|
||||
'Your iMessage handle',
|
||||
);
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Phone number or email',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
const isPhone = /^\+\d{8,15}$/.test(t);
|
||||
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t);
|
||||
if (!isPhone && !isEmail) {
|
||||
return "Use a +E.164 phone number or an email address";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const handle = (answer as string).trim();
|
||||
setupLog.userInput('imessage_handle', handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
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 ${accentGreen('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;
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
/**
|
||||
* Signal channel flow for setup:auto.
|
||||
*
|
||||
* `runSignalChannel(displayName)` owns the full branch from signal-cli
|
||||
* presence check through the welcome DM:
|
||||
*
|
||||
* 1. Probe signal-cli on PATH (or SIGNAL_CLI_PATH). On macOS without it,
|
||||
* offer `brew install signal-cli` inline. On Linux, surface the
|
||||
* GitHub releases URL and bail with an actionable error.
|
||||
* 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent).
|
||||
* 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as
|
||||
* a terminal QR the operator scans from Signal → Linked Devices.
|
||||
* 4. Persist SIGNAL_ACCOUNT to .env (+ data/env/env).
|
||||
* 5. Kick the service so the adapter picks up the new credentials.
|
||||
* 6. Ask operator role + agent name.
|
||||
* 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome
|
||||
* DM path delivers the greeting through the adapter.
|
||||
*
|
||||
* Signal's `link` flow creates a *secondary* device. The phone number
|
||||
* comes from the primary (the phone that scanned the QR); this host then
|
||||
* sends/receives as that primary number. No registration of new numbers.
|
||||
*
|
||||
* Output obeys the three-level contract: clack UI for the user, structured
|
||||
* entries in logs/setup.log, full raw output in per-step files under
|
||||
* logs/setup-steps/. See docs/setup-flow.md.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import {
|
||||
type Block,
|
||||
type StepResult,
|
||||
dumpTranscriptOnFailure,
|
||||
ensureAnswer,
|
||||
fail,
|
||||
runQuietChild,
|
||||
spawnStep,
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { accentGreen, fmtDuration, note } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
export async function runSignalChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
note(
|
||||
[
|
||||
"NanoClaw links to Signal as a *secondary* device on your existing",
|
||||
"phone — no new number needed. Your assistant will send and receive",
|
||||
"messages as the number on that phone.",
|
||||
'',
|
||||
"Here's what's about to happen — no input needed for any of it:",
|
||||
'',
|
||||
' 1. Set up signal-cli (auto-installs if missing)',
|
||||
' 2. Install the Signal adapter',
|
||||
' 3. Show a QR code — scan it from Signal → Settings → Linked Devices',
|
||||
' 4. Wire your assistant and send a welcome message',
|
||||
].join('\n'),
|
||||
'Set up Signal',
|
||||
);
|
||||
|
||||
const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({
|
||||
message: 'Ready to set up Signal?',
|
||||
options: [
|
||||
{ value: 'continue', label: 'Continue' },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
initialValue: 'continue',
|
||||
}));
|
||||
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
|
||||
await ensureSignalCli();
|
||||
|
||||
const install = await runQuietChild(
|
||||
'signal-install',
|
||||
'bash',
|
||||
['setup/add-signal.sh'],
|
||||
{
|
||||
running: 'Installing the Signal adapter…',
|
||||
done: 'Signal adapter installed.',
|
||||
skipped: 'Signal adapter already installed.',
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'signal-install',
|
||||
"Couldn't install the Signal adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const auth = await runSignalAuth();
|
||||
if (!auth.ok) {
|
||||
const reason = auth.terminal?.fields.ERROR ?? 'unknown';
|
||||
await fail(
|
||||
'signal-auth',
|
||||
`Signal link failed (${reason}).`,
|
||||
reason === 'qr_timeout'
|
||||
? 'The code expired. Re-run setup to get a fresh one.'
|
||||
: 'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
const account = auth.terminal?.fields.ACCOUNT;
|
||||
if (!account) {
|
||||
await fail(
|
||||
'signal-auth',
|
||||
'Linked with Signal but couldn\'t read the phone number back.',
|
||||
'Run `signal-cli listAccounts` to confirm, then re-run setup.',
|
||||
);
|
||||
}
|
||||
|
||||
writeSignalAccount(account!);
|
||||
await restartService();
|
||||
|
||||
const role = await askOperatorRole('Signal');
|
||||
setupLog.userInput('signal_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'signal',
|
||||
'--user-id', account!,
|
||||
'--platform-id', account!,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to Signal…`,
|
||||
done: `${agentName} is ready. Check Signal for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'signal',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: account!,
|
||||
ROLE: role,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'You can retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSignalCli(): Promise<void> {
|
||||
const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli';
|
||||
const probeFor = (): boolean => {
|
||||
const r = spawnSync(cli, ['--version'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return !r.error && r.status === 0;
|
||||
};
|
||||
if (probeFor()) return;
|
||||
|
||||
note(
|
||||
[
|
||||
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
||||
"We'll install it for you now — about 30 seconds, one-time only.",
|
||||
'',
|
||||
process.platform === 'darwin'
|
||||
? "On this Mac we'll use Homebrew (no admin password needed)."
|
||||
: "On Linux we'll grab the native release binary (no Java needed) and install it to ~/.local/bin.",
|
||||
].join('\n'),
|
||||
'Setting up signal-cli',
|
||||
);
|
||||
|
||||
const install = await runQuietChild(
|
||||
'install-signal-cli',
|
||||
'bash',
|
||||
['setup/install-signal-cli.sh'],
|
||||
{
|
||||
running: 'Installing signal-cli…',
|
||||
done: 'signal-cli installed.',
|
||||
},
|
||||
);
|
||||
|
||||
if (install.ok && probeFor()) return;
|
||||
|
||||
const reason = install.terminal?.fields.ERROR;
|
||||
if (process.platform === 'darwin') {
|
||||
note(
|
||||
[
|
||||
"We couldn't install signal-cli automatically.",
|
||||
reason === 'homebrew_not_installed'
|
||||
? ' Reason: Homebrew is not installed.'
|
||||
: ` Reason: ${reason ?? 'unknown'}.`,
|
||||
'',
|
||||
'You can install it manually:',
|
||||
'',
|
||||
k.cyan(' brew install signal-cli'),
|
||||
'',
|
||||
'Then re-run setup.',
|
||||
].join('\n'),
|
||||
"Couldn't install signal-cli",
|
||||
);
|
||||
} else {
|
||||
note(
|
||||
[
|
||||
"We couldn't install signal-cli automatically.",
|
||||
` Reason: ${reason ?? 'unknown'}.`,
|
||||
'',
|
||||
'You can install it manually from GitHub:',
|
||||
'',
|
||||
k.cyan(' https://github.com/AsamK/signal-cli/releases'),
|
||||
'',
|
||||
'Then re-run setup.',
|
||||
].join('\n'),
|
||||
"Couldn't install signal-cli",
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
'install-signal-cli',
|
||||
'signal-cli is required but the auto-install failed.',
|
||||
'Install it manually and re-run setup.',
|
||||
);
|
||||
}
|
||||
|
||||
async function runSignalAuth(): Promise<
|
||||
StepResult & { rawLog: string; durationMs: number }
|
||||
> {
|
||||
const rawLog = setupLog.stepRawLog('signal-auth');
|
||||
const start = Date.now();
|
||||
const s = p.spinner();
|
||||
s.start('Starting Signal link…');
|
||||
let spinnerActive = true;
|
||||
|
||||
const stopSpinner = (msg: string, code?: number): void => {
|
||||
if (spinnerActive) {
|
||||
s.stop(msg, code);
|
||||
spinnerActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Tracks how many lines the QR block occupies so we can wipe it in-place
|
||||
// once linking succeeds (Signal's link URL doesn't rotate like WhatsApp's,
|
||||
// but we still want to erase the QR from screen once it's served).
|
||||
let qrLinesPrinted = 0;
|
||||
|
||||
const result = await spawnStep(
|
||||
'signal-auth',
|
||||
[],
|
||||
(block: Block) => {
|
||||
if (block.type === 'SIGNAL_AUTH_QR') {
|
||||
const qr = block.fields.QR ?? '';
|
||||
if (!qr) return;
|
||||
void renderQr(qr).then((lines) => {
|
||||
stopSpinner('Scan this QR from Signal → Settings → Linked Devices.');
|
||||
process.stdout.write(lines.join('\n') + '\n');
|
||||
qrLinesPrinted = lines.length;
|
||||
s.start('Waiting for you to scan…');
|
||||
spinnerActive = true;
|
||||
});
|
||||
} else if (block.type === 'SIGNAL_AUTH') {
|
||||
const status = block.fields.STATUS;
|
||||
// Wipe the QR block regardless of outcome — it's either scanned
|
||||
// and useless, or expired and misleading.
|
||||
if (qrLinesPrinted > 0) {
|
||||
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
|
||||
qrLinesPrinted = 0;
|
||||
}
|
||||
const account = block.fields.ACCOUNT;
|
||||
if (status === 'skipped') {
|
||||
stopSpinner(
|
||||
account
|
||||
? `Signal already linked as ${k.cyan(account)}.`
|
||||
: 'Signal already linked.',
|
||||
);
|
||||
} else if (status === 'success') {
|
||||
stopSpinner(`Signal linked as ${k.cyan(String(account ?? ''))}.`);
|
||||
} else if (status === 'failed') {
|
||||
const err = block.fields.ERROR ?? 'unknown';
|
||||
stopSpinner(`Signal link failed: ${err}`, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
rawLog,
|
||||
);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
if (spinnerActive) {
|
||||
stopSpinner(
|
||||
result.ok ? 'Done.' : 'Signal link ended unexpectedly.',
|
||||
result.ok ? 0 : 1,
|
||||
);
|
||||
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
|
||||
writeStepEntry('signal-auth', result, durationMs, rawLog);
|
||||
return { ...result, rawLog, durationMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the raw linking URL as a block-art QR, returned line-by-line so
|
||||
* the caller can count lines for in-place cleanup. Uses small-mode so the
|
||||
* code stays scannable on 24-row terminals. If qrcode isn't installed
|
||||
* (add-signal.sh should have handled it, but we're defensive), fall back
|
||||
* to the raw URL and ask the user to paste it into an external renderer.
|
||||
*/
|
||||
async function renderQr(url: string): Promise<string[]> {
|
||||
try {
|
||||
const QRCode = await import('qrcode');
|
||||
const qrText = await QRCode.toString(url, { type: 'terminal', small: true });
|
||||
const caption = k.dim(
|
||||
' Signal → Settings → Linked Devices → Link New Device → scan.',
|
||||
);
|
||||
return [...qrText.trimEnd().split('\n'), '', caption];
|
||||
} catch {
|
||||
return [
|
||||
'Linking URL (render at https://qr.io or similar):',
|
||||
'',
|
||||
url,
|
||||
'',
|
||||
k.dim('Signal → Settings → Linked Devices → Link New Device → scan.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */
|
||||
function writeSignalAccount(account: string): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
let contents = '';
|
||||
try {
|
||||
contents = fs.readFileSync(envPath, 'utf-8');
|
||||
} catch {
|
||||
contents = '';
|
||||
}
|
||||
if (/^SIGNAL_ACCOUNT=/m.test(contents)) {
|
||||
contents = contents.replace(
|
||||
/^SIGNAL_ACCOUNT=.*$/m,
|
||||
`SIGNAL_ACCOUNT=${account}`,
|
||||
);
|
||||
} else {
|
||||
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
|
||||
contents += `SIGNAL_ACCOUNT=${account}\n`;
|
||||
}
|
||||
fs.writeFileSync(envPath, contents);
|
||||
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
|
||||
|
||||
setupLog.userInput('signal_account', account);
|
||||
}
|
||||
|
||||
async function restartService(): Promise<void> {
|
||||
const s = p.spinner();
|
||||
s.start('Restarting NanoClaw so it sees your Signal account…');
|
||||
const start = Date.now();
|
||||
const platform = process.platform;
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
spawnSync(
|
||||
'launchctl',
|
||||
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
const unit = getSystemdUnit();
|
||||
const user = spawnSync('systemctl', ['--user', 'restart', unit], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (user.status !== 0) {
|
||||
spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore' });
|
||||
}
|
||||
}
|
||||
// Give the adapter a moment to connect to signal-cli before
|
||||
// init-first-agent's welcome DM hits the delivery path.
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('signal-restart', 'success', Date.now() - start, {
|
||||
PLATFORM: platform,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
s.stop(`Restart may have failed: ${message}`, 1);
|
||||
setupLog.step('signal-restart', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
// Non-fatal — the user can restart manually if init-first-agent fails.
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
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 ${accentGreen('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;
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
/**
|
||||
* Slack channel flow for setup:auto.
|
||||
*
|
||||
* `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. 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
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { openUrl } from '../lib/browser.js';
|
||||
import { isHeadless } from '../platform.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { readEnvKey } from '../environment.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';
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
interface WorkspaceInfo {
|
||||
teamName: string;
|
||||
teamId: string;
|
||||
botName: string;
|
||||
botUserId: string;
|
||||
}
|
||||
|
||||
export async function runSlackChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const intro = await walkThroughAppCreation();
|
||||
if (intro === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
|
||||
const token = await collectBotToken();
|
||||
const signingSecret = await collectSigningSecret();
|
||||
const info = await validateSlackToken(token);
|
||||
|
||||
const install = await runQuietChild(
|
||||
'slack-install',
|
||||
'bash',
|
||||
['setup/add-slack.sh'],
|
||||
{
|
||||
running: `Connecting Slack to @${info.botName} (${info.teamName})…`,
|
||||
done: 'Slack adapter installed.',
|
||||
},
|
||||
{
|
||||
env: {
|
||||
SLACK_BOT_TOKEN: token,
|
||||
SLACK_SIGNING_SECRET: signingSecret,
|
||||
},
|
||||
extraFields: {
|
||||
BOT_NAME: info.botName,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'slack-install',
|
||||
"Couldn't connect Slack.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
||||
// Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s
|
||||
// per-line formatter so the URL stands out against the rest of the body.
|
||||
const linkBlock = isHeadless()
|
||||
? [`\x1b[97mGet started: ${SLACK_APPS_URL}\x1b[39m`, '']
|
||||
: [];
|
||||
|
||||
note(
|
||||
[
|
||||
"You'll create a Slack app that the assistant talks through.",
|
||||
"Free and stays inside the workspaces you pick.",
|
||||
'',
|
||||
...linkBlock,
|
||||
' 1. Create a new app "From scratch", name it, pick a workspace',
|
||||
' 2. OAuth & Permissions → add Bot Token Scopes:',
|
||||
' • im:write, im:history',
|
||||
' • channels:read, channels:history',
|
||||
' • groups:read, groups:history',
|
||||
' • chat:write',
|
||||
' • users:read',
|
||||
' • reactions:write',
|
||||
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
||||
' slash commands and messages from the messages tab"',
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
].join('\n'),
|
||||
'Create a Slack app',
|
||||
);
|
||||
|
||||
// Back-aware gate replacing the old `confirmThenOpen` "Press Enter to open
|
||||
// Slack app settings" so users can bail out of Slack before we open the
|
||||
// browser or ask for tokens.
|
||||
const choice = ensureAnswer(await brightSelect<'open' | 'back'>({
|
||||
message: 'Open Slack app settings in your browser?',
|
||||
options: [
|
||||
{ value: 'open', label: 'Open Slack app settings' },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
initialValue: 'open',
|
||||
}));
|
||||
if (choice === 'back') return 'back';
|
||||
if (!isHeadless()) openUrl(SLACK_APPS_URL);
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Got your bot token and signing secret?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
async function collectBotToken(): Promise<string> {
|
||||
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?`,
|
||||
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',
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Token is required';
|
||||
if (!t.startsWith('xoxb-')) return 'Bot tokens start with xoxb-';
|
||||
if (t.length < 24) return "That's shorter than a real Slack bot token";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'slack_bot_token',
|
||||
`${token.slice(0, 10)}…${token.slice(-4)}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function collectSigningSecret(): Promise<string> {
|
||||
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?',
|
||||
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',
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Signing secret is required';
|
||||
// Slack signing secrets are 32-char hex strings, but newer apps
|
||||
// sometimes emit longer variants — leniently require hex only.
|
||||
if (!/^[a-f0-9]{16,}$/i.test(t)) {
|
||||
return 'Signing secrets are a string of hex characters';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const secret = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'slack_signing_secret',
|
||||
`${secret.slice(0, 4)}…${secret.slice(-4)}`,
|
||||
);
|
||||
return secret;
|
||||
}
|
||||
|
||||
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Checking your bot token…');
|
||||
try {
|
||||
const res = await fetch(`${SLACK_API}/auth.test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
team?: string;
|
||||
team_id?: string;
|
||||
user?: string;
|
||||
user_id?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (data.ok && data.team && data.user) {
|
||||
s.stop(
|
||||
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
|
||||
);
|
||||
const info: WorkspaceInfo = {
|
||||
teamName: data.team,
|
||||
teamId: data.team_id ?? '',
|
||||
botName: data.user,
|
||||
botUserId: data.user_id ?? '',
|
||||
};
|
||||
setupLog.step('slack-validate', 'success', Date.now() - start, {
|
||||
BOT_NAME: info.botName,
|
||||
BOT_USER_ID: info.botUserId,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
});
|
||||
return info;
|
||||
}
|
||||
const reason = data.error ?? `HTTP ${res.status}`;
|
||||
s.stop(`Slack didn't accept that token: ${reason}`, 1);
|
||||
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
await fail(
|
||||
'slack-validate',
|
||||
"Slack didn't accept that token.",
|
||||
reason === 'invalid_auth' || reason === 'token_revoked'
|
||||
? 'Copy the token again from OAuth & Permissions and retry setup.'
|
||||
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
|
||||
);
|
||||
} catch (err) {
|
||||
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,
|
||||
});
|
||||
await fail(
|
||||
'slack-validate',
|
||||
"Couldn't reach Slack.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectSlackUserId(): Promise<string> {
|
||||
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<string> {
|
||||
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;
|
||||
};
|
||||
if (data.ok && data.channel?.id) {
|
||||
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,
|
||||
});
|
||||
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) {
|
||||
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,
|
||||
});
|
||||
await fail(
|
||||
'slack-open-dm',
|
||||
"Couldn't reach Slack.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
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 ${accentGreen('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 {
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`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. 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',
|
||||
` • Request URL: https://<your-public-host>/webhook/slack`,
|
||||
' • Subscribe to bot events: message.channels, message.groups,',
|
||||
' message.im, app_mention',
|
||||
' • Save Changes',
|
||||
'',
|
||||
' 3. In your Slack app → Interactivity & Shortcuts:',
|
||||
' • Toggle "Interactivity" on',
|
||||
` • Request URL: https://<your-public-host>/webhook/slack`,
|
||||
' • Save Changes',
|
||||
'',
|
||||
' 4. Slack will prompt you to reinstall the app — do it to apply',
|
||||
' the new settings',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Finish setting up Slack',
|
||||
);
|
||||
}
|
||||
+43
-124
@@ -30,8 +30,6 @@ import path from 'path';
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import {
|
||||
isHelpEscape,
|
||||
@@ -41,9 +39,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';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
|
||||
const CHANNEL = 'teams';
|
||||
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
|
||||
@@ -58,62 +54,20 @@ interface Collected {
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export async function runTeamsChannel(_displayName: string): Promise<ChannelFlowResult> {
|
||||
export async function runTeamsChannel(_displayName: string): Promise<void> {
|
||||
const collected: Collected = {};
|
||||
const completed: string[] = [];
|
||||
|
||||
const existingAppId = readEnvKey('TEAMS_APP_ID');
|
||||
const existingPassword = readEnvKey('TEAMS_APP_PASSWORD');
|
||||
if (existingAppId && existingPassword) {
|
||||
const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({
|
||||
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
|
||||
options: [
|
||||
{ value: 'yes', label: 'Yes, use the existing credentials' },
|
||||
{ value: 'no', label: "No, set up new ones" },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
initialValue: 'yes',
|
||||
}));
|
||||
if (choice === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
if (choice === 'yes') {
|
||||
collected.appId = existingAppId;
|
||||
collected.appPassword = existingPassword;
|
||||
collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
|
||||
if (collected.appType === 'SingleTenant') {
|
||||
collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined;
|
||||
}
|
||||
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();
|
||||
|
||||
const prereqsResult = await confirmPrereqs({ collected, completed });
|
||||
if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
await confirmPrereqs({ collected, completed });
|
||||
await stepPublicUrl({ collected, completed });
|
||||
if (await stepAppRegistration({ collected, completed }) === 'back') {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
if (await stepClientSecret({ collected, completed }) === 'back') {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
if (await stepAzureBot({ collected, completed }) === 'back') {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
if (await stepEnableTeamsChannel({ collected, completed }) === 'back') {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
await stepAppRegistration({ collected, completed });
|
||||
await stepClientSecret({ collected, completed });
|
||||
await stepAzureBot({ collected, completed });
|
||||
await stepEnableTeamsChannel({ collected, completed });
|
||||
const manifestResult = await stepGenerateManifest({ collected, completed });
|
||||
if (
|
||||
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath })
|
||||
=== 'back'
|
||||
) {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath });
|
||||
|
||||
await installAdapter(collected);
|
||||
completed.push('Adapter installed and service restarted.');
|
||||
@@ -124,7 +78,7 @@ export async function runTeamsChannel(_displayName: string): Promise<ChannelFlow
|
||||
// ─── step: intro / prereqs ──────────────────────────────────────────────
|
||||
|
||||
function printIntro(): void {
|
||||
note(
|
||||
p.note(
|
||||
[
|
||||
'Setting up Teams is more involved than the other channels — about',
|
||||
'7 steps across the Azure portal and Teams admin.',
|
||||
@@ -137,8 +91,8 @@ function printIntro(): void {
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
'Before we start, confirm you have:',
|
||||
'',
|
||||
@@ -152,42 +106,19 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[]
|
||||
'Prereqs',
|
||||
);
|
||||
|
||||
// Back-aware variant of stepGate — Back is only offered on the very first
|
||||
// step of the Teams flow so users can bail out before any state is taken.
|
||||
while (true) {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<'done' | 'help' | 'reshow' | 'back'>({
|
||||
message: 'How did that go?',
|
||||
options: [
|
||||
{ value: 'done', label: "Done — let's continue" },
|
||||
{ value: 'help', label: 'Stuck — hand me off to Claude' },
|
||||
{ value: 'reshow', label: 'Show me the steps again' },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (choice === 'back') return 'back';
|
||||
if (choice === 'done') break;
|
||||
if (choice === 'help') {
|
||||
await offerHandoff({
|
||||
step: 'teams-prereqs',
|
||||
stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel',
|
||||
args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (choice === 'reshow') {
|
||||
return confirmPrereqs(args);
|
||||
}
|
||||
}
|
||||
await stepGate({
|
||||
stepName: 'teams-prereqs',
|
||||
stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel',
|
||||
reshow: () => confirmPrereqs(args),
|
||||
args,
|
||||
});
|
||||
args.completed.push('Prereqs confirmed.');
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
// ─── step: public URL ──────────────────────────────────────────────────
|
||||
|
||||
async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise<void> {
|
||||
note(
|
||||
p.note(
|
||||
[
|
||||
"Azure Bot Service delivers messages to an HTTPS endpoint you",
|
||||
"control. The endpoint needs to reach this machine's webhook",
|
||||
@@ -242,8 +173,8 @@ async function stepPublicUrl(args: { collected: Collected; completed: string[] }
|
||||
async function stepAppRegistration(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
|
||||
'2. Name it (e.g. "NanoClaw")',
|
||||
@@ -275,17 +206,15 @@ async function stepAppRegistration(args: {
|
||||
);
|
||||
}
|
||||
|
||||
const gate = await stepGate({
|
||||
await stepGate({
|
||||
stepName: 'teams-app-registration',
|
||||
stepDescription: 'registering an app in Azure and collecting App ID + tenant type',
|
||||
reshow: () => stepAppRegistration(args),
|
||||
args,
|
||||
});
|
||||
if (gate === 'back') return 'back';
|
||||
args.completed.push(
|
||||
`App registered: ${args.collected.appId} (${args.collected.appType})`,
|
||||
);
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
async function askAppType(args: {
|
||||
@@ -294,7 +223,7 @@ async function askAppType(args: {
|
||||
}): Promise<'SingleTenant' | 'MultiTenant'> {
|
||||
while (true) {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
await p.select({
|
||||
message: 'Which account type did you pick?',
|
||||
options: [
|
||||
{
|
||||
@@ -328,8 +257,8 @@ async function askAppType(args: {
|
||||
async function stepClientSecret(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
`1. In your app registration, open "Certificates & secrets"`,
|
||||
'2. Click "New client secret"',
|
||||
@@ -346,7 +275,6 @@ 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';
|
||||
@@ -371,15 +299,13 @@ async function stepClientSecret(args: {
|
||||
break;
|
||||
}
|
||||
|
||||
const gate = await stepGate({
|
||||
await stepGate({
|
||||
stepName: 'teams-client-secret',
|
||||
stepDescription: 'creating and copying the client secret',
|
||||
reshow: () => stepClientSecret(args),
|
||||
args,
|
||||
});
|
||||
if (gate === 'back') return 'back';
|
||||
args.completed.push('Client secret captured.');
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
// ─── step: Azure Bot resource ──────────────────────────────────────────
|
||||
@@ -387,7 +313,7 @@ async function stepClientSecret(args: {
|
||||
async function stepAzureBot(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<'continue' | 'back'> {
|
||||
}): Promise<void> {
|
||||
const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`;
|
||||
const tenantFlag =
|
||||
args.collected.appType === 'SingleTenant'
|
||||
@@ -401,7 +327,7 @@ async function stepAzureBot(args: {
|
||||
` --appid ${args.collected.appId} \\\n` +
|
||||
` ${tenantFlag}--endpoint "${endpoint}"`;
|
||||
|
||||
note(
|
||||
p.note(
|
||||
[
|
||||
`In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`,
|
||||
'',
|
||||
@@ -422,16 +348,14 @@ async function stepAzureBot(args: {
|
||||
'Step 3 of 6 — Create Azure Bot resource',
|
||||
);
|
||||
|
||||
const gate = await stepGate({
|
||||
await stepGate({
|
||||
stepName: 'teams-azure-bot',
|
||||
stepDescription:
|
||||
'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint',
|
||||
reshow: () => stepAzureBot(args),
|
||||
args,
|
||||
});
|
||||
if (gate === 'back') return 'back';
|
||||
args.completed.push('Azure Bot created; messaging endpoint configured.');
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
// ─── step: enable Teams channel ────────────────────────────────────────
|
||||
@@ -439,8 +363,8 @@ async function stepAzureBot(args: {
|
||||
async function stepEnableTeamsChannel(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
'1. Open your Azure Bot resource → Channels',
|
||||
'2. Click Microsoft Teams → Accept terms → Apply',
|
||||
@@ -450,15 +374,13 @@ async function stepEnableTeamsChannel(args: {
|
||||
].join('\n'),
|
||||
'Step 4 of 6 — Enable Teams channel on the bot',
|
||||
);
|
||||
const gate = await stepGate({
|
||||
await stepGate({
|
||||
stepName: 'teams-enable-channel',
|
||||
stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource',
|
||||
reshow: () => stepEnableTeamsChannel(args),
|
||||
args,
|
||||
});
|
||||
if (gate === 'back') return 'back';
|
||||
args.completed.push('Teams channel enabled on the bot.');
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
// ─── step: manifest zip ────────────────────────────────────────────────
|
||||
@@ -511,8 +433,8 @@ async function stepSideload(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
zipPath: string;
|
||||
}): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
'1. Open Microsoft Teams',
|
||||
'2. Go to Apps → Manage your apps → Upload an app',
|
||||
@@ -526,15 +448,13 @@ async function stepSideload(args: {
|
||||
].join('\n'),
|
||||
'Step 5 of 6 — Sideload the app into Teams',
|
||||
);
|
||||
const gate = await stepGate({
|
||||
await stepGate({
|
||||
stepName: 'teams-sideload',
|
||||
stepDescription: 'uploading the generated zip into Teams as a custom app',
|
||||
reshow: () => stepSideload({ ...args, zipPath: args.zipPath }),
|
||||
reshow: () => stepSideload(args),
|
||||
args,
|
||||
});
|
||||
if (gate === 'back') return 'back';
|
||||
args.completed.push('App sideloaded into Teams.');
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
// ─── step: install adapter ─────────────────────────────────────────────
|
||||
@@ -580,7 +500,7 @@ async function finishWithHandoff(
|
||||
collected: Collected,
|
||||
completed: string[],
|
||||
): Promise<void> {
|
||||
note(
|
||||
p.note(
|
||||
[
|
||||
'The Teams adapter is live and the service is running.',
|
||||
'',
|
||||
@@ -595,7 +515,7 @@ async function finishWithHandoff(
|
||||
);
|
||||
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
await p.select({
|
||||
message: 'Ready to finish?',
|
||||
options: [
|
||||
{
|
||||
@@ -609,7 +529,7 @@ async function finishWithHandoff(
|
||||
);
|
||||
|
||||
if (choice === 'self') {
|
||||
note(
|
||||
p.note(
|
||||
[
|
||||
' 1. Find your bot in Teams (search by name, or via the sideloaded',
|
||||
' app) and send it a message ("hi" is fine)',
|
||||
@@ -646,23 +566,21 @@ async function finishWithHandoff(
|
||||
async function stepGate(args: {
|
||||
stepName: string;
|
||||
stepDescription: string;
|
||||
reshow: () => Promise<'continue' | 'back'>;
|
||||
reshow: () => Promise<void> | Promise<unknown>;
|
||||
args: { collected: Collected; completed: string[] };
|
||||
}): Promise<'continue' | 'back'> {
|
||||
}): Promise<void> {
|
||||
while (true) {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
await p.select({
|
||||
message: 'How did that go?',
|
||||
options: [
|
||||
{ value: 'done', label: "Done — let's continue" },
|
||||
{ value: 'help', label: 'Stuck — hand me off to Claude' },
|
||||
{ value: 'reshow', label: 'Show me the steps again' },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (choice === 'done') return 'continue';
|
||||
if (choice === 'back') return 'back';
|
||||
if (choice === 'done') return;
|
||||
if (choice === 'help') {
|
||||
await offerHandoff({
|
||||
step: args.stepName,
|
||||
@@ -672,7 +590,8 @@ async function stepGate(args: {
|
||||
continue;
|
||||
}
|
||||
if (choice === 'reshow') {
|
||||
return args.reshow();
|
||||
await args.reshow();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-82
@@ -21,10 +21,7 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { isHeadless } from '../platform.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { confirmThenOpen, formatNoteLink, openUrl } from '../lib/browser.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import {
|
||||
type Block,
|
||||
@@ -36,15 +33,12 @@ import {
|
||||
spawnStep,
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js';
|
||||
import { brandBold } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
export async function runTelegramChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const tokenOrBack = await collectTelegramToken();
|
||||
if (tokenOrBack === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
const token = tokenOrBack;
|
||||
export async function runTelegramChannel(displayName: string): Promise<void> {
|
||||
const token = await collectTelegramToken();
|
||||
const botUsername = await validateTelegramToken(token);
|
||||
|
||||
// Deep-link the user into the bot's chat so they're on the right screen
|
||||
@@ -53,37 +47,15 @@ export async function runTelegramChannel(displayName: string): Promise<ChannelFl
|
||||
// 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}`;
|
||||
// Two card variants — auto-open fires only on GUI, so headless users
|
||||
// need full self-serve instructions inside the card itself, while GUI
|
||||
// users get a leaner status line plus the auto-open + a single
|
||||
// combined dim fallback line (URL + mobile alternative) on the
|
||||
// confirm prompt below.
|
||||
if (isHeadless()) {
|
||||
note(
|
||||
[
|
||||
`Open @${botUsername} in Telegram now — the pairing code is coming next, and that's where you'll send it.`,
|
||||
'',
|
||||
`Get started: ${botUrl}`,
|
||||
'',
|
||||
`Don't have Telegram installed here? Open it on any device and search for @${botUsername}`,
|
||||
].join('\n'),
|
||||
'Open Telegram',
|
||||
);
|
||||
} else {
|
||||
note(
|
||||
p.note(
|
||||
[
|
||||
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
|
||||
'Open Telegram',
|
||||
);
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: `Press Enter to open Telegram (must be installed here)\n${k.dim(
|
||||
`If browser does not appear, please visit: ${botUrl} — or search for @${botUsername} in Telegram`,
|
||||
)}`,
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
openUrl(botUrl);
|
||||
}
|
||||
'',
|
||||
k.dim(botUrl),
|
||||
].join('\n'),
|
||||
'Open Telegram',
|
||||
);
|
||||
await confirmThenOpen(botUrl, 'Press Enter to open Telegram');
|
||||
|
||||
const install = await runQuietChild(
|
||||
'telegram-install',
|
||||
@@ -159,32 +131,13 @@ export async function runTelegramChannel(displayName: string): Promise<ChannelFl
|
||||
}
|
||||
}
|
||||
|
||||
async function collectTelegramToken(): Promise<string | 'back'> {
|
||||
const existing = readEnvKey('TELEGRAM_BOT_TOKEN');
|
||||
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
|
||||
const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({
|
||||
message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`,
|
||||
options: [
|
||||
{ value: 'yes', label: 'Yes, use the existing token' },
|
||||
{ value: 'no', label: 'No, paste a new one' },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
initialValue: 'yes',
|
||||
}));
|
||||
if (choice === 'back') return 'back';
|
||||
if (choice === 'yes') {
|
||||
setupLog.userInput('telegram_token', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
// 'no' falls through to the paste flow below
|
||||
}
|
||||
|
||||
note(
|
||||
async function collectTelegramToken(): Promise<string> {
|
||||
p.note(
|
||||
[
|
||||
"Your assistant talks to you through a Telegram bot you create.",
|
||||
"Here's how:",
|
||||
'',
|
||||
" 1. Open Telegram and message @BotFather — Telegram's official bot for creating and managing bots",
|
||||
' 1. Open Telegram and message @BotFather',
|
||||
' 2. Send /newbot and follow the prompts',
|
||||
' 3. Copy the token it gives you (it looks like <digits>:<chars>)',
|
||||
'',
|
||||
@@ -194,23 +147,9 @@ async function collectTelegramToken(): Promise<string | 'back'> {
|
||||
'Set up your Telegram bot',
|
||||
);
|
||||
|
||||
// Back-aware gate before the password prompt — `p.password` doesn't
|
||||
// accept extra options, so we offer Back as a separate brightSelect
|
||||
// immediately after the BotFather instructions and before the paste.
|
||||
const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({
|
||||
message: 'Ready to paste your bot token?',
|
||||
options: [
|
||||
{ value: 'continue', label: 'Yes, paste it on the next prompt' },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
initialValue: 'continue',
|
||||
}));
|
||||
if (proceed === 'back') return 'back';
|
||||
|
||||
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())) {
|
||||
@@ -239,9 +178,10 @@ async function validateTelegramToken(token: string): Promise<string> {
|
||||
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(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
setupLog.step('telegram-validate', 'success', Date.now() - start, {
|
||||
BOT_USERNAME: username,
|
||||
BOT_ID: data.result.id ?? '',
|
||||
@@ -259,7 +199,8 @@ async function validateTelegramToken(token: string): Promise<string> {
|
||||
'Copy the token again from @BotFather and try setup once more.',
|
||||
);
|
||||
} catch (err) {
|
||||
s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
@@ -299,12 +240,12 @@ async function runPairTelegram(): Promise<
|
||||
} else {
|
||||
stopSpinner("Old code expired. Here's a fresh one.");
|
||||
}
|
||||
note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
|
||||
s.start(fitToWidth('Waiting for you to send the code from Telegram…', ''));
|
||||
p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
|
||||
s.start('Waiting for you to send the code from Telegram…');
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
|
||||
stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`);
|
||||
s.start(fitToWidth('Waiting for the correct code…', ''));
|
||||
s.start('Waiting for the correct code…');
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'PAIR_TELEGRAM') {
|
||||
if (block.fields.STATUS === 'success') {
|
||||
@@ -350,7 +291,7 @@ async function resolveAgentName(): Promise<string> {
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
message: 'What should your assistant be called?',
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
|
||||
+18
-26
@@ -33,9 +33,6 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
||||
import {
|
||||
type Block,
|
||||
type StepResult,
|
||||
@@ -47,16 +44,15 @@ import {
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { accentGreen, brandBody, brandBold, fmtDuration, note } from '../lib/theme.js';
|
||||
import { brandBold } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
|
||||
|
||||
type AuthMethod = 'qr' | 'pairing-code';
|
||||
|
||||
export async function runWhatsAppChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
export async function runWhatsAppChannel(displayName: string): Promise<void> {
|
||||
const method = await askAuthMethod();
|
||||
if (method === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined;
|
||||
|
||||
const install = await runQuietChild(
|
||||
@@ -150,9 +146,9 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
}
|
||||
}
|
||||
|
||||
async function askAuthMethod(): Promise<AuthMethod | 'back'> {
|
||||
async function askAuthMethod(): Promise<AuthMethod> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
await p.select({
|
||||
message: 'How would you like to authenticate with WhatsApp?',
|
||||
options: [
|
||||
{
|
||||
@@ -165,19 +161,15 @@ async function askAuthMethod(): Promise<AuthMethod | 'back'> {
|
||||
label: 'Enter a pairing code on your phone',
|
||||
hint: 'no camera needed',
|
||||
},
|
||||
{
|
||||
value: 'back',
|
||||
label: '← Back to channel selection',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as AuthMethod | 'back';
|
||||
if (choice !== 'back') setupLog.userInput('whatsapp_auth_method', choice);
|
||||
) as AuthMethod;
|
||||
setupLog.userInput('whatsapp_auth_method', choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function askPhoneNumber(): Promise<string> {
|
||||
note(
|
||||
p.note(
|
||||
[
|
||||
"Enter your phone number the way WhatsApp expects it:",
|
||||
'',
|
||||
@@ -255,7 +247,7 @@ async function runWhatsAppAuth(
|
||||
} else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') {
|
||||
const code = block.fields.CODE ?? '????';
|
||||
stopSpinner('Your pairing code is ready.');
|
||||
note(formatPairingCard(code), 'Pairing code');
|
||||
p.note(formatPairingCard(code), 'Pairing code');
|
||||
s.start('Waiting for you to enter the code…');
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'WHATSAPP_AUTH') {
|
||||
@@ -273,7 +265,7 @@ async function runWhatsAppAuth(
|
||||
if (spinnerActive) {
|
||||
stopSpinner('WhatsApp linked.');
|
||||
} else {
|
||||
p.log.success(brandBody('WhatsApp linked.'));
|
||||
p.log.success('WhatsApp linked.');
|
||||
}
|
||||
} else if (status === 'failed') {
|
||||
if (qrLinesPrinted > 0) {
|
||||
@@ -318,7 +310,7 @@ async function renderQr(qr: string): Promise<string[]> {
|
||||
const QRCode = await import('qrcode');
|
||||
const qrText = await QRCode.toString(qr, { type: 'terminal', small: true });
|
||||
const caption = k.dim(
|
||||
' Open WhatsApp → You / Settings → Linked Devices → Link a Device → scan.',
|
||||
' Open WhatsApp → Settings → Linked Devices → Link a Device → scan.',
|
||||
);
|
||||
return [...qrText.trimEnd().split('\n'), '', caption];
|
||||
} catch {
|
||||
@@ -334,7 +326,7 @@ function formatPairingCard(code: string): string {
|
||||
'',
|
||||
` ${brandBold(spaced)}`,
|
||||
'',
|
||||
k.dim(' Open WhatsApp → You / Settings → Linked Devices → Link a Device'),
|
||||
k.dim(' Open WhatsApp → Settings → Linked Devices → Link a Device'),
|
||||
k.dim(' → "Link with phone number instead" → enter this code.'),
|
||||
k.dim(' It expires in ~60 seconds.'),
|
||||
].join('\n');
|
||||
@@ -366,18 +358,17 @@ async function restartService(): Promise<void> {
|
||||
if (platform === 'darwin') {
|
||||
spawnSync(
|
||||
'launchctl',
|
||||
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
|
||||
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/com.nanoclaw`],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
const unit = getSystemdUnit();
|
||||
const user = spawnSync(
|
||||
'systemctl',
|
||||
['--user', 'restart', unit],
|
||||
['--user', 'restart', 'nanoclaw'],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
if (user.status !== 0) {
|
||||
spawnSync('sudo', ['systemctl', 'restart', unit], {
|
||||
spawnSync('sudo', ['systemctl', 'restart', 'nanoclaw'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
}
|
||||
@@ -385,7 +376,8 @@ async function restartService(): Promise<void> {
|
||||
// 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));
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
|
||||
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
|
||||
PLATFORM: platform,
|
||||
});
|
||||
@@ -400,7 +392,7 @@ async function restartService(): Promise<void> {
|
||||
}
|
||||
|
||||
async function askChatPhone(authedPhone: string): Promise<string> {
|
||||
note(
|
||||
p.note(
|
||||
[
|
||||
`Authenticated with ${k.cyan('+' + authedPhone)}.`,
|
||||
'',
|
||||
@@ -467,7 +459,7 @@ async function resolveAgentName(): Promise<string> {
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
message: 'What should your assistant be called?',
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
|
||||
+2
-10
@@ -8,7 +8,6 @@
|
||||
* Args:
|
||||
* --display-name <name> (required) operator's display name
|
||||
* --agent-name <name> (optional) agent persona name, defaults to display-name
|
||||
* --folder <name> (optional) explicit folder name, defaults to cli-with-<normalized-display-name>
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import path from 'path';
|
||||
@@ -19,11 +18,9 @@ import { emitStatus } from './status.js';
|
||||
function parseArgs(args: string[]): {
|
||||
displayName: string;
|
||||
agentName?: string;
|
||||
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];
|
||||
@@ -37,10 +34,6 @@ function parseArgs(args: string[]): {
|
||||
agentName = val;
|
||||
i++;
|
||||
break;
|
||||
case '--folder':
|
||||
folder = val;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,18 +46,17 @@ function parseArgs(args: string[]): {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
return { displayName, agentName, folder };
|
||||
return { displayName, agentName };
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const { displayName, agentName, folder } = parseArgs(args);
|
||||
const { displayName, agentName } = 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 });
|
||||
|
||||
|
||||
+13
-37
@@ -7,7 +7,6 @@ import path from 'path';
|
||||
import { setTimeout as sleep } from 'timers/promises';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { getDefaultContainerImage } from '../src/install-slug.js';
|
||||
import { commandExists, getPlatform } from './platform.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
@@ -82,7 +81,7 @@ function parseArgs(args: string[]): { runtime: string } {
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const { runtime } = parseArgs(args);
|
||||
const image = getDefaultContainerImage(projectRoot);
|
||||
const image = 'nanoclaw-agent:latest';
|
||||
const logFile = path.join(projectRoot, 'logs', 'setup.log');
|
||||
|
||||
if (runtime !== 'docker') {
|
||||
@@ -127,22 +126,11 @@ export async function run(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
// Socket is unreachable due to group perms — current shell's supplementary
|
||||
// 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.
|
||||
// groups are fixed at login, so `usermod -aG docker` (via install-docker.sh
|
||||
// or a prior install) doesn't affect us until next login. Re-exec this
|
||||
// step under `sg docker` so the child picks up docker as its primary
|
||||
// group and can talk to /var/run/docker.sock without a logout.
|
||||
if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) {
|
||||
// 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',
|
||||
@@ -186,31 +174,19 @@ export async function run(args: string[]): Promise<void> {
|
||||
// .env is optional; absence is normal on a fresh checkout
|
||||
}
|
||||
|
||||
// Build — stdio inherit so the parent setup runner can tail docker's
|
||||
// per-step output and render it in a rolling window. Previously we used
|
||||
// execSync which buffered everything; users couldn't tell whether a
|
||||
// 3–10 minute build was making progress or hung.
|
||||
// Build
|
||||
let buildOk = false;
|
||||
log.info('Building container', { runtime, buildArgs });
|
||||
const buildRes = spawnSync(
|
||||
buildCmd.split(' ')[0],
|
||||
[
|
||||
...buildCmd.split(' ').slice(1),
|
||||
...buildArgs.flatMap((a) => a.split(' ')),
|
||||
'-t',
|
||||
image,
|
||||
'.',
|
||||
],
|
||||
{
|
||||
try {
|
||||
const argsStr = buildArgs.length > 0 ? ' ' + buildArgs.join(' ') : '';
|
||||
execSync(`${buildCmd}${argsStr} -t ${image} .`, {
|
||||
cwd: path.join(projectRoot, 'container'),
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
if (buildRes.status === 0) {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
buildOk = true;
|
||||
log.info('Container build succeeded');
|
||||
} else {
|
||||
log.error('Container build failed', { exitCode: buildRes.status });
|
||||
} catch (err) {
|
||||
log.error('Container build failed', { err });
|
||||
}
|
||||
|
||||
// Test
|
||||
|
||||
+48
-62
@@ -1,7 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
@@ -19,63 +17,58 @@ describe('environment detection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectRegisteredGroups', () => {
|
||||
let tempDir: string;
|
||||
describe('registered groups DB query', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-test-'));
|
||||
fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true });
|
||||
db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
container_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1
|
||||
)`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
it('returns 0 for empty table', () => {
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
expect(row.count).toBe(0);
|
||||
});
|
||||
|
||||
it('returns false when no registration state exists', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects pre-migration registered_groups.json', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
fs.writeFileSync(path.join(tempDir, 'data', 'registered_groups.json'), '[]');
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for an empty v2 central DB', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL,
|
||||
agent_group_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.close();
|
||||
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects wired agent groups in the v2 central DB', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL,
|
||||
agent_group_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-1');
|
||||
it('returns correct count after inserts', () => {
|
||||
db.prepare(
|
||||
'INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id) VALUES (?, ?, ?)',
|
||||
).run('mga-1', 'mg-1', 'ag-1');
|
||||
db.close();
|
||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
'123@g.us',
|
||||
'Group 1',
|
||||
'group-1',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(true);
|
||||
db.prepare(
|
||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
'456@g.us',
|
||||
'Group 2',
|
||||
'group-2',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
expect(row.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,28 +77,21 @@ 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|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content);
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/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|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);
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/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|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content);
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
expect(hasCredentials).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+21
-68
@@ -7,77 +7,11 @@ import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { STORE_DIR } from '../src/config.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const dbPath = path.join(projectRoot, 'data', 'v2.db');
|
||||
if (!fs.existsSync(dbPath)) return false;
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT COUNT(DISTINCT ag.id) as count FROM agent_groups ag
|
||||
JOIN messaging_group_agents mga ON mga.agent_group_id = ag.id`,
|
||||
)
|
||||
.get() as { count: number };
|
||||
return row.count > 0;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(_args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
@@ -105,7 +39,26 @@ export async function run(_args: string[]): Promise<void> {
|
||||
const authDir = path.join(projectRoot, 'store', 'auth');
|
||||
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
|
||||
|
||||
const hasRegisteredGroups = detectRegisteredGroups(projectRoot);
|
||||
let hasRegisteredGroups = false;
|
||||
// Check JSON file first (pre-migration)
|
||||
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
|
||||
hasRegisteredGroups = true;
|
||||
} else {
|
||||
// Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed)
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
if (row.count > 0) hasRegisteredGroups = true;
|
||||
db.close();
|
||||
} catch {
|
||||
// Table might not exist yet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing OpenClaw installation
|
||||
const homedir = (await import('os')).homedir();
|
||||
|
||||
@@ -14,10 +14,8 @@ 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'),
|
||||
mounts: () => import('./mounts.js'),
|
||||
service: () => import('./service.js'),
|
||||
verify: () => import('./verify.js'),
|
||||
|
||||
+21
-31
@@ -17,40 +17,30 @@ if command -v node >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if command -v uvx >/dev/null 2>&1; then
|
||||
echo "STEP: uvx-nodeenv"
|
||||
uvx nodeenv -n lts ~/node
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf ~/node/bin/node ~/.local/bin/node
|
||||
ln -sf ~/node/bin/npm ~/.local/bin/npm
|
||||
ln -sf ~/node/bin/npx ~/.local/bin/npx
|
||||
ln -sf ~/node/bin/pnpm ~/.local/bin/pnpm
|
||||
else
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
echo "STEP: brew-install-node"
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run."
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
fi
|
||||
brew install node@22
|
||||
;;
|
||||
Linux)
|
||||
echo "STEP: nodesource-setup"
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
echo "STEP: apt-install-nodejs"
|
||||
sudo apt-get install -y nodejs
|
||||
;;
|
||||
*)
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
echo "STEP: brew-install-node"
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: Unsupported platform: $(uname -s)"
|
||||
echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run."
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
brew install node@22
|
||||
;;
|
||||
Linux)
|
||||
echo "STEP: nodesource-setup"
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
echo "STEP: apt-install-nodejs"
|
||||
sudo apt-get install -y nodejs
|
||||
;;
|
||||
*)
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: Unsupported platform: $(uname -s)"
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "STATUS: failed"
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# install-signal-cli.sh — auto-install signal-cli on the host.
|
||||
#
|
||||
# NanoClaw needs `signal-cli` on PATH to talk to Signal. Picks the right
|
||||
# install method per platform:
|
||||
# macOS → `brew install signal-cli` (bottled, no Java needed)
|
||||
# Linux → download latest native binary from GitHub releases to
|
||||
# ~/.local/bin/signal-cli (no Java, no sudo)
|
||||
#
|
||||
# Emits the standard NanoClaw STATUS block on success or failure so the
|
||||
# `runQuietChild` driver can parse the outcome.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="0.14.3"
|
||||
INSTALL_DIR="${HOME}/.local/bin"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
echo "=== NANOCLAW SETUP: INSTALL_SIGNAL_CLI ==="
|
||||
echo "STATUS: ${status}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[install-signal-cli] $*" >&2; }
|
||||
|
||||
uname_s=$(uname)
|
||||
|
||||
if [[ "${uname_s}" == "Darwin" ]]; then
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
emit_status failed "homebrew_not_installed"
|
||||
exit 1
|
||||
fi
|
||||
log "Installing signal-cli via Homebrew…"
|
||||
brew install signal-cli >&2 || {
|
||||
emit_status failed "brew_install_failed"
|
||||
exit 1
|
||||
}
|
||||
emit_status success
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${uname_s}" != "Linux" ]]; then
|
||||
emit_status failed "unsupported_platform_${uname_s}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Linux native build (no Java required) → ~/.local/bin/signal-cli.
|
||||
URL="https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz"
|
||||
TARBALL=$(mktemp -t signal-cli.XXXXXX.tar.gz)
|
||||
|
||||
log "Downloading signal-cli v${VERSION} (~96MB)…"
|
||||
if ! curl -fLsS -o "${TARBALL}" "${URL}"; then
|
||||
rm -f "${TARBALL}"
|
||||
emit_status failed "download_failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Extracting…"
|
||||
EXTRACT_DIR=$(mktemp -d)
|
||||
if ! tar -xzf "${TARBALL}" -C "${EXTRACT_DIR}"; then
|
||||
rm -rf "${TARBALL}" "${EXTRACT_DIR}"
|
||||
emit_status failed "extract_failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
log "Installing to ${INSTALL_DIR}/signal-cli…"
|
||||
if ! mv "${EXTRACT_DIR}/signal-cli" "${INSTALL_DIR}/signal-cli"; then
|
||||
rm -rf "${TARBALL}" "${EXTRACT_DIR}"
|
||||
emit_status failed "install_failed"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "${INSTALL_DIR}/signal-cli"
|
||||
rm -rf "${TARBALL}" "${EXTRACT_DIR}"
|
||||
|
||||
emit_status success
|
||||
Regular → Executable
+1
-1
@@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { classifyPingResult } from './agent-ping.js';
|
||||
|
||||
describe('classifyPingResult', () => {
|
||||
it('treats a normal text reply as ok', () => {
|
||||
expect(classifyPingResult(0, 'pong\n')).toBe('ok');
|
||||
});
|
||||
|
||||
it('detects Anthropic auth errors printed as a chat reply', () => {
|
||||
expect(
|
||||
classifyPingResult(
|
||||
0,
|
||||
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token"}}',
|
||||
),
|
||||
).toBe('auth_error');
|
||||
});
|
||||
|
||||
it('detects auth errors on stderr too', () => {
|
||||
expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error');
|
||||
});
|
||||
|
||||
it('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');
|
||||
});
|
||||
|
||||
it('treats empty output as no reply', () => {
|
||||
expect(classifyPingResult(0, '')).toBe('no_reply');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user