2 Commits

Author SHA1 Message Date
c4753da8f5 docs: add detailed answers for 21 learning roadmap questions 2026-05-13 03:40:13 +00:00
a8d90d2980 docs: add source code learning roadmap 2026-05-13 03:08:45 +00:00
20 changed files with 1500 additions and 590 deletions

View File

@@ -22,7 +22,6 @@ ARG INSTALL_CJK_FONTS=false
ARG CLAUDE_CODE_VERSION=2.1.128
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=52.2.1
ARG OPENCODE_VERSION=1.4.17
ARG BUN_VERSION=1.3.12
# ---- System dependencies -----------------------------------------------------
@@ -111,9 +110,6 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "opencode-ai@${OPENCODE_VERSION}"
# ---- ncl CLI wrapper ----------------------------------------------------------
# Actual script lives in the mounted source at /app/src/cli/ncl.ts.
RUN printf '#!/bin/sh\nexec bun /app/src/cli/ncl.ts "$@"\n' > /usr/local/bin/ncl && \

View File

@@ -7,7 +7,6 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
"@modelcontextprotocol/sdk": "^1.12.1",
"@opencode-ai/sdk": "1.4.17",
"cron-parser": "^5.0.0",
"zod": "^4.0.0",
},
@@ -45,8 +44,6 @@
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.17", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-fb60CIZussOZNpYtmVkayUgeN5fodZ0QWAgUWhMev+CoTbskAoCVF1evKZHfPOeKTxw7hmKMi/DjWBCwLDEh4Q=="],
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],

View File

@@ -11,7 +11,6 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
"@modelcontextprotocol/sdk": "^1.12.1",
"@opencode-ai/sdk": "1.4.17",
"cron-parser": "^5.0.0",
"zod": "^4.0.0"
},

View File

@@ -4,4 +4,3 @@
import './claude.js';
import './mock.js';
import './opencode.js';

View File

@@ -1,59 +0,0 @@
import { describe, it, expect } from 'bun:test';
import { mcpServersToOpenCodeConfig } from './mcp-to-opencode.js';
describe('mcpServersToOpenCodeConfig', () => {
it('maps nanoclaw + extra server like v2 index.ts merge', () => {
const servers = {
nanoclaw: {
command: 'node',
args: ['/app/src/mcp-tools/index.js'],
env: {
SESSION_INBOUND_DB_PATH: '/workspace/inbound.db',
SESSION_OUTBOUND_DB_PATH: '/workspace/outbound.db',
SESSION_HEARTBEAT_PATH: '/workspace/.heartbeat',
},
},
extra: {
command: 'npx',
args: ['-y', 'some-mcp'],
env: { FOO: 'bar' },
},
};
const mcp = mcpServersToOpenCodeConfig(servers);
expect(mcp.nanoclaw).toEqual({
type: 'local',
command: ['node', '/app/src/mcp-tools/index.js'],
environment: {
SESSION_INBOUND_DB_PATH: '/workspace/inbound.db',
SESSION_OUTBOUND_DB_PATH: '/workspace/outbound.db',
SESSION_HEARTBEAT_PATH: '/workspace/.heartbeat',
},
enabled: true,
});
expect(mcp.extra).toEqual({
type: 'local',
command: ['npx', '-y', 'some-mcp'],
environment: { FOO: 'bar' },
enabled: true,
});
});
it('omits environment when env is empty', () => {
const mcp = mcpServersToOpenCodeConfig({
x: { command: 'true', args: [], env: {} },
});
expect(mcp.x).toEqual({
type: 'local',
command: ['true'],
enabled: true,
});
});
it('returns empty record for undefined', () => {
expect(mcpServersToOpenCodeConfig(undefined)).toEqual({});
});
});

View File

@@ -1,39 +0,0 @@
import type { McpServerConfig } from './types.js';
/** OpenCode `mcp` entry shape (local stdio server). */
export type OpenCodeMcpLocal = {
type: 'local';
command: string[];
environment?: Record<string, string>;
enabled: true;
};
/** OpenCode `mcp` entry shape (remote HTTP server). */
export type OpenCodeMcpRemote = {
type: 'remote';
url: string;
headers?: Record<string, string>;
enabled: true;
};
export type OpenCodeMcpEntry = OpenCodeMcpLocal | OpenCodeMcpRemote;
/**
* Map NanoClaw v2 MCP definitions (same shape as Claude Agent SDK) into
* OpenCode config `mcp` field. Stdio-only until `McpServerConfig` gains remote.
*/
export function mcpServersToOpenCodeConfig(
servers: Record<string, McpServerConfig> | undefined,
): Record<string, OpenCodeMcpEntry> {
const out: Record<string, OpenCodeMcpEntry> = {};
if (!servers) return out;
for (const [name, cfg] of Object.entries(servers)) {
out[name] = {
type: 'local',
command: [cfg.command, ...cfg.args],
...(Object.keys(cfg.env).length > 0 ? { environment: cfg.env } : {}),
enabled: true,
};
}
return out;
}

View File

@@ -1,10 +0,0 @@
import { describe, it, expect } from 'bun:test';
import { createProvider } from './factory.js';
import { OpenCodeProvider } from './opencode.js';
describe('createProvider (opencode)', () => {
it('returns OpenCodeProvider for opencode', () => {
expect(createProvider('opencode')).toBeInstanceOf(OpenCodeProvider);
});
});

View File

@@ -1,423 +0,0 @@
import { spawn, type ChildProcess } from 'child_process';
import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk';
import { registerProvider } from './provider-registry.js';
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
import { mcpServersToOpenCodeConfig } from './mcp-to-opencode.js';
function log(msg: string): void {
console.error(`[opencode-provider] ${msg}`);
}
const SESSION_STATUS_RETRY_ERROR_AFTER = 3;
/** Stale / dead OpenCode session heuristics (complement Claude-centric host patterns). */
const STALE_SESSION_RE =
/no conversation found|ENOENT.*\.jsonl|session.*not found|NotFoundError|connection reset|ECONNRESET|404|event timeout/i;
function killProcessTree(proc: ChildProcess): void {
if (!proc.pid) return;
try {
process.kill(-proc.pid, 'SIGKILL');
} catch {
try {
proc.kill('SIGKILL');
} catch {
/* ignore */
}
}
}
function spawnOpencodeServer(config: Record<string, unknown>, timeoutMs = 10_000): Promise<{ url: string; proc: ChildProcess }> {
return new Promise((resolve, reject) => {
const hostname = '127.0.0.1';
const port = 4096;
const proc = spawn('opencode', ['serve', `--hostname=${hostname}`, `--port=${port}`], {
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
},
detached: true,
});
const id = setTimeout(() => {
killProcessTree(proc);
reject(new Error(`Timeout waiting for OpenCode server to start after ${timeoutMs}ms`));
}, timeoutMs);
let output = '';
proc.stdout?.on('data', (chunk: Buffer) => {
output += chunk.toString();
for (const line of output.split('\n')) {
if (line.startsWith('opencode server listening')) {
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
if (match) {
clearTimeout(id);
resolve({ url: match[1], proc });
}
}
}
});
proc.stderr?.on('data', (chunk: Buffer) => {
output += chunk.toString();
});
proc.on('exit', (code) => {
clearTimeout(id);
let msg = `OpenCode server exited with code ${code}`;
if (output.trim()) msg += `\nServer output: ${output}`;
reject(new Error(msg));
});
proc.on('error', (err) => {
clearTimeout(id);
reject(err);
});
});
}
function wrapPromptWithContext(text: string, systemInstructions?: string): string {
let out = text;
if (systemInstructions) {
out = `<system>\n${systemInstructions}\n</system>\n\n${out}`;
}
return out;
}
function buildOpenCodeConfig(options: ProviderOptions): Record<string, unknown> {
const provider = process.env.OPENCODE_PROVIDER || 'anthropic';
const model = process.env.OPENCODE_MODEL;
const smallModel = process.env.OPENCODE_SMALL_MODEL;
const proxyUrl = process.env.ANTHROPIC_BASE_URL;
const providerModelId = model ? model.replace(new RegExp(`^${provider}/`), '') : undefined;
const providerSmallModelId = smallModel ? smallModel.replace(new RegExp(`^${provider}/`), '') : undefined;
const modelsToRegister = [providerModelId, providerSmallModelId]
.filter(Boolean)
.filter((mid, i, a) => a.indexOf(mid as string) === i);
const providerOptions: Record<string, unknown> =
provider === 'anthropic'
? {}
: {
[provider]: {
options: { apiKey: 'placeholder', baseURL: proxyUrl },
...(modelsToRegister.length > 0
? {
models: Object.fromEntries(
modelsToRegister.map((mid) => [mid, { id: mid, name: mid, tool_call: true }]),
),
}
: {}),
},
};
const mcp = mcpServersToOpenCodeConfig(options.mcpServers);
// Load shared base + per-group fragments + per-group memory through OpenCode's
// native instructions pipeline (session/instruction.ts). Absolute paths with
// globs are supported. Files are read raw — `@./...` includes are NOT expanded
// by OpenCode, so point at the concrete files, not at composed CLAUDE.md.
const instructions = [
'/app/CLAUDE.md',
'/workspace/agent/.claude-fragments/*.md',
'/workspace/agent/CLAUDE.local.md',
];
return {
...(model ? { model } : {}),
...(smallModel ? { small_model: smallModel } : {}),
enabled_providers: [provider],
permission: 'allow',
autoupdate: false,
snapshot: false,
provider: providerOptions,
instructions,
mcp,
};
}
type SharedRuntime = {
proc: ChildProcess;
client: OpencodeClient;
stream: AsyncGenerator<{ type: string; properties: Record<string, unknown> }, void, void>;
streamRelease: () => void;
};
let sharedRuntime: SharedRuntime | null = null;
let sharedConfigKey: string | null = null;
let sharedInit: Promise<SharedRuntime> | null = null;
function runtimeConfigKey(options: ProviderOptions): string {
return JSON.stringify({
mcp: mcpServersToOpenCodeConfig(options.mcpServers),
model: process.env.OPENCODE_MODEL,
small: process.env.OPENCODE_SMALL_MODEL,
op: process.env.OPENCODE_PROVIDER,
});
}
async function ensureSharedRuntime(options: ProviderOptions): Promise<SharedRuntime> {
const key = runtimeConfigKey(options);
if (sharedRuntime && sharedConfigKey === key) return sharedRuntime;
if (sharedInit) return sharedInit;
sharedInit = (async () => {
if (sharedRuntime) {
destroySharedRuntime();
}
const config = buildOpenCodeConfig(options);
const { url, proc } = await spawnOpencodeServer(config);
const client = createOpencodeClient({ baseUrl: url });
const sub = await client.event.subscribe();
const stream = sub.stream as AsyncGenerator<{ type: string; properties: Record<string, unknown> }, void, void>;
sharedRuntime = {
proc,
client,
stream,
streamRelease: () => {
void stream.return?.(undefined);
},
};
sharedConfigKey = key;
sharedInit = null;
return sharedRuntime;
})();
return sharedInit;
}
export function destroySharedRuntime(): void {
if (sharedRuntime) {
try {
sharedRuntime.streamRelease();
} catch {
/* ignore */
}
killProcessTree(sharedRuntime.proc);
sharedRuntime = null;
sharedConfigKey = null;
}
sharedInit = null;
}
function sessionErrorMessage(props: { error?: unknown }): string {
const err = props.error as { data?: { message?: string } } | undefined;
if (err && typeof err === 'object' && err.data && typeof err.data.message === 'string') {
return err.data.message;
}
return JSON.stringify(props.error) || 'OpenCode session error';
}
export class OpenCodeProvider implements AgentProvider {
readonly supportsNativeSlashCommands = false;
private readonly options: ProviderOptions;
private activeSessionId: string | undefined;
constructor(options: ProviderOptions = {}) {
this.options = options;
}
isSessionInvalid(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return STALE_SESSION_RE.test(msg);
}
query(input: QueryInput): AgentQuery {
if (input.continuation) {
this.activeSessionId = input.continuation;
} else {
this.activeSessionId = undefined;
}
const pending: string[] = [];
let waiting: (() => void) | null = null;
let ended = false;
let aborted = false;
const systemInstructions = input.systemContext?.instructions;
pending.push(wrapPromptWithContext(input.prompt, systemInstructions));
const kick = (): void => {
waiting?.();
};
const self = this;
const IDLE_TIMEOUT_MS = Number(process.env.OPENCODE_IDLE_TIMEOUT_MS) || 300_000;
async function* gen(): AsyncGenerator<ProviderEvent> {
let initYielded = false;
const rt = await ensureSharedRuntime(self.options);
const { client, stream } = rt;
while (!aborted) {
while (pending.length === 0 && !ended && !aborted) {
await new Promise<void>((resolve) => {
waiting = resolve;
});
waiting = null;
}
if (aborted) return;
if (pending.length === 0 && ended) return;
const text = pending.shift()!;
let sessionId = self.activeSessionId;
if (!sessionId) {
const created = await client.session.create();
if (created.error) {
throw new Error(`OpenCode: failed to create session: ${JSON.stringify(created.error)}`);
}
sessionId = created.data?.id;
if (!sessionId) throw new Error('OpenCode: failed to create session (no id)');
self.activeSessionId = sessionId;
}
if (!initYielded) {
yield { type: 'init', continuation: sessionId };
initYielded = true;
}
const promptRes = await client.session.promptAsync({
path: { id: sessionId },
body: { parts: [{ type: 'text', text }] },
});
if (promptRes.error) {
self.activeSessionId = undefined;
throw new Error(`OpenCode promptAsync: ${JSON.stringify(promptRes.error)}`);
}
const partTextByMessageId = new Map<string, string>();
const roleByMessageId = new Map<string, string>();
let lastEventAt = Date.now();
let eventTimedOut = false;
const timeoutCheck = setInterval(() => {
if (Date.now() - lastEventAt > IDLE_TIMEOUT_MS) {
log(`OpenCode event timeout (${IDLE_TIMEOUT_MS}ms) — clearing session ${sessionId}`);
eventTimedOut = true;
self.activeSessionId = undefined;
destroySharedRuntime();
kick();
}
}, 5000);
try {
turn: while (true) {
if (aborted) return;
if (eventTimedOut) {
throw new Error(`OpenCode event timeout (${IDLE_TIMEOUT_MS}ms)`);
}
const { value: ev, done } = await stream.next();
if (done) {
throw new Error('OpenCode SSE stream ended unexpectedly');
}
if (!ev?.type || ev.type === 'server.connected' || ev.type === 'server.heartbeat') continue;
lastEventAt = Date.now();
yield { type: 'activity' };
switch (ev.type) {
case 'message.updated': {
const info = ev.properties.info as { id?: string; role?: string } | undefined;
if (info?.id && info?.role) {
roleByMessageId.set(info.id, info.role);
}
break;
}
case 'message.part.updated': {
const part = ev.properties.part as { type?: string; messageID?: string; text?: string } | undefined;
if (part?.type === 'text' && part.messageID && part.text) {
partTextByMessageId.set(part.messageID, part.text);
}
break;
}
case 'permission.updated': {
const perm = ev.properties as { id?: string; sessionID?: string };
if (perm.sessionID === sessionId && perm.id) {
try {
await client.postSessionIdPermissionsPermissionId({
path: { id: sessionId, permissionID: perm.id },
body: { response: 'always' },
});
} catch (err) {
log(`Failed to auto-reply permission: ${err instanceof Error ? err.message : String(err)}`);
}
}
break;
}
case 'session.status': {
const props = ev.properties as {
sessionID?: string;
status?: { type?: string; attempt?: number; message?: string };
};
if (props.sessionID !== sessionId) break;
const st = props.status;
if (
st?.type === 'retry' &&
typeof st.attempt === 'number' &&
st.attempt >= SESSION_STATUS_RETRY_ERROR_AFTER &&
st.message
) {
self.activeSessionId = undefined;
throw new Error(`OpenCode retry limit (${st.attempt}): ${st.message}`);
}
break;
}
case 'session.error': {
const props = ev.properties as { sessionID?: string; error?: unknown };
if (props.sessionID === sessionId || props.sessionID === undefined) {
self.activeSessionId = undefined;
throw new Error(sessionErrorMessage(props));
}
break;
}
case 'session.idle': {
const sid = (ev.properties as { sessionID?: string }).sessionID;
if (sid === sessionId) {
break turn;
}
break;
}
default:
break;
}
}
} finally {
clearInterval(timeoutCheck);
}
let resultText = '';
for (const [msgId, role] of roleByMessageId) {
if (role === 'assistant') {
resultText = partTextByMessageId.get(msgId) ?? resultText;
}
}
yield { type: 'result', text: resultText || null };
}
}
return {
push: (message: string) => {
pending.push(wrapPromptWithContext(message, systemInstructions));
kick();
},
end: () => {
ended = true;
kick();
},
events: gen(),
abort: () => {
aborted = true;
this.activeSessionId = undefined;
kick();
destroySharedRuntime();
},
};
}
}
registerProvider('opencode', (opts) => new OpenCodeProvider(opts));

