feat/session-reconfigure:给运行中的会话热改模型 / 权限 / 思考强度,外加模型发现
eyrie · origin/main...feat/session-reconfigure(merge-base 99c04e1e) · 2026-07-08 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1TL;DR
这个 PR 给 agent 会话加了一个新的运行时操作:reconfigure——在不丢弃 provider 上下文(不重开会话、不清历史)的前提下,改掉会话的模型、权限模式(permissionMode)或思考强度(effort)。一次调用只改一个字段。
三个 provider 走三条不同的落地路径:Claude 通过 stdin 的 control_request 热改活体进程;Codex 把新设置攒进下一个 turn/start 的 payload(下一轮生效);ACP 走 provider 自己的 session/set_config_option 接口。附带做了模型发现:Codex 通过 model/list 分页拉、ACP 开一个短会话读它的 config 面。
动机(从 commit 推断,无随附文档):会话创建后模型/权限就固化了,用户改设置只能销毁重建、丢掉对话上下文。reconfigure 让这些成为可热调的旋钮。桌面端 UI 曾在分支中间做过(FloatingComposer、codex-native 控件),但在两次 merge main 后又被抹掉——净 diff 里没有 desktop 源码改动,这是一个纯 daemon + API 契约的 PR。
2变更地图(称重)
4,198 行里约 62% 是测试。设计承载的生产代码集中在 apps/daemon/src/agent 的三个 provider 适配层 + service 编排层;packages/api 那 171 行几乎全是给新端点搭 schema/DTO/tRPC 契约。
| 子系统 | 设计重心(细读) | 可放心略过 |
|---|---|---|
| service 编排 | service.ts 的 reconfigureUnlocked + 底部一堆自由函数(校验、持久化拆分、四桶协调) | 文件里 approval / input-request / teardown 大段是既有代码,未改 |
| Claude | runner.ts 的 reconfigure + control_response 通道;reconfigure.ts(新) | control-protocol.ts 删掉的 buildClaudeHooks(搬走了,不是逻辑变更) |
| Codex | index.ts 的 turnSettings/permissionSettings/listCodexModels;reconfigure.ts(新) | module.ts 只是填了三条内置模型 |
| ACP | discovery.ts(新,192 行);runner.ts 的 applySessionConfigOptions + startup config;json-rpc-connection.ts 的 closed-for-writes | protocol.ts 全是新增类型声明,只看形状 |
| API | schemas.ts 的 reconfigureSessionSchema(单字段 + model 归一化) | dto.ts 一行注释、trpc.ts 机械接线 |
3架构一图流
核心结构变化:AgentProvider 和 AgentRunner 两个接口各长出了 reconfigure 相关的新方法,service 由此获得一条「先问 provider 能不能接、再落库、再让活体 runner 协调」的新链路。
以前 · 会话配置在创建时固化
现在 · reconfigure 双写(库 + 活体)
4数据与状态先行
reconfigure 全链路围着几个新类型转,先把形状认清,后面旅程就只讲行为。
ReconfigureParams 是最松的一层:一个 Record<string, unknown>。service 不认识 provider 的字段名,原样透传给 runner——provider 自己负责认字段。
// provider 拥有的运行时参数,原样转发给活体 runner
export type ReconfigureParams = Record<string, unknown>
// provider 落库前的校验结果
export type ReconfigParamsCheck = {
accepted: ReconfigureParams // 本 provider 支持、可安全落库+转发的键
unsupported: string[] // 本 provider 根本没有的运行时旋钮
invalid: string[] // 支持的键但值过不了 provider 校验
}
// runner reconfigure 的结果:四个桶联合覆盖每个提交键,互不重叠
export type ReconfigureResult = {
applied: string[] // 热改进了活体进程
unsupported: string[] // 本 provider 不支持
deferred: string[] // 已落库,下一轮 / 下次 spawn 生效
failed: string[] // provider 没能接受或没能提交
}
配套三个 helper(都在 types.ts)供 leaf runner 直接返回,不必自己拼四桶:deferReconfigure(全 deferred)、rejectReconfigure(全 unsupported,ACP 关闭态用)、failReconfigure(全 failed)。放在 types.ts 而非 providers/ 下,是为了让叶子适配器不用越过 provider-internals 边界就能共享。
接口新增点——AgentProvider 长出 checkReconfigParams / validateRunnerConfig / 可选 listModels;AgentRunner 长出 reconfigure;AgentRegistry 长出可选 listProviderModels。这几个方法是本 PR 所有旅程的挂钩点。
ACP 侧新增协议形状(protocol.ts,只看形状):AcpSelectConfigOption 是 provider 报上来的一个「下拉选择」控件,带 category(如 model、thought_level)、id(回写时用)、options(可选值,可能分组)、currentValue。Eyrie 把「改模型」映射成「设置 category=model 的那个 select 控件」。
export type AcpSelectConfigOption = {
type: 'select'
id: string // session/set_config_option 回写用
category?: string | null // 语义类别:model / thought_level
options: Array<AcpSelectConfigChoice | AcpSelectConfigChoiceGroup>
currentValue?: string | null
[key: string]: unknown
}
5底座:一次 reconfigure 的骨架
三条 provider 旅程都从同一个 service 入口进来,走同一套「锁 → 校验 → 落库 → 活体协调 → 四桶归并」骨架。这一节把共用骨架走一遍,各 provider 的特有逻辑留到各自旅程。
service.ts reconfigure→ 会话锁
withSessionRunnerLock→ provider 校验
checkReconfigParams→ 落库
patchSessionConfig→ 活体协调
applyLiveReconfigure→ 归并
reconcileCommitted
5.1校验 → 持久化 → 活体协调
入口 reconfigure 只做一件事:把整个流程放进 withSessionRunnerLock。这把锁是既有的 per-session 串行队列,作用是让「读会话配置 → 建 runner → 写配置」这一串对同一会话不并发——否则一个 reconfigure 和一个 startTurn 可能同时在建 runner,造成重复进程。
锁内的 reconfigureUnlocked 按固定次序推进。第一层是空值与 provider 可用性把关:
const normalizedParams = normalizeReconfigureParams(params) // 只对 model 做 trim + 长度校验
// 空 patch 直接拒——否则调用方会把 no-op 误读成"改成功了"
if (Object.keys(normalizedParams).length === 0) {
throw new AppError({ code: EyrieErrorCode.validation.failed })
}
const provider = await this.repo.getProvider(session.providerId)
assertUsableReconfigureProvider(provider) // enabled + kind/transport 合法,否则 notFound
const handleOrCreating = this.runnerManager.getOrCreating(sessionId) // 活体 or 正在建 or null
const runtimeProvider = await this.registry.getProvider(session.providerId)
const paramsCheck = runtimeProvider.checkReconfigParams(normalizedParams)
if (paramsCheck.invalid.length > 0) throw reconfigureValidationError(paramsCheck.invalid)
第二层:如果 provider 一个键都没接受(全 unsupported),提前返回一个「什么都没改」的结果,不落库。这是把「provider 压根没这个旋钮」和「值非法」区分开——后者上面已经抛 400 了。
第三层是关键顺序——先用 provider 的完整启动校验试跑一遍假想的 post-patch 配置,再落库。validateReconfigureRunnerConfig 把 session 现有配置和新参数合成一个完整 RunnerConfig,扔给 provider.validateRunnerConfig(内部复用 provider 正常 spawn 时的归一化校验,但不真的起进程)。过了才写库:
validateReconfigureRunnerConfig(runtimeProvider, session, paramsCheck.accepted)
const updated = await this.persistReconfigure(session, paramsCheck.accepted) // 先落库
if (!handleOrCreating) {
// 没有活体 runner:全部 deferred,下次 spawn 时从库里读到
return { session: updated,
result: mergeUnsupported(await deferReconfigure(paramsCheck.accepted), paramsCheck.unsupported) }
}
const result = await this.applyLiveReconfigure(handleOrCreating, paramsCheck.accepted) // 有活体:让它协调
const committed = reconcileCommittedReconfigure(paramsCheck.accepted, result)
if (committed.markStale) this.runnerManager.markStale(sessionId) // 活体应用出错 → 标记下轮重建
return { session: updated,
result: mergeUnsupported(committed.result, paramsCheck.unsupported) }
persistReconfigure 把 accepted 拆成两半写库:model 键单独走 session 的 model 列,其余键进 sessionConfigJson(浅合并)。这解释了为什么测试里 patched 总是 { model: 'claude-next', sessionConfigJson: { permissionMode: 'plan' } } 这种形状——model 和其它字段分列存储。仓储侧 patchSessionConfig(drizzle-repository.ts)用一个事务读-改-写,浅合并时丢弃 null 值键(v1 无删除语义),合并后空对象存回 null(对齐 createSession 的地板值)。
5.2四桶结果与 markStale
活体 runner 返回的四桶结果,service 不直接透传,而是过一道 reconcileCommittedReconfigure 重算。原因:库已经写成功了,所以从 service 的视角,凡是 runner 没能「热应用」的键,都已经落库、都算 deferred(下轮生效),而不是 failed。
function reconcileCommittedReconfigure(params, liveResult) {
const applied = new Set(liveResult.applied.filter((key) => Object.hasOwn(params, key)))
const deferred = Object.keys(params).filter((key) => !applied.has(key)) // 没热应用的 = 已落库待生效
return {
result: { applied: [...applied], unsupported: [], deferred, failed: [] },
markStale: liveResult.failed.length > 0 || liveResult.unsupported.length > 0, // 活体没吃下 → 重建
}
}
markStale 是这套设计的兜底闩:当活体 runner 报告 failed 或 unsupported(含 applyLiveReconfigure catch 到异常时返回的全 failed),说明库里的配置和活体进程状态可能已经不一致。runnerManager.markStale(sessionId) 不立即杀进程,而是给它打个标——下一次 getOrCreate 时先 dispose 再重建,让新进程从库里读到正确配置。这是「持久化是真相源,活体尽力而为」原则的落地。
reconfigure.ts 里把键加进 checkXxxReconfigParams(决定 accepted / unsupported / invalid);② 在 runner 的 reconfigure 里决定它是热应用还是 deferred;③ 若热应用,补上 provider 协议里对应的请求构造;④ validateRunnerConfig 会自动覆盖它,因为它复用完整启动校验。
排查路标 · 底座
| 症状 | 从哪下手 |
|---|---|
| reconfigure 报 400 但值看着没错 | service.ts:validateReconfigureRunnerConfig —— post-patch 完整配置过不了 provider 归一化 |
| 改了设置但库里没变 | service.ts:persistReconfigure;drizzle-repository.ts:patchSessionConfig 的 null 过滤 |
| 改设置后下一轮进程行为还是旧的 | runner-manager.ts:markStale / getOrCreate 的 stale 分支是否触发重建 |
| reconfigure 和 startTurn 打架 / 重复进程 | service.ts:withSessionRunnerLock 队列 |
6旅程 A:改 Claude 模型(热应用)
Claude 是唯一能不重启进程就改设置的 provider。它有一个 stdin/stdout 的 control 协议,Eyrie 往 stdin 写一个 control_request,Claude 从 stdout 回一个 control_response。以前 Eyrie 只往一个方向用(Claude 主动发权限请求),这条旅程新增的是 Eyrie 主动发请求、并等 Claude 确认 的反向通道。
reconfigure.ts→ runner.reconfigure
runner.ts→ 构造请求
process.ts→ 等待响应
control-protocol.ts
A.1control_response 反向通道
runner 的 reconfigure 逐键处理。对每个键:先用 parseClaudeReconfigureParam 校验(不认识→unsupported,认识但值非法→failed);然后看有没有活体进程。没进程就只改内存里的 providerConfig 并记为 deferred;有进程就发 control_request 等确认。
const process = this.getProcessState()?.process
if (!process) {
applyClaudeReconfigureConfig(this.providerConfig, parsed) // 只改内存配置
deferred.push(key)
continue
}
if (await this.applyLiveReconfigure(process, buildClaudeReconfigureRequest(parsed))) {
applyClaudeReconfigureConfig(this.providerConfig, parsed) // 进程确认后才改内存,保持一致
applied.push(key)
continue
}
failed.push(key)
buildClaudeReconfigureRequest 按键映射到不同的 control subtype——这张表是「Eyrie 字段名 ↔ Claude 协议」的对照:model→set_model、permissionMode→set_permission_mode、effort→apply_flag_settings(带 settings.effortLevel)。
发出后怎么知道 Claude 接受了?靠一个 pendingControlResponses 的 requestId→Deferred 映射,加一个 5 秒超时竞速:
const waiter = createDeferred<void>()
this.pendingControlResponses.set(requestId, waiter)
try {
await process.writeLine(JSON.stringify(request))
return await Promise.race([
waiter.promise.then(() => true, () => false), // Claude 回 success → true,error → false
delay(RECONFIGURE_TIMEOUT_MS).then(() => false), // 5 秒没回也算 false
])
} catch { return false } finally {
this.pendingControlResponses.delete(requestId)
}
回程在 handleStdoutLine 里被优先拦截:新加的 handleControlResponseLine 排在 handleControlRequestLine 之前,把 Eyrie 自己发起的 control 的响应先捞出来 resolve/reject 对应 waiter,不让它流进通用的 stream-json 翻译。进程被拆除时 cancelPendingWaiters 会把所有 pending 的 waiter reject 掉,避免 reconfigure 永久挂起。
A.2permissionMode 不再编码进 hooks
这是一处不改就没法做热改权限的前置重构。以前 Claude 的权限模式是启动时通过 buildClaudeHooks 编进 initialize 请求的 hooks 表里的——权限模式写死在 hooks 的正则 matcher 里,改模式就得重开进程。这条路被拆掉了。
与此同时,启动参数也从条件式的 --dangerously-skip-permissions(仅 bypassPermissions 模式加)改成了无条件加 --allow-dangerously-skip-permissions + --permission-mode <mode>。前者是「直接跳过」,后者是「允许 CLI 在运行时切到 bypass 模式」——把静态开关换成运行时可切换的能力,正是为了让 reconfigure 能把权限改到任意模式(含 bypass)。buildClaudeInitializeRequest / buildClaudeInterruptRequest 现在都收敛到统一的 buildClaudeControlRequest 工厂。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 改 Claude 模型 5 秒后报 deferred(没热应用) | runner.ts:applyLiveReconfigure 超时;Claude 没回 control_response |
| Claude 回了 error | control-protocol.ts:parseClaudeControlResponse;waiter 被 reject |
| 权限模式切了但工具审批行为没变 | process.ts:buildStartupArgs 的 --permission-mode;set_model/set_permission_mode 的 subtype 映射表 |
| reconfigure 永久挂起 | runner.ts:cancelPendingWaiters 是否清了 pendingControlResponses |
7旅程 B:改 Codex 设置(下一轮生效)
Codex 走的是 JSON-RPC app-server,没有热改单个设置的接口。它的策略是:reconfigure 只把新值存进内存 providerConfig,全部记为 deferred;真正生效是在下一次 turn/start 时,把这些设置塞进 turn 的 payload。
const { accepted, unsupported, invalid } = checkCodexReconfigParams(params)
const deferred: string[] = []
for (const key of Object.keys(accepted)) {
if (key === 'model' ...) { this.providerConfig.model = accepted.model; deferred.push(key) }
if (key === 'effort' ...) { this.providerConfig.effort = accepted.effort; deferred.push(key) }
if (key === 'permissionMode' ...) { this.providerConfig.permissionMode = ...; deferred.push(key) }
}
return { applied: [], unsupported, deferred, failed: invalid } // 永远 applied 为空
新增的 turnSettings() 是消费端:每次 startTurn 现在会把 model、effort、以及权限映射出来的 approvalPolicy + sandboxPolicy 一起并进 turn/start 请求。权限的映射逻辑(permissionSettings())是这条旅程的核心——它把 Eyrie 的四个抽象权限预设翻译成 Codex 的审批策略 + 沙箱策略:
case 'readOnly': approvalPolicy 'on-request' + sandbox { readOnly, 无网络 }
case 'workspaceWrite': approvalPolicy 'on-request' + sandbox { 可写 cwd, 无网络 }
case 'auto': approvalPolicy 'on-failure' + sandbox { 可写 cwd, 无网络 }
case 'fullAccess': approvalPolicy 'never' + sandbox { dangerFullAccess }
注意 turnSettings 里有个覆盖优先级:permissionMode 映射出的 policy 优先,只有当它没给出 sandboxPolicy/approvalPolicy 时,才回退到 providerConfig 里 provider-native 的原始 sandboxPolicy/approvalPolicy。也就是抽象预设赢过裸配置。module.ts 顺带给 Codex 填了三条内置模型(gpt-5-codex 默认 / gpt-5 / gpt-5-mini),替代了原来「Codex 无模型发现」的空列表。
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 改了 Codex 设置当轮没变 | 符合设计——deferred,看下一轮。消费端在 index.ts:turnSettings |
| 权限预设没按预期沙箱 | index.ts:permissionSettings 的四分支映射 |
| native sandboxPolicy 被忽略 | index.ts:turnSettings 的回退优先级(预设赢过裸配置) |
8旅程 C:改 ACP 模型(config_option)
ACP(Agent Client Protocol,Eyrie 与外部 agent 通信的一套 JSON-RPC 协议)把「可配置项」建模成 provider 报上来的一组 config option 控件。改模型 = 找到 category=model 的那个 select 控件,调 session/set_config_option 回写它的值。
runner 维护一份 configOptions 快照,它在三个时机被刷新:session/new 或 resume 的响应里带、provider 主动推 config_option_update 通知时、以及每次 set_config_option 的响应里带。applySessionConfigOptions 是核心,逐键校验后回写:
const category = acpConfigCategoryForReconfigureKey(key) // model→model, effort→thought_level
if (!category) { unsupported.push(key); continue }
const option = findSelectConfigOption(this.configOptions, category)
if (!option) { unsupported.push(key); continue } // provider 没报这个控件
if (!selectConfigOptionHasValue(option, nextValue)) { failed.push(key); continue } // 值不在可选集
const response = await this.connection.request('session/set_config_option',
{ sessionId, configId: option.id, value: nextValue })
this.updateConfigOptions(response.configOptions) // 用响应里的新快照刷新
applied.push(key)
ACP 还有一条不同于其他 provider 的路径——startup config。因为 ACP 的模型/effort 只能在会话建立后通过 config option 设,所以持久化的 model/effort 不能靠 spawn 参数带上,得在 session/new 或 resume 之后、第一个 prompt 之前补设。applyStartupConfig 就干这个,且它对失败是硬失败——如果启动配置没设上,直接抛错让会话建立失败,而不是带着错误配置往下跑:
const result = await this.applySessionConfigOptions(sessionId, params)
const rejected = [...result.unsupported, ...result.failed]
if (rejected.length > 0) {
throw new Error(`ACP session config could not be applied: ${rejected.join(', ')}.`)
}
reconfigure 本身对关闭中/已关闭状态特判:直接把所有键记为 deferred 返回,不去碰连接(连接都要没了,热改无意义,交给下次 spawn)。这就是 commit defer acp reconfigure after runner close 的落点。
C.1JSON-RPC 连接的 closed-for-writes 闸
ACP reconfigure 会在连接可能正在关闭的窗口里往里写请求,所以 json-rpc-connection.ts 加了一道写入闸门 isClosedForWrites。以前连接关闭后写请求会静默进一个永不 resolve 的 pending;现在关闭态下 request/write 立即抛「connection is closed」,且 closed promise settle 时会把所有 pending 请求 reject 掉。
request<T>(method, params?) {
if (this.isClosedForWrites()) // closing / failed / closedForWrites / 流已destroyed/ended
return Promise.reject(new Error('ACP JSON-RPC connection is closed.'))
...
try { this.write({ jsonrpc: '2.0', id, method, ... }) }
catch (err) { this.pending.delete(idKey(id)); return Promise.reject(err) } // 写失败即清 pending
return response
}
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| 改 ACP 模型报 unsupported | runner.ts:findSelectConfigOption —— provider 没报 category=model 的 select |
| 改 ACP 模型报 failed | runner.ts:selectConfigOptionHasValue —— 值不在可选集,或 set_config_option 抛错 |
| ACP 会话启动就失败 | runner.ts:applyStartupConfig 硬失败;持久化的 model/effort 不在 provider 可选集 |
| 关闭时 reconfigure 挂起 / 报错 | json-rpc-connection.ts:isClosedForWrites;runner 关闭态特判 |
9旅程 D:发现模型列表
模型发现是本 PR 的第二条主线,回答「一个 provider 有哪些模型可选」。入口在 registry 的新方法 listProviderModels:它调 provider.listModels()(provider 有就用,没有回退到 module 的静态 baseline),再叠加用户自定义的 model delta(用户改的 label/default 永远赢)。
Codex 发现(index.ts listCodexModels):起一个短命 app-server,initialize 后分页调 model/list,去重收集,读完 nextCursor 为空为止;隐藏行(hidden === true)和无 id 的行丢弃。读完 SIGTERM→等待→SIGKILL 收尾。
ACP 发现(discovery.ts discoverAcpModels):开一个短 ACP 会话,session/new 的响应里 deriveAcpModelOptions 优先读 models.availableModels,没有再回退到 category=model 的 config option 的可选值。整个握手被 withDiscoveryRequestDeadline(默认 30 秒)看着,防止一个卡死的 provider 把 listProviderModels 永久堵住。
const availableModels = ...modelState?.availableModels?.flatMap(modelOptionFromAvailableModel)
if (availableModels.length > 0) return withSingleDefault(availableModels, currentModelId)
// 回退:从 category=model 的 select 控件推导
const modelOption = findSelectConfigOption(response.configOptions, 'model')
if (!modelOption) return []
return withSingleDefault(
flattenSelectConfigChoices(modelOption.options).map((choice) => ({ id: choice.value, name: choice.name, ... })),
modelOption.currentValue ?? undefined)
discovery.ts 里的 findSelectConfigOption / flattenSelectConfigChoices 是发现和 reconfigure 共用的——旅程 C 的 runner 也 import 它们,所以「哪些是合法值」在发现端和回写端用的是同一套解析,不会一边发现出来另一边回写又不认。
排查路标 · 旅程 D
| 症状 | 从哪下手 |
|---|---|
| 某 provider 模型列表为空 | Codex 看 index.ts readCodexModelPage(hidden/无id 过滤);ACP 看 discovery.ts deriveAcpModelOptions 两级来源 |
| listProviderModels 卡住 | discovery.ts:withDiscoveryRequestDeadline 30 秒;Codex 看子进程收尾 |
| 用户改的模型 label 被覆盖 | registry.ts:mergedModels —— user delta 应赢过 baseline |
10计划 vs 实现的偏差
本 PR 无随附技术方案文档,但 46 条 commit 的 message 里 fix:/defer/harden/restore 这些字眼是变卦的尸检报告。挑出实质偏差:
| 最初做法 | 实际收敛成 | 为什么变 |
|---|---|---|
桌面端做了 reconfigure UI(FloatingComposer、codex-native 控件)——commit feat(desktop): wire session reconfigure composer 等 |
净 diff 里没有任何 desktop 源码改动,只剩 1 行测试补丁 | 两次 merge main 后 desktop 侧被抹掉。本 PR 收敛为纯 daemon + API 契约,UI 另行处理 |
| reconfigure 接受多字段 patch | API schema 收敛成单字段(key + value,一次一个),commit accept single-field reconfigure input |
单字段让 tRPC 层 assertReconfigureSucceeded 能对唯一的键给出明确 400/409,多字段的部分成功语义在 wire 层难表达 |
| Claude 权限模式随 initialize 的 hooks 一起下发 | hooks 不再编码权限模式,改为独立的 set_permission_mode control + 无条件 --allow-dangerously-skip-permissions |
热改权限的前置条件;commit align Claude permission reconfigure with SDK control / enable dangerous permission reconfigure |
| ACP reconfigure 无条件走连接 | 关闭中/已关闭状态直接 defer;连接加 closed-for-writes 闸 | commit defer acp reconfigure after runner close / guard failed reconfigure sends——关闭窗口里写请求会挂死 |
session.model 可空(string | null) |
createSession 时用 resolveDefaultSessionModel 定出具体默认模型;repo 的 CreateSessionInput.model 收窄为 string |
commit use concrete session model defaults / handle default session model——reconfigure 要能改 model,就得先保证 model 总有具体值 |
11心智模型补丁
12新词表
| reconfigure 核心 | |
|---|---|
ReconfigureParams | 松散的 Record,provider 运行时参数,service 不解释直接透传给 runner |
ReconfigParamsCheck | provider 落库前校验结果:accepted / unsupported / invalid 三分 |
ReconfigureResult | runner 应用结果四桶:applied / unsupported / deferred / failed,联合覆盖每个键 |
markStale | 给活体 runner 打「过期」标,下次 getOrCreate 先销毁再从库重建 |
| deferred | 「已落库、下一轮或下次 spawn 才生效」的键;区别于 failed(根本没落库) |
| Claude | |
| control_request / control_response | Claude stdin/stdout 上的控制帧;本 PR 新增 Eyrie 主动发请求并等响应的方向 |
| set_model / set_permission_mode / apply_flag_settings | Eyrie 字段热改时对应的 Claude control subtype |
| Codex | |
| turnSettings | 每次 turn/start 时把 deferred 的 model/effort/权限并进 payload 的消费端 |
| approvalPolicy / sandboxPolicy | Eyrie 四个权限预设映射到的 Codex 审批策略 + 沙箱策略 |
| ACP | |
| ACP | Agent Client Protocol——Eyrie 与外部 agent 通信的 JSON-RPC 协议 |
| config option / AcpSelectConfigOption | provider 报上来的「可配置项」控件;category=model 的 select 就是模型选择器 |
| session/set_config_option | 回写一个 config option 值的 ACP 请求 |
| startup config | ACP 会话建立后、首个 prompt 前补设持久化 model/effort 的步骤,硬失败 |
| closed-for-writes | JSON-RPC 连接的写入闸:关闭态下 request/write 立即抛错而非挂死 |
13测试与风险地图
测试占比高(~62%),reconfigure 的 service 编排契约被钉得很密。
有兜底的(测试钉住的行为)
- service 四桶归并(agent-service-methods.test.ts):活体 failed 键降级为 deferred(库已写);活体 throw 时全 deferred + markStale;持久化失败时不碰活体 runner;无活体时全 deferred;空 patch 拒绝。
- provider 分流:跳过 unsupported 键不写库;invalid 值不写库;stale provider 先拒;Codex native 键持久化并 defer;ACP reconfigure 返回 unsupported 不写库。
- Claude 热改(claude-runner-reconfigure.test.ts,277 行新):model 走 set_model 活体应用;error 响应记 failed;超时行为。
- 并发:reconfigure 串在 runner 创建之后;startTurn 未 settle 时 reconfigure 排队。
- 发现:Codex 内置模型列表;ACP provider registry;providers-list tRPC。
- ACP 连接(acp-json-rpc-connection.test.ts):closed-for-writes reject 行为。
薄冰(重要逻辑、留意)
- 🟠Claude effort 走 apply_flag_settings——这个 subtype 与 model/permissionMode 结构不同(settings 嵌套),映射表里独一份,依赖 Claude CLI 真实接受该帧;测试用 FakeClaudeProcess,真实 CLI 的 effort 热改未在此 PR 端到端验证。
- 🟠Codex permissionSettings 的沙箱映射——四个预设翻译成的 approvalPolicy/sandboxPolicy 是 Codex app-server 的契约;映射值(如 workspaceWrite 的 writableRoots)正确性依赖 Codex 侧语义,属跨进程约定。
- 🟡ACP startup config 硬失败——持久化的 model/effort 若不在 provider 当前可选集,会话直接建不起来。provider 换版本导致可选值变化时,老会话 resume 可能因此失败。
- 🟡listCodexModels / discoverAcpModels 起短命子进程——每次发现都 spawn 一个进程再收尾,频繁调用的开销与子进程泄漏风险靠 SIGTERM→SIGKILL 兜底,未见并发去重。
- ⚪reconfigure 无鉴权语义变化——权限模式可被改到 fullAccess/bypass,这是设计内的用户能力,非缺陷,但值得知悉。
14验收提示
- 别把 desktop 的「消失」当缺陷:git log 里有
feat(desktop): wire session reconfigure composer等 commit,但净 diff 无 desktop 源码改动——是 merge main 后被抹掉,本 PR 有意收敛为 daemon+API。UI 不在本 PR 范围。 - control-protocol.ts 删掉 buildClaudeHooks 不是丢功能:权限模式改由独立 set_permission_mode control 下发,能力搬家而非删除。
- Codex reconfigure 恒返回 applied:[] 是设计:Codex 无热改接口,全 deferred,下一轮 turn 生效,不是没实现。
- ACP validateRunnerConfig 是空函数:ACP 配置有效性在 runner reconfigure 时对活体 config option 校验,不在这一步,空实现是刻意的。
- service.ts 大部分未改:文件很长,但 approval/input-request/teardown 段都是既有代码,本 PR 只加了 reconfigure 一族方法和底部自由函数。
15覆盖声明
主力(我)全量 Read 了以下生产源码并亲手裁剪引用:service.ts(全 1123 行)、Claude reconfigure.ts / runner+process+control-protocol 的 diff、Codex index.ts / module.ts / reconfigure.ts diff、ACP discovery.ts(全)/ provider / runner / protocol / json-rpc-connection diff、registry / runner-manager / repository / drizzle-repository diff、API 层 schemas / dto / trpc / services / trpc-services diff。测试文件读了 claude-runner-reconfigure.test.ts 头部与 agent-service-methods.test.ts 的 reconfigure 段(1480–1620 行)及全部 it 名,用于提取契约;其余测试文件(acp-runner、codex-runner、各 registry 测试)仅按变更量与名称归类,未逐行精读——第 13 节「有兜底的」清单基于测试名与已读段落,非全测试精读。子系统 subagent 因服务端模型错误未能运行,全部素材为主力亲读。desktop 侧确认净 diff 无源码改动(仅经 git ancestry 验证 composer commit 未进 main、当前树 apps/desktop/src 无 reconfigure 引用)。