feat/session-reconfigure:给运行中的会话热改模型 / 权限 / 思考强度,外加模型发现

eyrie · origin/main...feat/session-reconfigure(merge-base 99c04e1e) · 2026-07-08 · 自包含,读完即弃

46 commits
56 文件
+4198 / −207
~62% 是测试代码

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

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 契约。

daemon/tests
~2560 行 · 测试
daemon/src
~1670 行 · 设计重心
packages/api
~171 行 · 契约
apps/desktop
1 行 · 测试补丁
子系统设计重心(细读)可放心略过
service 编排service.tsreconfigureUnlocked + 底部一堆自由函数(校验、持久化拆分、四桶协调)文件里 approval / input-request / teardown 大段是既有代码,未改
Clauderunner.tsreconfigure + control_response 通道;reconfigure.ts(新)control-protocol.ts 删掉的 buildClaudeHooks(搬走了,不是逻辑变更)
Codexindex.tsturnSettings/permissionSettings/listCodexModelsreconfigure.ts(新)module.ts 只是填了三条内置模型
ACPdiscovery.ts(新,192 行);runner.tsapplySessionConfigOptions + startup config;json-rpc-connection.ts 的 closed-for-writesprotocol.ts 全是新增类型声明,只看形状
APIschemas.tsreconfigureSessionSchema(单字段 + model 归一化)dto.ts 一行注释、trpc.ts 机械接线

3架构一图流

核心结构变化:AgentProviderAgentRunner 两个接口各长出了 reconfigure 相关的新方法,service 由此获得一条「先问 provider 能不能接、再落库、再让活体 runner 协调」的新链路。

以前 · 会话配置在创建时固化

createSession
model + sessionConfig 一次写死
session row
改设置
无热改通道
销毁重建

现在 · reconfigure 双写(库 + 活体)

tRPC reconfigure
单字段
service
provider.check
accepted
patchSessionConfig
runner.reconfigure
热应用 / 下一轮 / set_config_option
provider 进程

4数据与状态先行

reconfigure 全链路围着几个新类型转,先把形状认清,后面旅程就只讲行为。

ReconfigureParams 是最松的一层:一个 Record<string, unknown>。service 不认识 provider 的字段名,原样透传给 runner——provider 自己负责认字段。

apps/daemon/src/agent/types.tsreconfigure 三件套
// 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 / 可选 listModelsAgentRunner 长出 reconfigureAgentRegistry 长出可选 listProviderModels。这几个方法是本 PR 所有旅程的挂钩点。

ACP 侧新增协议形状(protocol.ts,只看形状):AcpSelectConfigOption 是 provider 报上来的一个「下拉选择」控件,带 category(如 modelthought_level)、id(回写时用)、options(可选值,可能分组)、currentValue。Eyrie 把「改模型」映射成「设置 category=model 的那个 select 控件」。

apps/daemon/src/agent/acp/protocol.tsACP 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 的特有逻辑留到各自旅程。

全景 · 涉及 4 个文件
入口
service.ts reconfigure
会话锁
withSessionRunnerLock
provider 校验
checkReconfigParams
落库
patchSessionConfig
活体协调
applyLiveReconfigure
归并
reconcileCommitted

5.1校验 → 持久化 → 活体协调

入口 reconfigure 只做一件事:把整个流程放进 withSessionRunnerLock。这把锁是既有的 per-session 串行队列,作用是让「读会话配置 → 建 runner → 写配置」这一串对同一会话不并发——否则一个 reconfigure 和一个 startTurn 可能同时在建 runner,造成重复进程。

锁内的 reconfigureUnlocked 按固定次序推进。第一层是空值与 provider 可用性把关:

apps/daemon/src/agent/service.tsreconfigureUnlocked · 前半
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 时的归一化校验,但不真的起进程)。过了才写库:

apps/daemon/src/agent/service.tsreconfigureUnlocked · 落库与分叉
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。