View File

@@ -0,0 +1,139 @@
# Q1-Q3: 全局架构
---
## Q1: 一条用户消息从 Slack 发出,到 agent 回复出现在聊天框里,完整路径是什么?
### 答案
一条消息的完整生命周期横跨四个阶段、两个进程、三个数据库。
**阶段一入站路由Host / Node**
1. Slack channel adapter 收到消息事件,调用 `routeInbound(event)``src/router.ts:158`
2. 应用线程策略非线程适配器折叠线程line 166
3. `getMessagingGroupWithAgentCount()``src/db/messaging-groups.ts:53`)通过一次 JOIN 查询中央库 `v2.db`,同时获得 messaging group 行和 wired agent 数量。自动创建规则:只有 @mention 时才自动创建 messaging group`router.ts:184-201`),普通消息静默丢弃
4. 如果 agent 数量为 0记录到 `unregistered_senders`reason 为 `no_agent_wired``router.ts:210-246`,写入 `src/db/dropped-messages.ts:16`
5. Sender resolver hook 执行upsert 用户行到中央库 `users` 表(`router.ts:252`
6. `getMessagingGroupAgents()` 从中央库取出所有 wired agent 行,按 `priority DESC` 排序(`messaging-groups.ts:193-196`
7. 对每个 agent 独立评估:`evaluateEngage()` 检查 trigger 模式(`router.ts:364`access gate 检查权限line 283通过则执行 `deliverToAgent()`line 287
8. `resolveSession()``session-manager.ts:92`)在中央库 `sessions` 表查找/创建 session必要时创建目录结构并初始化 `inbound.db` + `outbound.db`
9. `writeSessionMessage()``session-manager.ts:193`)写消息到 `inbound.db``messages_in` 表,使用**偶数 seq**;每次 open-write-close
10. `wakeContainer()``container-runner.ts:85`检查已运行容器、去重、spawn Docker 容器
**阶段二容器处理Container / Bun**
11. `container/agent-runner/src/poll-loop.ts:53``runPollLoop()`
- `getPendingMessages()``messages-in.ts:65`)只读打开 `inbound.db`,读 `status='pending'`
- `markProcessing()`line 101`processing_ack``outbound.db`
- 格式化消息,调 `provider.query()`line 170
- 流式过程中每 500ms poll 后续消息,调用 `provider.push()`
- `dispatchResultText()` 解析 `<message to="..">` XML`sendToDestination()``writeMessageOut()``outbound.db``messages_out`,使用**奇数 seq**
- `markCompleted()``outbound.db``processing_ack`
- `touchHeartbeat()` 更新 `.heartbeat` 文件的 mtime
**阶段三出站投递Host / Node**
12. `src/delivery.ts` 两层轮询:
- Active poll1s`pollActive()` 仅扫描正在运行容器的 session
- Sweep poll60s`pollSweep()` 扫描所有 active session
- `inflightDeliveries` Set 防止二者竞态line 50-51
13. `drainSession()`:只读打开 `outbound.db`,读写打开 `inbound.db`
- `getDueOutboundMessages()``outbound.db``messages_out`
-`inbound.db``delivered` 表做去重比对
- `deliverMessage()``deliveryAdapter.deliver()` 发到 Slack
- `markDelivered()``inbound.db``delivered`
- 清理 `outbox/` 目录
### 每个阶段涉及的数据库
| 阶段 | DB | 表 | Writer | Reader |
|------|-----|-----|--------|--------|
| 路由查找 | `v2.db` | `messaging_groups`, `messaging_group_agents` | Host | Host |
| 发送者解析 | `v2.db` | `users` | Host | Host |
| Session 解析 | `v2.db` | `sessions` | Host | Host |
| 写入站消息 | `inbound.db` | `messages_in` | Host | — |
| Container poll | `inbound.db` | `messages_in` | — | Container (RO) |
| 声明处理中 | `outbound.db` | `processing_ack` | Container | — |
| 写回复 | `outbound.db` | `messages_out` | Container | — |
| 读回复 | `outbound.db` | `messages_out` | — | Host (RO) |
| 跟踪投递 | `inbound.db` | `delivered` | Host | — |
| 丢弃记录 | `v2.db` | `unregistered_senders` | Host | — |
### 涉及进程
- **Node host 进程**:单进程,运行 `src/index.ts`,同时跑 router + delivery + host-sweep
- **Docker 容器**:每个活跃 session 一个独立容器,运行 `bun run /app/src/index.ts`
### 边界情况
- **Messaging group 不存在**:仅 @mention 时自动创建,普通聊天静默返回
- **频道被 owner 拒绝**`mg.denied_at` 已设置):静默丢弃
- **没有 agent 响应engage**:记录到 `unregistered_senders`reason 为 `no_agent_engaged`
- **投递失败**:重试最多 3 次,然后标记永久失败
- **`accumulate` 模式**:不 engage 但 `ignored_message_policy='accumulate'` 的消息写入 `trigger=0`,静默存为上下文
---
## Q2: 为什么 Host 用 NodeContainer 用 Bun两套运行时之间的"协议"是什么?
### 答案
**为什么 Host 用 Node**
- BaileysWhatsApp adapter依赖 `libsignal-node` 原生绑定和一个久经考验的 WebSocket/HTTP 栈。Bun 的 Node-API 兼容性已有改善,但风险仍然太高
- Host 负责所有平台级网络 I/ONode 的生态系统在这个领域最成熟
**为什么 Container 用 Bun**
1. `bun:sqlite` 是内建的——不需要每次重建镜像时编译 `better-sqlite3` 原生模块
2. TypeScript 源码直接运行——镜像构建和容器启动时不需要 `tsc` 编译步骤
3. `bun install``npm install` 快 5-10 倍
**两套运行时的"协议"——SQLite不是 IPC**
Host 和 Container **从不共享代码或模块**(各有自己的包树:`pnpm-lock.yaml` vs `container/agent-runner/bun.lock`)。它们之间的"协议"是两个 SQLite 文件:
```
Host ──写──▶ inbound.db ──读──▶ Container (Bun, 只读)
Host ◀──读── outbound.db ◀──写── Container (Bun)
```
没有 HTTP、没有 Unix socket、没有 stdin pipe。Container 的 `poll-loop.ts``{readonly: true}` 打开 `inbound.db``container/agent-runner/src/db/connection.ts:53`并轮询新行。Host 以 `{readonly: true}` 打开 `outbound.db``src/db/session-db.ts:30`)并轮询新行。
Host 在 spawn 容器时把 session 文件夹(`inbound.db``outbound.db``.heartbeat`)挂载到 `/workspace`agent group 文件夹挂载到 `/workspace/agent``src/container-runner.ts:267-271`)。
---
## Q3: `inbound.db` 和 `outbound.db` 为什么各只能有一个 writer`journal_mode=DELETE` 为什么是必须的seq 奇偶分配的规则是怎样的?
### 答案
**为什么每文件只有一个 writer**
`src/session-manager.ts:7-11` 声明了三条跨 mount 不变式:
1. `journal_mode=DELETE` — WAL 模式的 `-shm` 是内存映射的VirtioFS 不传播 mmap 一致性。Container 会卡在第一次读到的快照上,永远看不到新消息
2. Host 每次 open-write-close ——关闭连接会使 Container 的页缓存失效;长连接会冻结在第一次读取时的视图
3. 每文件一个 writer —— DELETE 模式的 journal 文件解链/重建在跨 mount 边界上不是原子的,并发 writer 会损坏数据库
**`journal_mode=DELETE` 为什么负载关键:**
`container/agent-runner/src/db/connection.ts:12-18` 说明WAL 的 `-shm` 内存映射不会被 VirtioFS 从 host 传播到 guest所以 WAL 模式的 `inbound.db` 会让 container reader 卡在早期快照上,永远看不到新的 host 消息。
Host 和 Container 两边都在每次打开 DB 时设置 `PRAGMA journal_mode = DELETE``session-db.ts:15,23,38` / `connection.ts:78`。Container 还额外设置 `mmap_size = 0``connection.ts:55`)禁用内存映射 I/O确保每次读都走内核无缓冲路径。
**Host open-write-close 每次操作:**
`src/session-manager.ts:189-192` 明确说明不要在多次调用间复用长连接——每次打开、写入、关闭使 SQLite 页缓存失效Container 才能看到最新写入。Container 对 `getPendingMessages()` 也使用 `openInboundDb()`(每次新连接),但用长期单例 `getInboundDb()` 读 host 只在 spawn 时写入一次的表destinations、session_routing
**seq 奇偶分配规则:**
- **Host偶数** `src/db/session-db.ts:89``nextEvenSeq()` 只读 `messages_in``MAX(seq)`输出0→2, 1→2, 2→4, 3→4, 4→6...
- **Container奇数** `container/agent-runner/src/db/messages-out.ts:45-56` — 读 `messages_in``messages_out` 两者的 `MAX(seq)`取较大者向上取奇数0→1, 1→3, 2→3, 3→5...
**为什么奇偶分配如此关键:** seq 是 agent 面对的消息 ID。当 agent 调用 `edit_message(seq=5)` 时,`getMessageIdBySeq()``messages-out.ts:90`)用奇偶性做快速路由:
- **奇数 → `messages_out`**container 发出的)
- **偶数 → `messages_in`**user/host 发出的)
奇偶性单字段即可区分消息归属,无需 JOIN 查询。

View File

