跳到主要内容

数据截至 (上游 commit 7538cc96774b)

进程与协议边界:GUI 怎么把 agent 关在门外

30 秒导读: Kun 的桌面端不在自己进程里跑 agent。它把 agent 塞进一个独立子进程(kun serve), 那个进程只在 127.0.0.1 上开一个 HTTP 服务,GUI 隔着 bearer token 敲门、隔着 SSE 收结果。 这一章只讲这道墙:墙怎么砌、门怎么开、敲门声怎么验、进程死了怎么办。

本章不碰墙内的事:AgentLoop 控制流看 02,上下文拼装看 03, 工具执行与审批看 04,模型请求看 05,GUI 业务视图看 06


1. 这一章讲什么(零基础也能懂)

一句话定义: 这是 Kun 的"进程隔离层"——把会读写你磁盘、会执行 shell 命令的那部分代码, 从桌面 App 里搬出去,变成一个能单独启动、单独崩溃、单独重启的服务。

为什么要有这道墙

假设 agent 和 GUI 挤在同一个 Electron 进程里,三件事会同时变糟:

挤在一起的后果具体表现
一起崩agent 一个未捕获异常,整个窗口白屏,用户的对话记录连带丢
一起卡agent 读一个几百 MB 的历史文件,主线程阻塞,界面直接假死
一起有权限渲染进程里的任何一段代码(包括你渲染的 markdown)都离 fsspawn 只差一步

拆开之后,这三条各自有了解法:子进程崩了 GUI 还在、子进程卡了 GUI 只是转圈、 渲染进程连 fetch 那个端口的权限都没有(第 3.3 节会讲这是被 CSP 硬堵死的)。

它长什么样

Kun 的运行时本身就是一个可以裸跑的 CLI。GUI 只是它的一个客户端:

# 手起一个 runtime(GUI 平时也是这么起的,只是参数从设置里来)
node kun/dist/cli/serve-entry.js \
--host 127.0.0.1 --port 18899 \
--data-dir ~/.kun/data \
--model deepseek-chat

# 它会在 stdout 打一行握手标记,再打一份人类可读的启动信息
KUN_READY {"service":"kun","mode":"serve","host":"127.0.0.1","port":18899,...}

另一个终端就能敲它:

curl -s http://127.0.0.1:18899/health
# {"status":"ok","service":"kun","mode":"serve"}

curl -s http://127.0.0.1:18899/v1/threads \
-H "Authorization: Bearer $KUN_RUNTIME_TOKEN"