apps/daemon/src/agent/service.tsreconcileCommittedReconfigure
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 的 provider 字段的标准步骤:① 在 provider 的 reconfigure.ts 里把键加进 checkXxxReconfigParams(决定 accepted / unsupported / invalid);② 在 runner 的 reconfigure 里决定它是热应用还是 deferred;③ 若热应用,补上 provider 协议里对应的请求构造;④ validateRunnerConfig 会自动覆盖它,因为它复用完整启动校验。
排查路标 · 底座
症状从哪下手
reconfigure 报 400 但值看着没错service.tsvalidateReconfigureRunnerConfig —— post-patch 完整配置过不了 provider 归一化
改了设置但库里没变service.tspersistReconfiguredrizzle-repository.tspatchSessionConfig 的 null 过滤
改设置后下一轮进程行为还是旧的runner-manager.tsmarkStale / getOrCreate 的 stale 分支是否触发重建
reconfigure 和 startTurn 打架 / 重复进程service.tswithSessionRunnerLock 队列

6旅程 A:改 Claude 模型(热应用)

Claude 是唯一能不重启进程就改设置的 provider。它有一个 stdin/stdout 的 control 协议,Eyrie 往 stdin 写一个 control_request,Claude 从 stdout 回一个 control_response。以前 Eyrie 只往一个方向用(Claude 主动发权限请求),这条旅程新增的是 Eyrie 主动发请求、并等 Claude 确认 的反向通道。

全景 · 涉及 4 个文件
校验
reconfigure.ts
runner.reconfigure
runner.ts
构造请求
process.ts
等待响应
control-protocol.ts

A.1control_response 反向通道

runner 的 reconfigure 逐键处理。对每个键:先用 parseClaudeReconfigureParam 校验(不认识→unsupported,认识但值非法→failed);然后看有没有活体进程。没进程就只改内存里的 providerConfig 并记为 deferred;有进程就发 control_request 等确认。

apps/daemon/src/agent/providers/claude/runner.tsClaudeRunner.reconfigure · 逐键
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 协议」的对照:modelset_modelpermissionModeset_permission_modeeffortapply_flag_settings(带 settings.effortLevel)。

发出后怎么知道 Claude 接受了?靠一个 pendingControlResponses 的 requestId→Deferred 映射,加一个 5 秒超时竞速:

apps/daemon/src/agent/providers/claude/runner.tsapplyLiveReconfigure · 等确认
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 里,改模式就得重开进程。这条路被拆掉了。

以前
initialize 请求带 buildClaudeHooks(permissionMode)
权限模式编进 hooks 正则 matcher
改权限 = 重开进程重发 hooks
现在
initialize 请求 不带 hooks(buildClaudeInitializeRequest(undefined))
启动时单发一条 set_permission_mode control
改权限 = reconfigure 再发一条 set_permission_mode

与此同时,启动参数也从条件式的 --dangerously-skip-permissions(仅 bypassPermissions 模式加)改成了无条件--allow-dangerously-skip-permissions + --permission-mode <mode>。前者是「直接跳过」,后者是「允许 CLI 在运行时切到 bypass 模式」——把静态开关换成运行时可切换的能力,正是为了让 reconfigure 能把权限改到任意模式(含 bypass)。buildClaudeInitializeRequest / buildClaudeInterruptRequest 现在都收敛到统一的 buildClaudeControlRequest 工厂。

排查路标 · 旅程 A
症状从哪下手
改 Claude 模型 5 秒后报 deferred(没热应用)runner.tsapplyLiveReconfigure 超时;Claude 没回 control_response
Claude 回了 errorcontrol-protocol.tsparseClaudeControlResponse;waiter 被 reject
权限模式切了但工具审批行为没变process.tsbuildStartupArgs--permission-mode;set_model/set_permission_mode 的 subtype 映射表
reconfigure 永久挂起runner.tscancelPendingWaiters 是否清了 pendingControlResponses

7旅程 B:改 Codex 设置(下一轮生效)

Codex 走的是 JSON-RPC app-server,没有热改单个设置的接口。它的策略是:reconfigure 只把新值存进内存 providerConfig,全部记为 deferred;真正生效是在下一次 turn/start 时,把这些设置塞进 turn 的 payload。

apps/daemon/src/agent/providers/codex/index.tsCodexAppServerRunner.reconfigure
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 的审批策略 + 沙箱策略:

apps/daemon/src/agent/providers/codex/index.tspermissionSettings · 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.tsturnSettings
权限预设没按预期沙箱index.tspermissionSettings 的四分支映射
native sandboxPolicy 被忽略index.tsturnSettings 的回退优先级(预设赢过裸配置)

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 是核心,逐键校验后回写:

apps/daemon/src/agent/acp/runner.tsapplySessionConfigOptions · 逐键
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 就干这个,且它对失败是硬失败——如果启动配置没设上,直接抛错让会话建立失败,而不是带着错误配置往下跑:

apps/daemon/src/agent/acp/runner.tsapplyStartupConfig · 硬失败
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 掉。

apps/daemon/src/agent/acp/json-rpc-connection.ts请求前的写入闸
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 模型报 unsupportedrunner.tsfindSelectConfigOption —— provider 没报 category=model 的 select
改 ACP 模型报 failedrunner.tsselectConfigOptionHasValue —— 值不在可选集,或 set_config_option 抛错
ACP 会话启动就失败runner.tsapplyStartupConfig 硬失败;持久化的 model/effort 不在 provider 可选集
关闭时 reconfigure 挂起 / 报错json-rpc-connection.tsisClosedForWrites;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 永久堵住。

apps/daemon/src/agent/acp/discovery.tsderiveAcpModelOptions · 两级来源
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.tswithDiscoveryRequestDeadline 30 秒;Codex 看子进程收尾
用户改的模型 label 被覆盖registry.tsmergedModels —— 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心智模型补丁

会话的模型/权限在 createSession 时固化,改设置只能销毁重建 存在 reconfigure 操作,能热改运行中会话的 model/permissionMode/effort,不丢上下文
AgentProvider 只管发现和建 runner AgentProvider 还得实现 checkReconfigParams + validateRunnerConfig;AgentRunner 还得实现 reconfigure
给任何 provider 加运行时旋钮,都要过这套 check→validate→runner.reconfigure 挂钩。
「改配置成功」= 改动立刻生效 改配置成功分三态:applied(热改活体)/ deferred(已落库下轮生效)/ failed(没落库)
Codex 恒为 deferred;Claude 热应用失败也降级为 deferred(库已写);只有落库前出错才是 failed(409)。
库配置和活体进程状态默认一致 活体应用失败时用 markStale 标记,下轮 getOrCreate 先 dispose 再从库重建
持久化是真相源,活体尽力而为;不一致靠「下轮重建」收敛,不靠立即杀进程。
Claude 的 control 协议只有 Claude→Eyrie 一个方向(权限请求) Eyrie 也主动发 control_request 并等 control_response(pendingControlResponses + 5 秒超时)
Codex 无模型发现,模型列表空 Codex 有内置三模型 + model/list 分页发现;ACP 开短会话读 config 面发现
session.model 可能是 null createSession 总会解析出具体默认模型;DTO 里 model 仅对遗留/手工播种行才为 null

12新词表

reconfigure 核心
ReconfigureParams松散的 Record,provider 运行时参数,service 不解释直接透传给 runner
ReconfigParamsCheckprovider 落库前校验结果:accepted / unsupported / invalid 三分
ReconfigureResultrunner 应用结果四桶:applied / unsupported / deferred / failed,联合覆盖每个键
markStale给活体 runner 打「过期」标,下次 getOrCreate 先销毁再从库重建
deferred「已落库、下一轮或下次 spawn 才生效」的键;区别于 failed(根本没落库)
Claude
control_request / control_responseClaude stdin/stdout 上的控制帧;本 PR 新增 Eyrie 主动发请求并等响应的方向
set_model / set_permission_mode / apply_flag_settingsEyrie 字段热改时对应的 Claude control subtype
Codex
turnSettings每次 turn/start 时把 deferred 的 model/effort/权限并进 payload 的消费端
approvalPolicy / sandboxPolicyEyrie 四个权限预设映射到的 Codex 审批策略 + 沙箱策略
ACP
ACPAgent Client Protocol——Eyrie 与外部 agent 通信的 JSON-RPC 协议
config option / AcpSelectConfigOptionprovider 报上来的「可配置项」控件;category=model 的 select 就是模型选择器
session/set_config_option回写一个 config option 值的 ACP 请求
startup configACP 会话建立后、首个 prompt 前补设持久化 model/effort 的步骤,硬失败
closed-for-writesJSON-RPC 连接的写入闸:关闭态下 request/write 立即抛错而非挂死

13测试与风险地图

测试占比高(~62%),reconfigure 的 service 编排契约被钉得很密。

有兜底的(测试钉住的行为)

薄冰(重要逻辑、留意)

14验收提示

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 引用)。