@@ -0,0 +1,173 @@
# Q4-Q6: 路由与会话
---
## Q4: 一个 messaging group 怎么决定路由到哪个 agent group没匹配上怎么办
### 答案
路由发生在 `src/router.ts:158``routeInbound()` 函数中,分多步决策:
**第一步:快速截断**`getMessagingGroupWithAgentCount(channelType, platformId)`line 176一次数据库读就能判断频道是否已知、是否有 agent。如果没有匹配的 messaging group 且没有被 @mention直接静默返回line 211
**第二步:记录无法投递的消息** — 如果 agentCount 为 0 但被 @mention 了(或频道已通过审批但没 agent
- 如果 `mg.denied_at` 被设置owner 拒绝了这个频道)→ 静默丢弃line 212-217
- 否则 → 记录到 `unregistered_senders`reason 为 `no_agent_wired`line 221
- 可选触发 `channelRequestGate` 升级给 ownerline 231-238
**第三步Fan-out** — 如果 agentCount > 0`getMessagingGroupAgents()`line 256取出所有 wired agent 行,按 `priority DESC` 排序。
**第四步:逐个评估 engage 条件**`evaluateEngage()`line 364
| 模式 | 行为 |
|------|------|
| `pattern` | 对消息文本做正则匹配;`'.'` 匹配所有消息 |
| `mention` | bot 必须被 @mention |
| `mention-sticky` | @mention **或者** 此 (agent, mg, thread) 组合已存在活跃 sessionline 384-390 |
**第五步access gate + senderScope gate**line 283-284做模块级策略拒绝。
**第六步:交付决策:**
- **engages + 通过 gate** → `deliverToAgent()``wake=true`
- **不 engage 但 `ignored_message_policy='accumulate'`** → `deliverToAgent()``wake=false`,写入 `trigger=0` 作为静默上下文
- **都不满足** → 丢弃,记录 `no_agent_engaged`
### `unregistered_senders` 表的作用
尽管模块名叫 `dropped-messages.ts`,实际表名是 `unregistered_senders``src/db/dropped-messages.ts:16-38`)。它是核心审计基础设施:
- 记录结构性丢弃no agent wired / no engagement和策略拒绝access gate 拒绝)
-`ON CONFLICT DO UPDATE` 做 per-channel 聚合,统计 `message_count`、更新 `last_seen`
- 可通过 `ncl dropped-messages list` 只读查看
- `reason` 字段记录丢弃原因
### 边界情况
- **多个 agent wired 到同一频道**:每个 agent 独立评估,一个频道消息可以唤醒多个 agent 容器
- **mention-sticky + 线程平台**`adapter.subscribe()` 被调用一次line 306订阅线程以便后续消息不需重新 @mention
- **pattern 模式的正则错误**`evaluateEngage()` 捕获正则错误并 fail-openline 378让 admin 看到 agent 响应并可以修复
- **access gate 拒绝 + accumulate**消息不累积line 310-316这是安全决策——静默存储越权消息会破坏 gate 的目的
---
## Q5: Session 什么时候创建?三种隔离模式的复用逻辑有什么不同?
### 答案
Session **懒创建**——只在第一条匹配 (agent group, messaging group, thread) 的消息到达时创建。创建由 `deliverToAgent()` 调用 `resolveSession()` 触发(`src/router.ts:415`)。
三种模式实现于 `src/session-manager.ts:92-133`
### `agent-shared`(完全不隔离)
```sql
SELECT * FROM sessions WHERE agent_group_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1
```
`src/db/sessions.ts:56-59`
- 完全忽略 `messagingGroupId``threadId`,所有 wired 的 messaging group 共用一个 session
- GitHub PR 评论和 Slack 消息出现在同一个对话中
- 如果没有 session创建一个且 `messaging_group_id = null`
### `shared`per-channel
```sql
SELECT * FROM sessions WHERE agent_group_id = ? AND messaging_group_id = ? AND thread_id IS NULL AND status = 'active'
```
`src/db/sessions.ts:36-53`
- 折叠所有线程——同一 messaging group 内使用一个 session
- 每个 messaging group 有自己的 session但共享 agent group workspace
### `per-thread`(最严格隔离)
```sql
SELECT * FROM sessions WHERE agent_group_id = ? AND messaging_group_id = ? AND thread_id = ? AND status = 'active'
```
- 每个 Slack thread / Discord thread 一个独立 session
- 支持线程的 adapterSlack、Discord会把 `session_mode: 'shared'` 的 wiring 强制重写为 `per-thread``router.ts:410-413`),除非 wiring 明确使用 `agent-shared`
- DM 除外(`mg.is_group === 0`),不做强制重写
### Session 创建的副作用
首次创建时(`created: true`line 128-131
1. `createSession()` 写中央库 `sessions`
2. `initSessionFolder()`line 136-143`data/v2-sessions/<agent_group_id>/<session_id>/` 下创建目录,初始化 `inbound.db` + `outbound.db` 的 schema
### 边界情况
- **Fan-out 到多个 agent**:每个 agent 有自己的 session。`resolveSession()``agent_group_id` 限定查询范围
- **容器重启后 session 复用**session 在中央库持久存在。同一 (agent, mg, thread) 的新消息触发 `resolveSession()`,找到已有 session 返回 `created: false`
---
## Q6: 容器 idle 后被 kill用户再发消息怎么被唤醒`on_wake` 为什么不会被旧容器偷走?
### 答案
**唤醒路径有两个:**
1. **路由时立即唤醒:** `wakeContainer(session)``src/router.ts:478` 被调用(仅对 `wake=true` 的消息)→ `container-runner.ts:85`
- 检查 `activeContainers.has(session.id)` — 已在运行则直接返回
- 检查 `wakePromises.get(session.id)` — 去重并发唤醒line 90-94
- 调用 `spawnContainer()`,重新构建所有 mount、组合 CLAUDE.md、生成 container.jsonspawn Docker 容器
2. **Host sweep 兜底唤醒:** `src/host-sweep.ts:180-186`
```
dueCount = countDueMessages(inDb)
if dueCount > 0 && !isContainerRunning(session.id) → wakeContainer(session)
```
处理 `wakeContainer()` 返回 `false`(瞬时失败)的情况——消息保持 pending下次 60s sweep 周期重试。
### `on_wake` 防竞态机制
**第一步:`on_wake` 列** — `docs/db-session.md:51``messages_in` 表有 `on_wake INTEGER NOT NULL DEFAULT 0` 列。当 `restartAgentGroupContainers()` 被调用且带有 `wakeMessage` 时(`container-restart.ts:28-41`),写入 `onWake: 1`。
**第二步Container 端的 `isFirstPoll` 过滤器** — `container/agent-runner/src/db/messages-in.ts:65-97`
```sql
AND (on_wake = 0 OR ?1 = 1)
```
- `isFirstPoll` 仅在容器第一次 poll 时为 `true``poll-loop.ts:71-74`
- `isFirstPoll = true` 时:过滤条件变为 `on_wake = 0 OR true` → 所有 pending 消息都可见
- `isFirstPoll = false` 时:过滤条件变为 `on_wake = 0 OR false` → `on_wake=1` 的行不可见
- **正在被 kill 的旧容器已过第一轮 poll**,即使它撑到下一轮 poll也看不到 `on_wake=1` 的消息
### `killContainer` 的 `onExit` 回调机制
`src/container-runner.ts:193-207`
```typescript
export function killContainer(sessionId, reason, onExit?) {
const entry = activeContainers.get(sessionId);
if (onExit) entry.process.once('close', onExit); // 先注册回调
stopContainer(entry.containerName); // 再发送 kill 信号
}
```
`container-restart.ts:47-51` 中的 `onExit` 回调:
```typescript
() => {
const s = getSession(session.id);
if (s) wakeContainer(s);
}
```
这个设计保证:
1. 旧容器**完全退出**进程终止mount 释放)后才触发新容器 spawn
2. 新容器的 `clearStaleProcessingAcks()``connection.ts:175`)清掉旧容器遗留的 `processing` 声明
3. 新容器第一轮 poll 时 `isFirstPoll=true`,捡起 `on_wake=1` 消息
### 容器从 idle 到 kill 到重新唤醒的生命周期
Host sweep 在 `host-sweep.ts:147-211` 中判断容器健康:
1. **天花板检查**line 99-105heartbeat mtime 年龄 > max(30min, Bash 超时) → kill
2. **per-claim 卡住检查**line 107-115每个 `processing_ack` 行,如果 claim 时间 > max(60s, Bash 超时) 且从此以后 heartbeat mtime 没有前进 → kill
3. **崩溃容器清理**line 199-201容器已死但 `processing_ack` 仍有遗留 → `resetStuckProcessingRows()` 用指数退避(基数 5s × 2^tries最多 5 次)重新调度消息
4. **孤儿 claim 清理**line 319kill 后 `deleteOrphanProcessingClaims()` 删掉 `outbound.db` 中的 processing 行,防止 sweep 立即 kill 新 spawn 的容器
### 边界情况
- **新容器还没有 heartbeat**:天花板检查在 `heartbeatMtimeMs === 0` 时跳过line 92-93
- **`wakePromises` 去重**spawn 过程中来多条消息,只 spawn 一个容器
- **Spawn 前清 heartbeat 文件**`container-runner.ts:155` `fs.rmSync(heartbeatPath(...), {force: true})` 清除孤儿 heartbeat
- **Bash 自定义超时**:天花板和 claim-stuck 容忍度都扩展到 `max(DEFAULT, declaredTimeoutMs)`,确保长时间 Bash 工具不被误杀

View File