那个 Authorization 头现在是真的必须带。 0.3.0 起 insecure 是设置里一个显式的布尔位、默认 false (src/shared/app-settings-kun-defaults.ts:195),isAuthorized 在非 insecure 时要求 token 非空且匹配 (kun/src/server/auth.ts:8)。空 token + 非 insecure 时 /v1/* 一律 401;--insecure 是给本地开发留的显式旁路 (细节见 §3.3 的关卡③与 §7)。

一句话直觉

把 runtime 当成一台"本地微服务",GUI 当成它的浏览器前端。 唯一的特别之处是:这台微服务只对 127.0.0.1 开门,而且前端连直接访问它的网络权限都没有。


2. 顶层全景:三个进程 + 一个本地服务

怎么读这张图

从左到右是请求方向,从右到左是事件方向。注意渲染进程画在最左边、且没有任何一条线直接连到 HTTP—— 它必须绕主进程走,这是这张图最重要的信息。

① 渲染进程 (React) ② 主进程 (Electron main) ③ Kun 运行时子进程
┌──────────────────┐ ┌────────────────────────┐ ┌─────────────────────┐
│ 界面 / 会话状态 │ │ 进程管家 + 唯一出口 │ │ kun serve │
│ │ IPC │ │HTTP│ ┌───────────────┐ │
│ KunRuntime ├─────►│ 路径白名单 ├───►│ │ Router + 鉴权 │ │
│ Provider │ │ 加 Bearer token │ │ ├───────────────┤ │
│ │◄─────┤ SSE 拆帧 + 批量转发 │◄───┤ │ AgentLoop 等 │ │
└──────────────────┘ IPC └────────────────────────┘SSE │ └───────────────┘ │
没有网络权限 持有 token,不给前端 │ 只听 127.0.0.1 │
└──────────┬──────────┘

~/.kun/data(JSONL + SQLite)

部件一句话职责

部件干什么在哪个文件
serve-entry.tsCLI 入口:解析参数、启动服务、打 KUN_READY、装崩溃处理kun/src/cli/serve-entry.ts
createKunServeRuntime组装根:把所有适配器接到端口上,产出一个 ServerRuntimekun/src/server/runtime-composition.ts:15
buildRouter全部 HTTP 路由表 + 每条路由的鉴权kun/src/server/routes/index.ts:8
startNodeHttpServer把 Node 的 IncomingMessage 翻译成 Web Request/Responsekun/src/server/node-http-server.ts:14
buildEventStreamResponse唯一的出口方向:SSE 事件流(含回放与去重)kun/src/server/routes/events.ts:40
kun-process.tsGUI 侧进程管家:找二进制、拼 argv、spawn、等就绪、杀停src/main/kun-process.ts
runtimeRequestViaHostGUI 侧唯一的 HTTP 出口,负责挂 token 和失败重试src/main/runtime/kun-adapter.ts:354
registerRuntimeSseIpc主进程里的 SSE 客户端:拆帧、批量、断线重连src/main/runtime-sse-ipc.ts:178
KunRuntimeProvider渲染进程侧的契约适配器,只发 IPC 不发网络请求src/renderer/src/agent/kun-runtime.ts:187

主线走一遍(高层,不进代码)

用户点"发送"之后发生什么:

点击发送
→ 渲染进程 IPC: runtime:request POST /v1/threads/{id}/turns
→ 主进程校验路径在白名单里,挂上 Bearer token,fetch 本地端口
→ runtime 校验 token → 建 turn → 立刻返回 202 → 后台跑 loop
→ 渲染进程 IPC: runtime:sse:start(threadId, sinceSeq)
→ 主进程发起 GET /v1/threads/{id}/events
→ runtime 先回放 seq > since_seq 的历史,再挂上实时订阅
→ 事件按 100ms 批量经 IPC 推回渲染进程,界面开始出字

这条主线的关键在于"两条腿":写操作是一次普通的 HTTP 请求-响应(还立刻返回 202), 读结果完全走另一条长连接。请求那条腿短、幂等、可重试;结果那条腿长、可断、可回放。

真源码:startTurn 收下请求后经 onAdmitted 回调触发后台 loop,自己直接返回 202 (kun/src/server/routes/turns.ts:47-51)。路由里那个回调就是 runtime.runTurn(threadId, turnId),没有 await(kun/src/server/routes/register-thread-routes.ts:165-170)—— HTTP 请求和 agent 执行在这里就分家了。


3. 边界是怎么砌起来的

下面五个小节,依次回答:进程从哪来 → 起没起来算数 → 谁能敲门 → 结果怎么回来 → 死了怎么办。

3.1 进程从哪来:GUI 怎么找到并拉起 runtime

它要解决的小问题: 打包后的 App 里没有独立的可执行文件,runtime 是一坨编译好的 JS。 谁来执行它?

思路: 复用 Electron 自带的 Node。resolveKunExecutable 返回的 command 就是 process.execPath(即 Electron 本体),args[0]kun/dist/cli/serve-entry.js (src/main/resolve-kun-binary.ts:50)。再通过 ELECTRON_RUN_AS_NODE=1 让它退化成纯 Node (src/main/kun-process.ts:504)。

一个曾经的例外,0.3.0 已经消失: 早期 macOS 上开了 computer-use 时会故意ELECTRON_RUN_AS_NODE,让子进程以真 Electron 身份跑(libnut 首次抓屏会把纯 Node 进程提升成 Cocoa App、Dock 里多一个图标)。现在 computer-use 的输入动作整个搬进了主进程里一个独立的 桥接宿主,子进程永远以 Node 模式运行,不再有这个分叉(src/main/kun-process.ts:463-465:559-563;宿主见 src/main/computer-use/computer-use-host.ts:30)。

参数怎么传: buildKunServeArgs 是个纯函数,把设置拼成 argv(src/main/resolve-kun-binary.ts:112)。 看它拼了什么、没拼什么很重要:

配置项传输方式依据
host / port / data-dir / 策略命令行 argvsrc/main/resolve-kun-binary.ts:124-139
--insecure命令行 argv(仅当设置里显式打开)同上 :140
运行时 token环境变量 KUN_RUNTIME_TOKENsrc/main/kun-process.ts:527
模型 API key环境变量 DEEPSEEK_API_KEYsrc/main/kun-process.ts:468

两个密钥都走 env、不走 argv,因为 argv 在 ps 里对同机任意进程可见,env 不是。 runtime 侧的 parseServeOptions 对这两项的读取顺序是「argv → env → config 文件 → 默认值」 (kun/src/cli/serve.ts:136-146),所以 env 这条路是一等公民而不是后门。

端口冲突怎么办: resolveAvailableKunPort 有一套有优先级的策略 (src/main/kun-process-ports.ts:22):

想要 18899
├─ 当前托管子进程正占着它? ──► 就用它(别把活着的 child 搬走)
├─ 能 bind? ──► 就用它
├─ 占用者是我们上次崩掉的 kun? ──► 杀掉、夺回(killStaleKunOnPort:71)
└─ 都不行 ──► 18900、18901 … 顺次找一个空的

killStaleKunOnPort 只杀命令行看起来像自家 serve-entry 的进程;认不出来就绝不动手, 退回去换端口(src/main/kun-process-ports.ts:60-69 的注释明确写了这条安全约束)。

3.2 起没起来算数:两份证据,以健康检查为准

它要解决的小问题: 子进程 spawn 返回了,不代表 HTTP 服务能应答。过早认为就绪, 第一批请求就会 ECONNREFUSED

Kun 收两份证据:

证据来源说明什么
KUN_READY {json} 标记子进程 stdout进程活着、参数解析过了
GET /health 返回 {status:"ok",...}HTTP服务真的能应答

判定规则不对称,这是重点: 健康探针单独通过就足以判就绪;stdout 标记单独出现不够。 这不是靠注释约定,而是结构性的:settleReady 只有两个入口——健康探针确认,或者 「已见过 marker 根本没有健康探针在跑」(src/main/runtime/kun-runtime-health-monitor.ts:163-173)。 两个信号在 waitForKunStartup 里并行跑:一个 setInterval 每 500ms 探 /health (:97、:103、:124-126),一个 on('data') 扫 stdout(:169-173)。

/health 的响应体也被严格校验,不是"HTTP 200 就算"——必须三个字段全中 (src/main/kun-health.ts:10):

return record.status === 'ok' && record.service === 'kun' && record.mode === 'serve'

这样别的程序恰好占了这个端口并返回 200,不会被误判成"我们的 runtime 起来了"。

runtime 自己也自检一遍。 serve-entry.ts 在打 KUN_READY 之前,先 fetch 自己的 /health 直到通过或超时(kun/src/cli/serve-entry.ts:141 的调用,实现在 :303selfVerifyHealth)。 超时也只打一行 warning 继续走——它是加速信号,不是拦路虎。

超时窗口给得很宽: 15 秒下限、600 秒上限(src/main/runtime/kun-runtime-health-monitor.ts:5-6, resolveKunStartupTimeoutMs:80)。宽是有代价换来的:把一个"慢但活着"的启动打断, 只会让重启计时器重头再来,把一次慢启动放大成重启风暴(src/main/kun-process.ts:284-296waitForKunStartupSettled 注释明确写了这条理由,指向 issue #544)。

3.3 谁能敲门:三道关卡,一层比一层外

这是本章最值得抄走的部分。Kun 的鉴权不止 runtime 那一层。

先跟 04 的"多道闸门"划清界限。 那几道(沙箱 / 钩子 / 读后写 / 审批)管的是 一次工具调用能不能执行;这里三道管的是一个 HTTP 请求能不能发出去、发出去认不认。 两组是不同层的东西,不要串。

渲染进程 主进程 runtime
│ │ │
├─ 关卡① CSP ────────►│ │
│ 没有 connect-src, │ │
│ fetch 本地端口直接 │ │
│ 被浏览器拒绝 │ │
│ ├─ 关卡② IPC 路径白名单 ─────►│
│ │ 路径 + 方法不在表里就抛错 │
│ │ ├─ 关卡③ Bearer token
│ │ token 只在这一层持有 │ 逐路由校验

关卡① —— 渲染进程没有网络权限。 src/renderer/index.html:7 的 CSP 是 default-src 'self'; script-src 'self'; ... connect-src 'self'; ..., connect-src 被显式钉死为 'self'——渲染进程 fetch http://127.0.0.1:18899 会被浏览器直接拦掉。 窗口还开了 contextIsolation: true + sandbox: true(src/main/main-window.ts:63-64),preload 连 node:os 都 require 不了,home 目录只能靠 additionalArguments 传进去(src/main/main-window.ts:67src/preload/index.ts:11)。 结论:渲染进程想碰 runtime,只有 IPC 一条路,这不是约定,是被环境堵死的。

关卡② —— 主进程的路径白名单。 runtime:request 这个 IPC 通道不是透传。payload 先过一个 zod schema, schema 上挂了 .refine(isAllowedRuntimeRequest)(src/main/ipc/app-ipc-schemas/runtime.ts:265-276)。 白名单是 92 条编译好的路径模板,每条带自己允许的方法(:153-245):

compileEndpoint(KUN_THREAD_TEMPLATE, ['GET', 'PATCH', 'DELETE']),
compileEndpoint(KUN_THREAD_TURNS_TEMPLATE, ['POST']),

模板里的 {id} / {turn} 被替换成 [^/]+,其余字符转义(:137-146),所以 /v1/threads/../../etc 这类拼接过不了正则。匹配是首个命中即定音—— 所以 /v1/attachments/diagnostics 必须排在 /v1/attachments/{id} 前面,源码里正是这个顺序(:177-179)。

这层白名单比 runtime 的路由表窄。router 里有、白名单里没有的路径,渲染进程根本发不出去:

runtime 有但 GUI 白名单里没有谁在用
GET /v1/workspace/status主进程内部或外部客户端
POST /v1/threads/{id}/summarize同上
POST /v1/chat/completions · /v1/responsesOpenAI 兼容透传端点,不对渲染进程开放
GET /v1/threads/{id}/events走独立的 runtime:sse:start 通道,不走 runtime:request

关卡③ —— runtime 侧的 bearer token。/health 和少量扩展公共路径外,每条路由的第一行都是同一句 (如 kun/src/server/routes/register-thread-routes.ts:160-163):

router.add('POST', '/v1/threads/:id/turns', async (request, ctx) => {
if (!authorize(request, runtime)) return ERRORS.unauthorized()

authorize 转手调 isAuthorized(kun/src/server/routes/route-auth.ts:5kun/src/server/auth.ts:8),逻辑只有两行:

if (insecure) return true
return expectedToken.length > 0 && bearerToken(headers) === expectedToken

和早期版本相反:默认配置下这一层是开着的。 insecure 现在是设置里的显式布尔位,默认 false (src/shared/app-settings-kun-defaults.ts:195),isKunRuntimeInsecure 只看这个布尔、不再由空 token 推导 (src/shared/app-settings-kun-migration.ts:327-329),argv 里只有显式打开才会带 --insecure (src/main/resolve-kun-binary.ts:140)。空 token + 非 insecure 时 isAuthorized 一律返回 false—— token 是硬要求。兜底的第二层仍是绑定地址:默认 host 是 127.0.0.1 (kun/src/cli/cli-options.ts:55),GUI 侧更是硬编码 '127.0.0.1' (src/main/kun-process.ts:433),normalizeLocalKunHost 只接受 localhost / 127.0.0.1 / ::1, 其它一律抛错(src/main/kun-base-url.ts:11-15)。 所以本机之外敲不到;同机其它进程能不能敲——取决于用户填的 token 或是否显式打开了 insecure

3.4 结果怎么回来:SSE、回放、去重,和那个心跳坑

它要解决的小问题: 一个 turn 会持续几十秒到几分钟,期间不断产出增量。HTTP 响应回不了这个; 而且用户可能中途关窗口、切线程、GUI 重启——回来时不能丢事件,也不能重复。

思路:每个事件带一个单调递增的 seq,客户端只需要记住"我读到哪了"。

事件编码极简(kun/src/server/sse.ts:3):

return `id: ${event.seq}\nevent: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`

id: 就是 seq。于是标准 SSE 的 Last-Event-ID 头天然能用作游标。 服务端一个函数同时认两处来源,查询参数优先、且显式的 0 也算数 (parseEventCursor,kun/src/server/routes/events.ts:291-300):

const query = url.searchParams.get('since_seq')
const raw = query === null ? request.headers.get('Last-Event-ID') : query

一次订阅的四个阶段:

1. 订阅 eventBus.subscribe(threadId, deliver) —— 先挂上,回放期间先攒进缓冲
2. 回放 iteratePersistedEvents(sinceSeq) 分页迭代,逐条 deliver
3. 合并 把回放期间攒下的实时事件按 seq 排序补发,再切到直投模式
4. 心跳 每 15s 发一条 kind:'heartbeat'

(早期版本是"先查高水位决定要不要读文件";现在改为先订阅再分页回放,中间的缝用有界缓冲补上, 缓冲溢出宁可断流让客户端带游标重连——kun/src/server/routes/events.ts:132-157。)

去重靠一个连接内的高水位 lastDeliveredSeq(kun/src/server/routes/events.ts:106-131):

const deliver = (event: RuntimeEvent, frame?: Uint8Array): boolean => {
if (typeof event.seq === 'number' && event.seq <= lastDeliveredSeq) return false // 重叠部分丢掉
...
if (typeof event.seq === 'number') lastDeliveredSeq = event.seq
controller.enqueue(frame ?? frameFor(event))

为什么会重叠?因为"回放"和"订阅"之间有一个时间缝,恰好落在缝里的事件会同时出现在两边。 而它一定会出现在两边,是因为记录器坚持"先落盘、再广播"—— RuntimeEventRecorder.recordCommittedsessionStore.appendEvent,再 eventBus.publish (kun/src/services/runtime-event-recorder.ts:109-116)。顺序反过来才是真灾难: 先广播后落盘的事件,可能既没进回放(还没落盘)也没进订阅(还没订上),永久丢失—— 源码注释把这句写在类文档里了(:40-44)。

那个坑:心跳的 seq 必须复用高水位,不能新分配。 心跳事件是这么发的(kun/src/server/routes/events.ts:227-251):

encodeSseEvent({ kind: 'heartbeat', seq: lastDeliveredSeq, ... })

lastDeliveredSeq 而不是新 seq。原因在文件头注释里(:30-38):runtime 重启后, 内存里的 seq 计数器从头开始;如果心跳拿这些低 seq 去盖 id: 字段,客户端的游标就被倒推了, 下次订阅会带着一个很小的 since_seq 回来,于是整条线程的历史被重新回放进实时记录区。 一个心跳字段写错,表现是"聊天窗口莫名其妙把全部历史又滚了一遍"。

seq 是怎么分配的: InMemoryEventBus.allocateSeq 是纯内存计数 (kun/src/adapters/in-memory-event-bus.ts:99),重启即归零。所以 recorder 在每个线程的 第一次 record 时会去读一次磁盘高水位并缓存,取 max(allocated, floor+1) (kun/src/services/runtime-event-recorder.ts:144-161)——重启后不会跟历史 seq 撞车。 事件总线本身只保留每线程最近 256 条/4MB(:10-11),因为持久回放是 session store 的活,总线只管实时扇出

GUI 侧的对称实现。 主进程用普通 fetch 消费这条流,自己拆帧(takeSseBlock 同时认 \n\n\r\n\r\n,src/main/runtime-sse-ipc.ts:111),自己维护 nextSinceSeq, 断线后带着它重连,退避 750ms → 5s 封顶(:19-20)。 还有两个工程细节:

  • 按量批量 + 回执:事件攒进 pendingEvents,满 128 条或 512KB 就一次性 IPC 推给渲染进程, 渲染进程确认后才推进游标(MAX_SSE_BATCH_EVENTS/MAX_SSE_BATCH_BYTES,:24-25;批满即冲 :310-320)。 逐条推 IPC 在高频 token 流下会把渲染进程打爆,加了回执后也不用怕"推了但没收到"。
  • 区分致命与可重试:4xx(除 408/429)判为致命,直接报错不重连;其余一律退避重连 (isFatalSseStatus,:141-143)。token 错了就别死循环重连。

渲染进程侧只做一件事:从批次里取最大 seq 交给 sink.onSeq,把游标记在自己这 (src/renderer/src/agent/kun-runtime-services.ts:521)。它对 since_seq 的所有权,就是断线重连的基础。

3.5 死了怎么办:三种"死"分三种治法

Kun 明确区分了三种故障,而且给的处理不一样——这比"统一重启"要克制得多。

故障判定处理依据
后台 promise 拒绝unhandledRejection只打日志,进程继续跑kun/src/cli/serve-crash-handlers.ts:41-44
未捕获异常uncaughtException打 stderr → 有界优雅关闭 → 退出码 70同上 :40、:27(ServeExitCode.runtime = 70,kun/src/cli/serve.ts:293-297)
无异常但不应答GUI 侧 /health 连挂 3 次GUI 强制重启子进程src/main/kun-runtime-supervisor.ts:130-131

第一条最反直觉,也最值钱。 注释解释得很清楚:unhandledRejection 不会破坏进程状态, 一个 streamable-http MCP server 在重连途中断开这种事完全可恢复;为它掀掉整个 runtime、 让用户界面白掉,是"错误的取舍"(源码原文指向 issue #639)。 而 uncaughtException 是栈在半路展开的,进程状态确实不可信,所以才退出、交给 GUI 重启。

GUI 侧的监管有两条独立通道:

通道 A:进程真的退了
child.on('exit') ──► handleUnexpectedExit ──► 重启循环
└─ RestartBudget:60 秒内最多 3 次,退避 1s → 3s → 9s
└─ 预算用完 ──► state:'failed',停止自动重启,等用户手动

通道 B:进程还在但不应答
每 30s 一次 watchdogTick ──► /health 探测(5s 超时)
└─ 连续 3 次失败 ──► recoverFromWatchdog 重启

RestartBudget 是个滑动窗口 + 指数退避的小类(src/main/kun-runtime-supervisor.ts:38), note() 返回 {allowed, attempt, delayMs},窗口满了就 allowed:false,调用方据此熔断 (看门狗路径 src/main/kun-runtime-supervisor.ts:332-341,崩溃路径 :401-414)。看门狗触发的重启前, 还会检查有没有别的重启/设置应用正在进行,有就跳过这一拍(operations.hasPendingOperation(),:333), 避免几条路径同时杀同一个子进程。

看门狗最怕的一种误判,runtime 自己帮着排除了。 startEventLoopMonitor 每秒打一次心跳,发现自己比预定时间晚了 ≥2 秒就打日志 (kun/src/server/event-loop-monitor.ts:30-31:49-50)。于是"GUI 说我不健康"就能分成两种:

  • 有 stall 日志、进程还活着 → CPU 饥饿。某个同步步骤把事件循环占满了,日志里的毫秒数 就是 /health 无法应答的窗口。
  • GUI 说不健康,但一条 stall 日志都没有 → 硬挂死。心跳定时器自己都没再响过。

这两种的修法完全不同(前者优化那段同步代码,后者查死锁),所以这个只花一个 unref 定时器的 监控是划算的(注释指向 issue #621,kun/src/server/event-loop-monitor.ts:32-48)。同一个 issue 也是 冷读 loadItems 改用"按 id 去重 + 周期性让出事件循环"的原因 (readLatestItemsFromJsonl,kun/src/adapters/file/file-session-jsonl.ts:234)——那里就是曾经真实存在的 stall 源头, 慢读超过 1 秒会打警告(kun/src/adapters/file/file-session-store.ts:40:446-452)。


4. 路由面:一家一张注册表

0.3.0 把原来一张 300 行的路由表拆成了四个注册函数,buildRouter 按固定顺序调用 (kun/src/server/routes/index.ts:8-18):

注册函数管哪族路由
registerCoreRouteshealth / runtime / 模型连接 / 模型路由 / MCP / skills,并在此内再挂扩展平台公共与管理路由
registerGraphRoutesGraph Mode:graphs、graph-drafts、graph-projects 全家
registerResourceRoutesskills 配置 / supply-chain / attachments / background-shells / delegation / usage / migrations 等
registerThreadRoutesthreads 的 turns / events / goal / todos / review 等

Router 仍是注册序优先的极简实现:段数不等直接跳过,:param 段吃任意一段 (kun/src/server/router.ts:15 起),所以静态路径必须先于同形的参数路径注册 (例:GET /v1/attachments/diagnostics 先于 GET /v1/attachments/{id})。

4.1 路由族速览

路由总数已经涨到 200 余条,逐条列表没有读的价值,按族记即可:

路由族代表路径干什么鉴权
存活探针GET /health响应体带 service/mode
runtime 自身/v1/runtime/info/tools/config/apply/shutdown进程信息、诊断、热配置、停机
threads/v1/threads/{id}/fork/compact/rewind/summarize/state/timeline会话 CRUD 与操作
turnsPOST /v1/threads/{id}/turns(202 后台跑)、/steer/interrupt/tool-calls/{callId}/cancel一个 turn 的生命周期
SSEGET /v1/threads/{id}/events事件流(§3.4)
goal / todos/goal/todos线程目标与任务清单
工具面/v1/approvals/{id}/v1/user-input(s)/{id}/v1/skills审批、用户输入回填、技能
模型连接/v1/model-connections/*/v1/models/v1/model-routes/v1/provider-quotas连接管理、模型目录、路由池
OpenAI 兼容透传POST /v1/chat/completions/v1/responses给外部客户端当 OpenAI 端点用
Graph Mode/v1/graphs/*/v1/graph-drafts/*/v1/graph-projects/*多 agent 图编排(见第 2/6 章)
扩展平台/v1/extensions/*UI 插件的安装/视图/账号(公共路径除外)部分
其余资源attachments / memory / background-shells / delegation / migrations / supply-chain / usage / debug各子系统

路径模板在 GUI 侧集中定义了一份(src/shared/kun-endpoints.ts),渲染进程和主进程白名单 都从这一份派生,注释写明意图是"加一个 endpoint 只改一个文件"。

错误体形状统一,由一张常量表产出(kun/src/server/routes/runtime-error.ts:13): unauthorized 401、notFound 404、validation_error 400、conflict 409、 capability_unavailable 503、internal_error 500。路由没命中时兜底 404 (kun/src/server/http-server.ts:23),handler 抛异常时最外层兜底 500 (kun/src/server/node-http-server.ts:95-98)。


5. 进程随时会死,所以磁盘才是真相

边界能立住的前提是:进程是可丢弃的。做到这点,靠的是"状态全在磁盘、内存只是缓存"。

5.1 分层:哪些在内存,哪些在磁盘

内存(可丢) 磁盘(真相)
┌──────────────────────────┐ ┌───────────────────────────────────┐
│ InMemoryEventBus │ │ {dataDir}/threads/{id}/ │
│ └ 每线程最近 256 条 │ │ ├ thread.json 线程元信息 │
│ AppendOnlySessionLog │◄────►│ ├ messages.jsonl 全量条目 │
│ └ 窗口 1000 条 │ │ ├ events.jsonl 全量事件 │
│ FileSessionStore 缓存 │ │ └ session.json 会话快照 │
│ └ 最近 4 个线程的 items │ │ {dataDir}/threads/index.json │
└──────────────────────────┘ │ {dataDir}/index.sqlite3 (可重建) │
└───────────────────────────────────┘

AppendOnlySessionLog 是这个分层的教科书样本(kun/src/loop/append-only-session-log.ts:11): 类文档一句话说清——"有界内存窗口 + 全量磁盘回放,内存窗口是快路径,老事件被驱逐到文件存储"。 evict() 就是把数组切成最后 windowSize 条(默认 1000,:69-79)。 内存里没有的,loadEventsSince 一定读得回来。

5.2 写:小 JSON 原子换,大历史只追加

两种写法各管一摊:

数据写法为什么
thread.json / index.json / session.json写 tmp 再 rename小、要整体一致,不能读到半个 JSON
messages.jsonl / events.jsonlappendFile 直接追加大、只增不改,追加天然是原子的一行

atomicWriteFile 是那个"写 tmp 再 rename"的实现(kun/src/adapters/file/atomic-write.ts:17), 里面有两条为 Windows 打的补丁:rename 遇到 EPERM/EACCES/EBUSY带线性退避重试 (renameFileWithRetry,:62-79;可重试错误码表在 :15),重试还不行且平台是 win32 就退化成直接覆盖写 (:30-33 的 fallback 分支,判定函数 :91-93)。不完美,但比在 Windows 上随机报错好。

读 JSONL 时逐行 try/catch,一行坏了只跳过那一行,不让整次回放失败 (readJsonl,kun/src/adapters/file/file-thread-store.ts:150-168)。

5.3 SQLite 是索引不是真相

storage.backend 默认走 hybrid 分支(createPersistentStores,kun/src/server/runtime-factory-storage.ts:25-52), 组合出 HybridThreadStore + HybridSessionStore。类文档一句话定性: "JSONL 文件是权威的,SQLite 是可重建的索引;SQLite 写入永远发生在 metadata JSONL 追加之后" (kun/src/adapters/hybrid/hybrid-thread-store.ts:44-46)。

HybridSessionStore 的实现把这句执行得很干净——它压根没有自己的存储,只是包住 FileSessionStore 并在写成功之后补一次索引(kun/src/adapters/hybrid/hybrid-session-store.ts:39-41):

async appendEvent(threadId: string, event: RuntimeEvent): Promise<void> {
await this.delegate.appendEvent(threadId, event) // 先落 JSONL
await this.index.noteEvent(event) // 再更新 SQLite
}

读的时候索引与文件取最大值(highestSeq,kun/src/adapters/hybrid/hybrid-session-store.ts:126-136): SQLite 高水位只是"快提示",注释写明中断可能让索引落后,绝不能让它压低文件高水位—— 否则 SSE 会复用 seq、跳过已落盘事件。

索引坏了不会拖垮 runtime。 ready()/初始化整个包在 try/catch 里,better-sqlite3 加载或建库失败就把 this.db = null,退回纯 JSONL 扫描 (kun/src/adapters/hybrid/hybrid-thread-store.ts:82,缺失索引可从文件重建的注释在 :104)。 可选的原生依赖不该是硬依赖。

5.4 重启后的三次补救

startKunServelisten 成功之后、不阻塞就绪地跑一批扫尾工作 (kun/src/server/runtime-server-start.ts:112-119reconcileRuntimeAfterRestart, 实现在 kun/src/server/runtime-restart-reconciliation.ts:21-82):

补救治什么依据
reconcileOrphanedChildRuns()上个进程遗留的 queued/running 子 agent 记录结掉runtime-restart-reconciliation.ts:27
reconcileOrphanedTurns()上次崩溃时卡在 running 的 turn 标记为 failed,客户端别再空转同上 :37
resumeInterruptedGoals / resumeInterruptedTurns被打断的 goal / turn 自动续跑,别让它永远"进行中"同上 :58-72

第四件在组装期就做了:seedUsageCarryover 把每个线程最后一次 usage 快照喂回 UsageService,让重启后的费用统计接得上(kun/src/server/runtime-factory-storage.ts:58)。 它先试 loadLatestUsageSnapshots,失败才退回逐线程扫 JSONL(:71-84)—— 又一次"索引优先、文件兜底"。

关闭方向值得注意顺序:close 先停内存压力监视器,再 runtime.shutdown(收内部资源), 最后server.close() 断 HTTP(kun/src/server/runtime-server-start.ts:145-151)—— 先让在飞的请求收尾,再拆插座。


6. 巧妙之处(可以抄走的)

1. 就绪判定看"能应答",不看"进程起来了"。 两个信号并行收,但结构上只有健康探针能单独定案(src/main/runtime/kun-runtime-health-monitor.ts:163-173)。 且探针校验响应体三字段,防止端口被别人占了还误判成功(src/main/kun-health.ts:10)。

2. 秘密走环境变量,配置走 argv。 token 和 API key 只经 KUN_RUNTIME_TOKEN / DEEPSEEK_API_KEY 传递 (src/main/kun-process.ts:527:524),ps 里看不到。

3. 用 CSP 把"渲染进程不许发网络请求"变成硬约束。 connect-src 'self' 显式写进 CSP(src/renderer/index.html:7)。 这比在代码评审里喊"不要直接 fetch"可靠一个数量级。

4. IPC 白名单比服务端路由表窄。 主进程只放行渲染进程真正需要的 92 条路径+方法组合 (src/main/ipc/app-ipc-schemas/runtime.ts:154-247),而 runtime 侧注册了 200 余条路由—— 差出来的那些诊断类、透传类接口不对前端开放。

5. 持久化先于广播,是 SSE 不丢事件的前提。 RuntimeEventRecorder.recordCommitted 的两行顺序不能换 (kun/src/services/runtime-event-recorder.ts:109-116),原因写在类文档里(:40-44)。

6. 心跳复用高水位 seq。 seq: lastDeliveredSeq(kun/src/server/routes/events.ts:242)。一个字段写错, 症状是"重启后整条历史被重放进实时区",而且极难复现——值得专门记住。

7. 把"卡"和"死"当成两种故障来观测。 事件循环监视器只花一个 unref 定时器,却让看门狗重启这件事变得可归因 (kun/src/server/event-loop-monitor.ts:32-50)。

8. unhandledRejection 不杀进程。 区分"状态被污染"和"只是一个后台 promise 挂了",后者继续跑 (kun/src/cli/serve-crash-handlers.ts:9-19:40-44)。

9. 端口冲突优先"夺回"而不是"改号"。 只杀命令行认得出的自家 stale 进程,认不出就换端口 (src/main/kun-process-ports.ts:60-83)。

10. 慢启动不打断。 waitForKunStartupSettled 让设置变更路径等待在飞的启动,而不是 SIGTERM 它 (src/main/kun-process.ts:284-296)。


7. 边界与局限(诚实的部分)

--insecure 是显式的本地开发开关,开着就全放行。 设置里 insecure 默认 false (src/shared/app-settings-kun-defaults.ts:195),但一旦打开,isAuthorized 直接返回 true (kun/src/server/auth.ts:8-9),同机上的其它本地进程可以直调全部 /v1/*——包括会执行命令的那些。

token 比较不是恒定时间的。 bearerToken(headers) === expectedToken 是普通字符串比较 (kun/src/server/auth.ts:10)。本地场景下时序攻击不现实,但它确实不是恒定时间实现。

请求体有上限,但按端点分了档。 默认控制面 JSON 请求限 1MB (DEFAULT_MAX_JSON_BODY_BYTES,kun/src/server/read-json-body.ts:8-10);GUI 侧的 IPC 层另有 2,000,000 字节上限 (MAX_BODY_BYTES,src/main/ipc/app-ipc-schemas/common.ts:2)。

单进程、单事件循环。 所有线程共用一个 runtime 进程。一个线程的重活会阻塞其余全部线程 的 /health 和 SSE——这正是 event-loop monitor 存在的原因,而不是它已经被解决的证据。

JSONL 只增不减(usage 事件除外)。 只有 usage 事件在 events.jsonl 超过 5MB 时会被 按天+模型合并压缩(DEFAULT_USAGE_EVENT_COMPACTION_MAX_BYTES,kun/src/adapters/file/file-session-store.ts:37; 执行在 compactUsageEventsIfLarge,同文件 :678)。 messages.jsonl 会一直长,冷读时间随之增长——源码里那句 "took Nms for X raw → Y items" 的警告(:446-452)就是给这个准备的。

没有 CORS 处理。 router 里没有 OPTIONS 路由,也没有任何 Access-Control-* 头。 设置里那个 extraCorsOrigins 字段是旧版遗留(src/shared/app-settings-kun-defaults.ts:119:151), 在本 commit 的服务端代码里找不到消费方。浏览器直连这个 runtime 用不了。

insecure 没有和绑定地址联动校验。 若有人把 --host 改成 0.0.0.0 并保持 insecure, runtime 不会拒绝启动。GUI 走不到这条路(host 硬编码 127.0.0.1),裸跑 CLI 可以。


8. 代码地图(导航索引)

Runtime 侧(克隆内 kun/)

主题文件路径关键符号
CLI 入口 / 握手 / 信号kun/src/cli/serve-entry.tsmainserveMainKUN_READY_PREFIXselfVerifyHealth
参数解析(argv→env→config)kun/src/cli/serve.tsparseServeOptionsparseServeOptionsSafeServeExitCode
参数 schema 与默认值kun/src/cli/cli-options.tsServeOptionsSchemaDEFAULT_SERVE_PORTDEFAULT_SERVE_OPTIONS
崩溃分级处理kun/src/cli/serve-crash-handlers.tsinstallServeCrashHandlers
组装根(拆分后的接线族)kun/src/server/runtime-composition.ts 及同目录 runtime-composition-*.tscreateKunServeRuntime
启动 / 关闭顺序 / 重启补救kun/src/server/runtime-server-start.tsstartKunServereconcileRuntimeAfterRestart
持久化组装 / usage 播种kun/src/server/runtime-factory-storage.tscreatePersistentStoresseedUsageCarryover
HTTP 适配(Node ↔ Fetch API)kun/src/server/node-http-server.tsstartNodeHttpServertoFetchRequestwriteFetchResponse
分发与 404 兜底kun/src/server/http-server.tsdispatchRequest
极简路由匹配kun/src/server/router.tsRouterRouter.match
鉴权kun/src/server/auth.tsisAuthorizedbearerToken
JSON 响应封装kun/src/server/response.tsjsonResponseJsonResponse
错误常量表kun/src/server/routes/runtime-error.tsERRORSerrorResponse
路由注册(一族一文件)kun/src/server/routes/index.tsregister-*-routes.tsbuildRouterregisterCoreRoutes
存活探针kun/src/server/routes/health.tshealthJsonResponse
SSE 流:回放 / 去重 / 心跳kun/src/server/routes/events.tsbuildEventStreamResponseHEARTBEAT_INTERVAL_MSparseEventCursor
SSE 帧编码kun/src/server/sse.tsencodeSseEvent
turn 的 202 契约kun/src/server/routes/turns.tsstartTurnsteerTurninterruptTurn
路由依赖集合kun/src/server/routes/server-runtime.tsServerRuntime
事件循环停顿观测kun/src/server/event-loop-monitor.tsstartEventLoopMonitorresolveEventLoopStallThresholdMs
落盘先于广播kun/src/services/runtime-event-recorder.tsRuntimeEventRecorder.recordnextSeq
实时扇出 + seq 分配kun/src/adapters/in-memory-event-bus.tsInMemoryEventBusallocateSeqMAX_RETAINED_EVENTS_PER_THREAD
有界内存窗口kun/src/loop/append-only-session-log.tsAppendOnlySessionLogevict
JSONL 会话存储kun/src/adapters/file/file-session-store.ts · file-session-jsonl.tsFileSessionStorereadLatestItemsFromJsonlcompactUsageEventsIfLarge
JSON 线程存储kun/src/adapters/file/file-thread-store.tsFileThreadStorereadJsonl
原子写 + Windows 退避kun/src/adapters/file/atomic-write.tsatomicWriteFilerenameFileWithRetry
SQLite 索引(可重建)kun/src/adapters/hybrid/hybrid-thread-store.tsHybridThreadStorereadynoteEventgetEventSeqHighWater
索引与文件取最大kun/src/adapters/hybrid/hybrid-session-store.tsHybridSessionStoreappendEventhighestSeq

GUI 侧(克隆内 src/)

主题文件路径关键符号
进程管家:spawn / 就绪 / 停止src/main/kun-process.tsstartKunChildprepareKunLaunchstopKunChildAndWait
端口策略 / stale 进程夺回src/main/kun-process-ports.tsresolveAvailableKunPortkillStaleKunOnPort
启动就绪双信号src/main/runtime/kun-runtime-health-monitor.tswaitForKunStartupresolveKunStartupTimeoutMs
二进制定位 + argv 拼装src/main/resolve-kun-binary.tsresolveKunExecutablebuildKunServeArgs
本地 base URL(只许 localhost)src/main/kun-base-url.tsgetKunBaseUrlnormalizeLocalKunHost
健康响应体校验src/main/kun-health.tsisKunHealthResponseBody
崩溃预算 / 退避 / 熔断 / 看门狗src/main/kun-runtime-supervisor.tsRestartBudgetRestartVerdict
唯一 HTTP 出口 + token 注入src/main/runtime/kun-adapter.tskunRuntimeAdapterruntimeRequestViaHostruntimeAuthHeadersgetRuntimeBaseUrlForSettings
IPC 路径白名单src/main/ipc/app-ipc-schemas/runtime.tsENDPOINTScompileEndpointisAllowedRuntimeRequestruntimeRequestPayloadSchema
SSE 客户端:拆帧 / 批量 / 重连src/main/runtime-sse-ipc.tsregisterRuntimeSseIpctakeSseBlockparseSseDataisFatalSseStatus
沙箱化桥接src/preload/index.tscontextBridge.exposeInMainWorld('kunGui', api)
渲染进程契约适配器src/renderer/src/agent/kun-runtime.tsKunRuntimeProvidersubscribeThreadEventsconnect
IPC 薄封装src/renderer/src/agent/runtime-client.tsRendererRuntimeClientrendererRuntimeClient
DTO 形状 / 映射src/renderer/src/agent/kun-contract.ts · kun-mapper.tsCoreThreadJsonCoreRuntimeEventJsondispatchKunRuntimeEvents
渲染侧游标维护src/renderer/src/agent/kun-runtime-services.tssink.onSeq
端点路径单一来源src/shared/kun-endpoints.tsKUN_THREAD_TURNS_TEMPLATEkunThreadEventsPathkunThreadPath
鉴权开关判定(显式 insecure)src/shared/app-settings-kun-migration.tsisKunRuntimeInsecure
runtime 默认设置src/shared/app-settings-kun-defaults.tsdefaultKunRuntimeSettings

下一章: 请求越过这道墙之后会发生什么 —— 02 Agent Loop:一个 turn 从出生到收尾