Skip to content

G3|App-server 与 Daemon:一只插座,不是每端一只 Session ​

先别把「前端只适配」读成「每个客户端 embed 一份 core」 ​

E1 说智能在 Session,前端只投影。读到 start_embedded_app_server 的人,下一步几乎都会以为 TUI、IDE、Python SDK 各自在进程里 ThreadManager::new。然后「共享 thread」被理解成拷贝 JSONL,「关 TUI」被理解成杀掉所有会话。

判断先说清楚:本地图形客户端和 Python SDK 的真边界是 app-server 的 JSON-RPC v2。 Session 仍只有一份循环(B1)。插座可以嵌在当前进程里,也可以是本机 daemon 上那只长寿命进程。embed 和 daemon 是两种进程模型,不是两套 run_turn。

E1 讲谁连谁。这一章讲协议版本、进程怎么活、turn 在哪一侧重。


1. 现行面是 v2;v1 还在,但不要往上加 ​

方法名是 <resource>/<method>,资源名单数:thread/start、turn/start、thread/resume(app-server-protocol/src/protocol/common.rs:559-569)。payload 在 protocol/v2/:thread、turn、notification、plugin、mcp、remote_control……#[ts(export_to = "v2/")] 把生成的 TypeScript 钉在同一命名空间(例如 v2/notification.rs:10)。

v1 仍在 protocol/v1.rs,给旧客户端。仓库纪律写在 AGENTS.md:新 API 只加 v2。不要在 v1 上开一个「比较方便的」方法,让 IDE 和 TUI 永远对不齐。

传输垫片在 app-server-transport/:stdio、UDS、启动锁、daemon 关机套接字。它不管模型,只保证「同一只插座怎么被连上」。

没有「v2 才是边界」会怎样?每个前端自己发明 RPC,或者继续往 v1 堆字段,TS 生成物和 Rust 枚举各长各的。E1 的「Agent 实现一次」前提就是这一份协议。


2. embed 和 daemon:同一份 server,两种寿命 ​

TUI 的 AppServerTarget 三种(tui/src/lib.rs:300-304):

目标进程thread 跟谁走
Embedded当前 TUI / exec 进程内这个进程死,in-process server 一起死
LocalDaemon本机长寿命 app-server关 TUI,thread 还在 daemon 里
Remote另一台机器上的 host工作区在那边;本地只是遥控器(E3)

app-server-daemon 文件头(app-server-daemon/src/lib.rs:1):管的是 跨 CLI 调用和 updater 串行化的生命周期。pid 文件、daemon.lock、settings.json 住在 CODEX_HOME/app-server-daemon/(:41-43)。LifecycleCommand 是 Start / Restart / Stop / Version(:46-50),不是第三种 TaskKind。

所以「共享 thread」的意思是:两个客户端连 同一只 daemon 上的同一份 Thread Store(G2),不是把 rollout 拷来拷去。codex agents 浏览的就是这只共享宿主上的会话。

没有 daemon 这一档会怎样?要么每个 TUI 窗口各养一只 Session,同一条 thread 写穿;要么关终端等于丢掉正在跑的 Goal。embed 适合「我就是这次任务」;daemon 适合「宿主比窗口活得长」。


3. 探测、上锁、升级:TUI 不会默默养一只 daemon ​

TUI 启动会 maybe_probe_default_daemon_socket(tui/src/lib.rs:461-496):对着 CODEX_HOME 下的 control socket 连一下,超时 50ms(:267-268)。连上了才走 LocalDaemon;socket 不在或超时,测试钉死 skip(:2850-2856),改 embed。探测不是 LifecycleCommand::Start。敲 codex 不会在后台偷偷拉起一只长寿命 server。Windows 连上之后还要 ensure_non_elevated_peer(:479-480):对端提权了就当没探测到,避免高权限 daemon 被普通 TUI 误接。

谁才养 daemon?

入口干什么
codex app-server 无子命令前台跑一只 server(默认 stdio://,cli/src/main.rs:552-571)
codex app-server daemon start|restart|stop|version走 LifecycleCommand(:789-808);daemon.lock 挡住 CLI 和 updater 同时动手
codex remote-control同一只 server,打开远程控制(E3)
--managed-daemon关机时把已加载 thread 落盘(:581-583)

update_loop.rs:1 管的是定时/手动安装、重启、替换二进制。升级和 Start 抢同一把锁,避免「一边 stop、一边新版本 start」写穿 pid。thread_recovery.rs:1 更窄:计划中的替换之前丢掉陈旧 snapshot,不是 A2 的 resume。

没有「先探测、再上锁」会怎样?每个 TUI 窗口 Start 一次,本机一堆 app-server 抢同一份 Thread Store;或者升级时旧进程没 drain 完,新进程接着写。寿命是运维协议,不是 Session 的 Op。


4. 准入、费用、通知在插座这一侧 ​

TurnAdmission(app-server/src/turn_admission.rs:1)跟踪的是:关机 drain 开始之后,还准不准新的 turn/start。begin_drain 把大门关上(:35-39);widget 看不见这把锁。IDE 点发送被拒,原因是 server 在收工,不是模型忙。

费用和用量回放在 turn_cost_worker.rs 这类 worker 里,不在 chatwidget。notification 类型(鉴权恢复、媒体、thread 事件、skills/changed)从 v2 出去,前端只投影。B1 的 EventMsg 仍是内核;插座负责把它收成 JSON-RPC notification,并在多客户端之间扇出。Skill 目录怎么被 FileWatcher 盯住、为什么当前枪不改写,见 D1 第 5 节——插座只扇出,不拥有发现。

没有「准入在插座」会怎样?每个前端自己数「现在有几个 turn」,drain 时有人还在 turn/start,daemon 停不干净。关窗和关循环必须是协议,不能是 widget 的 drop。


5. Remote Control 看的是这只 host,不是第二套 runtime ​

codex remote-control 启动的是 带远程控制开关的同一只 app-server(E3)。配对、手机看桌面,连的是 G3 这只插座,不是云端 chatgpt.com 的编排。E3 的缝仍然是 git apply;G3 不负责云端沙箱。

抄多前端时:先抄 v2 方法和进程模型(embed vs daemon),再抄 Remote 的配对 UX。不要为「手机遥控」再实现一遍 run_turn。

没有这层切割会怎样?Remote 被做成第三条循环,审批和 rollout 在手机上另存一份。遥控器只该看 host。


6. 结语:带走一句话 ​

多前端抄的是 v2 插座和 embed/daemon 两种寿命——TUI 只探测已有 socket,不默默 Start;turn 准入和通知在 server 这一侧,widget 只投影;不要每个客户端再 embed 一份 run_turn。