@@ -0,0 +1,166 @@
# Q7-Q9: 权限与安全
---
## Q7: 用户身份怎么确定owner / admin / member 三级权限检查在哪里完成?
### 答案
**用户身份确定(两步):**
`src/modules/permissions/index.ts:67-103``extractAndUpsertUser` 函数:
1. **解析原始 handle** 从入站消息 JSON payload 的三处位置按优先级查找:`senderId``sender``author.userId`chat-sdk-bridge 的嵌套格式)。三处都没有返回 `null`
2. **命名空间标准化:** 如果原始 handle 已含 `:` 前缀(如 `slack:U0ABC`),直接使用;否则前缀 `channelType:` → 全局唯一 `userId`
3. **Upsert** `users` 表中不存在则创建(`db/users.ts:13-22``kind` 记录 channel 类型,`display_name` 从消息中提取
这个 resolver 通过 `setSenderResolver(extractAndUpsertUser)` 注册到 router`index.ts:171`),在 agent 解析**之前**执行(`router.ts:252`),确保即使后续被拒绝访问,`users` 行也已存在。
**三级权限表:**
| 表 | 用途 | 关键字段 |
|-----|------|---------|
| `users` | 用户身份 | `id`命名空间化的userId`kind``display_name` |
| `user_roles` | 特权角色 | `user_id``role`owner/admin`agent_group_id`NULL=全局) |
| `agent_group_members` | 非特权成员 | `user_id``agent_group_id` |
**`canAccessAgentGroup` 完整逻辑:**
`src/modules/permissions/access.ts:21-28` — 五步短路求值:
```typescript
function canAccessAgentGroup(userId: string, agentGroupId: string): AccessDecision {
if (!getUser(userId)) return { allowed: false, reason: 'unknown_user' }; // Gate 0
if (isOwner(userId)) return { allowed: true, reason: 'owner' }; // Gate 1
if (isGlobalAdmin(userId)) return { allowed: true, reason: 'global_admin' };// Gate 2
if (isAdminOfAgentGroup(userId, agentGroupId)) return { allowed: true, reason: 'admin_of_group' };// Gate 3
if (isMember(userId, agentGroupId)) return { allowed: true, reason: 'member' };// Gate 4
return { allowed: false, reason: 'not_member' };
}
```
**查库函数**`db/user-roles.ts`
- `isOwner``role='owner' AND agent_group_id IS NULL`line 36-41—— owner 必须全局
- `isGlobalAdmin``role='admin' AND agent_group_id IS NULL`line 43-48
- `isAdminOfAgentGroup``role='admin' AND agent_group_id=<id>`line 50-55
**`isMember` 的隐式 admin 语义**`db/agent-group-members.ts:28-36`
先检查 `isOwner || isGlobalAdmin || isAdminOfAgentGroup` 再查 `agent_group_members` 表——owner 和 admin **自动成为 member**,不需要额外插入行。
---
## Q8: 陌生人在群里 @bot系统怎么决定忽略、审批、还是直接响应
### 答案
**`unknown_sender_policy` 三态**`src/types.ts:31`,默认 `'strict'`
| 值 | 行为 |
|-----|------|
| `public` | 完全跳过访问检查。`router.ts` access gate 在 `index.ts:175` 直接返回 `{ allowed: true }` |
| `strict` | 非 member 的消息静默丢弃,记录到 `unregistered_senders`,不发送通知 |
| `request_approval` | 非 member 的消息被丢弃 + 发起一轮审批流程DM 一个 admin/owner展示 Approve/Deny 卡片 |
注意:即使 `unknown_sender_policy='public'``sender_scope='known'` 仍可作为更严格的叠加层(`router.ts:284` `senderScopeGate`)。
**完整决策链路**`src/router.ts` `routeInbound`
1. Message interceptor 先检查拦截器 hook 是否消费了该消息
2. Messaging group 解析 → sender resolver → agent fan-out
3. **Access gate**line 283就是 `canAccessAgentGroup` —— 此处 `unknown_sender_policy` 生效
4. **Sender scope gate**line 284per-wiring 的 `sender_scope='known'` 额外检查
5. 如果 access gate 拒绝 → `handleUnknownSender`
### sender-approval 完整流程
**Phase 1: 触发与去重**`permissions/index.ts:113-169`
- 记录 `dropped_message`reason=`unknown_sender_request_approval`
- 调用 `requestSenderApproval`
- **去重门控**`sender-approval.ts:59-65`):检查 `pending_sender_approvals` 表中的 `UNIQUE(messaging_group_id, sender_identity)` —— 同一个人已有 pending 卡片时,后续消息静默丢弃
**Phase 2: 选择审批者**`sender-approval.ts:67-87`
- `pickApprover(agentGroupId)` → scoped admins → global admins → owners`primitive.ts:76-93`
- `pickApprovalDelivery``primitive.ts:103-119`):遍历审批者,调用 `ensureUserDm` 找到可 DM 的人,优先同 channel 类型
**Phase 3: 创建 pending + 发送卡片**
- 标题和内容:`"New sender / <name> wants to talk to your agent. Allow?"`
- `ask_question` 卡片,通过 `deliveryAdapter.deliver` 发到审批者 DM
**Phase 4: 审批响应**`index.ts:225-283`
**APPROVE**
1. **授权验证**line 234-244检查点击者是不是 `approver_user_id` **或**有 admin 权限——防止随机用户通过转发卡片自我授权
2. `addMember` 添加到 `agent_group_members`line 249-254
3. **先删除 pending 行**line 264**再**重新调用 `routeInbound(event)`line 268——此时重试经过 access gateuser 已是 member
**DENY**
- 仅删除 `pending_sender_approvals`
- **不创建拒收列表**——同一发送者的新消息会重新触发新审批卡片
### 失败模式
- 没有 owner/admin → 消息永久丢弃
- 没有任何审批者可达(无 DM channel→ 同上
- 没有 delivery adapter → pending 行仍创建(可手动查看),但卡片不发送
- 卡片发送异常 → error log消息已丢弃
---
## Q9: Agent 在容器里能用 `ncl` 命令吗?能查其他 agent group 的数据吗?`cli_scope` 的三个值在哪里被检查?
### 答案
**`cli_scope` 的三个值**`container_configs` 表,迁移 015 添加,默认 `'group'`
| 值 | 含义 |
|-----|------|
| `disabled` | Agent 完全不知道 ncl 存在host 拒绝所有 CLI 请求 |
| `group`(默认) | Agent 只能操作自己的 group4个资源args 被自动绑定到自己 group无法查看其他 group 数据 |
| `global` | 无限制。仅通过 `init-first-agent` 脚本为 owner 的 agent group 设置 |
### 四层防御
**防御层 1CLAUDE.md 层面**`src/claude-md-compose.ts:82-90`
`cli_scope === 'disabled'` 时,`cli.instructions.md` 从 CLAUDE.md 的 fragment 列表中排除。Agent 学不到 ncl 命令的存在。
**防御层 2Host dispatch 核心强制**`src/cli/dispatch.ts`
- **disabled 检查**line 46-48所有 CLI 请求立即拒绝
- **资源白名单**line 51-55`group` 只允许 `groups``sessions``destinations``members` 四种资源
- **参数强制绑定**line 60-72检查 `agent_group_id``group``--id` 参数,不匹配调用者的 group 则拒绝
- **特权升级阻止**line 74-77拒绝任何包含 `cli_scope``cli-scope` 参数的命令
- **自动填充**line 81-90主动填充 `agent_group_id``group``--id` 为调用者的 group ID——agent 不需要也不能指定
- **sessions-get 防 existence oracle**line 95-100如果传入 session UUID 属于其他 group返回 "not found" 而非 "forbidden"
- **Post-handler 过滤**line 150-173`list` 返回的 rows 按 `scopeField` 再次过滤,丢弃不属于调用者 group 的行
各资源的 `scopeField` 映射(`src/cli/resources/`
- `groups``'id'``groups.ts:41`
- `sessions``'agent_group_id'``sessions.ts:10`
- `destinations``'agent_group_id'``destinations.ts:11`
- `members``'agent_group_id'``members.ts:11`
**防御层 3Container 端**`container/agent-runner/src/cli/ncl.ts`
Container 内的 ncl **不做任何权限检查**——它是纯粹的 DB transport 客户端。将 CLI 请求写入 `outbound.db`,从 `inbound.db` poll 响应。权限检查全在 host 端。Container 无法绕过,因为它只能访问 session DB不能直接连中央库或 socket server。
**防御层 4Command Gate斜杠命令**`src/command-gate.ts:23-63`
独立于 `cli_scope`,在消息写入 `messages_in` 之前运行(`router.ts:430-448`
- **Filtered commands**`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/remote-control`)→ 静默丢弃永不进容器
- **Admin commands**`/clear`, `/compact`, `/context`, `/cost`, `/files`)→ 仅 owner / admin 可通过。否则直接写 `messages_out` 返回权限拒绝
- **降级安全**line 51`user_roles` 表不存在 → 所有 admin 命令放行
### 跨层防御矩阵总结
| 防御层 | disabled | group | global |
|--------|----------|-------|--------|
| CLAUDE.md 隐藏 ncl | ✅ 排除 | ❌ 包含 | ❌ 包含 |
| Host dispatch 拒绝所有 | ✅ 拒绝 | ❌ | ❌ |
| 资源白名单 | N/A | ✅ 4种 | ❌ 无限制 |
| 参数强制绑定 | N/A | ✅ 自动填+拒绝跨界 | ❌ |
| `cli_scope` 自我修改阻止 | N/A | ✅ 拒绝 | ❌ |
| Post-handler rows 过滤 | N/A | ✅ scopeField | ❌ |
| Sessions oracle 防护 | N/A | ✅ fail-closed | ❌ |
| Command gate 斜杠命令 | ✅ | ✅ | ✅ |
| Approval gating非host | N/A | ✅ | ✅ |
**关键 gotcha** group-scoped agent 可以通过 `ncl groups config get` 读到自己的完整 config包括 `cli_scope` 字段),所以知道自己在 `group` 范围内。但由于 `cli_scope` 参数被第 74-77 行硬阻止,不能修改自己的 scope。

View File

@@ -0,0 +1,142 @@
# Q10-Q12: 容器生命周期
---
## Q10: 启动 agent 容器时 mount 了哪些东西?
### 答案
Container mount 由 `src/container-runner.ts:242-335``buildMounts()` 构建。容器内文件系统结构:
| 容器路径 | 宿主机来源 | 权限 | 用途 |
|----------|-----------|------|------|
| `/workspace/` | `data/v2-sessions/<agentGroup>/<session>/` | RW | Session 目录:`inbound.db``outbound.db``.heartbeat``outbox/``inbox/` |
| `/workspace/agent/` | `groups/<folder>/` | RW | Per-group 工作文件 + `CLAUDE.local.md` |
| `/workspace/agent/container.json` | `groups/<folder>/container.json` | **RO** | 嵌套 RO 覆盖——agent 可读不能改line 276-278 |
| `/workspace/agent/CLAUDE.md` | `groups/<folder>/CLAUDE.md` | **RO** | 组合的 CLAUDE.mdspawn 时重新生成line 287-290 |
| `/workspace/agent/.claude-fragments/` | `groups/<folder>/.claude-fragments/` | **RO** | per-skill/per-MCP 指令片段 |
| `/workspace/global/` | `groups/global/` | **RO** | 共享全局记忆 |
| `/app/CLAUDE.md` | `container/CLAUDE.md` | **RO** | 共享基础 CLAUDE.md通过 `.claude-shared.md` symlink 导入 |
| `/home/node/.claude/` | `data/v2-sessions/<agentGroup>/.claude-shared/` | RW | Claude SDK 状态、`settings.json`、skill symlinks |
| `/app/src/` | `container/agent-runner/src/` | **RO** | 共享 agent-runner TypeScript 源码 |
| `/app/skills/` | `container/skills/` | **RO** | 共享容器技能 |
| 额外 | `containerConfig.additionalMounts` | → | Provider-contributed mountsline 330 |
### 调用链
1. `spawnContainer()`line 108`buildMounts()`line 134
2. Pre-mount 初始化:
- `initGroupFilesystem(agentGroup)`line 253幂等创建 `groups/<folder>/``CLAUDE.local.md``.claude-shared/` 目录和 DB 行
- `syncSkillSymlinks()`line 257根据 `container.json``skills` 选择,在 `.claude-shared/skills/` 下创建 symlink
- `composeGroupClaudeMd(agentGroup)`line 261重新生成组合 CLAUDE.md
3. Mount 按顺序组装line 267-333
4. 所有 volume mount 进入 `buildContainerArgs()`line 447-453
### 边界情况
- `CLAUDE.md``.claude-fragments/` 是嵌套 RO mount叠加在 RW group 目录上——agent 只能写 `CLAUDE.local.md`
- `container.json` 单独 RO mount 防止 agent 修改自己的配置
- Skill symlinks 指向容器内路径(`/app/skills/<name>`),在宿主机上是悬空符号链接,容器内有效
---
## Q11: Agent 的 system prompt 是怎么拼出来的?
### 答案
Agent 的 system prompt 由三部分拼成:**(A)** 共享基础 `CLAUDE.md`**(B)** per-skill/per-MCP 指令片段,**(C)** 运行时 addendum身份 + destinations
### Host 端组合spawn 时)
`src/claude-md-compose.ts:43-136``composeGroupClaudeMd()`
1. **共享基础 symlink**line 49-50`groups/<folder>/.claude-shared.md``/app/CLAUDE.md`21 行通用 agent 指令交流风格、workspace、memory、conversation history
2. **Fragment 发现**line 58-107
- **Skill fragments**line 66-76`container/skills/` 下任何有 `instructions.md` 的技能
- **内置模块 fragments**line 83-96`container/agent-runner/src/mcp-tools/` 下的 `.instructions.md`。**`cli.instructions.md``cli_scope='disabled'` 时被跳过**
- **MCP server fragments**line 100-107`container.json` 中外部 MCP server 的 `instructions` 字段,生成内联 fragment 文件
3. **Fragment 协调**line 110-122删除不再需要的过期 fragment创建/更新 symlink
4. **组合入口**line 125-130写出 `groups/<folder>/CLAUDE.md`,只含 import 指令:
```
@./.claude-shared.md
@./.claude-fragments/skill-onecli-gateway.md
@./.claude-fragments/skill-welcome.md
@./.claude-fragments/module-cli.md
```
Claude Code 跟随 `@` import 解决所有 fragment
5. **Per-group 记忆**line 132-135确保 `CLAUDE.local.md` 存在——这是唯一可写的 CLAUDE.md 文件
### Container 端运行时 addendum
`container/agent-runner/src/destinations.ts:82-92` → `buildSystemPromptAddendum()`
- **身份**line 85-87如果设置了 `assistantName``"You are <name>"` + 自我介绍和签名指引
- **Destination map**line 94-130从 `inbound.db` 的 `destinations` 表读取,生成 "Sending messages" 部分
### 各部分贡献
| 来源 | 内容 | Agent 可修改? |
|------|------|---------------|
| `container/CLAUDE.md`(共享基础) | 通用 agent 行为 | 否RO mount |
| Skill `instructions.md` | Per-skill 指引 | 否RO |
| MCP tool `.instructions.md` | 如何使用内置工具 | 否RO |
| MCP server `instructions` | 外部 MCP server 指引 | 仅 adminDB中 |
| `CLAUDE.local.md` | Per-group 记忆 | **是**(唯一可写) |
| `buildSystemPromptAddendum()` | 身份 + destination map | 生成时自动 |
---
## Q12: 容器心跳怎么检测?进程活着但 poll loop 卡死了怎么发现?
### 答案
心跳是**文件 touch 机制**,不是 DB 写入,避免跨 mount DB 写入争用。
### Container 端:心跳 touch
- **Path**`/workspace/.heartbeat``container/agent-runner/src/db/connection.ts:25`
- **`touchHeartbeat()`**`connection.ts:156-168`):用 `fs.utimesSync()` 更新文件 mtime。失败时回退 `fs.writeFileSync()`
- **触发时机**`poll-loop.ts:361`,在 `for await (const event of query.events)` 循环中——每个 SDK event`init`、`result`、`error`、`progress`)都触发。意味着**仅 agent 活跃流式回复时更新**poll 轮空间歇不更新
### Host 端:卡住检测
Host sweep 每 60s 运行(`host-sweep.ts:61`),对每个有运行容器的 session 调用 `enforceRunningContainerSla()`line 192 → line 228
**两种检测层级**`decideStuckAction()`line 82-118
**1. 绝对天花板**line 91-105heartbeat mtime 年龄 > `max(30 min, 当前Bash超时)` → `kill-ceiling`
- `ABSOLUTE_CEILING_MS = 30 * 60 * 1000`
- 扩展 `declaredBashMs`:从 `outbound.db` 读 `container_state`,如果当前工具是 Bash 且有 `tool_declared_timeout_ms`,天花板扩大到该值
- **关键守护**line 92-98`heartbeatMtimeMs === 0`(刚 spawn还没有 SDK activity→ 跳过
**2. Claim-stuck per-message**line 107-115对每个 `processing_ack` 中的 `processing` 行,如果 `(claim_age > tolerance)` 且 `(heartbeat_mtime <= status_changed)` → `kill-claim`
- `CLAIM_STUCK_MS = 60s`claim 一条消息后 60s 内没有任何 heartbeat → poll loop 卡住
- 条件 `heartbeat_mtime <= claimedAt` 恰好检测 "claim 了消息后没有任何生命迹象"
**3. 容器不在运行**line 199-201`!isContainerRunning` → `resetStuckProcessingRows()` 用指数退避(基数 5s × 2^tries最多 5 次)重调度消息
### 处理器卡在 poll loop 的完整场景
```
Container 进程存活poll loop 卡住:
poll-loop.ts:101 → markProcessing(ids) → DB: status='processing', status_changed=NOW
poll-loop.ts:174 → config.provider.query(...) → 启动,但 SDK 挂起
[没有 heartbeat touch因为没有 event 触发]
...
Host sweep60s后
→ getProcessingClaims(outDb) → 发现 claimstatus_changed 很旧
→ heartbeatMtimeMs() → 返回旧 mtime挂起前最后一次 event 的)
→ decideStuckAction(): claimAge > 60s, heartbeat_mtime <= claimedAt
→ action: 'kill-claim' → killContainer() + resetStuckProcessingRows()
```
### 边界情况
- **新 spawn 宽容期**heartbeat 文件不存在时跳过天花板检查,但 claim-stuck 检查仍然处理 "claim 了消息但在门口卡住"
- **每次 spawn 清 heartbeat**`container-runner.ts:155`):防止旧容器的过期 mtime 立即触发 kill
- **孤儿 claim 清理**line 319kill 后 `deleteOrphanProcessingClaims()` 清掉 `outbound.db` 中的 processing 行,防止 sweep 立即 kill 新容器
- **Bash 自定义超时**:扩展容忍度,确保长运行 Bash 不被误杀

