数据截至 (上游 commit ee230f304a1a)
多入口与常驻:CLI、headless、远程环境、消息渠道与定时
30 秒导读: 前面几章讲的是"一个 agent 怎么想、怎么动手"。这一章讲的是谁来按下开始键——同一个 agent,可以被终端界面、管道脚本、桌面端 WebSocket、Slack/Telegram 消息、或者一条 cron 表达式接管。Letta Code 让这五条入口最终汇到同一个内核上,而不是各写一套。
本章属于 Letta Code — 架构与原理 系列。回合本身怎么跑见 01-stateful-turn.md,批准环路见 03-permissions.md,记忆同步见 04-memory.md;这里只讲入口和常驻。
1. 这是什么(零基础也能懂)
一句话定义: 把"跑 agent"这件事从"人坐在终端前"解耦成"任何前端都能触发",并且让进程能一直挂着等活干。
为什么需要: 一个只能在终端里手打的 agent,做不了这些事:
- 你在 CI 里想让它改一个 PR —— 没有 TTY,不能有交互 UI。
- 你在手机上想接着刚才的对话 —— 得有个常驻进程替你在笔记本上执行。
- 你想在 Slack 里 @ 它 —— 得有人监听 Slack 并把消息投进正确的会话。
- 你想让它每天早上八点跑一次巡检 —— 得有个调度器,而且不能因为你关了终端就停。
入口一览:
| 入口 | 命令 | 有没有 UI | 谁在触发 |
|---|---|---|---|
| 交互式 TUI | letta | Ink 全屏界面 | 坐在终端前的人 |
| headless | letta -p "..." | 无,纯 stdout | 脚本 / CI / 父 agent |
| 远程环境(出站) | letta server | 状态行 | Letta Cloud 推来的消息 |
| App Server(入站) | letta server --listen | 无 | 桌面端 / 浏览器 / OpenAI 客户端 |
| 消息渠道 | letta server --channels slack | 无 | Slack/Telegram/... 的用户 |
| 定时 | letta cron add ... | 无 | 时钟 |
一句话直觉: 把 letta-code 想成一台机床。TUI 是机床自带的操作面板,headless 是数控程序输入口,远程环境是给机床装了一根网线,渠道是把网线接到 Slack,cron 是定时器开关。机床本身(工具层、权限、记忆)只有一套。
2. 顶层全景
2.1 一次启动怎么分流
src/index.ts 的 main() 是唯一入口。它的骨架只有四步,而且顺序有讲究:
letta <argv>
│
① 抽 --backend 标志 extractBackendFlag(index.ts:604)
│ └─ 部分子命令还需要提前定后端模式 subcommandNeedsEarlyBackendMode(router.ts:34)
│
② runSubcommand(argv) ── ┬─ 返回数字 ─► 子命令跑完,process.exit
│ (index.ts:649) └─ 返回 null ─► 不是子命令,继续
│
③ 初始化 settings / 校验凭据 / 解析完整参数
│
④ isHeadlessStartup? ──┬─ 是 ─► loadTools + handleHeadlessCommand(index.ts:1414)
(index.ts:834) └─ 否 ─► 懒加载 React + Ink + App(index.ts:1429)
为什么子命令要排在第二步:源码注释写得很直白——"Subcommands exit before TUI initialization and tool bootstrapping"(src/index.ts:648)。letta agents list 这种纯 JSON 查询不该为了打印一行结果去加载 Ink、加载全部工具、校验一遍 OAuth。
分流的判据只有一个函数:isHeadlessStartup(src/cli/startup-mode.ts:1)。规则三条,按优先级:
- 显式
-p/--run→ headless; - 还剩正位参数(子命令已经路由过了)→ 不是 headless,是"未知命令"报错;
- 否则看 stdin 是不是 TTY —— 管道进来就走 headless。
2.2 五种前端,一个内核
这张图从左到右读:前端 → 接入模块 → 共用内核。
终端 TUI ──► AppCoordinator.tsx ────┐
管道 / CI ──► headless.ts ───────────┤
├──► 本机工具层 + 权限 + MemFS
Cloud / 桌面 ──► listener runtime ──────┤ │
Slack / TG ──► ChannelGateway(子进程)─┤ ▼
定时任务 ──► cron scheduler ────────┘ Letta 后端(Cloud / 本地)
注意后三条的收敛点更靠内:远程环境、消息渠道、定时任务全都落到同一个 ListenerRuntime 上。渠道的 gateway 通过本机 App Server 的公开协议接进来,cron 的 fireCronTask 直接把一条 cron_prompt 塞进会话队列(src/cron/scheduler.ts:294-333)。它们不是三套并行实现,是同一个回合入口的三个上游。
2.3 部件职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
| CLI 分发 | 抽后端标志、路由子命令、判 headless | src/index.ts、src/cli/subcommands/router.ts |
| TUI | Ink 状态机 + 渲染 + 斜杠命令 | src/cli/app/ |
| headless | 一次性 / 流式 JSON 会话 | src/headless.ts、src/stream-json-writer.ts |
| listener | 多连接、回合生命周期、批准、恢复 | src/websocket/listener/ |
| App Server | 把本机 harness 暴露成 WS + OpenAI 兼容 HTTP | src/websocket/app-server*.ts |
| 渠道 | 插件注册、路由、门禁、配对、消息工具 | src/channels/ |
| 定时 | 租约、tick、抖动、错过判定 | src/cron/ |
3. 入口分发:一个 switch 就是全部路由
3.1 router.ts 的两张表
src/cli/subcommands/router.ts 只有 130 行,却是整个 CLI 的路由表。它导出两个函数:
subcommandNeedsEarlyBackendMode(command)(router.ts:34)—— 一张白名单,列出那些"还没解析完参数就得知道用哪个后端"的子命令(server、channel-gateway、agents、memory、teleport……)。runSubcommand(argv)(router.ts:62)—— 一个switch,命中返回退出码,不命中返回null让主流程继续。
返回类型 Promise<number | null> 是关键设计:null 不是"失败",是"这不归我管"。这让"letta 裸跑进 TUI"和"letta cron list 打印 JSON 退出"共用同一条入口,不需要在 index.ts 里再维护一份子命令名单。
3.2 子命令一览
| 子命令 | 干什么 | 实现 |
|---|---|---|
server | 二级分发:远程环境 或 App Server | server.ts:107 |
server --listen | 本机开 WS/HTTP 服务器 | app-server.ts:56 |
server(裸) / remote | 注册成远程环境并反连 Cloud | listen.tsx:272 |
channel-gateway | 渠道子进程(内部命令,不给人手打) | channel-gateway.ts:35 |
channels | 安装/配置/路由/配对,输出 JSON | channels.ts:576 |
cron | 增删查改定时任务 | cron.ts:954 |
environments / envs | 列出可用远程环境、查当前环境 | environments.ts:137 |
teleport | 把当前会话搬到另一个环境 | teleport.ts:163 |
agents / messages | 只读查询,JSON-only | agents.ts:99、messages.ts:166 |
local-backend | 本地后端 transcript 迁移 | local-backend.ts:32 |
letta server 是个二级路由。 resolveServerCommand(src/cli/subcommands/server.ts:48)扫一遍 argv:出现 --listen 就分成 { kind: "app-server" },否则 { kind: "remote" }。老的 letta app-server 保留成 deprecated 别名,靠 asLegacyAppServerCommand(server.ts:103)在前面补一个 --listen 再转发(router.ts:80-84)。
远程环境不是哪里都能起。 resolveListenerStartupMode(src/cli/subcommands/listen.tsx:138)返回三态:
| 返回 | 什么时候 | 后果 |
|---|---|---|
remote | 桌面模式,或 base URL 指向 Letta Cloud | 注册设备 + 反向 WS |
local-channels | 本地后端或自托管 server,且带了 --channels | 跳过环境注册,只跑渠道 |
unsupported-self-hosted | 自托管 server 且没带 --channels | 直接报错退出(listen.tsx:448-457) |
4. 交互式 TUI:状态与渲染分家
4.1 三层薄壳
公开入口 src/cli/App.tsx 只有 7 行,src/cli/app/App.tsx 只有 9 行,都是纯 re-export。真正的实现在 AppCoordinator.tsx。这层薄壳是故意的:src/cli/app/README.md 说明了整个目录按职责切分,好让"小上下文的 agent 能先打开最小的有用文件"。
按 README 的划分,主干是三个文件:
| 文件 | 职责 | 关键符号 |
|---|---|---|
AppCoordinator.tsx | Ink 状态、副作用、overlay 接线、组装渲染树 | App(:349) |
AppView.tsx | 只渲染,不持有状态 | AppView(:348) |
use-submit-handler.ts | 用户提交 + 斜杠命令路由 | useSubmitHandler(:602) |
其余按关切拆成 hook:use-conversation-loop.ts(流式回合循环)、use-approval-flow.ts(批准恢复与批量应答)、use-interrupt-handler.ts(ESC 中断)、use-conversation-switching.ts(/btw 与切 agent)等,点到为止即可——README 里每个都有一句话说明。
README 自己点出了一个对称关系:use-conversation-loop.ts 是 listener 那边 turn.ts 的交互版对应物。同一套回合语义,两个宿主各实现一遍,这是后面理解"为什么远程环境不是简单地复用 TUI"的前提。
4.2 斜杠命令路由:为什么有的命令能插队
用户敲的每一行都先过 useSubmitHandler。斜杠命令的分支在 use-submit-handler.ts:838-875,顺序是:
输入以 "/" 开头
│
├─ parseModSlashCommand → 查自定义命令 / mod 命令(它们覆盖内置)
│
├─ shouldSlashCommandBypassQueue(命令, {hasCustomCommand, modCommand})
│ (command-routing.ts:71)
│
└─ agent 正忙?
├─ 能插队 ─► 立刻执行
└─ 不能插队 ─► 报 "'/xxx' is disabled while the agent is running."
command-routing.ts 里只有两个集合和三个函数,判据非常朴素:
INTERACTIVE_SLASH_COMMANDS—— 会开浮层的命令(/model、/agents、/memory……)。理由写在文件头:让用户在 agent 干活时也能浏览,浮层里的修改会排队到回合结束。NON_STATE_COMMANDS—— 不改 agent 状态的命令(/usage、/export、/exit……),所以随时能跑。
自定义命令一律不能插队(hasCustomCommand 直接返回 false),mod 命令则自己声明 runWhenBusy。
4.3 两个取舍:内联 Ink 与 <Static>
取舍一:Ink 被就地打补丁。
package.json:156 依赖的仍是官方 ink@^5.0.0,但仓库带了一份 vendor/ink/,scripts/postinstall-patches.js:91-109 在 postinstall 阶段把它逐个文件复制进 node_modules/ink/build/。被替换的有:
| 被替换的文件 | 补什么 |
|---|---|
components/App.js | 抬高 stdin 监听上限、错误概览 |
hooks/use-input.js | Kitty CSI-u 序列、Linux 上 Shift+Enter 后抑制裸 Enter |
log-update.js | 满宽行跳过 eraseEndLine,绕开终端的延迟换行 bug |
wrap-text.js、devtools.js | 换行与调试 |
ink-text-input/build/index.js | 支持 externalCursorOffset |
脚本头一行就说了动机:"vendoring our Ink modifications without patch-package"。代价是 node_modules 被就地改写(装完才生效,bun install --frozen 之外的路径要小心),而且上游升 Ink 大版本时这几个文件得重新对齐。收益是不引入 patch-package 依赖,补丁内容是可读的完整文件而不是 diff。
取舍二:<Static> 转录只写一次。
StaticTranscript.tsx:44 把整个历史转录塞进 Ink 的 <Static>:
<Static
key={`${renderEpoch}-${hiddenToolCallId ?? ""}`}
items={items}
style={{ flexDirection: "column" }}
>
Ink 的 <Static> 一旦提交就冻结 props,永不重绘。好处是几百条历史消息不会随每次流式 token 重排——终端 scrollback 承担了存储,React 只管新增。坏处是想改历史就只能换 key 整体重挂,那会把全部历史重新打印一遍。
于是有了这段注释(StaticTranscript.tsx:36-41):lastShellToolCallId 故意不进 key。结果是"最后一条工具调用上的 ctrl+o 提示"在刚提交时正确,在更老的条目上可能过期——项目明确接受这个化妆品级的不一致,换取"不因为每次工具调用就重印历史"。
5. 非交互:headless 与 stream-json
5.1 两种形态
handleHeadlessCommand(src/headless.ts:709)按 --input-format 分岔:
| 形态 | 触发 | 行为 |
|---|---|---|
| 一次性 | 默认 | 从正位参数或 stdin 读一段 prompt,跑完退出 |
| 双向流式 | --input-format stream-json | 从 stdin 逐行读 JSON 消息,持续跑(headless.ts:3455 runBidirectionalMode) |
输出格式由 --output-format 定:text(默认)、json、stream-json(src/cli/args.ts:150-158)。
一次性形态里,没给 prompt 且 stdin 不是 TTY 时会把整个 stdin 读干当 prompt(headless.ts:778-786)——这正是"父 agent 用管道喂子 agent"的路径。
5.2 一个出口盖时间戳
src/stream-json-writer.ts 全文只有 61 行,却是个值得抄的小设计。它的存在理由写在文件头注释里(:1-22):
- 单一choke point:stream-json 模式下每一行都必须走
writeWireMessage(:31),不准散着写console.log(JSON.stringify(...)); - 统一盖
timestamp:ISO 8601 UTC,字段名和格式刻意对齐 Claude Code 与 Codex 的 stream-json,好让下游归一化器一视同仁; - 调用方不能忘:因为盖戳在写入函数里,新增调用点自动合规。
异步版 writeWireMessageAsync(:42)多返回一个 boolean:false 表示 stdout 已经 destroyed 或 ended,这一行根本没上线。注释点名了适用场景——子 agent 的最终 result 信封,父进程要靠它判断成败,不能把静默丢弃当成功。
5.3 headless 的凭据门槛
headless 跑 Cloud 时要求环境变量里有 LETTA_API_KEY,不接受保存下来的 OAuth(src/index.ts:1126-1138)。例外是 --ephemeral(临时会话可以复用已保存的 OAuth)、--dev-backend、以及本地后端。交互模式没有这条限制,没凭据会直接进 setup 流程。
6. 远程环境:同一套 runtime,两个方向
6.1 出站 vs 入站
出站(letta server):
本机 letta ──反向 WS──► Letta Cloud ──► ADE / 手机 / 桌面端
「我是一台可用的执行环境,有活派给我」
入站(letta server --listen):
桌面端 / 浏览器 / OpenAI 客户端 ──WS 或 HTTP──► 本机 letta(127.0.0.1:port)
「我在本机开了个口,你来连我」
两条路复用同一套东西:createRuntime()(src/websocket/listener/lifecycle.ts:290)造出 ListenerRuntime,attachOpenListenerSocket()(:510)把一条 socket 挂上去。startAppServer 里 options.runtime ?? createRuntime()(app-server.ts:177)——如果调用方已经有一个活的 runtime,就复用它。渠道网关正是靠这一点,把自己接进正在跑的 listener(见 §7.2)。
6.2 listener 的分层契约
src/websocket/listener/AGENTS.md 用几条硬规矩把这个目录钉住了,读代码前先读它能省很多力气:
- 唯一所有者:活跃回合状态只由
TurnLifecycle(turn-lifecycle.ts:86)持有,ConversationRuntime上的isProcessing、loopStatus、activeRunId全是只读投影——看runtime.ts:265-292,它们真的写成了 getter,转发给turnLifecycle。不准加 setter,不准加平行标志位。 - 四态互斥:
idle/command/active/cancelling。cancelling是个专门状态:UI 上投影成 idle,但队列仍然被堵住,直到那个租约结算。 - 租约规则:每个活跃回合有一个
TurnLease = { id, signal }。跨await之后必须重新isCurrent(lease)才能改状态或发事件;过期租约必须一声不吭。 - 终态规则:
finishListenerTurn()恰好收尾一次。分支结果用可辨识联合(continue | interrupted | terminal | error),不许退回terminated: boolean这种"调用方得猜谁已经收尾了"的形状。
requires_approval 被明确定义为延续边界而非终态:待批准仍然在同一个 active 租约里。这一条直接决定了批准环路的实现(见 03-permissions.md)。
6.3 关键模块
| 模块 | 职责 | 关键符号 |
|---|---|---|
runtime.ts | 进程级单例 + 按 (agent, conversation) 分片的会话 runtime | getActiveRuntime(:22)、createConversationRuntime(:235) |
connection.ts | 一个 runtime 挂多条连接,带订阅集与断线挂起 | openListenerConnection(:78)、suspendListenerConnection(:330) |
turn.ts | 一次入站消息的完整回合编排 | handleIncomingMessage(:89) |
turn-lifecycle.ts | 状态机本体、租约、转移 | TurnLifecycle(:86) |
queue.ts | 入站排队与"能否直通"的门禁 | shouldProcessInboundMessageDirectly(:219) |
approval.ts | 把批准请求发给订阅方并等应答 | requestApprovalOverWS(:371) |
recovery.ts | 进程重启 / 陈旧批准的恢复 | resolveRecoveredApprovalResponse(:388) |
memfs-sync.ts | 首次触达某 agent 时惰性同步记忆仓库 | ensureMemfsSyncedForAgent(:89) |
secrets-sync.ts | 服务端 secrets 的本地缓存水合 | ensureSecretsHydratedForAgent(:109) |
teleport.ts | 在环境之间搬会话 | handleTeleportRequest(:177) |
external-tools.ts | 把远端声明的工具桥成本地工具 | installExternalToolBridge(:100) |
下面挑四个不显然的说透。
排队门禁写成了一长串否定。 shouldProcessInboundMessageDirectly(queue.ts:245)不是"看起来空闲就直通",而是列了十来个条件,任何一个非零就走队列:队列长度、pump 是否在跑、pendingTurns、生命周期非 idle、待批准 resolver、待批准批次、恢复态、被中断的结果 / 上下文 / toolCallIds。宁可多排一次队,也不让直通路径撞上半收尾的状态。
批准要发给"订阅了这个 scope 的所有连接"。 requestApprovalOverWS(approval.ts:371)先取订阅者,空了就退回发起消息的那条连接,再空就退回 legacy connectionId(:388-404)。这是多前端同看一个会话的必然要求:你在桌面端发起,可能想在手机上点批准。
记忆同步是惰性的、可重试的。 memfs-sync.ts 的注释点名了一个真实事故:拉 agent 时忘了 include: ["agent.tags"],API 会对一个标了 tag 的 agent 返回空 tags,于是 listener 跳过 clone,把 agent 当白板跑(:26-31)。修法是硬编码这个 include。同一模块还做了两件事:per-agent 的 promise 合流保证并发触发只 clone 一次(:93-110),失败时从 map 里删掉让下一回合重试。
secrets 缓存用 while(true) 处理"取的时候被弄脏"。 ensureSecretsHydratedForAgent(secrets-sync.ts:109)有 60 秒新鲜窗、一个 dirty 集合、一个 in-flight map。妙处在于:等到别人的 in-flight promise 之后,它会再查一次 dirty,脏了就 continue 重跑,而不是把可能陈旧的结果交给调用方(:124-133)。
6.4 App Server:把本机 harness 变成端点
startAppServer(src/websocket/app-server.ts:162)默认绑 ws://127.0.0.1:0(端口随机)。它的安全姿势值得单列:
| 防护 | 做法 | 位置 |
|---|---|---|
| 非 loopback 必须带认证 | 没配 auth 就拒绝启动,直接抛错 | :168-174 |
拒绝带 Origin 的 HTTP 请求 | 一律 403 | :255-262 |
| 认证模式 | capability-token 或 signed-bearer-token(JWT) | app-server-auth.ts:66、:220 |
| 半开连接回收 | 30 秒 ping,90 秒没 pong 就 terminate | :42-43 |
拒绝 Origin 头是针对浏览器的:一个恶意网页可以向 127.0.0.1 发跨源请求,但它没法伪造/去掉 Origin。所以"带 Origin = 来自浏览器页面 = 拒绝"。
--openai-api 把每个 agent 当成一个 model。 打开后 isOpenAiCompatPath(app-server-openai.ts:63)接管三条路由:
| 路由 | 实现 |
|---|---|
/v1/models | handleListModels(app-server-openai-common.ts:219) |
/v1/chat/completions | handleChatCompletions(app-server-openai.ts:87) |
/v1/responses | handleResponses(app-server-openai-responses.ts:523) |
模型 id 的去歧义规则很讲究(buildAdvertisedModelMap,app-server-openai-common.ts:196-217):只有当 agent 名字在所有名字里唯一且不与任何 agent id 相同时,才用名字对外;否则用 agent id。这样"每个对外 id 恰好解析到一个 agent",而且用 agent id 直接查询永远不会被别人的名字劫持。
列表还刻意用异步迭代把所有页读完(上限 1000),注释解释了原因:只读第一页会在 agent 超过一页时静默丢失(:174-190)。
外部工具是反向的。 installExternalToolBridge(external-tools.ts:100)注册一个执行器:当模型调用一个由远端声明的工具时,listener 反向发 external_tool_call_request 给那条连接,然后等——超时 5 分钟(:22)。桌面端由此可以把自己的能力(比如 UI 操作)注入 agent 的工具集,而不用改 letta-code。
7. 消息渠道:插件契约 + 独立进程
7.1 插件契约
src/channels/README.md 定义了用户自定义渠道的落盘形状:
~/.letta/channels/whatsapp/
channel.json # 注册元数据
plugin.mjs # 实现
accounts.json # 账号(含 dmPolicy / allowedUsers / plugin 私有 config)
routing.yaml # 路由表
pairing.yaml # 配对状态
runtime/ # 由 letta channels install 装进来的运行时依赖
channel.json 四个字段承重:
| 字段 | 约束 |
|---|---|
id | 必须等于目录名,只能小写字母数字 _ - |
entry | 相对渠道目录解析 |
runtimePackages | letta channels install <id> 装进 runtime/ |
runtimeModules | 先找内置一方运行时,再找用户 runtime/ |
plugin.mjs 导出 channelPlugin(或 default),两个能力面:createAdapter(account) 返回一个有 start/stop/isRunning/sendMessage/onMessage 的适配器;messageActions 用来给共享的 MessageChannel 工具贡献动作和 schema 片段。加载在 plugin-registry.ts:325 loadChannelPlugin。
一方渠道(Slack/Telegram/Discord)住在 src/channels/<id>/,由内置注册表登记,可以有桌面端专属 UI 和兼容垫片;用户插件在这个 MVP 里刻意是 headless 的(README :302-310)。
7.2 进程模型:为什么要多一个子进程
letta server --channels telegram
│
├─ 主进程:listener runtime
│ └─ 起一个只绑 loopback 的 app-server ◄──── WS ─────┐
│ (listen.tsx:538 startAppServer) │
│ │
└─ spawn 子进程:letta channel-gateway --app-server-url … │
├─ ChannelGateway ───────────────────────────────┘
└─ Telegram / Slack / … 适配器 + 凭据
▲ 父子之间:子进程 stdout 的行前缀协议
CHANNEL_GATEWAY_READY / _RESPONSE {json} / _EVENT {json}
动机写在 gateway-core.ts:205-207 的类注释里:ChannelGateway 是"进程中立的桥,只说公开的 App Server 协议;渠道适配器和凭据留在注入的 hooks 后面"。落到工程上有三个好处:
- 凭据隔离 —— Telegram bot token、Slack app token 只活在子进程里;
- 崩溃隔离 —— 子进程异常退出触发
onUnexpectedExit(listen.tsx:558-561),不会把 listener 一起带走; - 协议是真的公开协议 —— gateway 用的接口和桌面端用的是同一套,不存在"内部捷径"。
监督者 startChannelGatewaySupervisor(gateway-supervisor.ts:66)负责拼命令行、spawn、按行解析三种前缀、30 秒命令超时、5 秒关闭超时。启动成功后,listener 把 runtime.serviceCommandHandler 接到监督者上(listen.tsx:575-587),于是"从 ADE 发一条 /channels 命令"能一路穿到子进程。
letta channel-gateway 本身(channel-gateway.ts:35)是内部命令:它要求 --app-server-url,起 startLocalChannelGateway,打印 CHANNEL_GATEWAY_READY 然后待命读 stdin 里的命令信封。
7.3 一条消息从平台到 agent
平台事件
│
① adapter.onMessage(msg)
│
② 门禁:evaluateChannelSenderAccess → allow | deny | pair
│ (access-control.ts:176)
│
③ 去抖:同 key 的连发合成一批
│ (inbound-debounce.ts:63)
│
④ 路由:getRouteForInboundMessage → {agentId, conversationId}
│ (routing.ts:209);没有路由就发配对码(pairing.ts:139)
│
⑤ 成形:buildChannelTurnSource + <channel-notification> XML
│ (processor.ts:34 / :53)
│
⑥ ChannelGateway.submit → App Server submit_input → 回合队列
(gateway-core.ts:248)
门禁的顺序是"最宽松优先",注释把它列成了五步(access-control.ts:153-175):allow-all 环境变量 → 有效allowlist(账号 allowedUsers + adminUsers + 两级环境变量,支持 *)→ 配对批准 → 才轮到 scope 策略。配对授权和 allowlist 是并集关系,不是二选一。
WhatsApp 和 Signal 的身份有多种写法(JID vs 手机号、UUID vs E.164),所以 allowlistMatches 对这两个渠道走归一化匹配(:120-138)。
路由的 key 是四元组:channel:account:chatId:threadId(routing.ts:42-48),thread 为空时归到 __root__。Telegram 私聊有个特例回退:带 threadId 找不到就退回 root 路由(:224-233)。
去抖保序。 createInboundDebouncer(inbound-debounce.ts:63)是从 openclaw 移植的"预留槽位"模型(文件头注释 :10-12):同 key 的立即项不能越过该 key 上等待 flush 的缓冲。这样"用户连发三条短消息"能合成一次回合,又不会让第四条乱 序插到前面。
批量消息包了标记。 formatBatchedChannelMessagesForAgent(processor.ts:68)在多条时包 --- Batched Channel Messages (n) ---,单条时原样透出。
7.4 回消息是一个工具
关键认知:渠道消息进来是"投递",出去是"agent 主动调工具"。 README 特意加了一段说明(:136-143):transcript 里能看到 <channel-notification> 但没有 MessageChannel 调用,通常是模型/提示词问题,不是适配器故障。
这条工具是动态拼出来的:
| 文件 | 干什么 |
|---|---|
message-channel-tool-definition.ts | 按当前活跃渠道拼 description 与 schema(比如没 Telegram 就删掉 send-rich 段落) |
message-channel-gateway-tool.ts:10 | buildGatewayMessageChannelTool:有路由 sources 就 scoped;没有就用可主动发起的 Slack 账号 |
message-channel-executor.ts:371 | executeMessageChannel:真正调适配器 |
message-channel-idempotency.ts:31 | 幂等域 |
幂等域的设计很克制:只压紧邻的重复(:26-29)。一旦有不同动作插进来,记忆就清空。而且压掉的重复会抛 MessageChannelDuplicateActionError,错误文案直接告诉模型"这条没发出去,别重试,继续你的回合"(:9-20)——给模型一个明确的失败,好过一个虚假的成功。
7.5 五个一方适配器
| 渠道 | 传输 | 一句话 |
|---|---|---|
| Slack | Bolt + Socket Mode | 应用级 WebSocket 收事件与斜杠命令;线程内首次参与后默认免 @,可按频道设 mention_only_channels |
| Telegram | grammY 长轮询 | 不需要 webhook,配置最简单,支持 send-rich |
| Discord | discord.js | 有独立的频道门禁与打字状态控制 |
Baileys(装进 runtime/) | JID/LID 身份归一化、媒体策略、重连调度 | |
| Signal | signal-cli JSON-RPC + SSE | 需要外部 daemon;self_chat_mode 是个人号的安全模式,只路由 Note to Self |
8. 定时:一台机器一个调度器
8.1 状态在文件里,不在内存里
定时任务存 ~/.letta/crons.json(cron-file.ts:124 getCronFilePath),读写走目录锁(acquireLock :343、withLock :401)。文件里除了任务数组,还有一个 scheduler_owner:
claimSchedulerLease() verifySchedulerLease(token) releaseSchedulerLease
(cron-file.ts:640) (:668) (:682)
│ │ │
写入 {pid, token, 每次 tick 都校验 停止时清空
started_at, 进程身份} pid+token 是否还是我
│
已有 owner 且进程还活着 → 抛错(拿不到租约)
owner 进程已死 / 就是自己 → 接管
isProcessAlive(:258)不只看 pid 存在,还比对捕获的进程身份,避免 pid 复用导致误判"别人还占着"。