View File

@@ -0,0 +1,175 @@
# Q13-Q15: 出站投递与系统动作
---
## Q13: Agent 回复消息后delivery.ts 怎么知道用哪个 channel adapter 发送?重试和失败怎么处理?
### 答案
Delivery 系统使用**两层轮询**active poll每 1s扫描有运行容器的 sessionsweep poll每 60s扫描所有 active session。从 `outbound.db`container-owned读取`inbound.db``delivered` 表中跟踪投递状态。Channel adapter 是 boot 时设置的单个全局 `ChannelDeliveryAdapter`
### Adapter 的选择
- `messages_out` 的每行带有 `channel_type``platform_id` 字段container 的 `writeMessageOut()` 填入)
- `delivery.ts:356-363``deliveryAdapter.deliver(channelType, platformId, threadId, kind, content, files)` 被调用。Adapter 收到 `channelType` + `platformId`,负责路由到正确的平台
- Adapter 通过 `setDeliveryAdapter()` 设置一次line 95是一个包装了所有 channel adapter 的 `ChannelDeliveryAdapter`
### 完整投递流程
**1. Poll 触发:** `pollActive()`1sline 121-133`getRunningSessions()``pollSweep()`60sline 136-149`getActiveSessions()`
**2. 防竞态**line 151-162`inflightDeliveries``Set<string>` — 如果 active poll 和 sweep poll 竞态同个 session第二个调用者跳过防止重复投递
**3. Drain session**`drainSession()`line 164-232
- 只读打开 `outbound.db`,读写打开 `inbound.db`
- `getDueOutboundMessages(outDb)``messages_out``deliver_after <= now`
- `getDeliveredIds(inDb)` 做去重比对line 183
- 对每条未投递消息调用 `deliverMessage()`line 192
**4. 消息路由**`deliverMessage()`line 234-375
- **System actions**line 255-258`msg.kind === 'system'``handleSystemAction()` → 查找 `actionHandlers` Map。模块通过 `registerDeliveryAction()` 注册处理器
- **Agent-to-agent**line 264-271`msg.channel_type === 'agent'``routeAgentMessage()`
- **Channel delivery**line 289-375
- **权限检查**line 289-311验证源 agent 是否有权发到目标 channel——要么目标是自己 session 的 origin`session.messaging_group_id` 匹配),要么 `agent_destinations` 中有显式行
- **Pending question 跟踪**line 317-340`ask_question` 类型创建 `pending_questions`
- **文件附件**line 348-354从 session 的 `outbox/<messageId>/` 读文件
- **Adapter call**line 356-363实际发送
- **清理**line 372`clearOutbox()` 删除 outbox 目录
### 重试和失败处理
- 投递尝试在 `deliveryAttempts` Map 中以消息 ID 为 key 在内存中跟踪(进程重启时重置,给失败消息全新机会)
-`MAX_DELIVERY_ATTEMPTS = 3` 次失败后,标记为 permanent failedline 206-225
- 重试是惰性的:消息留在 `messages_out` 表中未投递,下次 poll 迭代重新捡起
---
## Q14: Agent 发起 `install_packages` 或 `add_mcp_server` 的完整审批-执行链路是什么?
### 答案
自我修改采用 **fire-and-forget** 模式 + admin 审批门控。Agent 调 MCP tooltool 写 system message 到 `outbound.db`。Host delivery loop 拾起、验证/净化请求、排队审批admin 批准后应用修改、重建镜像如需要、kill 容器、写 `on_wake` 消息给新容器。
### 完整链路(逐步)
**Phase 1: Agent 请求container 端)**
1. Agent 调 `install_packages``add_mcp_server` MCP tool`container/agent-runner/src/mcp-tools/self-mod.ts`
- `install_packages`line 53-78用正则验证包名`APT_RE``NPM_RE`,最多 20 个),写 `kind: 'system'` + `action: 'install_packages'` 的 outbound message
- `add_mcp_server`line 97-117验证 name 和 command 存在,写 `action: 'add_mcp_server'`
2.`outbound.db`:用 `writeMessageOut()`,写入**奇数 seq**
**Phase 2: Host delivery 拾起**
3. `delivery.ts``deliverMessage()` 看到 `kind === 'system'``handleSystemAction()`
4. `handleSystemAction()`line 410-425`actionHandlers` Map。self-mod 模块注册了 handler
- `handleInstallPackages``self-mod/request.ts:20-64`
- `handleAddMcpServer``self-mod/request.ts:66-91`
**Phase 3: 请求验证 + 审批排队**
5. **Host 端验证**(深度防御第二层):对 package 名称再次验证(同一正则),失败时调 `notifyAgent()` 告知 agent**不创建审批**
6. **审批请求**`approvals/primitive.ts:164-220`
- `pickApprover(session.agent_group_id)` → scoped admins → global admins → owners
- `pickApprovalDelivery`:找到可 DM 的审批者,优先同 channel 类型
-`deliveryAdapter.deliver()``ask_question` 卡片到 admin DM
- 创建 `pending_approvals` 行,包含 `action``payload`JSON`approval_id``session_id`
**Phase 4: Admin 响应**
7. `approvals/response-handler.ts:24-43``handleApprovalsResponse()`
- **Reject**line 72-77`notify()` 告知 agent
- **Approve**line 80-105`getApprovalHandler(approval.action)` 注册的 handler传入 `{ session, payload, userId, notify }`
**Phase 5: 应用修改**
8. **`install_packages` handler** — `self-mod/apply.ts:22-83`
- 去重后追加新 apt/npm 包到 DB 中已有列表line 37-49
- `buildAgentGroupImage()`line 57构建 per-agent-group Docker 镜像(`container-runner.ts:468-515`),用 `docker build -t nanoclaw-agent:<agentGroupId>` 拉 900s 超时
-`on_wake: 1` 消息告知 agent
- **Kill 容器 with `onExit` callback**line 72-75`killContainer(sessionId, 'rebuild applied', () => { wakeContainer(s) })` —— 保证旧容器退出后新容器才 spawn
- 重建失败line 77-82通知 admin不 kill 容器
9. **`add_mcp_server` handler** — `self-mod/apply.ts:85-125`
- 添加 MCP server 到 DB 的 `mcp_servers` JSONline 99-105
-`on_wake: 1` 消息line 107-120
- Kill 容器 with `onExit``wakeContainer` callbackline 121-124
- **不需要重建镜像** —— Bun 直接运行 TS纯 MCP wiring 变动不需要 Dоcker 构建
**Phase 6: 新容器启动**
10. `onExit` callback 触发 → `wakeContainer()``spawnContainer()`
- 从 DB 物化新 `container.json`line 127
- `composeGroupClaudeMd()` 重新生成 CLAUDE.mdline 261
- `clearStaleProcessingAcks()` 清掉旧 processing ack`connection.ts:175-177`
- `getPendingMessages(isFirstPoll=true)` 捡起 `on_wake: 1` 消息 —— 仅第一轮 poll 可见
### 为什么 `on_wake` 是防竞态的
`messages_in``on_wake` 列 + `getPendingMessages()``isFirstPoll` 门控:第一轮 poll 包含 `on_wake = 1` 行,后续轮排除(它们已 `completed`)。结合 `killContainer``onExit` callback旧容器绝无可能先于新容器偷走 on_wake 消息。
---
## Q15: 定时任务cron怎么实现
### 答案
定时任务实现为 **`messages_in` 表中 `kind='task'` 的行**piggyback 在核心 schema 上没有专用表。Agent 通过 MCP tool 创建任务host 把它们写入 `inbound.db`recurrence 由 host sweep hook 驱动:克隆已完成的周期性任务为新 pending 行。
### 创建任务
1. Agent 通过 `schedule_task` MCP tool`container/agent-runner/src/mcp-tools/scheduling.ts`)写 `kind: 'system'` + `action: 'schedule_task'` 的 outbound message包含 `taskId``prompt``processAfter`(首次运行 ISO 时间戳)、可选 `recurrence`cron 表达式)
2. Host delivery 拾起 → `handleSystemAction()` → 注册的 `action: 'schedule_task'` handler`scheduling/actions.ts:19-40`
-`insertTask()``scheduling/db.ts:17-36`):插入 `messages_in` 行,`kind = 'task'``status = 'pending'``process_after = <首次运行时间>``recurrence = <cron-expr>``series_id = <taskId>`
- 内容存储为 JSON `{ prompt, script }`
3. Agent 也可以创建非周期性调度消息:`schedule_message` 工具同理,但 `kind` 匹配原始消息类型
### 触发Host sweep + `countDueMessages`
4. Host sweep 唤醒容器(`host-sweep.ts:180-186`
- `countDueMessages(inDb)` 计数 `status = 'pending' AND process_after <= now` 的行
- `dueCount > 0 && !isContainerRunning``wakeContainer(session)`
5. Container 处理任务:`getPendingMessages()` 读 pending 行,包括 task 行,格式化后给 provider
### Recurrencehost sweep + `handleRecurrence`
6. Recurrence fanout`scheduling/recurrence.ts:21-53`),每 60s sweep 周期调用(`host-sweep.ts:205-206`
- `getCompletedRecurring(inDb)``scheduling/db.ts:122-126`):找 `status = 'completed' AND recurrence IS NOT NULL` 的行
- 对每个完成的行:
- 在用户时区(非 UTC解析 cron 表达式
- 计算 `nextRun = interval.next().toISOString()`
- `insertRecurrence()``scheduling/db.ts:128-149`):复制原行,设置 `process_after = nextRun``status = 'pending'`
- `clearRecurrence()`line 151-153设原行 `recurrence = NULL`,防止下次周期重复克隆
### 其他任务生命周期操作
7. **Cancel/Pause/Resume**`actions.ts:42-70`
- `cancelTask()`line 38-42通过 `id OR series_id` 匹配,设所有 `pending`/`paused` 行为 `completed`
- `pauseTask()`line 44-48`resumeTask()`line 50-54同理
-`series_id` 匹配意味着 agent 可以引用任意一次执行取消整个系列
### Host sweep + recurrence 协作顺序
```
Host sweep每 60s
Step 1: syncProcessingAcks()
Step 2: countDueMessages() → 如果到期 + 容器不在运行 → wakeContainer()
Step 3: enforceRunningContainerSla() ← heartbeat/claim-stuck 检查
Step 4: resetStuckProcessingRows() ← 崩溃容器清理
Step 5: handleRecurrence() ← 扫描已完成周期性任务,克隆下次执行
```
**关键顺序:** Step 2唤醒到期消息在 Step 4崩溃容器清理之前运行确保新容器有机会在启动时清自己的孤儿 `processing_ack`
### 边界情况
- **没有专用表**:任务是 `messages_in` 行——核心 `messages_in` schema 中 `kind` 字段足以区分
- **`series_id`**:周期性任务的每次执行共享同一 `series_id`。Cancel/pause/resume 用 `id OR series_id` 匹配,影响整个系列
- **时区**Cron 表达式以用户配置的 `TIMEZONE` 解析(来自 `.env`),非 UTC
- **Pre-task scripts gating**Task 行可带 `script` 字段。`applyPreTaskScripts()` hook`poll-loop.ts:149,323`)先跑 script。如果返回 `wakeAgent: false`,任务标记完成但不唤醒 agent——实现 "仅用户活跃时运行" 等条件
- **Agent 不能写 inbound.db**Container 把 task 写成 `kind: 'system'` 的 outbound messagehost 的 delivery action handler 才是实际插入 `messages_in` 的组件——保持单 writer 不变式

View File

@@ -0,0 +1,167 @@
# Q16-Q17: 数据模型
---
## Q16: 中央库 `data/v2.db` 有哪些表?它们之间的外键关系是怎样的?
### 答案
中央库有约 20 张表,横跨多次迁移。以下按逻辑分类:
### 核心实体表(迁移 001
| # | 表 | 用途 | 关键 FK 关系 |
|---|-----|------|-------------|
| 1 | `agent_groups` | Agent 工作区folder、skills、CLAUDE.md | 根实体——无 FK 出 |
| 2 | `messaging_groups` | 一个平台聊天/频道/DM | 根实体;`UNIQUE(channel_type, platform_id)` |
| 3 | `messaging_group_agents` | 多对多 wiringagent↔channel | `→ messaging_groups(id)`, `→ agent_groups(id)``UNIQUE(messaging_group_id, agent_group_id)` |
| 4 | `users` | 平台用户身份(`<channel>:<handle>` | 根实体 |
| 5 | `user_roles` | 特权授予owner/admin | `→ users(id)` (x2: user + granted_by), `→ agent_groups(id)``PK (user_id, role, agent_group_id)` |
| 6 | `agent_group_members` | 非特权访问门 | `→ users(id)` (x2), `→ agent_groups(id)``PK (user_id, agent_group_id)` |
| 7 | `user_dms` | 冷 DM 缓存 | `→ users(id)`, `→ messaging_groups(id)``PK (user_id, channel_type)` |
| 8 | `sessions` | Session 生命周期元数据 | `→ agent_groups(id)`, `→ messaging_groups(id)` |
| 9 | `pending_questions` | 交互式问题状态 | `→ sessions(id)` |
### 后续迁移添加的表
| # | 表 | 迁移 | 说明 |
|---|-----|------|------|
| 10 | `chat_sdk_kv` | 002 | Chat SDK 不透明状态 |
| 11 | `chat_sdk_subscriptions` | 002 | Chat SDK 线程订阅 |
| 12 | `chat_sdk_locks` | 002 | Chat SDK 分布式锁 |
| 13 | `chat_sdk_lists` | 002 | Chat SDK 列表状态 |
| 14 | `pending_approvals` | module-003 | `→ sessions(id)`, `→ agent_groups(id)` |
| 15 | `agent_destinations` | module-004 | `→ agent_groups(id)``PK (agent_group_id, local_name)` |
| 16 | `unregistered_senders` | 008 | 审计跟踪;`PK (channel_type, platform_id)` |
| 17 | `container_configs` | 014 | `→ agent_groups(id) ON DELETE CASCADE` |
| 18 | `pending_sender_approvals` | 011 | `→ messaging_groups(id)`, `→ agent_groups(id)``UNIQUE(messaging_group_id, sender_identity)` |
| 19 | `pending_channel_approvals` | 012 | `→ messaging_groups(id)`, `→ agent_groups(id)` |
| 20 | `schema_version` | 内建 | 迁移分类账——无 FK |
### 已删除的表
- `pending_credentials` — 在迁移 009 中删除(已废弃)
### 列级变更
- `agent_groups.denied_at` — 迁移 012 添加
- `messaging_group_agents` 的 4 列(`engage_mode``engage_pattern``sender_scope``ignored_message_policy`)— 迁移 010 添加,替换旧的 `trigger_rules` + `response_scope`
- `container_configs.cli_scope` — 迁移 015 添加
### ER 关系图
```
agent_groups ──────────────────────────────────────────────────────────────────────────┐
│ │
├── messaging_group_agents ──→ messaging_groups ◄── user_dms │
│ (UNIQUE pair) │
│ │
├── sessions ──→ messaging_groups │
│ └── pending_questions │
│ │
├── user_roles (privilege: owner/admin, scoped or global) ──→ users (user_id, granted_by)
│ │
├── agent_group_members (unprivileged access gate) ──→ users (user_id, added_by) │
│ │
├── agent_destinations (ACL + name→target routing map) │
│ │
├── container_configs (1:1, ON DELETE CASCADE) │
│ │
├── pending_approvals │
├── pending_sender_approvals │
└── pending_channel_approvals │
users ────────────────────────────────────────────────────────────────────────────────┐
├── user_roles (user_id, granted_by 都自引用 users) │
├── agent_group_members │
└── user_dms │
schema_version, chat_sdk_*, unregistered_senders — 无 FK独立叶子表
```
### 关键不变式
- **Owner 必须全局:** `role='owner'` 意味着 `user_roles``agent_group_id IS NULL``grantRole()` 中强制
- **Admin 隐含 membership** agent group A 的 admin 自动是 member——不需要 `agent_group_members`
- **`agent_destinations` 双重角色:** 既是路由表也是 ACL。无行 = 未授权发送。源是中央库spawn 时投影写入容器内 `inbound.db`
- **Chat SDK 表是不透明的**NanoClaw 代码很少直接操作它们——由 `state-sqlite.ts` 持有
- **Session DB 是分离的**`inbound.db` / `outbound.db``data/v2-sessions/<session_id>/` 下,用 `CREATE TABLE IF NOT EXISTS` 做前向兼容
---
## Q17: DB 迁移怎么组织?我要加一张表或一个字段该改哪些文件?
### 答案
### 迁移文件组织
每个迁移是 `src/db/migrations/` 下的一个文件,导出 `Migration` 对象:
```typescript
// src/db/migrations/index.ts:18-22
interface Migration {
version: number; // 在 barrel 数组中的排序提示
name: string; // UNIQUE name — schema_version 中的去重 key
up: (db: Database) => void; // 在事务中运行
}
```
### 迁移注册顺序(`migrations/index.ts:24-38`
```
001-initial.ts → 核心表
002-chat-sdk-state.ts → Chat SDK 表
module-approvals-pending-approvals.ts → pending_approvals
module-agent-to-agent-destinations.ts → agent_destinations
module-approvals-title-options.ts → ALTER pending_approvals
008-dropped-messages.ts → unregistered_senders
009-drop-pending-credentials.ts → DROP pending_credentials
010-engage-modes.ts → ALTER messaging_group_agents
011-pending-sender-approvals.ts
012-channel-registration.ts
013-approval-render-metadata.ts
014-container-configs.ts
015-cli-scope.ts
```
005 和 006 编号空着——早前重编号的。
### 注册机制(`runMigrations``index.ts:40-77`
1. 如果不存在则创建 `schema_version` 表。唯一性在 `name`,不在 `version`
2.`SELECT name FROM schema_version``Set<string>` —— **去重 key 是 `name`**,不是 `version`。这允许模块迁移使用任意版本号
3. `migrations.filter(m => !applied.has(m.name))` 得到待执行列表
4.`db.transaction()` 中运行每个待执行迁移:
-`m.up(db)` 执行 DDL
- 计算 `next = MAX(version) + 1`
- 插入 `schema_version`
### 加一张表
1. 创建 `src/db/migrations/016-your-table.ts`
```typescript
import type Database from 'better-sqlite3';
import type { Migration } from './index.js';
export const migration016: Migration = {
version: 16,
name: 'your-table', // 必须在所有迁移中全局唯一
up(db: Database) {
db.exec(`CREATE TABLE IF NOT EXISTS your_table (...)`);
},
};
```
2. 在 `src/db/migrations/index.ts` 中:
- 添加 `import { migration016 } from './016-your-table.js';`
- 追加到 `migrations` 数组
### 加一个字段
同理,写一个 ALTER TABLE 的迁移。复杂变更(回填)做 JS 行级更新。**幂等守护模式**(迁移 012line 29-32ALTER TABLE ADD COLUMN 前先 `PRAGMA table_info()` 检查列是否已存在。
### 边界情况 / Gotchas
- **DROP TABLE 中的 FK 完整性:** 迁移事务中 DROP TABLE 会触发 SQLite FK 完整性检查。不能 toggle `PRAGMA foreign_keys`。优先 ALTER TABLE ADD COLUMN
- **模块迁移用任意版本:** `module-` 前缀文件的 `name` 与文件名不同——`name` 保持稳定,防止重命名后重新运行
- **Session DB 迁移是独立的:** Session DB schema`INBOUND_SCHEMA`、`OUTBOUND_SCHEMA`)用 `CREATE TABLE IF NOT EXISTS`,新列通过惰性迁移 helper`migrateDeliveredTable()` 等)落地,不跟踪 `schema_version`
- **Container 端也有自己的迁移**`container/agent-runner/src/db/connection.ts:86-110` 有惰性 session DB 迁移(添加 `on_wake` 列、`delivered` 表列等)

View File

@@ -0,0 +1,170 @@
# Q18-Q19: Provider 与 MCP
---
## Q18: Claude Agent SDK、OpenCode、Ollama 三个 provider 怎么抽象成统一接口?切换 provider 改什么?
### 答案
### 统一接口:`AgentProvider`
定义在 `container/agent-runner/src/providers/types.ts:1-92`
```typescript
interface AgentProvider {
readonly supportsNativeSlashCommands: boolean; // SDK 是否原生处理 /commands
query(input: QueryInput): AgentQuery; // 开始一轮对话
isSessionInvalid(err: unknown): boolean; // 检测过期的延续语境错误
}
```
- `QueryInput`line 40-60携带 `prompt``continuation`(不透明 session token`cwd``systemContext`。Provider 自己决定 "continuation" 的含义
- `AgentQuery`line 68-80流式句柄`push(message)` 推送后续输入,`events: AsyncIterable<ProviderEvent>` 流式输出,`abort()` 强制停止
- `ProviderEvent`line 82-92判别联合类型 `init | result | error | progress | activity`
- `ProviderOptions`line 23-38`assistantName``mcpServers``env``additionalDirectories``model``effort`
### 当前 Providers
**ClaudeProvider**`claude.ts:253-360`
- 包装 `@anthropic-ai/claude-agent-sdk``query()` 函数
- 用自定义 `MessageStream` async generatorline 80-112把后续消息推入 SDK 流式输入
- 把原始 SDK 事件翻译成 `ProviderEvent` 联合类型(`translateEvents()`line 318-346
- HooksPreToolUse记录 in-flight tool + 屏蔽不允许的 SDK builtins、PostToolUse清除 in-flight、PreCompact转录归档
- 在 line 360 注册:`registerProvider('claude', (opts) => new ClaudeProvider(opts))`
**MockProvider**`mock.ts:8-76`
- 测试用——从 `responseFactory` 返回预设回复
**OpenCode**`providers` 分支通过 `/add-opencode` skill 安装,实现 `AgentProvider` 接口
**Ollama** 尚未实现。会实现 `AgentProvider` 直接调用 Ollama API
### factory.ts 的选择逻辑
`container/agent-runner/src/providers/factory.ts`(全部 13 行):
```typescript
export function createProvider(name: string, options: ProviderOptions = {}): AgentProvider {
return getProviderFactory(name)(options);
}
```
这是对自注册 registry`provider-registry.ts`的薄分发。Registry 是 `Map<string, ProviderFactory>`,每个 provider 模块在模块作用域调用 `registerProvider(name, factory)`
选择逻辑:
1. 调用者传入 provider name 字符串(来自 `container_configs.provider` 列)
2. `getProviderFactory(name)` 查 Map——未找到抛错`"Unknown provider: ${name}"`
3. 调用 factory 函数并传入 options
### 注册流程
1. 每个 provider 文件在顶层调用 `registerProvider()`(如 `claude.ts:360`
2. Barrel `container/agent-runner/src/providers/index.ts` 导入所有 provider 模块做副作用:
```typescript
import './claude.js';
import './mock.js';
```
3. 运行时 agent-runner poll loop 从 `container_configs` 解析 provider name → `createProvider(name, options)` → 获取 `AgentProvider` → `provider.query(input)`
### 切换 Provider
修改 agent group 的 `container_configs.provider` 列:
```bash
ncl groups config update --id <group-id> --provider opencode
```
写入中央库 → 下次容器 spawn 物化到 `container.json` → agent-runner 调用 `createProvider('opencode', ...)`
### Host 端 Provider 注册
对于需要 host 端 setup 的 provider额外 mounts、env 传递),有独立的 registry`src/providers/provider-container-registry.ts:43-54`。Provider 注册 `ProviderContainerConfigFn`container-runner 在 spawn 时调用它来合并额外的 mounts/env。目前仅 `claude` 内建——用默认容器,无需额外注册。非默认 provider如 OpenCode会在这里注册。
---
## Q19: 容器里的 MCP server 怎么启动?内置工具和外部 MCP server 有什么不同?
### 答案
### MCP Server Bootstrap
入口是 `container/agent-runner/src/mcp-tools/index.ts:1-22`
```
1. 导入链index.ts → 导入 core.ts, scheduling.ts, interactive.ts,
agents.ts, self-mod.ts 以便它们的副作用 registerTools([...]) 调用生效
2. 所有导入解决后,调用 startMcpServer()
3. 如果 server 崩溃process.exit(1)
```
`startMcpServer()``server.ts:35-54`
1. 创建 MCP `Server` 实例name=`'nanoclaw'`version=`'2.0.0'`
2. 注册两个请求 handler
- `ListToolsRequestSchema` → 返回 `allTools.map(t => t.tool)`line 38-40
- `CallToolRequestSchema` → 查 `toolMap.get(name)`,调 `tool.handler(args)`line 42-48
3. 创建 `StdioServerTransport` 并连接line 51-52——Claude SDK 通过 stdio 发现 MCP server
4. 记录所有注册的 tool 名称
### Tool 自注册模式
每个 tool 模块在模块作用域调用 `registerTools([...])`。`registerTools()``server.ts:24-33`):将每个 `McpToolDefinition` 推入两个结构:`allTools[]`ListTools 用)和 `toolMap`name→definitionCallTool 用)。重复的名称警告但不报错。
`McpToolDefinition` 类型(`types.ts:1-6`
```typescript
interface McpToolDefinition {
tool: Tool; // MCP SDK Tool schema (name, description, inputSchema)
handler: (args) => Promise<CallToolResult>;
}
```
### 内置 Tool vs 外部 MCP Server
**内置in-tree工具** — 定义在 `container/agent-runner/src/mcp-tools/`
| 模块 | 工具 | 机制 |
|------|------|------|
| `core.ts`line 95-263 | `send_message`、`send_file`、`edit_message`、`add_reaction` | 写 `outbound.db` 的 `messages_out` 表;通过 local destinations map 解析目标 |
| `scheduling.ts` | `schedule_task` | 持久调度,`process_after` / `recurrence` 字段 |
| `interactive.ts` | `ask_user_question` | 写中央库 `pending_questions`host poll 并发送卡片 |
| `agents.ts` | `create_agent` | 通过 system actions spawn 子 agent 容器 |
| `self-mod.ts` | `install_packages`、`add_mcp_server` | 写 `pending_approvals` 表host 处理审批和容器重建 |
这些工具以 JavaScript 函数形式**运行在容器进程内部**。与 host 通信通过写 DB 表(`messages_out`、`pending_approvals` 等)——不是 IPC。
**外部 MCP server** — per-agent-group 配置在 `container_configs.mcp_servers`JSON 字符串,默认 `'{}'`
```json
{
"server-name": {
"command": "npx",
"args": ["-y", "@some/mcp-server"],
"env": { "API_KEY": "..." }
}
}
```
Claude provider 把 `this.mcpServers` 直接传给 SDK 的 `mcpServers` 选项(`claude.ts:306`。SDK 以子进程方式 **spawn 它们**,通过 stdio 自动发现它们的工具。Tool allowlist`claude.ts:66-68`)派生 MCP patterns
```typescript
function mcpAllowPattern(serverName: string): string {
return `mcp__${serverName.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
}
```
**关键区别:** 内置工具写 session DB外部 MCP server 是由 SDK 管理的 stdio 子进程,通过 MCP 协议通信。内置工具能直接访问所有内部 DB 表和 destination/resolution 系统;外部 MCP 工具只能看到 provider SDK 通过 MCP 协议暴露的内容。
### MCP Tools ↔ Provider 交互
交互是间接的:
1. Provider 启动 → 传 `mcpServers` config 给 SDK → SDK spawn 外部 MCP 进程并发现它们的 tool schema
2. **NanoClaw MCP server**stdio在 provider 之前由 `index.ts` 启动——SDK 连接它并发现内置 tool schema
3. AgentClaude 模型)决定调用 tool → SDK 路由到内置或外部 MCP server → 结果返回模型
4. Provider hooks`PreToolUse`、`PostToolUse`)对**所有** tool 调用运行,不管来源(`claude.ts:160-189`)——用于为 host sweep 的卡住容忍度逻辑跟踪 tool-in-flight 状态
Provider 永远不会直接"调用"MCP tool。模型调用。Provider 只设置环境MCP server configs、tool allowlists、hooks
### 不允许的 SDK Builtins
`claude.ts:25-35` 定义 `SDK_DISALLOWED_TOOLS` —— Claude Code SDK builtins 被屏蔽,因为 NanoClaw 有等价实现或它们不适合无头模型:
- `CronCreate/CronDelete/CronList/ScheduleWakeup` → 被 `mcp__nanoclaw__schedule_task` 替代
- `AskUserQuestion` → 被 `mcp__nanoclaw__ask_user_question` 替代
- `EnterPlanMode/ExitPlanMode/EnterWorktree/ExitWorktree` → Claude Code UI 功能;在无头容器中挂起
如果 disallowed tool 意外通过 allowlist 过滤器,`preToolUseHook`line 160-169在调用时拦截它——深度防御。

View File

@@ -0,0 +1,158 @@
# Q20-Q21: Channel 适配器
---
## Q20: 如果要加一个新的 channel比如钉钉需要实现什么接口、改哪些文件
### 答案
### `ChannelAdapter` 接口(完整合约)
定义在 `src/channels/adapter.ts:111-167`
**必需属性:**
| 属性 | 类型 | 说明 |
|------|------|------|
| `name` | `string` | 适配器显示名称 |
| `channelType` | `string` | 唯一类型标识(如 `'dingtalk'` |
| `supportsThreads` | `boolean` | `true`=平台以线程为主要对话单元,`false`=频道本身就是对话 |
**必需方法:**
| 方法 | 签名 | 说明 |
|------|------|------|
| `setup(config)` | `(config: ChannelSetup) => Promise<void>` | 初始化连接,注册事件 handler |
| `teardown()` | `() => Promise<void>` | 优雅关闭 |
| `isConnected()` | `() => boolean` | 连接状态 |
| `deliver(platformId, threadId, message)` | `(string, string\|null, OutboundMessage) => Promise<string\|undefined>` | 发送出站消息;如有则返回平台消息 ID |
**可选方法:**
| 方法 | 何时实现 | 用途 |
|------|---------|------|
| `setTyping?(platformId, threadId)` | 有 typing 指示器的聊天平台 | 显示 "bot is typing..." |
| `syncConversations?()` | 有频道发现的平台 | 列出所有可访问的对话 |
| `resolveChannelName?(platformId)` | 显示需要 | 频道 ID 的人类可读名称 |
| `subscribe?(platformId, threadId)` | 有线程订阅的平台 | 订阅 bot 到线程;幂等——调用两次是 no-op |
| `openDM?(userHandle)` | 需要区分 user ID 和 DM channel ID 的平台 | 打开或获取 DM 频道,返回 DM 的 `platform_id`。仅 Discord、Slack、Teams、Webex、gChat 需要。Telegram、WhatsApp、iMessage 等跳过——user handle 就是 DM chat ID |
### `ChannelSetup` 接口(适配器拿到的回调)
定义在 `adapter.ts:9-26`
```typescript
interface ChannelSetup {
onInbound(platformId, threadId, message: InboundMessage): void | Promise<void>; // 入站消息
onInboundEvent(event: InboundEvent): void; // CLI/admin 传输
onMetadata(platformId, name?, isGroup?): void; // 频道元数据
onAction(questionId, selectedOption, userId): void; // 按钮点击
}
```
### 两种实现模式
**模式 1原生 Adapter** — 直接实现 `ChannelAdapter`处理平台原始协议HTTP API、WebSocket 等。示例WhatsApp (Baileys)、Signal、iMessage。
**模式 2Chat SDK Bridge** — 用 `createChatSdkBridge(config)``chat-sdk-bridge.ts:122`)把已有 Chat SDK `Adapter` 包装成 `ChannelAdapter`。用于 Discord、Slack、Telegram、Teams、GitHub、Linear、Webex、Matrix、Google Chat。
### 加新 Channel 要创建/修改的文件
1. **创建适配器模块:**
- 如果用 Chat SDK`src/channels/dingtalk.ts``createChatSdkBridge({...})`
- 如果用原生:`src/channels/dingtalk.ts` 直接实现 `ChannelAdapter`
2. **自注册:**`registerChannelAdapter('dingtalk', { factory, containerConfig? })`
3. **接入导入:**`src/channels/index.ts` 添加 `import './dingtalk.js';`(启动时加载所有适配器的 barrel
4. **Container config可选** 如果适配器需要在 agent 容器内额外 mount 或 env var
```typescript
registerChannelAdapter('dingtalk', {
factory: () => createDingTalkAdapter(),
containerConfig: {
mounts: [{ hostPath: '/path/to/certs', containerPath: '/certs', readonly: true }],
env: { DINGTALK_APP_KEY: process.env.DINGTALK_APP_KEY! },
},
});
```
5. **Skill 打包:** 按 `CONTRIBUTING.md` 规范写成 channel install skill
- `skills/add-dingtalk/SKILL.md` — 面向用户的指引
- Skill 从 `channels` 分支复制 `src/channels/dingtalk.ts`,追加 import 到 barrel`pnpm install <pkg>`
### 关键边界情况
| 情况 | 如何处理 |
|------|---------|
| 缺少凭据 | Factory 返回 `null` → `initChannelAdapters` 跳过并 warn`channel-registry.ts:57-59` |
| Setup 时网络错误 | 指数退避重试:[2s, 5s, 10s]`channel-registry.ts:10,68-87` |
| 重复注册 | `registerChannelAdapter`line 26`registry.set()` 静默覆盖 |
| `isMention` flag 传播 | 知道平台 mention 语义的适配器设置 `InboundMessage.isMention`——router 用它替代正则 name-matching |
---
## Q21: Chat SDK bridge 是什么?为什么 Discord/Slack/Telegram 等共用它?
### 答案
### 什么是 Chat SDK Bridge
Chat SDK bridge`src/channels/chat-sdk-bridge.ts`680 行)是一个**通用适配器 shim**,把任何 [Chat SDK](https://github.com/nicepkg/chat) `Adapter` 实例包装成 NanoClaw 兼容的 `ChannelAdapter`。它一次性处理平台无关的 concern让每个具体 channel 模块只需提供平台特定配置。
核心工厂函数 `createChatSdkBridge(config)`line 122返回完整实现所有必需和可选方法的 `ChannelAdapter` 对象。
### Bridge 集中化的 10 项 concern
1. **四种 dispatch 路径**line 213-266——Chat SDK 区分四种入站消息bridge 把它们映射为正确的 `isMention` flag
- `onSubscribedMessage` → `onInbound(channelId, threadId, message, isMention=message.isMention)`
- `onNewMention` → `onInbound(channelId, threadId, message, isMention=true)`
- `onDirectMessage` → `onInbound(channelId, threadId, message, isMention=true, isGroup=false)`
- `onNewMessage(/[\s\S]*/)` → `onInbound(channelId, threadId, message, isMention=false, isGroup=true)`
2. **附件下载**line 138-163——序列化消息到 JSON 前先下载附件为 base64使其在 `inbound.db` content 列中存活
3. **Reply context 提取**line 164-169——平台特定 hook`extractReplyContext`
4. **Sender 字段归一化**line 173-180——把 Chat SDK 的嵌套 `author` 对象投影为 router 需要的扁平 `senderId`/`sender`/`senderName`
5. **卡片渲染**line 387-470——把 `ask_question` 和 `send_card` MCP tool payload 渲染为带按钮的 Card。处理按钮编码整数 index vs 全值,适配 Telegram 64 字节 callback_data 限制)
6. **文本拆分**line 480-497`splitForLimit` line 106-120——按 paragraph→line→space→char 边界拆分消息,适配 `maxTextLength`Discord 2000Telegram 4096
7. **Gateway listener**line 303-359——对支持 Gateway 的适配器Discord启动本地 HTTP server 接收转发事件,指数退避重启(上限 1h
8. **Webhook 注册**line 362——对非 gateway 适配器Slack、Teams、GitHub注册到共享 webhook server
9. **`openDM` 委托**line 534-549——直接委托 `adapter.openDM()` 而非 `chat.openDM()`
10. **Transform hook**line 124——`transformOutboundText` 允许 per-platform 文本清理
### 原生 Adapter vs Chat SDK Bridge
| 方面 | Chat SDK Bridge | 原生 Adapter |
|------|----------------|--------------|
| **平台协议** | 由 Chat SDK 的 `Adapter` 处理 | 直接处理(如 Baileys 处理 WhatsApp WebSocket |
| **消息解析** | Chat SDK 归一化为 `Message` 类型bridge 序列化为 JSON | Adapter 解析原始平台 payload直接构造 `InboundMessage` |
| **Dispatch** | Bridge 映射 4 个 SDK dispatch 路径到 `onInbound` | Adapter 从自己的 event handler 直接调用 `onInbound` |
| **卡片/按钮渲染** | Bridge 用 Chat SDK 的 `Card`/`Button`/`Actions` 组件渲染 | Adapter 必须实现平台特定的交互式消息渲染 |
| **Gateway/Webhook** | Bridge 处理 Gateway listener 生命周期 | Adapter 自己处理连接生命周期 |
| **openDM** | 委托 `adapter.openDM()` | Adapter 通过平台 API 直接实现 |
| **Channel ID 编码** | `adapter.channelIdFromThreadId()` | Adapter 用自定义 ID 方案 |
| **示例** | Discord、Slack、Telegram、Teams、GitHub、Linear、Webex、Matrix、Google Chat、Resend | WhatsApp (Baileys)、Signal、iMessage |
### Bridge 处理的边界情况
| 情况 | 位置 |
|------|------|
| Gateway crash → IP block 保护 | Line 314-357指数退避上限 1h5min 健康运行后重置计数器 |
| Telegram 64 字节 callback_data 限制 | Line 400-408把 button value 编码为整数 index |
| Discord interaction 更新 | Line 607-668通过 Discord REST API 更新卡片,再 dispatch 到 `onAction` |
| 序列化时附件数据丢失 | Line 138-163调用 `toJSON()` **前**先下载附件数据为 base64 |
| 媒体 only 空文本消息 | Line 263`onNewMessage(/[\s\S]*/)` 匹配所有消息包括空文本 |
| `chat.openDM` 对非标准 user ID 抛错 | Line 534-549直接委托 `adapter.openDM()` |
| Raw message 字段过大 | Line 183`serialized.raw = undefined` 省 DB 空间 |
### `user-dm.ts` 兜底
对于没有 `openDM` 支持的 channelTelegram、WhatsApp、iMessage、email、Matrix`src/user-dm.ts` 直接把 user handle 当 DM platform_id——user 本身就是 DM chat。Bridge 仅当底层 `adapter.openDM` 存在时才附着 `openDM`line 544

16
docs/answers/README.md Normal file
View File

@@ -0,0 +1,16 @@
# 源码学习路线图 — 问题答案
> 对应 [`docs/learning-roadmap.md`](../learning-roadmap.md) 中的 21 个引导性问题。按子系统分组,每个问题附带完整的调用链、关键代码位置和边界情况分析。
## 目录
| 文件 | 覆盖问题 | 内容 |
|------|----------|------|
| [01-global-architecture.md](01-global-architecture.md) | Q1, Q2, Q3 | 消息完整链路、Node/Bun 运行时分离、seq 奇偶规则、跨 mount 不变式 |
| [02-routing-and-sessions.md](02-routing-and-sessions.md) | Q4, Q5, Q6 | 路由决策、三种隔离模式、`on_wake` 防竞态机制 |
| [03-permissions-and-security.md](03-permissions-and-security.md) | Q7, Q8, Q9 | 三级权限检查、陌生人审批、`cli_scope` 四层防御 |
| [04-container-lifecycle.md](04-container-lifecycle.md) | Q10, Q11, Q12 | Mount 架构、system prompt 组合、心跳检测与卡住判定 |
| [05-delivery-and-system-actions.md](05-delivery-and-system-actions.md) | Q13, Q14, Q15 | 投递重试、自我修改审批链路、定时任务 cron |
| [06-data-model.md](06-data-model.md) | Q16, Q17 | 中央库 ER 图、迁移系统注册机制 |
| [07-provider-and-mcp.md](07-provider-and-mcp.md) | Q18, Q19 | Provider 工厂模式、内置 MCP vs 外部 MCP server |
| [08-channel-adapters.md](08-channel-adapters.md) | Q20, Q21 | `ChannelAdapter` 接口、Chat SDK bridge 架构 |

194
docs/learning-roadmap.md Normal file
View File

@@ -0,0 +1,194 @@
# NanoClaw 源码学习路线图
> **配套答案:** 路线图中 21 个问题的详细答案见 [`docs/answers/`](answers/) 目录。
## 前置知识
阅读源码前,确保理解以下核心概念:
- **两层运行时**Host 进程用 Node/pnpmContainer 进程用 Bun。两者不共享代码不共享模块。
- **唯一的 IO 界面**Host 和 Container 之间没有 IPC、没有 stdin pipe。两个 SQLite 文件是唯一的通讯界面:
- `inbound.db` — Host 写Container 读
- `outbound.db` — Container 写Host 读
- **seq 奇偶规则**Host 使用偶数 seqContainer 使用奇数 seq避免双方同时写同一个 DB 时的冲突。
- **实体模型**`users → messaging_groups → agent_groups → sessions`,中间通过 `messaging_group_agents` 多对多连接。
- **会话不等于代理组**:一个 agent group 可以有多个 session比如不同线程每个 session 有自己独立的 `inbound.db` + `outbound.db`
> 建议:先快速浏览 `docs/architecture.md` 和 `docs/architecture-diagram.md`,建立一个心智模型再开始。
---
## 第一层:全局鸟瞰(先读文档)
| 顺序 | 文件 | 解决什么问题 |
|------|------|-------------|
| 1 | `docs/architecture.md` | 整个系统的架构全貌,消息怎么流转 |
| 2 | `docs/architecture-diagram.md` | 消息从进到出的可视化链路 |
| 3 | `docs/db.md` | 三层 DB 模型central + inbound + outbound |
| 4 | `docs/db-central.md` | 中央库 `data/v2.db` 每一张表的职责和字段 |
| 5 | `docs/db-session.md` | 会话库的 schema + seq 奇偶分配机制 + cross-mount 不变式 |
| 6 | `docs/build-and-runtime.md` | 为什么 Host 用 NodeContainer 用 Bun两套锁文件互不干扰 |
---
## 第二层:主循环 — 消息从进到出的主干链路
这是整个系统的脊椎。按调用顺序读,每个文件不必求甚解,但要理解数据流向。
| 顺序 | 文件 | 在链路中的角色 |
|------|------|---------------|
| 7 | `src/index.ts` | 程序入口:初始化 DB、运行迁移、启动所有 channel adapter、启动 poll sweep、启动 CLI socket server |
| 8 | `src/router.ts` | 入站路由:收到消息 → 解析 messaging group → 解析 agent group → 权限门检查 → 创建/查找 session → 写 `inbound.db` → 唤醒容器 |
| 9 | `src/session-manager.ts` | 会话生命周期:管理 session 目录、打开/关闭 DB、心跳检测、跨 mount 的 `journal_mode=DELETE` 不变式 |
| 10 | `src/delivery.ts` | 出站投递:轮询 `outbound.db` → 通过 channel adapter 发送消息 → 处理系统动作(审批、调度等) |
| 11 | `src/host-sweep.ts` | 后台清扫60 秒周期 — `processing_ack` 同步、僵死容器检测、到期消息唤醒、定时任务触发 |
> **到这里你应该能回答:一条用户消息从发出来到 agent 回复,经历了哪些步骤。**
---
## 第三层:容器侧 — Agent 在里面干什么
理解 Host 怎么把消息交给容器之后,钻进去看容器内部。
| 顺序 | 文件 | 在链路中的角色 |
|------|------|---------------|
| 12 | `container/agent-runner/src/index.ts` | 容器入口:加载 `/workspace/agent/container.json`、构建 MCP server 配置、创建 provider、进入 poll loop |
| 13 | `container/agent-runner/src/poll-loop.ts` | 核心大循环:读 `inbound.db` → 调 providerClaude/OpenCode→ 格式化输出 → 写 `outbound.db` |
| 14 | `container/agent-runner/src/providers/factory.ts` | Provider 工厂:根据 `container.json` 中的 `agent_provider` 字段选择 Claude 或 OpenCode |
| 15 | `container/agent-runner/src/providers/claude.ts` | Claude Agent SDK 的具体对接实现 |
| 16 | `container/agent-runner/src/formatter.ts` | 出站消息格式化:按目标 channel 类型做内容适配 |
| 17 | `container/agent-runner/src/mcp-tools/index.ts` | MCP 工具注册入口agent 能调用的所有工具列表 |
---
## 第四层:带着问题深入子系统
以下问题按系统层次组织,每个问题后面标注了需要读的关键文件。带着问题去读比按文件线性读高效得多。
---
### 全局架构
#### Q1: 一条用户消息从 Slack 发出,到 agent 回复出现在聊天框里,完整路径是什么?
> 追踪:`src/router.ts` → `src/session-manager.ts` → `container/agent-runner/src/poll-loop.ts` → `src/delivery.ts`。动手画一张时序图,标注每个环节读/写了哪个数据库。
#### Q2: 为什么 Host 用 NodeContainer 用 Bun两套运行时之间的"协议"是什么?
> `docs/build-and-runtime.md` + `src/container-runner.ts`
#### Q3: `inbound.db` 和 `outbound.db` 为什么各只能有一个 writer`journal_mode=DELETE` 为什么是必须的?
> `docs/db-session.md` + `src/session-manager.ts` 中的注释块
---
### 路由与会话
#### Q4: 一个 messaging group比如 Slack 频道)怎么决定路由到哪个 agent group没匹配上怎么办
> `src/router.ts` 的 `routeInbound()` + `src/db/messaging-groups.ts`
#### Q5: Session 什么时候创建?`agent-shared`、`shared`、per-thread 三种隔离模式的区别是什么?
> `docs/isolation-model.md` + `src/session-manager.ts`
#### Q6: 容器 idle 后被 kill用户再发消息时怎么被唤醒`on_wake` 消息为什么不会被旧容器偷走?
> `src/container-runner.ts` 的 `killContainer()` + `src/container-restart.ts`
---
### 权限与安全
#### Q7: 用户身份怎么确定owner / admin / member 三级的权限检查在哪张表、哪段代码里完成?
> `src/modules/permissions/access.ts` + `src/modules/permissions/db/user-roles.ts`
#### Q8: 陌生人在群里 @bot系统怎么决定是忽略、审批、还是直接响应
> `src/modules/permissions/sender-approval.ts` + `src/router.ts` 中的 access gate 回调
#### Q9: Agent 在容器里能用 `ncl` 命令吗?能查其他 agent group 的数据吗?`cli_scope` 在哪里被检查?
> `src/cli/dispatch.ts` + `src/command-gate.ts` + `src/db/migrations/015-cli-scope.ts`
---
### 容器生命周期
#### Q10: 启动 agent 容器时 mount 了哪些东西?`/workspace`、session DB、CLAUDE.md、skills 分别从哪里来?
> `src/container-runner.ts` 的 mount 参数 + `src/group-init.ts`
#### Q11: Agent 的 system prompt 是怎么拼出来的CLAUDE.md + 全局指令 + container config 各自贡献了什么?
> `src/claude-md-compose.ts` + `container/agent-runner/src/index.ts`
#### Q12: 容器心跳怎么检测?进程活着但 poll loop 卡死了host 怎么发现?
> `src/host-sweep.ts` + `src/session-manager.ts` 中 `.heartbeat` 文件的逻辑
---
### 出站投递与系统动作
#### Q13: Agent 回复消息后delivery.ts 怎么知道用哪个 channel adapter 发送?重试和失败怎么处理?
> `src/delivery.ts` + `src/channels/adapter.ts`
#### Q14: Agent 发起 `install_packages` 或 `add_mcp_server`,从发出请求到容器重建完成,完整的审批-执行链路是什么?
> `container/agent-runner/src/mcp-tools/self-mod.ts` → `src/modules/self-mod/request.ts` → `src/modules/approvals/primitive.ts` → `src/modules/self-mod/apply.ts`
#### Q15: 定时任务cron怎么实现Agent 在容器里能创建吗?触发时谁写消息进 `inbound.db`
> `src/modules/scheduling/recurrence.ts` + `src/modules/scheduling/db.ts` + `src/host-sweep.ts` 中 recurrence 处理
---
### 数据模型
#### Q16: 中央库 `data/v2.db` 有哪些表?它们之间的外键关系是怎样的?
> `src/db/schema.ts` + `docs/db-central.md`。建议自己画一张 ER 图。
#### Q17: DB 迁移怎么组织?我要加一张表或一个字段该改哪些文件?
> `src/db/migrations/index.ts` + 任意一个 `src/db/migrations/0XX-*.ts`
---
### Provider 与 MCP
#### Q18: Claude Agent SDK、OpenCode、Ollama 三个 provider 怎么抽象成统一接口?切换 provider 改什么?
> `container/agent-runner/src/providers/factory.ts` + `container/agent-runner/src/providers/types.ts`
#### Q19: 容器里的 MCP server 怎么启动内置工具core、agents、self-mod 等)和外部 MCP server 有什么不同?
> `container/agent-runner/src/mcp-tools/server.ts` + `container/agent-runner/src/index.ts` 中的 MCP 构建逻辑
---
### Channel 适配器
#### Q20: 如果要加一个新的 channel比如钉钉需要实现什么接口、改哪些文件
> `src/channels/adapter.ts`(接口定义)+ `src/channels/channel-registry.ts`(注册表)+ `src/channels/chat-sdk-bridge.ts`(如果用 Chat SDK 模式)
#### Q21: Chat SDK bridge 是什么?为什么 Discord/Slack/Telegram 等共用它?
> `src/channels/chat-sdk-bridge.ts`
---
## 建议的阅读策略
1. **先跑通主干**第一层→第二层Q1不求甚解但要能画出消息的完整流转路径
2. **再读容器侧**(第三层),理解 agent 内部怎么调 Claude、怎么写回结果
3. **挑一个子系统深入**(第四层),根据你最关心的方向:
- 关注**多租户/权限** → Q7, Q8, Q9
- 关注**容器/部署** → Q6, Q10, Q11, Q12
- 关注**扩展新 provider/channel** → Q18, Q19, Q20, Q21
- 关注**运维/稳定性** → Q2, Q3, Q5, Q14, Q15
4. **最后回到文档**,通读 `docs/api-details.md``docs/agent-runner-details.md`
---
## 关键文件速查表
| 想了解... | 先读这个文件 |
|-----------|-------------|
| 整体启动流程 | `src/index.ts` |
| 消息怎么进来 | `src/router.ts` |
| 消息怎么出去 | `src/delivery.ts` |
| Session 怎么管理 | `src/session-manager.ts` |
| 容器怎么启动/杀死 | `src/container-runner.ts` |
| Agent 的主循环 | `container/agent-runner/src/poll-loop.ts` |
| Agent 能调用哪些工具 | `container/agent-runner/src/mcp-tools/index.ts` |
| 权限检查入口 | `src/modules/permissions/access.ts` |
| ncl CLI 怎么工作 | `src/cli/dispatch.ts` |
| DB 有哪些表 | `src/db/schema.ts` |
| 审批流程 | `src/modules/approvals/primitive.ts` |
| 自我修改如何实现 | `src/modules/self-mod/apply.ts` |

View File

@@ -4,4 +4,3 @@
// needs (claude, mock) don't appear here.
//
// Skills add a new provider by appending one import line below.
import './opencode.js';

View File

@@ -1,49 +0,0 @@
/**
* Host-side container config for the `opencode` provider.
*
* OpenCode's `opencode serve` process stores state under XDG_DATA_HOME, which
* we pin to a per-session host directory mounted at /opencode-xdg. The
* OPENCODE_* env vars tell the CLI which provider/model to use at runtime
* (read on the host, injected into the container). NO_PROXY / no_proxy are
* merged with host values so the in-container OpenCode client can talk to
* 127.0.0.1 even when HTTPS_PROXY is set by OneCLI.
*/
import fs from 'fs';
import path from 'path';
import { registerProviderContainerConfig } from './provider-container-registry.js';
function mergeNoProxy(current: string | undefined, additions: string): string {
if (!current?.trim()) return additions;
const parts = new Set(
current
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean),
);
for (const addition of additions.split(',')) {
const trimmed = addition.trim();
if (trimmed) parts.add(trimmed);
}
return [...parts].join(',');
}
registerProviderContainerConfig('opencode', (ctx) => {
const opencodeDir = path.join(ctx.sessionDir, 'opencode-xdg');
fs.mkdirSync(opencodeDir, { recursive: true });
const env: Record<string, string> = {
XDG_DATA_HOME: '/opencode-xdg',
NO_PROXY: mergeNoProxy(ctx.hostEnv.NO_PROXY, '127.0.0.1,localhost'),
no_proxy: mergeNoProxy(ctx.hostEnv.no_proxy, '127.0.0.1,localhost'),
};
for (const key of ['OPENCODE_PROVIDER', 'OPENCODE_MODEL', 'OPENCODE_SMALL_MODEL'] as const) {
const value = ctx.hostEnv[key];
if (value) env[key] = value;
}
return {
mounts: [{ hostPath: opencodeDir, containerPath: '/opencode-xdg', readonly: false }],
env,
};
});