Browse Source

feat(ai): 接入 Codex 主 Agent 并修复写作大纲选择

darknessomi 2 tháng trước cách đây
mục cha
commit
0bda150e50
40 tập tin đã thay đổi với 3819 bổ sung và 878 xóa
  1. 612 312
      src-tauri/src/commands/codex_cli.rs
  2. 4 3
      src-tauri/src/lib.rs
  3. 4 3
      src-tauri/src/main.rs
  4. 3 8
      src/components/settings/llm-presets.ts
  5. 12 1
      src/components/settings/llm-wiki-model-settings.spec.ts
  6. 6 8
      src/components/settings/preset-resolver.ts
  7. 6 0
      src/components/settings/sections/llm-provider-section.spec.ts
  8. 8 4
      src/components/settings/sections/llm-provider-section.tsx
  9. 2 2
      src/i18n/en.json
  10. 2 2
      src/i18n/zh.json
  11. 303 0
      src/lib/agent/codex-app-server-runner.spec.ts
  12. 410 0
      src/lib/agent/codex-app-server-runner.ts
  13. 4 1
      src/lib/agent/config.spec.ts
  14. 0 2
      src/lib/agent/config.ts
  15. 14 164
      src/lib/agent/runner.ts
  16. 180 0
      src/lib/agent/tool-executor.ts
  17. 160 0
      src/lib/codex-app-server-client.spec.ts
  18. 309 0
      src/lib/codex-app-server-client.ts
  19. 25 0
      src/lib/codex-cli-model.spec.ts
  20. 16 0
      src/lib/codex-cli-model.ts
  21. 24 0
      src/lib/codex-cli-timeout.spec.ts
  22. 18 0
      src/lib/codex-cli-timeout.ts
  23. 155 0
      src/lib/codex-cli-transport.spec.ts
  24. 219 209
      src/lib/codex-cli-transport.ts
  25. 29 0
      src/lib/context-hub/data-source-cache.spec.ts
  26. 12 2
      src/lib/context-hub/data-source-cache.ts
  27. 5 0
      src/lib/local-cli-config.ts
  28. 9 21
      src/lib/novel/context-data-sources.ts
  29. 17 16
      src/lib/novel/context-engine-outline-read.spec.ts
  30. 25 2
      src/lib/novel/context-engine.spec.ts
  31. 46 87
      src/lib/novel/context-engine.ts
  32. 40 0
      src/lib/novel/deep-chapter-generation.spec.ts
  33. 19 23
      src/lib/novel/deep-chapter-generation.ts
  34. 187 0
      src/lib/novel/outline-context-index.spec.ts
  35. 679 0
      src/lib/novel/outline-context-index.ts
  36. 37 4
      src/lib/novel/section-briefing.ts
  37. 94 0
      src/lib/project-store.integration.test.ts
  38. 85 0
      src/lib/project-store.ts
  39. 25 3
      src/lib/settings-model-list.spec.ts
  40. 14 1
      src/lib/settings-model-list.ts

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 612 - 312
src-tauri/src/commands/codex_cli.rs


+ 4 - 3
src-tauri/src/lib.rs

@@ -58,7 +58,7 @@ pub fn run() {
                 eprintln!("[proxy] could not resolve app_data_dir");
             }
             app.manage(commands::claude_cli::ClaudeCliState::default());
-            app.manage(commands::codex_cli::CodexCliState::default());
+            app.manage(commands::codex_cli::CodexAppServerState::default());
             app.manage(commands::cursor_cli::CursorProxyState::default());
             app.manage(commands::file_sync::FileSyncState::default());
             app.manage(commands::mcp_stdio::McpStdioState::default());
@@ -103,8 +103,9 @@ pub fn run() {
             commands::claude_cli::claude_cli_spawn,
             commands::claude_cli::claude_cli_kill,
             commands::codex_cli::codex_cli_detect,
-            commands::codex_cli::codex_cli_spawn,
-            commands::codex_cli::codex_cli_kill,
+            commands::codex_cli::codex_app_server_start,
+            commands::codex_cli::codex_app_server_write,
+            commands::codex_cli::codex_app_server_stop,
             commands::cursor_cli::cursor_cli_detect,
             commands::cursor_cli::cursor_proxy_status,
             commands::cursor_cli::cursor_proxy_ensure,

+ 4 - 3
src-tauri/src/main.rs

@@ -60,7 +60,7 @@ fn main() {
                 eprintln!("[proxy] could not resolve app_data_dir");
             }
             app.manage(commands::claude_cli::ClaudeCliState::default());
-            app.manage(commands::codex_cli::CodexCliState::default());
+            app.manage(commands::codex_cli::CodexAppServerState::default());
             app.manage(commands::cursor_cli::CursorProxyState::default());
             app.manage(commands::file_sync::FileSyncState::default());
             app.manage(commands::mcp_stdio::McpStdioState::default());
@@ -105,8 +105,9 @@ fn main() {
             commands::claude_cli::claude_cli_spawn,
             commands::claude_cli::claude_cli_kill,
             commands::codex_cli::codex_cli_detect,
-            commands::codex_cli::codex_cli_spawn,
-            commands::codex_cli::codex_cli_kill,
+            commands::codex_cli::codex_app_server_start,
+            commands::codex_cli::codex_app_server_write,
+            commands::codex_cli::codex_app_server_stop,
             commands::cursor_cli::cursor_cli_detect,
             commands::cursor_cli::cursor_proxy_status,
             commands::cursor_cli::cursor_proxy_ensure,

+ 3 - 8
src/components/settings/llm-presets.ts

@@ -1,5 +1,6 @@
 import type { AzureModelFamily } from "@/stores/wiki-store"
 import { MIN_USER_LLM_CONTEXT_SIZE } from "@/lib/llm-context-size"
+import { CODEX_CLI_SUGGESTED_MODELS, DEFAULT_CODEX_CLI_MODEL } from "@/lib/codex-cli-model"
 
 /**
  * Curated LLM provider presets.
@@ -120,14 +121,8 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
     label: "Codex CLI (local)",
     hint: "Uses the local `codex` binary — no API key needed",
     provider: "codex-cli",
-    defaultModel: "gpt-5.4-mini",
-    suggestedModels: [
-      "gpt-5.4-mini",
-      "gpt-5.4",
-      "gpt-5.3-codex",
-      "gpt-5.3-codex-spark",
-      "gpt-5.2",
-    ],
+    defaultModel: DEFAULT_CODEX_CLI_MODEL,
+    suggestedModels: [...CODEX_CLI_SUGGESTED_MODELS],
     suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {

+ 12 - 1
src/components/settings/llm-wiki-model-settings.spec.ts

@@ -130,9 +130,20 @@ describe("QMAI model settings", () => {
     )
     expect(codex.provider).toBe("codex-cli")
     expect(codex.localCliIsolation).toBe(true)
-    expect(codex.model).toBe("")
+    expect(codex.model).toBe("gpt-5.6-terra")
     expect(codex.codexCliTimeoutMinutes).toBe(45)
 
+    const codexPreset = preset("codex-cli")
+    expect(codexPreset.defaultModel).toBe("gpt-5.6-terra")
+    expect(codexPreset.suggestedModels).toEqual([
+      "gpt-5.6-terra",
+      "gpt-5.6-sol",
+      "gpt-5.6-luna",
+    ])
+    const codexDefault = resolveConfig(preset("codex-cli"), {}, fallback)
+    expect(codexDefault.model).toBe("gpt-5.6-terra")
+    expect(codexDefault.codexCliTimeoutMinutes).toBe(40)
+
     const cursor = resolveConfig(
       preset("cursor-cli"),
       { baseUrl: "http://127.0.0.1:8765/v1" },

+ 6 - 8
src/components/settings/preset-resolver.ts

@@ -6,6 +6,7 @@ import {
   normalizeUserLlmContextSize,
   normalizeUserLlmMaxOutputTokens,
 } from "@/lib/llm-context-size"
+import { resolveCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
 
 /**
  * Build a full LlmConfig from a preset template + the user's saved
@@ -29,10 +30,7 @@ export function resolveConfig(
   const reasoning = ov.reasoning ?? { mode: "auto" as const }
   const localCliIsolation = ov.localCliIsolation === true
   const functionCallingEnabled = ov.functionCallingEnabled !== false
-  const codexCliTimeoutMinutes =
-    typeof ov.codexCliTimeoutMinutes === "number" && Number.isFinite(ov.codexCliTimeoutMinutes)
-      ? Math.max(1, Math.min(240, Math.floor(ov.codexCliTimeoutMinutes)))
-      : undefined
+  const codexCliTimeoutMinutes = resolveCodexCliTimeoutMinutes(ov.codexCliTimeoutMinutes)
 
   let config: LlmConfig
 
@@ -80,13 +78,13 @@ export function resolveConfig(
     }
   } else if (preset.provider === "claude-code" || preset.provider === "codex-cli") {
     // Subprocess transport — no apiKey, no endpoint URL. Model id is
-    // passed straight to the local CLI's model flag when the user
-    // explicitly sets one. Leaving it empty lets the local CLI use the
-    // machine's own configured default model.
+    // passed straight to the local CLI. Claude can inherit its machine
+    // default; Codex is pinned to QMAI's curated default unless the user
+    // explicitly selects another app-server model.
     config = {
       provider: preset.provider,
       apiKey: "",
-      model: ov.model?.trim() || "",
+      model: ov.model?.trim() || (preset.provider === "codex-cli" ? preset.defaultModel : "") || "",
       ollamaUrl: fallback.ollamaUrl,
       customEndpoint: fallback.customEndpoint,
       maxContextSize: rawMaxContextSize,

+ 6 - 0
src/components/settings/sections/llm-provider-section.spec.ts

@@ -31,4 +31,10 @@ describe("LLM provider model controls", () => {
     expect(source).toContain("functionCallingEnabled")
     expect(source).toContain('settings.sections.llm.functionCalling.label')
   })
+
+  it("keeps Codex CLI isolated and requires app-server dynamic tools", () => {
+    expect(source).toContain('const showLocalCliIsolation = preset.provider === "claude-code"')
+    expect(source).toContain("r.installed && r.appServerReady === true && r.dynamicToolsReady === true")
+    expect(source).toContain('invoke<DetectResult>("codex_cli_detect")')
+  })
 })

+ 8 - 4
src/components/settings/sections/llm-provider-section.tsx

@@ -25,6 +25,7 @@ import {
   normalizeUserLlmMaxOutputTokens,
 } from "@/lib/llm-context-size"
 import { thinkingMinMaxTokens } from "@/lib/llm-providers"
+import { resolveCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
 
 const MODEL_PARAM_DOCS_URL = "https://global.modelmesh.info/model"
 
@@ -215,8 +216,8 @@ function PresetRow({
   )
   const reasoning = ov.reasoning ?? { mode: "auto" as const }
   const localCliIsolation = ov.localCliIsolation === true
-  const codexCliTimeoutMinutes = Math.max(1, Math.min(240, ov.codexCliTimeoutMinutes ?? 10))
-  const isLocalCliProvider = preset.provider === "claude-code" || preset.provider === "codex-cli"
+  const codexCliTimeoutMinutes = resolveCodexCliTimeoutMinutes(ov.codexCliTimeoutMinutes)
+  const showLocalCliIsolation = preset.provider === "claude-code"
   const isCursorCliProvider = preset.provider === "cursor-cli"
   const [testState, setTestState] = useState<ProviderTestState>({ kind: "idle" })
   const [modelOptions, setModelOptions] = useState<string[]>([])
@@ -491,7 +492,7 @@ function PresetRow({
             </div>
           )}
 
-          {isLocalCliProvider && (
+          {showLocalCliIsolation && (
             <div className="space-y-2 rounded-md border p-3">
               <div className="flex items-start justify-between gap-3">
                 <div>
@@ -1087,6 +1088,9 @@ interface DetectResult {
   installed: boolean
   version: string | null
   path: string | null
+  appServerReady?: boolean
+  dynamicToolsReady?: boolean
+  models?: string[]
   error: string | null
 }
 
@@ -1216,7 +1220,7 @@ function CodexCliStatusPill() {
     try {
       const r = await invoke<DetectResult>("codex_cli_detect")
       setResult(r)
-      setState(r.installed ? "ok" : "err")
+      setState(r.installed && r.appServerReady === true && r.dynamicToolsReady === true ? "ok" : "err")
     } catch (e) {
       setResult({
         installed: false,

+ 2 - 2
src/i18n/en.json

@@ -875,12 +875,12 @@
         "azureModelFamilyGpt5": "GPT-5 / o-series",
         "azureModelFamilyHint": "Azure deployment names are arbitrary. Pick GPT-5 / o-series when that deployment needs strict completion parameters.",
         "localCliIsolation": "Isolate local CLI config",
-        "localCliIsolationHint": "When enabled, Claude/Codex CLI will try to ignore user rules and local config for more predictable tests and generation.",
+        "localCliIsolationHint": "When enabled, Claude CLI will try to ignore user rules and local config for more predictable tests and generation.",
         "localCliIsolationOn": "Isolation enabled: local rules and user config will be ignored where supported.",
         "localCliIsolationOff": "Isolation disabled: current CLI login and local config are used.",
         "codexCliTimeout": "Codex CLI timeout",
         "codexCliTimeoutUnit": "minutes",
-        "codexCliTimeoutHint": "Overall subprocess timeout for longer generations, from 1 to 240 minutes.",
+        "codexCliTimeoutHint": "Overall app-server timeout for longer generations. Defaults to 40 minutes; configurable from 1 to 240 minutes.",
         "cursorBridgeApiKey": "Proxy API key (optional)",
         "cursorBridgeApiKeyPlaceholder": "Only if the proxy was started with CURSOR_BRIDGE_API_KEY",
         "cursorBridgeApiKeyHint": "Leave blank by default. Fill in the same value if you started the proxy with CURSOR_BRIDGE_API_KEY.",

+ 2 - 2
src/i18n/zh.json

@@ -582,12 +582,12 @@
         "azureModelFamilyGpt5": "GPT-5 / o 系列",
         "azureModelFamilyHint": "Azure 部署名可自定义,必要时手动声明 GPT-5 或 o 系列行为。",
         "localCliIsolation": "隔离本地 CLI 配置",
-        "localCliIsolationHint": "为 Claude/Codex CLI 使用隔离配置目录,避免读写用户全局 CLI 配置。",
+        "localCliIsolationHint": "为 Claude CLI 使用隔离配置目录,避免读写用户全局 CLI 配置。",
         "localCliIsolationOn": "已隔离,不会使用全局 CLI 配置。",
         "localCliIsolationOff": "未隔离,将继承本机 CLI 登录状态。",
         "codexCliTimeout": "Codex CLI 超时",
         "codexCliTimeoutUnit": "分钟",
-        "codexCliTimeoutHint": "长文本生成的子进程总超时时间,可设置 1-240 分钟。",
+        "codexCliTimeoutHint": "长文本生成的 app-server 总超时时间,默认 40 分钟,可设置 1-240 分钟。",
         "cursorBridgeApiKey": "Proxy API Key(可选)",
         "cursorBridgeApiKeyPlaceholder": "仅当 proxy 设置了 CURSOR_BRIDGE_API_KEY 时填写",
         "cursorBridgeApiKeyHint": "默认无需填写。若手动启动 proxy 时设置了 CURSOR_BRIDGE_API_KEY,在此填入相同值。",

+ 303 - 0
src/lib/agent/codex-app-server-runner.spec.ts

@@ -0,0 +1,303 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { ToolRegistry } from "./registry"
+import type { AgentConfig, AgentMessage, AgentRunCallbacks, Tool } from "./types"
+
+const appServerMock = vi.hoisted(() => ({
+  handler: null as null | {
+    onEnvelope?: (envelope: Record<string, unknown>) => void
+    onDynamicToolCall?: (request: Record<string, unknown>) => Promise<unknown>
+  },
+  turnNumber: 0,
+  onTurn: null as null | ((turnNumber: number) => void | Promise<void>),
+  call: vi.fn(),
+  interrupt: vi.fn(async () => undefined),
+}))
+
+vi.mock("@/lib/codex-app-server-client", () => ({
+  getCodexAppServerClient: () => ({
+    isolatedCwd: "/tmp/qmai-codex/workspace",
+    ensureStarted: vi.fn(async () => undefined),
+    call: appServerMock.call,
+    interrupt: appServerMock.interrupt,
+    registerThread: (_threadId: string, handler: typeof appServerMock.handler) => {
+      appServerMock.handler = handler
+      return () => {
+        appServerMock.handler = null
+      }
+    },
+  }),
+}))
+
+import { CodexAppServerRunner } from "./codex-app-server-runner"
+
+const llmConfig: LlmConfig = {
+  provider: "codex-cli",
+  apiKey: "",
+  model: "gpt-test",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 8192,
+  codexCliTimeoutMinutes: 10,
+}
+
+const messages: AgentMessage[] = [
+  { role: "system", content: "QMAI system" },
+  { role: "user", content: "读取大纲后回答" },
+]
+
+function callbacks(): AgentRunCallbacks {
+  return {
+    onText: vi.fn(),
+    onReasoningToken: vi.fn(),
+    onToolCall: vi.fn(),
+    onToolResult: vi.fn(),
+    onToolError: vi.fn(),
+    onToolEvent: vi.fn(),
+    onUsage: vi.fn(),
+    onDone: vi.fn(),
+    onError: vi.fn(),
+  }
+}
+
+function config(tools: Tool[], overrides: Partial<AgentConfig> = {}): AgentConfig {
+  return {
+    maxRounds: 3,
+    tools,
+    systemPrompt: "QMAI system",
+    llmConfig,
+    ...overrides,
+  }
+}
+
+function envelope(method: string, params: Record<string, unknown>): void {
+  appServerMock.handler?.onEnvelope?.({ method, params })
+}
+
+describe("CodexAppServerRunner", () => {
+  beforeEach(() => {
+    appServerMock.handler = null
+    appServerMock.turnNumber = 0
+    appServerMock.onTurn = null
+    appServerMock.call.mockReset()
+    appServerMock.interrupt.mockClear()
+    appServerMock.call.mockImplementation(async (method: string, params: Record<string, unknown>) => {
+      if (method === "thread/start") {
+        return { thread: { id: "thread-1" }, instructionSources: [] }
+      }
+      if (method === "turn/start") {
+        appServerMock.turnNumber += 1
+        const current = appServerMock.turnNumber
+        queueMicrotask(() => void appServerMock.onTurn?.(current))
+        return { turn: { id: `turn-${current}` } }
+      }
+      throw new Error(`unexpected method: ${method} ${JSON.stringify(params)}`)
+    })
+  })
+
+  it("publishes QMAI dynamic tools, executes a read tool, and returns the final record", async () => {
+    const tool: Tool = {
+      name: "read_outline",
+      description: "读取大纲",
+      category: "read",
+      parameters: { path: { type: "string", description: "路径", required: true } },
+      execute: vi.fn(async () => "大纲内容"),
+    }
+    const registry = new ToolRegistry()
+    registry.register(tool)
+    appServerMock.onTurn = async () => {
+      const response = await appServerMock.handler?.onDynamicToolCall?.({
+        threadId: "thread-1",
+        turnId: "turn-1",
+        callId: "call-1",
+        namespace: null,
+        tool: "read_outline",
+        arguments: { path: "QM/outlines/总纲.md" },
+      }) as { success: boolean; contentItems: Array<{ text: string }> }
+      expect(response.success).toBe(true)
+      expect(response.contentItems[0].text).toContain("大纲内容")
+      envelope("item/reasoning/summaryTextDelta", { threadId: "thread-1", delta: "思考" })
+      envelope("item/agentMessage/delta", { threadId: "thread-1", delta: "最终回答" })
+      envelope("thread/tokenUsage/updated", {
+        threadId: "thread-1",
+        tokenUsage: {
+          last: { inputTokens: 10, outputTokens: 2, totalTokens: 12 },
+          total: { inputTokens: 18, outputTokens: 4, totalTokens: 22 },
+        },
+      })
+      envelope("turn/completed", { threadId: "thread-1", turn: { status: "completed" } })
+    }
+    const cb = callbacks()
+
+    const record = await new CodexAppServerRunner().run(
+      config([tool]), registry, messages, cb,
+    )
+
+    const threadStart = appServerMock.call.mock.calls.find(([method]) => method === "thread/start")
+    expect(threadStart?.[1]).toEqual(expect.objectContaining({
+      approvalPolicy: "never",
+      sandbox: "read-only",
+      ephemeral: true,
+      dynamicTools: [expect.objectContaining({ name: "read_outline" })],
+    }))
+    expect(threadStart?.[1].baseInstructions).toContain("## 任务契约")
+    expect(tool.execute).toHaveBeenCalledWith(
+      { path: "QM/outlines/总纲.md" }, undefined, expect.any(Object),
+    )
+    expect(record.finalText).toBe("最终回答")
+    expect(record.toolCalls[0]).toEqual(expect.objectContaining({ name: "read_outline", status: "done" }))
+    expect(record.lastRequestUsage).toEqual(expect.objectContaining({ totalTokens: 12 }))
+    expect(record.usage).toEqual(expect.objectContaining({ totalTokens: 22 }))
+    expect(cb.onText).toHaveBeenCalledWith("最终回答")
+    expect(cb.onDone).toHaveBeenCalledOnce()
+    expect(cb.onError).not.toHaveBeenCalled()
+  })
+
+  it("returns approval_required previews without executing confirmation tools", async () => {
+    const tool: Tool = {
+      name: "write_outline_node",
+      description: "修改大纲",
+      category: "write",
+      permission: "confirm",
+      parameters: { content: { type: "string", description: "内容", required: true } },
+      execute: vi.fn(async () => "不应执行"),
+      generatePreview: vi.fn(async () => "将写入:新内容"),
+    }
+    const registry = new ToolRegistry()
+    registry.register(tool)
+    appServerMock.onTurn = async () => {
+      const response = await appServerMock.handler?.onDynamicToolCall?.({
+        threadId: "thread-1",
+        turnId: "turn-1",
+        callId: "call-write",
+        namespace: null,
+        tool: "write_outline_node",
+        arguments: { content: "新内容" },
+      }) as { success: boolean; contentItems: Array<{ text: string }> }
+      expect(response.success).toBe(true)
+      expect(response.contentItems[0].text).toContain("尚未执行")
+      envelope("item/agentMessage/delta", { threadId: "thread-1", delta: "等待确认" })
+      envelope("turn/completed", { threadId: "thread-1", turn: { status: "completed" } })
+    }
+    const cb = callbacks()
+
+    const record = await new CodexAppServerRunner().run(config([tool]), registry, messages, cb)
+
+    expect(tool.generatePreview).toHaveBeenCalledOnce()
+    expect(tool.execute).not.toHaveBeenCalled()
+    expect(record.toolCalls[0]).toEqual(expect.objectContaining({
+      status: "approval_required",
+      preview: "将写入:新内容",
+    }))
+  })
+
+  it("continues in the same thread when requiredToolsOnce is missing", async () => {
+    const tool: Tool = {
+      name: "list_outlines",
+      description: "列出大纲",
+      category: "read",
+      parameters: {},
+      execute: vi.fn(async () => "总纲.md"),
+    }
+    const registry = new ToolRegistry()
+    registry.register(tool)
+    appServerMock.onTurn = async (turnNumber) => {
+      if (turnNumber === 1) {
+        envelope("item/agentMessage/delta", { threadId: "thread-1", delta: "未读取直接回答" })
+        envelope("turn/completed", { threadId: "thread-1", turn: { status: "completed" } })
+        return
+      }
+      await appServerMock.handler?.onDynamicToolCall?.({
+        threadId: "thread-1",
+        turnId: "turn-2",
+        callId: "call-list",
+        namespace: null,
+        tool: "list_outlines",
+        arguments: {},
+      })
+      envelope("item/agentMessage/delta", { threadId: "thread-1", delta: "读取后回答" })
+      envelope("turn/completed", { threadId: "thread-1", turn: { status: "completed" } })
+    }
+    const cb = callbacks()
+
+    const record = await new CodexAppServerRunner().run(
+      config([tool], { requiredToolsOnce: ["list_outlines"] }),
+      registry,
+      messages,
+      cb,
+    )
+
+    expect(appServerMock.turnNumber).toBe(2)
+    const turnStarts = appServerMock.call.mock.calls.filter(([method]) => method === "turn/start")
+    expect(turnStarts[1][1].input[0].text).toContain("list_outlines")
+    expect(record.finalText).toBe("读取后回答")
+    expect(cb.onText).not.toHaveBeenCalledWith("未读取直接回答")
+  })
+
+  it("hard-fails when Codex emits a native shell item", async () => {
+    appServerMock.onTurn = () => {
+      envelope("item/started", {
+        threadId: "thread-1",
+        item: { id: "native-1", type: "commandExecution", command: "pwd" },
+      })
+    }
+    const cb = callbacks()
+
+    await new CodexAppServerRunner().run(config([]), new ToolRegistry(), messages, cb)
+
+    expect(cb.onError).toHaveBeenCalledWith(expect.objectContaining({
+      message: expect.stringContaining("禁止 Codex 原生能力"),
+    }))
+    expect(appServerMock.interrupt).toHaveBeenCalledWith("thread-1", "turn-1")
+    expect(cb.onDone).not.toHaveBeenCalled()
+  })
+
+  it("rejects non-object dynamic tool arguments without executing the tool", async () => {
+    const tool: Tool = {
+      name: "read_outline",
+      description: "读取大纲",
+      category: "read",
+      parameters: {},
+      execute: vi.fn(async () => "不应执行"),
+    }
+    const registry = new ToolRegistry()
+    registry.register(tool)
+    appServerMock.onTurn = async () => {
+      const response = await appServerMock.handler?.onDynamicToolCall?.({
+        threadId: "thread-1",
+        turnId: "turn-1",
+        callId: "bad-args",
+        namespace: null,
+        tool: "read_outline",
+        arguments: "bad",
+      }) as { success: boolean }
+      expect(response.success).toBe(false)
+      envelope("item/agentMessage/delta", { threadId: "thread-1", delta: "参数失败" })
+      envelope("turn/completed", { threadId: "thread-1", turn: { status: "completed" } })
+    }
+
+    const record = await new CodexAppServerRunner().run(config([tool]), registry, messages, callbacks())
+
+    expect(tool.execute).not.toHaveBeenCalled()
+    expect(record.toolCalls[0].status).toBe("error")
+  })
+
+  it("maps AbortSignal cancellation to turn/interrupt", async () => {
+    const controller = new AbortController()
+    const cb = callbacks()
+    const run = new CodexAppServerRunner().run(
+      config([]),
+      new ToolRegistry(),
+      messages,
+      cb,
+      controller.signal,
+    )
+    await vi.waitFor(() => expect(appServerMock.turnNumber).toBe(1))
+    controller.abort()
+    await run
+
+    expect(appServerMock.interrupt).toHaveBeenCalledWith("thread-1", "turn-1")
+    expect(cb.onError).toHaveBeenCalledWith(expect.objectContaining({ message: "操作已取消" }))
+    expect(cb.onDone).not.toHaveBeenCalled()
+  })
+})

+ 410 - 0
src/lib/agent/codex-app-server-runner.ts

@@ -0,0 +1,410 @@
+import { getCodexAppServerClient, type CodexAppServerEnvelope } from "@/lib/codex-app-server-client"
+import {
+  buildCodexTurnInput,
+  codexNativeBoundaryError,
+  codexReasoningEffort,
+  restrictedCodexConfig,
+} from "@/lib/codex-cli-transport"
+import { trimChatMessagesToTokenBudget } from "@/lib/chat-request-budget"
+import { getEffectiveMaxContextSize } from "@/lib/llm-providers"
+import { resolveCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
+import type { LlmUsage } from "@/lib/llm-usage"
+import { applyGlobalUserMemoryToMessages } from "@/lib/user-memory/request-integration"
+import type { ToolRegistry } from "./registry"
+import {
+  RequiredToolsNotCalledError,
+  buildRequiredToolNudgeMessage,
+  missingRequiredToolsOnce,
+} from "./required-tools-gate"
+import { executeAgentTool } from "./tool-executor"
+import { ToolEvidenceLedger } from "./tool-evidence-ledger"
+import {
+  clearTaskBreakpoint,
+  createTaskBreakpoint,
+  saveTaskBreakpoint,
+  updateBreakpointStage,
+  type TaskBreakpoint,
+} from "./task-breakpoint"
+import type { AgentConfig, AgentMessage, AgentRunCallbacks, AgentRunRecord } from "./types"
+
+interface ThreadStartResponse {
+  thread: { id: string }
+  instructionSources?: string[]
+}
+
+interface TurnStartResponse {
+  turn: { id: string }
+}
+
+interface TurnCompletion {
+  status: string
+  error?: string
+}
+
+function messageContentText(content: AgentMessage["content"]): string {
+  if (typeof content === "string") return content
+  return content.filter((block) => block.type === "text").map((block) => block.text).join("")
+}
+
+function toDynamicTools(config: AgentConfig): Array<Record<string, unknown>> {
+  return config.tools.map((tool) => {
+    const properties: Record<string, unknown> = {}
+    const required: string[] = []
+    for (const [name, parameter] of Object.entries(tool.parameters)) {
+      properties[name] = {
+        type: parameter.type,
+        description: parameter.description,
+        ...(parameter.enum?.length ? { enum: parameter.enum } : {}),
+      }
+      if (parameter.required) required.push(name)
+    }
+    return {
+      type: "function",
+      name: tool.name,
+      description: tool.description,
+      inputSchema: {
+        type: "object",
+        properties,
+        required,
+        additionalProperties: false,
+      },
+    }
+  })
+}
+
+function toLlmUsage(value: Record<string, unknown> | undefined): LlmUsage | undefined {
+  if (!value) return undefined
+  return {
+    inputTokens: Number(value.inputTokens) || 0,
+    outputTokens: Number(value.outputTokens) || 0,
+    totalTokens: Number(value.totalTokens) || 0,
+    cachedInputTokens: Number(value.cachedInputTokens) || 0,
+    cacheWriteInputTokens: Number(value.cacheWriteInputTokens) || 0,
+  }
+}
+
+function usageFromEnvelope(envelope: CodexAppServerEnvelope): {
+  last?: LlmUsage
+  total?: LlmUsage
+} | undefined {
+  if (envelope.method !== "thread/tokenUsage/updated") return undefined
+  const tokenUsage = envelope.params?.tokenUsage as Record<string, unknown> | undefined
+  const last = tokenUsage?.last as Record<string, unknown> | undefined
+  return {
+    last: toLlmUsage(last),
+    total: toLlmUsage(tokenUsage?.total as Record<string, unknown> | undefined),
+  }
+}
+
+export class CodexAppServerRunner {
+  async run(
+    config: AgentConfig,
+    registry: ToolRegistry,
+    messages: AgentMessage[],
+    callbacks: AgentRunCallbacks,
+    signal?: AbortSignal,
+  ): Promise<AgentRunRecord> {
+    const record: AgentRunRecord = { toolCalls: [], roundsUsed: 0, finalText: "" }
+    const client = getCodexAppServerClient()
+    const evidenceLedger = new ToolEvidenceLedger(config.toolResultContextLimit ?? 6000)
+    let threadId = ""
+    let activeTurnId = ""
+    let turnText = ""
+    let turnUsage: LlmUsage | undefined
+    let cumulativeUsage: LlmUsage | undefined
+    let turnResolve: ((completion: TurnCompletion) => void) | null = null
+    let turnReject: ((error: Error) => void) | null = null
+    let unregister = () => {}
+    let terminalError: Error | null = null
+    const emittedItems = new Set<string>()
+    const agentMessagePhases = new Map<string, string | null>()
+    const projectPath = config.projectPath
+    const latestUserContent = [...messages].reverse().find((message) => message.role === "user")?.content
+    const taskGoalText = config.taskGoal || (latestUserContent ? messageContentText(latestUserContent) : "") || "未命名任务"
+    let taskBreakpoint: TaskBreakpoint | null = projectPath
+      ? createTaskBreakpoint({ taskGoal: taskGoalText, currentStage: "agent_round_1" })
+      : null
+    const persistTaskBreakpoint = async () => {
+      if (!projectPath || !taskBreakpoint) return
+      try {
+        await saveTaskBreakpoint(projectPath, taskBreakpoint)
+      } catch {
+        // 断点保存失败不应中断当前 AI 会话。
+      }
+    }
+    const clearPersistedBreakpoint = async () => {
+      if (!projectPath) return
+      try {
+        await clearTaskBreakpoint(projectPath)
+      } catch {
+        // 清理失败不改变本轮模型结果。
+      }
+    }
+
+    if (taskBreakpoint) await persistTaskBreakpoint()
+
+    const taskContract: AgentMessage = {
+      role: "system",
+      content: `## 任务契约\n初始任务目标:${taskGoalText.slice(0, 1800)}\n执行过程中不得因历史裁剪丢失该目标;当前用户新要求优先。`,
+    }
+    const messagesWithContract = [...messages]
+    const contractIndex = messagesWithContract.findIndex((message) => message.role !== "system")
+    messagesWithContract.splice(contractIndex < 0 ? messagesWithContract.length : contractIndex, 0, taskContract)
+
+    const { messages: memoryMessages, decision } = applyGlobalUserMemoryToMessages(
+      messagesWithContract,
+      config.requestOverrides,
+    )
+    record.userMemoryDecision = decision
+    callbacks.onUserMemoryDecision?.(decision)
+    const budget = Math.max(1, Math.floor(getEffectiveMaxContextSize(config.llmConfig) * 0.75))
+    let preparedMessages: AgentMessage[]
+    try {
+      preparedMessages = trimChatMessagesToTokenBudget(
+        memoryMessages,
+        budget,
+      ) as AgentMessage[]
+    } catch {
+      const error = new Error("模型上下文不足:当前对话即使压缩后仍放不下系统提示与最新请求。")
+      callbacks.onError(error)
+      return record
+    }
+
+    const completeActiveTurn = (completion: TurnCompletion) => {
+      turnResolve?.(completion)
+      turnResolve = null
+      turnReject = null
+    }
+    const failActiveTurn = (error: Error) => {
+      terminalError = error
+      turnReject?.(error)
+      turnResolve = null
+      turnReject = null
+      if (threadId && activeTurnId) void client.interrupt(threadId, activeTurnId)
+    }
+
+    const timeoutMinutes = resolveCodexCliTimeoutMinutes(config.llmConfig.codexCliTimeoutMinutes)
+    const timeout = setTimeout(() => {
+      failActiveTurn(new Error(`Codex app-server 超时(${timeoutMinutes} 分钟)`))
+    }, timeoutMinutes * 60_000)
+    const abort = () => failActiveTurn(new Error("操作已取消"))
+    signal?.addEventListener("abort", abort, { once: true })
+
+    try {
+      await client.ensureStarted()
+      const preparedSystemInstructions = preparedMessages
+        .filter((message) => message.role === "system")
+        .map((message) => messageContentText(message.content))
+        .filter(Boolean)
+        .join("\n\n")
+      const started = await client.call<ThreadStartResponse>("thread/start", {
+        model: config.modelId?.trim() || config.llmConfig.model.trim() || null,
+        cwd: client.isolatedCwd,
+        approvalPolicy: "never",
+        sandbox: "read-only",
+        ephemeral: true,
+        baseInstructions: preparedSystemInstructions || config.systemPrompt,
+        developerInstructions: [
+          "You are the QMAI main agent.",
+          "Use only client-provided dynamic tools.",
+          "Never use native shell, file changes, MCP, plugins, skills, apps, browser, web search, image generation, or subagents.",
+          "Project data is available only through QMAI tools. Do not guess file contents.",
+        ].join("\n"),
+        dynamicTools: toDynamicTools(config),
+        config: restrictedCodexConfig(),
+      })
+      if (started.instructionSources?.length) {
+        throw new Error(`QMAI 禁止 Codex 加载本机或项目规则:${started.instructionSources.join(", ")}`)
+      }
+      threadId = started.thread.id
+
+      unregister = client.registerThread(threadId, {
+        onDynamicToolCall: async (request) => {
+          if (!request.arguments || typeof request.arguments !== "object" || Array.isArray(request.arguments)) {
+            const message = `错误: 工具 ${request.tool} 的参数必须是 JSON 对象`
+            const now = Date.now()
+            callbacks.onToolCall({ id: request.callId, name: request.tool, arguments: {} })
+            callbacks.onToolEvent?.({
+              type: "call_started",
+              callId: request.callId,
+              name: request.tool,
+              params: {},
+              timestamp: now,
+            })
+            record.toolCalls.push({
+              id: request.callId,
+              name: request.tool,
+              params: {},
+              result: message,
+              status: "error",
+              startedAt: now,
+              finishedAt: now,
+            })
+            callbacks.onToolError(request.callId, message)
+            callbacks.onToolEvent?.({
+              type: "error",
+              callId: request.callId,
+              name: request.tool,
+              params: {},
+              result: message,
+              timestamp: now,
+            })
+            return {
+              contentItems: [{ type: "inputText", text: message }],
+              success: false,
+            }
+          }
+          const params = request.arguments as Record<string, unknown>
+          const executed = await executeAgentTool(
+            { id: request.callId, name: request.tool, arguments: params },
+            registry,
+            callbacks,
+            signal,
+          )
+          record.toolCalls.push(executed.record)
+          if (taskBreakpoint) {
+            const usedTools = taskBreakpoint.usedTools.includes(request.tool)
+              ? taskBreakpoint.usedTools
+              : [...taskBreakpoint.usedTools, request.tool]
+            taskBreakpoint = updateBreakpointStage(
+              { ...taskBreakpoint, usedTools },
+              `agent_round_${record.roundsUsed}`,
+              `tool:${request.tool}`,
+            )
+            await persistTaskBreakpoint()
+          }
+          return {
+            contentItems: [{
+              type: "inputText",
+              text: evidenceLedger.format(request.tool, params, executed.responseText),
+            }],
+            success: executed.success,
+          }
+        },
+        onEnvelope: (envelope) => {
+          const boundaryError = codexNativeBoundaryError(envelope, true)
+          if (boundaryError) {
+            failActiveTurn(boundaryError)
+            return
+          }
+          if (envelope.method === "item/started") {
+            const item = envelope.params?.item as Record<string, unknown> | undefined
+            if (item?.type === "agentMessage" && typeof item.id === "string") {
+              agentMessagePhases.set(item.id, typeof item.phase === "string" ? item.phase : null)
+            }
+          } else if (envelope.method === "item/agentMessage/delta") {
+            const delta = envelope.params?.delta
+            const itemId = typeof envelope.params?.itemId === "string" ? envelope.params.itemId : ""
+            if (typeof delta === "string" && agentMessagePhases.get(itemId) !== "commentary") {
+              turnText += delta
+            }
+          } else if (
+            envelope.method === "item/reasoning/summaryTextDelta" ||
+            envelope.method === "item/reasoning/textDelta"
+          ) {
+            const delta = envelope.params?.delta
+            if (typeof delta === "string" && delta) callbacks.onReasoningToken?.(delta)
+          } else if (envelope.method === "thread/tokenUsage/updated") {
+            const usage = usageFromEnvelope(envelope)
+            turnUsage = usage?.last
+            cumulativeUsage = usage?.total ?? cumulativeUsage
+            if (turnUsage) callbacks.onUsage?.(turnUsage)
+          } else if (envelope.method === "item/completed") {
+            const item = envelope.params?.item as Record<string, unknown> | undefined
+            const itemId = typeof item?.id === "string" ? item.id : ""
+            if (
+              item?.type === "agentMessage" &&
+              item.phase !== "commentary" &&
+              typeof item.text === "string" &&
+              !emittedItems.has(itemId)
+            ) {
+              if (!turnText) turnText = item.text
+              if (itemId) emittedItems.add(itemId)
+            }
+          } else if (envelope.method === "turn/completed") {
+            const turn = envelope.params?.turn as Record<string, unknown> | undefined
+            const error = turn?.error as Record<string, unknown> | undefined
+            completeActiveTurn({
+              status: String(turn?.status || "completed"),
+              error: typeof error?.message === "string" ? error.message : undefined,
+            })
+          } else if (envelope.method === "error") {
+            const error = envelope.params?.error as Record<string, unknown> | undefined
+            if (envelope.params?.willRetry !== true) {
+              failActiveTurn(new Error(String(error?.message || "Codex app-server 错误")))
+            }
+          }
+        },
+      })
+
+      let turnInput = buildCodexTurnInput(preparedMessages)
+      for (let round = 0; round < Math.max(1, config.maxRounds); round += 1) {
+        if (signal?.aborted) throw new Error("操作已取消")
+        record.roundsUsed = round + 1
+        turnText = ""
+        turnUsage = undefined
+        const completionPromise = new Promise<TurnCompletion>((resolve, reject) => {
+          turnResolve = resolve
+          turnReject = reject
+        })
+        const turn = await client.call<TurnStartResponse>("turn/start", {
+          threadId,
+          input: turnInput,
+          cwd: client.isolatedCwd,
+          approvalPolicy: "never",
+          sandboxPolicy: { type: "readOnly", networkAccess: false },
+          model: config.modelId?.trim() || config.llmConfig.model.trim() || null,
+          effort: codexReasoningEffort(config.llmConfig),
+        })
+        activeTurnId = turn.turn.id
+        if (terminalError) void client.interrupt(threadId, activeTurnId)
+        const completion = await completionPromise
+        activeTurnId = ""
+        if (completion.status === "failed") {
+          throw new Error(completion.error || "Codex app-server turn 失败")
+        }
+        if (completion.status === "interrupted") {
+          throw terminalError ?? new Error("操作已取消")
+        }
+        const completedUsage = turnUsage as LlmUsage | undefined
+        if (completedUsage) {
+          record.lastRequestUsage = { ...completedUsage }
+          record.usage = cumulativeUsage ? { ...cumulativeUsage } : { ...completedUsage }
+        }
+
+        const missing = missingRequiredToolsOnce({
+          requiredToolsOnce: config.requiredToolsOnce,
+          availableToolNames: config.tools.map((tool) => tool.name),
+          calledToolNames: record.toolCalls.map((call) => call.name),
+          toolsEnabled: config.tools.length > 0,
+        })
+        if (missing.length === 0) {
+          record.finalText = turnText
+          if (turnText) callbacks.onText(turnText)
+          await clearPersistedBreakpoint()
+          callbacks.onDone()
+          return record
+        }
+        if (round >= Math.max(1, config.maxRounds) - 1) {
+          await clearPersistedBreakpoint()
+          throw new RequiredToolsNotCalledError(missing)
+        }
+        turnInput = [{
+          type: "text",
+          text: buildRequiredToolNudgeMessage(missing),
+          text_elements: [],
+        }]
+      }
+
+      throw new Error(`Agent 已达到最大调用轮次(${config.maxRounds}),请尝试减少引用内容或拆分任务`)
+    } catch (error) {
+      const resolved = error instanceof Error ? error : new Error(String(error))
+      callbacks.onError(resolved)
+      return record
+    } finally {
+      clearTimeout(timeout)
+      signal?.removeEventListener("abort", abort)
+      unregister()
+    }
+  }
+}

+ 4 - 1
src/lib/agent/config.spec.ts

@@ -35,9 +35,12 @@ describe("function calling helpers", () => {
 
   it("blocks local CLI providers from agent tools", () => {
     expect(modelSupportsTools("opus", "claude-code")).toBe(false)
-    expect(modelSupportsTools("gpt-5", "codex-cli")).toBe(false)
     expect(modelSupportsTools("composer-2-fast", "cursor-cli")).toBe(false)
   })
+
+  it("allows Codex app-server dynamic tools", () => {
+    expect(modelSupportsTools("gpt-5", "codex-cli")).toBe(true)
+  })
 })
 
 describe("buildAgentConfig functionCallingEnabled", () => {

+ 0 - 2
src/lib/agent/config.ts

@@ -10,13 +10,11 @@ export const TOOL_UNSUPPORTED_MODEL_PREFIXES: string[] = [
   "o3-mini",
   "deepseek-reasoner",
   "claude-code",
-  "codex-cli",
   "cursor-cli",
 ]
 
 const TOOL_UNSUPPORTED_PROVIDERS = new Set<LlmConfig["provider"]>([
   "claude-code",
-  "codex-cli",
   "cursor-cli",
 ])
 

+ 14 - 164
src/lib/agent/runner.ts

@@ -5,7 +5,7 @@ import { accumulateToolCalls, parseTextToolCalls } from "./tool-call-parser"
 import { toOpenAITools } from "./tools-schema"
 import type { ToolRegistry } from "./registry"
 import type { AgentConfig, AgentMessage, AgentRunCallbacks, AgentRunRecord, ToolCall, ToolCallDelta } from "./types"
-import { DEFAULT_MAX_ROUNDS, TOOL_EXECUTE_TIMEOUT_MS } from "./types"
+import { DEFAULT_MAX_ROUNDS } from "./types"
 import type { TaskBreakpoint } from "./task-breakpoint"
 import {
   clearTaskBreakpoint,
@@ -24,7 +24,8 @@ import {
   buildRequiredToolNudgeMessage,
   missingRequiredToolsOnce,
 } from "./required-tools-gate"
-import { isToolErrorResult } from "./tool-result"
+import { executeAgentTool } from "./tool-executor"
+import { CodexAppServerRunner } from "./codex-app-server-runner"
 
 export class ModelDoesNotSupportToolsError extends Error {
   constructor() {
@@ -40,17 +41,6 @@ function messageContentText(content: AgentMessage["content"]): string {
     .join("")
 }
 
-function withToolTimeout<T>(operation: Promise<T>, timeoutMs: number | undefined): Promise<T> {
-  const resolvedTimeoutMs = timeoutMs ?? TOOL_EXECUTE_TIMEOUT_MS
-  if (resolvedTimeoutMs <= 0) return operation
-  return Promise.race([
-    operation,
-    new Promise<never>((_, reject) =>
-      setTimeout(() => reject(new Error("工具执行超时")), resolvedTimeoutMs),
-    ),
-  ])
-}
-
 export class AgentRunner {
   async run(
     config: AgentConfig,
@@ -59,6 +49,9 @@ export class AgentRunner {
     callbacks: AgentRunCallbacks,
     signal?: AbortSignal,
   ): Promise<AgentRunRecord> {
+    if (config.llmConfig.provider === "codex-cli") {
+      return new CodexAppServerRunner().run(config, registry, messages, callbacks, signal)
+    }
     const record: AgentRunRecord = { toolCalls: [], roundsUsed: 0, finalText: "" }
     const workingMessages = [...messages]
     let finalText = ""
@@ -359,7 +352,6 @@ export class AgentRunner {
       // Execute each tool call
       for (const tc of toolCalls) {
         const toolName = tc.function.name
-        const tool = registry.get(toolName)
 
         const saveToolProgress = async () => {
           if (!taskBreakpoint) return
@@ -378,159 +370,17 @@ export class AgentRunner {
           try { return JSON.parse(tc.function.arguments || "{}") }
           catch { return {} }
         })()
-
-        const toolCallRecord: AgentRunRecord["toolCalls"][number] = {
-          id: tc.id,
-          name: toolName,
-          params,
-          result: "",
-          status: "running",
-          startedAt: Date.now(),
-          finishedAt: Date.now(),
-        }
-
-        const callbackToolCall: ToolCall = { id: tc.id, name: toolName, arguments: params }
-        const executionContext = {
-          callId: tc.id,
-          toolName,
-          onToolEvent: callbacks.onToolEvent,
-          onActivityEvent: callbacks.onActivityEvent,
-        }
-        callbacks.onToolCall(callbackToolCall)
-        callbacks.onToolEvent?.({
-          type: "call_started",
-          callId: tc.id,
-          name: toolName,
-          params,
-          timestamp: toolCallRecord.startedAt,
-        })
-
-        if (!tool) {
-          const errorMsg = `错误: 未知工具 ${toolName}`
-          callbacks.onToolError(tc.id, errorMsg)
-          toolCallRecord.status = "error"
-          toolCallRecord.result = errorMsg
-          toolCallRecord.finishedAt = Date.now()
-          record.toolCalls.push(toolCallRecord)
-          callbacks.onToolEvent?.({
-            type: "error",
-            callId: tc.id,
-            name: toolName,
-            params,
-            result: errorMsg,
-            timestamp: toolCallRecord.finishedAt,
-          })
-          workingMessages.push({
-            role: "tool",
-            content: evidenceLedger.format(toolName, params, toolCallRecord.result),
-            tool_call_id: tc.id,
-            name: toolName,
-          })
-          await saveToolProgress()
-          continue
-        }
-
-        const permission = tool.permission ?? (tool.category === "write" ? "confirm" : "auto")
-        if (permission === "confirm") {
-          let preview = ""
-          try {
-            const previewFn = tool.generatePreview ?? tool.execute
-            preview = await withToolTimeout(previewFn(params, signal, executionContext), tool.executeTimeoutMs)
-          } catch (e) {
-            const errorMsg = `预览生成失败:${e instanceof Error ? e.message : String(e)}`
-            toolCallRecord.status = "error"
-            toolCallRecord.result = errorMsg
-            toolCallRecord.finishedAt = Date.now()
-            record.toolCalls.push(toolCallRecord)
-            callbacks.onToolError(tc.id, errorMsg)
-            callbacks.onToolEvent?.({
-              type: "error",
-              callId: tc.id,
-              name: toolName,
-              params,
-              result: errorMsg,
-              timestamp: toolCallRecord.finishedAt,
-            })
-            workingMessages.push({
-              role: "tool",
-              content: evidenceLedger.format(toolName, params, errorMsg),
-              tool_call_id: tc.id,
-              name: toolName,
-            })
-            await saveToolProgress()
-            continue
-          }
-          toolCallRecord.status = "approval_required"
-          ;(toolCallRecord as any).preview = preview
-          toolCallRecord.result = preview
-          toolCallRecord.finishedAt = Date.now()
-          record.toolCalls.push(toolCallRecord)
-          callbacks.onToolEvent?.({
-            type: "approval_required",
-            callId: tc.id,
-            name: toolName,
-            params,
-            result: preview,
-            preview,
-            timestamp: toolCallRecord.finishedAt,
-          })
-          workingMessages.push({
-            role: "tool",
-            content: evidenceLedger.format(toolName, params, preview),
-            tool_call_id: tc.id,
-            name: toolName,
-          })
-          await saveToolProgress()
-          continue
-        }
-
-        try {
-          const result = await withToolTimeout(tool.execute(params, signal, executionContext), tool.executeTimeoutMs)
-          toolCallRecord.result = result
-          toolCallRecord.finishedAt = Date.now()
-          if (isToolErrorResult(result)) {
-            toolCallRecord.status = "error"
-            callbacks.onToolError(tc.id, result)
-            callbacks.onToolEvent?.({
-              type: "error",
-              callId: tc.id,
-              name: toolName,
-              params,
-              result,
-              timestamp: toolCallRecord.finishedAt,
-            })
-          } else {
-            toolCallRecord.status = "done"
-            callbacks.onToolResult(tc.id, result)
-            callbacks.onToolEvent?.({
-              type: "result",
-              callId: tc.id,
-              name: toolName,
-              params,
-              result,
-              timestamp: toolCallRecord.finishedAt,
-            })
-          }
-        } catch (err) {
-          toolCallRecord.status = "error"
-          toolCallRecord.result = `错误: ${err instanceof Error ? err.message : String(err)}`
-          toolCallRecord.finishedAt = Date.now()
-          callbacks.onToolError(tc.id, toolCallRecord.result)
-          callbacks.onToolEvent?.({
-            type: "error",
-            callId: tc.id,
-            name: toolName,
-            params,
-            result: toolCallRecord.result,
-            timestamp: toolCallRecord.finishedAt,
-          })
-        }
-
-        record.toolCalls.push(toolCallRecord)
+        const executed = await executeAgentTool(
+          { id: tc.id, name: toolName, arguments: params } satisfies ToolCall,
+          registry,
+          callbacks,
+          signal,
+        )
+        record.toolCalls.push(executed.record)
         await saveToolProgress()
         workingMessages.push({
           role: "tool",
-          content: evidenceLedger.format(toolName, params, toolCallRecord.result),
+          content: evidenceLedger.format(toolName, params, executed.responseText),
           tool_call_id: tc.id,
           name: toolName,
         })

+ 180 - 0
src/lib/agent/tool-executor.ts

@@ -0,0 +1,180 @@
+import type { ToolRegistry } from "./registry"
+import type { AgentRunCallbacks, AgentRunRecord, ToolCall } from "./types"
+import { TOOL_EXECUTE_TIMEOUT_MS } from "./types"
+import { isToolErrorResult } from "./tool-result"
+
+export function withToolTimeout<T>(operation: Promise<T>, timeoutMs: number | undefined): Promise<T> {
+  const resolvedTimeoutMs = timeoutMs ?? TOOL_EXECUTE_TIMEOUT_MS
+  if (resolvedTimeoutMs <= 0) return operation
+  return new Promise<T>((resolve, reject) => {
+    const timer = setTimeout(() => reject(new Error("工具执行超时")), resolvedTimeoutMs)
+    operation.then(
+      (value) => {
+        clearTimeout(timer)
+        resolve(value)
+      },
+      (error) => {
+        clearTimeout(timer)
+        reject(error)
+      },
+    )
+  })
+}
+
+export interface ExecuteAgentToolResult {
+  record: AgentRunRecord["toolCalls"][number]
+  responseText: string
+  success: boolean
+}
+
+export async function executeAgentTool(
+  call: ToolCall,
+  registry: ToolRegistry,
+  callbacks: AgentRunCallbacks,
+  signal?: AbortSignal,
+): Promise<ExecuteAgentToolResult> {
+  const startedAt = Date.now()
+  const record: AgentRunRecord["toolCalls"][number] = {
+    id: call.id,
+    name: call.name,
+    params: call.arguments,
+    result: "",
+    status: "running",
+    startedAt,
+    finishedAt: startedAt,
+  }
+  callbacks.onToolCall(call)
+  callbacks.onToolEvent?.({
+    type: "call_started",
+    callId: call.id,
+    name: call.name,
+    params: call.arguments,
+    timestamp: startedAt,
+  })
+
+  const tool = registry.get(call.name)
+  if (!tool) {
+    const result = `错误: 未知工具 ${call.name}`
+    record.status = "error"
+    record.result = result
+    record.finishedAt = Date.now()
+    callbacks.onToolError(call.id, result)
+    callbacks.onToolEvent?.({
+      type: "error",
+      callId: call.id,
+      name: call.name,
+      params: call.arguments,
+      result,
+      timestamp: record.finishedAt,
+    })
+    return { record, responseText: result, success: false }
+  }
+
+  const executionContext = {
+    callId: call.id,
+    toolName: call.name,
+    onToolEvent: callbacks.onToolEvent,
+    onActivityEvent: callbacks.onActivityEvent,
+  }
+  const permission = tool.permission ?? (tool.category === "write" ? "confirm" : "auto")
+  if (permission === "confirm") {
+    try {
+      const previewFn = tool.generatePreview ?? tool.execute
+      const preview = await withToolTimeout(
+        previewFn(call.arguments, signal, executionContext),
+        tool.executeTimeoutMs,
+      )
+      record.status = "approval_required"
+      record.preview = preview
+      record.result = preview
+      record.finishedAt = Date.now()
+      callbacks.onToolEvent?.({
+        type: "approval_required",
+        callId: call.id,
+        name: call.name,
+        params: call.arguments,
+        result: preview,
+        preview,
+        timestamp: record.finishedAt,
+      })
+      return {
+        record,
+        responseText: `尚未执行:该操作需要用户确认。\n\n${preview}`,
+        success: true,
+      }
+    } catch (error) {
+      const result = `预览生成失败:${error instanceof Error ? error.message : String(error)}`
+      record.status = "error"
+      record.result = result
+      record.finishedAt = Date.now()
+      callbacks.onToolError(call.id, result)
+      callbacks.onToolEvent?.({
+        type: "error",
+        callId: call.id,
+        name: call.name,
+        params: call.arguments,
+        result,
+        timestamp: record.finishedAt,
+      })
+      return { record, responseText: result, success: false }
+    }
+  }
+
+  try {
+    const result = await withToolTimeout(
+      tool.execute(call.arguments, signal, executionContext),
+      tool.executeTimeoutMs,
+    )
+    record.result = result
+    record.finishedAt = Date.now()
+    if (isToolErrorResult(result)) {
+      record.status = "error"
+      callbacks.onToolError(call.id, result)
+      callbacks.onToolEvent?.({
+        type: "error",
+        callId: call.id,
+        name: call.name,
+        params: call.arguments,
+        result,
+        timestamp: record.finishedAt,
+      })
+      return { record, responseText: result, success: false }
+    }
+    record.status = "done"
+    callbacks.onToolResult(call.id, result)
+    callbacks.onToolEvent?.({
+      type: "result",
+      callId: call.id,
+      name: call.name,
+      params: call.arguments,
+      result,
+      timestamp: record.finishedAt,
+    })
+    return { record, responseText: result, success: true }
+  } catch (error) {
+    const result = `错误: ${error instanceof Error ? error.message : String(error)}`
+    record.status = signal?.aborted ? "cancelled" : "error"
+    record.result = result
+    record.finishedAt = Date.now()
+    if (record.status === "cancelled") {
+      callbacks.onToolEvent?.({
+        type: "cancelled",
+        callId: call.id,
+        name: call.name,
+        params: call.arguments,
+        timestamp: record.finishedAt,
+      })
+    } else {
+      callbacks.onToolError(call.id, result)
+      callbacks.onToolEvent?.({
+        type: "error",
+        callId: call.id,
+        name: call.name,
+        params: call.arguments,
+        result,
+        timestamp: record.finishedAt,
+      })
+    }
+    return { record, responseText: result, success: false }
+  }
+}

+ 160 - 0
src/lib/codex-app-server-client.spec.ts

@@ -0,0 +1,160 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const invokeMock = vi.hoisted(() => vi.fn())
+const listenMock = vi.hoisted(() => vi.fn())
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }))
+vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock }))
+
+import {
+  CodexAppServerClient,
+  type CodexAppServerEnvelope,
+} from "./codex-app-server-client"
+
+type EventHandler = (event: { payload: unknown }) => void
+
+describe("CodexAppServerClient", () => {
+  let handlers: Map<string, EventHandler>
+  let writes: CodexAppServerEnvelope[]
+  let generation: number
+
+  const emitLine = (envelope: CodexAppServerEnvelope) => {
+    handlers.get("codex-app-server:event")?.({
+      payload: { generation, line: JSON.stringify(envelope) },
+    })
+  }
+
+  beforeEach(() => {
+    handlers = new Map()
+    writes = []
+    generation = 1
+    listenMock.mockReset()
+    listenMock.mockImplementation(async (name: string, handler: EventHandler) => {
+      handlers.set(name, handler)
+      return () => handlers.delete(name)
+    })
+    invokeMock.mockReset()
+    invokeMock.mockImplementation(async (command: string, args?: Record<string, unknown>) => {
+      if (command === "codex_app_server_start") {
+        return { generation, cwd: "/tmp/qmai-codex/workspace" }
+      }
+      if (command === "codex_app_server_stop") return undefined
+      if (command === "codex_app_server_write") {
+        const envelope = JSON.parse(String(args?.data)) as CodexAppServerEnvelope
+        writes.push(envelope)
+        if (envelope.method === "initialize") {
+          queueMicrotask(() => emitLine({ id: envelope.id, result: { userAgent: "codex-test" } }))
+        } else if (
+          envelope.method === "thread/start" &&
+          Array.isArray((envelope.params as Record<string, unknown> | undefined)?.dynamicTools)
+        ) {
+          queueMicrotask(() => emitLine({
+            id: envelope.id,
+            result: { thread: { id: "probe-thread" }, instructionSources: [] },
+          }))
+        }
+        return undefined
+      }
+      throw new Error(`unexpected command: ${command}`)
+    })
+  })
+
+  it("initializes once and correlates concurrent JSON-RPC responses by id", async () => {
+    const client = new CodexAppServerClient()
+    await client.ensureStarted()
+
+    const alpha = client.call<string>("test/alpha")
+    const beta = client.call<string>("test/beta")
+    await Promise.resolve()
+    const alphaRequest = writes.find((item) => item.method === "test/alpha")!
+    const betaRequest = writes.find((item) => item.method === "test/beta")!
+    emitLine({ id: betaRequest.id, result: "B" })
+    emitLine({ id: alphaRequest.id, result: "A" })
+
+    await expect(alpha).resolves.toBe("A")
+    await expect(beta).resolves.toBe("B")
+    expect(writes.filter((item) => item.method === "initialize")).toHaveLength(1)
+    expect(writes.some((item) => item.method === "initialized")).toBe(true)
+  })
+
+  it("routes item/tool/call to the registered thread and writes DynamicToolCallResponse", async () => {
+    const client = new CodexAppServerClient()
+    await client.ensureStarted()
+    const onDynamicToolCall = vi.fn(async () => ({
+      contentItems: [{ type: "inputText" as const, text: "tool-result" }],
+      success: true,
+    }))
+    client.registerThread("thread-1", { onDynamicToolCall })
+
+    emitLine({
+      id: 99,
+      method: "item/tool/call",
+      params: {
+        threadId: "thread-1",
+        turnId: "turn-1",
+        callId: "call-1",
+        namespace: null,
+        tool: "read_outline",
+        arguments: { path: "QM/outlines/总纲.md" },
+      },
+    })
+    await vi.waitFor(() => {
+      expect(writes.some((item) => item.id === 99 && item.result)).toBe(true)
+    })
+
+    expect(onDynamicToolCall).toHaveBeenCalledWith(expect.objectContaining({
+      tool: "read_outline",
+      arguments: { path: "QM/outlines/总纲.md" },
+    }))
+    expect(writes.find((item) => item.id === 99)?.result).toEqual({
+      contentItems: [{ type: "inputText", text: "tool-result" }],
+      success: true,
+    })
+  })
+
+  it("rejects all pending requests on process exit and starts a new generation only later", async () => {
+    const client = new CodexAppServerClient()
+    await client.ensureStarted()
+    const pending = client.call("test/slow")
+    await Promise.resolve()
+
+    handlers.get("codex-app-server:exit")?.({ payload: { generation } })
+    await expect(pending).rejects.toThrow("本轮请求不会自动重放")
+
+    generation = 2
+    await client.ensureStarted()
+    expect(invokeMock.mock.calls.filter(([command]) => command === "codex_app_server_start")).toHaveLength(2)
+    expect(writes.filter((item) => item.method === "initialize")).toHaveLength(2)
+  })
+
+  it("rejects forbidden native server requests instead of dispatching them", async () => {
+    const client = new CodexAppServerClient()
+    await client.ensureStarted()
+    client.registerThread("thread-1", {})
+
+    emitLine({
+      id: 100,
+      method: "item/commandExecution/requestApproval",
+      params: { threadId: "thread-1" },
+    })
+    await vi.waitFor(() => {
+      expect(writes.some((item) => item.id === 100 && item.error)).toBe(true)
+    })
+    expect(writes.find((item) => item.id === 100)?.error?.message).toContain("禁止 Codex 原生能力")
+  })
+
+  it("times out an individual RPC without stopping or replaying the process", async () => {
+    const client = new CodexAppServerClient()
+    await client.ensureStarted()
+    vi.useFakeTimers()
+    try {
+      const pending = client.call("test/timeout", undefined, 25)
+      const assertion = expect(pending).rejects.toThrow("Codex app-server 调用超时:test/timeout")
+      await vi.advanceTimersByTimeAsync(25)
+      await assertion
+      expect(invokeMock.mock.calls.filter(([command]) => command === "codex_app_server_start")).toHaveLength(1)
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+})

+ 309 - 0
src/lib/codex-app-server-client.ts

@@ -0,0 +1,309 @@
+import { invoke } from "@tauri-apps/api/core"
+import { listen, type UnlistenFn } from "@tauri-apps/api/event"
+
+export type JsonRpcId = string | number
+
+export interface CodexAppServerEnvelope {
+  jsonrpc?: "2.0"
+  id?: JsonRpcId
+  method?: string
+  params?: Record<string, unknown>
+  result?: unknown
+  error?: { code?: number; message?: string; data?: unknown }
+}
+
+interface StartResult {
+  generation: number
+  cwd: string
+}
+
+interface EventPayload {
+  generation: number
+  line: string
+}
+
+interface ExitPayload {
+  generation: number
+}
+
+interface PendingRequest {
+  resolve: (value: unknown) => void
+  reject: (error: Error) => void
+  timer: ReturnType<typeof setTimeout>
+}
+
+export interface DynamicToolCallRequest {
+  threadId: string
+  turnId: string
+  callId: string
+  namespace: string | null
+  tool: string
+  arguments: unknown
+}
+
+export interface DynamicToolCallResponse {
+  contentItems: Array<{ type: "inputText"; text: string }>
+  success: boolean
+}
+
+export interface CodexThreadHandler {
+  onEnvelope?: (envelope: CodexAppServerEnvelope) => void
+  onDynamicToolCall?: (request: DynamicToolCallRequest) => Promise<DynamicToolCallResponse>
+}
+
+const DEFAULT_RPC_TIMEOUT_MS = 30_000
+const APP_SERVER_EXIT_MESSAGE = "Codex app-server 已退出;本轮请求不会自动重放"
+const APP_SERVER_UPGRADE_MESSAGE = "当前 Codex CLI 不支持 QMAI 主 Agent,请升级 Codex CLI"
+
+function rpcError(error: unknown, fallback: string): Error {
+  if (error instanceof Error) return error
+  if (typeof error === "string" && error.trim()) return new Error(error)
+  return new Error(fallback)
+}
+
+export class CodexAppServerClient {
+  private nextId = 1
+  private generation: number | null = null
+  private cwd = ""
+  private startPromise: Promise<void> | null = null
+  private initializedGeneration: number | null = null
+  private pending = new Map<JsonRpcId, PendingRequest>()
+  private handlers = new Map<string, CodexThreadHandler>()
+  private unlistenEvent: UnlistenFn | null = null
+  private unlistenExit: UnlistenFn | null = null
+  private listenersPromise: Promise<void> | null = null
+
+  get isolatedCwd(): string {
+    return this.cwd
+  }
+
+  async ensureStarted(): Promise<void> {
+    if (this.generation !== null && this.initializedGeneration === this.generation) return
+    if (this.startPromise) return this.startPromise
+    this.startPromise = this.startInternal().finally(() => {
+      this.startPromise = null
+    })
+    return this.startPromise
+  }
+
+  async call<T = unknown>(
+    method: string,
+    params?: Record<string, unknown>,
+    timeoutMs = DEFAULT_RPC_TIMEOUT_MS,
+  ): Promise<T> {
+    await this.ensureStarted()
+    return this.rawCall<T>(method, params, timeoutMs)
+  }
+
+  async notify(method: string, params?: Record<string, unknown>): Promise<void> {
+    await this.ensureStarted()
+    await this.send({ jsonrpc: "2.0", method, ...(params ? { params } : {}) })
+  }
+
+  registerThread(threadId: string, handler: CodexThreadHandler): () => void {
+    this.handlers.set(threadId, handler)
+    return () => {
+      if (this.handlers.get(threadId) === handler) this.handlers.delete(threadId)
+    }
+  }
+
+  async interrupt(threadId: string, turnId: string): Promise<void> {
+    await this.call("turn/interrupt", { threadId, turnId }).catch(() => undefined)
+  }
+
+  async stop(): Promise<void> {
+    this.rejectAll(new Error(APP_SERVER_EXIT_MESSAGE))
+    this.generation = null
+    this.initializedGeneration = null
+    this.cwd = ""
+    this.handlers.clear()
+    await invoke("codex_app_server_stop")
+  }
+
+  private async startInternal(): Promise<void> {
+    await this.ensureListeners()
+    const started = await invoke<StartResult>("codex_app_server_start")
+    const changed = this.generation !== started.generation
+    this.generation = started.generation
+    this.cwd = started.cwd
+    if (!changed && this.initializedGeneration === started.generation) return
+
+    try {
+      const initialized = await this.rawCall<{ userAgent?: string }>("initialize", {
+        clientInfo: { name: "QMaiWrite", title: "QMaiWrite", version: "3.1.8" },
+        capabilities: { experimentalApi: true, requestAttestation: false },
+      })
+      if (!initialized || typeof initialized !== "object") {
+        throw new Error("Codex app-server initialize 返回无效")
+      }
+      await this.send({ jsonrpc: "2.0", method: "initialized" })
+      let probe: { instructionSources?: string[] }
+      try {
+        probe = await this.rawCall("thread/start", {
+          cwd: this.cwd,
+          approvalPolicy: "never",
+          sandbox: "read-only",
+          ephemeral: true,
+          baseInstructions: "QMAI capability probe. Do not use native tools.",
+          developerInstructions: "Use only client-provided dynamic tools.",
+          dynamicTools: [{
+            type: "function",
+            name: "qmai_capability_probe",
+            description: "QMAI capability probe; never call it.",
+            inputSchema: { type: "object", properties: {}, additionalProperties: false },
+          }],
+        })
+      } catch (error) {
+        throw new Error(`${APP_SERVER_UPGRADE_MESSAGE}。${rpcError(error, APP_SERVER_UPGRADE_MESSAGE).message}`)
+      }
+      if (probe.instructionSources?.length) {
+        throw new Error(`QMAI 禁止 Codex 加载本机或项目规则:${probe.instructionSources.join(", ")}`)
+      }
+      this.initializedGeneration = started.generation
+    } catch (error) {
+      this.generation = null
+      this.initializedGeneration = null
+      this.cwd = ""
+      await invoke("codex_app_server_stop").catch(() => undefined)
+      throw error
+    }
+  }
+
+  private async ensureListeners(): Promise<void> {
+    if (this.unlistenEvent && this.unlistenExit) return
+    if (this.listenersPromise) return this.listenersPromise
+    this.listenersPromise = (async () => {
+      this.unlistenEvent = await listen<EventPayload>("codex-app-server:event", (event) => {
+        const payload = event.payload
+        if (!payload || payload.generation !== this.generation) return
+        this.handleLine(payload.line)
+      })
+      this.unlistenExit = await listen<ExitPayload>("codex-app-server:exit", (event) => {
+        if (event.payload?.generation !== this.generation) return
+        this.generation = null
+        this.initializedGeneration = null
+        this.cwd = ""
+        const error = new Error(APP_SERVER_EXIT_MESSAGE)
+        this.rejectAll(error)
+        for (const handler of this.handlers.values()) {
+          handler.onEnvelope?.({ method: "qmai/app-server-exit", params: { message: error.message } })
+        }
+        this.handlers.clear()
+      })
+    })().finally(() => {
+      this.listenersPromise = null
+    })
+    return this.listenersPromise
+  }
+
+  private rawCall<T>(
+    method: string,
+    params?: Record<string, unknown>,
+    timeoutMs = DEFAULT_RPC_TIMEOUT_MS,
+  ): Promise<T> {
+    const id = this.nextId++
+    return new Promise<T>((resolve, reject) => {
+      const timer = setTimeout(() => {
+        this.pending.delete(id)
+        reject(new Error(`Codex app-server 调用超时:${method}`))
+      }, timeoutMs)
+      this.pending.set(id, {
+        resolve: (value) => resolve(value as T),
+        reject,
+        timer,
+      })
+      void this.send({
+        jsonrpc: "2.0",
+        id,
+        method,
+        ...(params ? { params } : {}),
+      }).catch((error) => {
+        const pending = this.pending.get(id)
+        if (!pending) return
+        clearTimeout(pending.timer)
+        this.pending.delete(id)
+        pending.reject(rpcError(error, `Codex app-server 写入失败:${method}`))
+      })
+    })
+  }
+
+  private async send(envelope: CodexAppServerEnvelope): Promise<void> {
+    if (this.generation === null) throw new Error("Codex app-server 未启动")
+    await invoke("codex_app_server_write", {
+      generation: this.generation,
+      data: JSON.stringify(envelope),
+    })
+  }
+
+  private handleLine(line: string): void {
+    let envelope: CodexAppServerEnvelope
+    try {
+      envelope = JSON.parse(line) as CodexAppServerEnvelope
+    } catch {
+      return
+    }
+
+    if (envelope.id !== undefined && !envelope.method) {
+      const pending = this.pending.get(envelope.id)
+      if (!pending) return
+      clearTimeout(pending.timer)
+      this.pending.delete(envelope.id)
+      if (envelope.error) {
+        pending.reject(new Error(envelope.error.message || "Codex app-server 调用失败"))
+      } else {
+        pending.resolve(envelope.result)
+      }
+      return
+    }
+
+    const threadId = typeof envelope.params?.threadId === "string"
+      ? envelope.params.threadId
+      : null
+    const handler = threadId ? this.handlers.get(threadId) : undefined
+    handler?.onEnvelope?.(envelope)
+
+    if (envelope.id === undefined || !envelope.method) return
+    if (envelope.method === "item/tool/call" && handler?.onDynamicToolCall) {
+      void handler.onDynamicToolCall(envelope.params as unknown as DynamicToolCallRequest)
+        .then((result) => this.send({ jsonrpc: "2.0", id: envelope.id!, result }))
+        .catch((error) => this.send({
+          jsonrpc: "2.0",
+          id: envelope.id!,
+          result: {
+            contentItems: [{ type: "inputText", text: rpcError(error, "QMAI 工具执行失败").message }],
+            success: false,
+          },
+        }))
+        .catch(() => undefined)
+      return
+    }
+
+    void this.send({
+      jsonrpc: "2.0",
+      id: envelope.id,
+      error: {
+        code: -32601,
+        message: `QMAI 禁止 Codex 原生能力:${envelope.method}`,
+      },
+    }).catch(() => undefined)
+  }
+
+  private rejectAll(error: Error): void {
+    for (const pending of this.pending.values()) {
+      clearTimeout(pending.timer)
+      pending.reject(error)
+    }
+    this.pending.clear()
+  }
+}
+
+let sharedClient: CodexAppServerClient | null = null
+
+export function getCodexAppServerClient(): CodexAppServerClient {
+  if (!sharedClient) sharedClient = new CodexAppServerClient()
+  return sharedClient
+}
+
+export function resetCodexAppServerClientForTests(): void {
+  sharedClient = null
+}

+ 25 - 0
src/lib/codex-cli-model.spec.ts

@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest"
+import {
+  CODEX_CLI_SUGGESTED_MODELS,
+  DEFAULT_CODEX_CLI_MODEL,
+  migrateLegacyDefaultCodexCliModel,
+} from "./codex-cli-model"
+
+describe("Codex CLI model defaults", () => {
+  it("uses the current 5.6 family and maps the old mini role to Terra", () => {
+    expect(DEFAULT_CODEX_CLI_MODEL).toBe("gpt-5.6-terra")
+    expect(CODEX_CLI_SUGGESTED_MODELS).toEqual([
+      "gpt-5.6-terra",
+      "gpt-5.6-sol",
+      "gpt-5.6-luna",
+    ])
+  })
+
+  it("migrates only the missing or exact legacy default", () => {
+    expect(migrateLegacyDefaultCodexCliModel(undefined)).toBe("gpt-5.6-terra")
+    expect(migrateLegacyDefaultCodexCliModel("  ")).toBe("gpt-5.6-terra")
+    expect(migrateLegacyDefaultCodexCliModel("gpt-5.4-mini")).toBe("gpt-5.6-terra")
+    expect(migrateLegacyDefaultCodexCliModel("gpt-5.4")).toBe("gpt-5.4")
+    expect(migrateLegacyDefaultCodexCliModel(" gpt-5.6-sol ")).toBe("gpt-5.6-sol")
+  })
+})

+ 16 - 0
src/lib/codex-cli-model.ts

@@ -0,0 +1,16 @@
+export const DEFAULT_CODEX_CLI_MODEL = "gpt-5.6-terra"
+export const LEGACY_DEFAULT_CODEX_CLI_MODEL = "gpt-5.4-mini"
+
+export const CODEX_CLI_SUGGESTED_MODELS = [
+  DEFAULT_CODEX_CLI_MODEL,
+  "gpt-5.6-sol",
+  "gpt-5.6-luna",
+] as const
+
+export function migrateLegacyDefaultCodexCliModel(model: string | undefined): string {
+  const normalized = model?.trim() ?? ""
+  if (!normalized || normalized === LEGACY_DEFAULT_CODEX_CLI_MODEL) {
+    return DEFAULT_CODEX_CLI_MODEL
+  }
+  return normalized
+}

+ 24 - 0
src/lib/codex-cli-timeout.spec.ts

@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest"
+import {
+  DEFAULT_CODEX_CLI_TIMEOUT_MINUTES,
+  migrateLegacyCodexCliTimeoutMinutes,
+  resolveCodexCliTimeoutMinutes,
+} from "./codex-cli-timeout"
+
+describe("Codex CLI timeout", () => {
+  it("defaults missing values to 40 minutes", () => {
+    expect(DEFAULT_CODEX_CLI_TIMEOUT_MINUTES).toBe(40)
+    expect(resolveCodexCliTimeoutMinutes(undefined)).toBe(40)
+  })
+
+  it("keeps explicit runtime values within the supported range", () => {
+    expect(resolveCodexCliTimeoutMinutes(20)).toBe(20)
+    expect(resolveCodexCliTimeoutMinutes(300)).toBe(240)
+  })
+
+  it("lifts legacy values below 40 once without lowering larger values", () => {
+    expect(migrateLegacyCodexCliTimeoutMinutes(undefined)).toBe(40)
+    expect(migrateLegacyCodexCliTimeoutMinutes(20)).toBe(40)
+    expect(migrateLegacyCodexCliTimeoutMinutes(60)).toBe(60)
+  })
+})

+ 18 - 0
src/lib/codex-cli-timeout.ts

@@ -0,0 +1,18 @@
+export const DEFAULT_CODEX_CLI_TIMEOUT_MINUTES = 40
+export const MAX_CODEX_CLI_TIMEOUT_MINUTES = 240
+
+export function resolveCodexCliTimeoutMinutes(value: number | undefined): number {
+  if (!Number.isFinite(value)) return DEFAULT_CODEX_CLI_TIMEOUT_MINUTES
+  return Math.max(1, Math.min(MAX_CODEX_CLI_TIMEOUT_MINUTES, Math.floor(value as number)))
+}
+
+/**
+ * One-time persisted-config migration. Old releases defaulted to 10 minutes,
+ * and some existing users saved 20 minutes explicitly. Lift every legacy
+ * value below the new default once; after the migration marker is written,
+ * later deliberate user reductions remain untouched.
+ */
+export function migrateLegacyCodexCliTimeoutMinutes(value: number | undefined): number {
+  const resolved = resolveCodexCliTimeoutMinutes(value)
+  return Math.max(DEFAULT_CODEX_CLI_TIMEOUT_MINUTES, resolved)
+}

+ 155 - 0
src/lib/codex-cli-transport.spec.ts

@@ -0,0 +1,155 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+
+const clientMock = vi.hoisted(() => ({
+  call: vi.fn(),
+  handler: null as null | { onEnvelope?: (envelope: Record<string, unknown>) => void },
+  interrupt: vi.fn(async () => undefined),
+}))
+
+vi.mock("./codex-app-server-client", () => ({
+  getCodexAppServerClient: () => ({
+    isolatedCwd: "/tmp/qmai/workspace",
+    ensureStarted: vi.fn(async () => undefined),
+    call: clientMock.call,
+    interrupt: clientMock.interrupt,
+    registerThread: (_threadId: string, handler: typeof clientMock.handler) => {
+      clientMock.handler = handler
+      return () => {
+        clientMock.handler = null
+      }
+    },
+  }),
+}))
+
+import {
+  buildCodexTurnInput,
+  codexNativeBoundaryError,
+  restrictedCodexConfig,
+  streamCodexCli,
+} from "./codex-cli-transport"
+
+const config: LlmConfig = {
+  provider: "codex-cli",
+  apiKey: "",
+  model: "gpt-test",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 8192,
+  codexCliTimeoutMinutes: 10,
+}
+
+describe("codex app-server transport", () => {
+  beforeEach(() => {
+    clientMock.call.mockReset()
+    clientMock.handler = null
+    clientMock.interrupt.mockClear()
+  })
+
+  it("disables native extension, shell, web, image, and project-rule surfaces", () => {
+    const restricted = restrictedCodexConfig()
+    expect(restricted).toEqual(expect.objectContaining({
+      web_search: "disabled",
+      project_doc_max_bytes: 0,
+      project_root_markers: [],
+      tools: { view_image: false, web_search: false },
+      features: expect.objectContaining({
+        apps: false,
+        plugins: false,
+        shell_tool: false,
+        multi_agent: false,
+        skill_search: false,
+      }),
+    }))
+  })
+
+  it("serializes trimmed chat history and images into turn input", () => {
+    const input = buildCodexTurnInput([
+      { role: "system", content: "system" },
+      { role: "user", content: [
+        { type: "text", text: "看图" },
+        { type: "image", mediaType: "image/png", dataBase64: "YWJj" },
+      ] },
+      { role: "assistant", content: "收到" },
+    ])
+    expect(input[0].text).toContain("<USER>\n看图")
+    expect(input[0].text).toContain("<ASSISTANT>\n收到")
+    expect(input).toContainEqual({ type: "image", url: "data:image/png;base64,YWJj" })
+  })
+
+  it("allows only QMAI dynamic tool server requests and rejects native MCP notifications", () => {
+    const dynamic = { id: 1, method: "item/tool/call", params: {} }
+    expect(codexNativeBoundaryError(dynamic, true)).toBeNull()
+    expect(codexNativeBoundaryError(dynamic)).toEqual(expect.objectContaining({
+      message: expect.stringContaining("禁止 Codex 原生能力"),
+    }))
+    expect(codexNativeBoundaryError({
+      method: "mcpServer/startupStatus/updated",
+      params: {},
+    })).toEqual(expect.objectContaining({
+      message: expect.stringContaining("mcpServer/startupStatus/updated"),
+    }))
+    expect(codexNativeBoundaryError({
+      method: "hook/started",
+      params: {},
+    })).toEqual(expect.objectContaining({
+      message: expect.stringContaining("hook/started"),
+    }))
+  })
+
+  it("uses app-server for plain streamChat without exposing dynamic tools", async () => {
+    clientMock.call.mockImplementation(async (method: string, params: Record<string, unknown>) => {
+      if (method === "thread/start") {
+        expect(params).not.toHaveProperty("dynamicTools")
+        return { thread: { id: "thread-text" }, instructionSources: [] }
+      }
+      if (method === "turn/start") {
+        queueMicrotask(() => {
+          clientMock.handler?.onEnvelope?.({
+            method: "item/agentMessage/delta",
+            params: { threadId: "thread-text", delta: "纯文本结果" },
+          })
+          clientMock.handler?.onEnvelope?.({
+            method: "turn/completed",
+            params: { threadId: "thread-text", turn: { status: "completed" } },
+          })
+        })
+        return { turn: { id: "turn-text" } }
+      }
+      throw new Error(`unexpected method: ${method}`)
+    })
+    const callbacks = {
+      onToken: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+
+    await streamCodexCli(config, [
+      { role: "system", content: "只输出正文" },
+      { role: "user", content: "生成大纲" },
+    ], callbacks)
+
+    expect(callbacks.onToken).toHaveBeenCalledWith("纯文本结果")
+    expect(callbacks.onDone).toHaveBeenCalledOnce()
+    expect(callbacks.onError).not.toHaveBeenCalled()
+  })
+
+  it("fails closed when app-server reports inherited instruction sources", async () => {
+    clientMock.call.mockResolvedValueOnce({
+      thread: { id: "thread-bad" },
+      instructionSources: ["/Users/test/.codex/AGENTS.md"],
+    })
+    const callbacks = {
+      onToken: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+
+    await streamCodexCli(config, [{ role: "user", content: "测试" }], callbacks)
+
+    expect(callbacks.onError).toHaveBeenCalledWith(expect.objectContaining({
+      message: expect.stringContaining("禁止 Codex 加载本机或项目规则"),
+    }))
+    expect(callbacks.onDone).not.toHaveBeenCalled()
+  })
+})

+ 219 - 209
src/lib/codex-cli-transport.ts

@@ -1,95 +1,122 @@
-/**
- * Codex CLI subprocess transport.
- *
- * Rust-side counterpart: src-tauri/src/commands/codex_cli.rs. The Rust
- * command spawns `codex exec --json`, sends a single reconstructed prompt
- * over stdin, and emits each JSONL stdout line back as `codex-cli:{streamId}`.
- */
-
-import { invoke } from "@tauri-apps/api/core"
-import { listen, type UnlistenFn } from "@tauri-apps/api/event"
 import type { LlmConfig } from "@/stores/wiki-store"
+import { resolveCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
 import type { ChatMessage, ContentBlock, RequestOverrides } from "./llm-providers"
 import type { StreamCallbacks } from "./llm-client"
+import { getCodexAppServerClient, type CodexAppServerEnvelope } from "./codex-app-server-client"
 
-export function parseCodexCliLine(rawLine: string): string | null {
-  const line = rawLine.trim()
-  if (!line) return null
+interface ThreadStartResponse {
+  thread: { id: string }
+  instructionSources?: string[]
+}
 
-  let evt: unknown
-  try {
-    evt = JSON.parse(line)
-  } catch {
-    return null
-  }
+interface TurnStartResponse {
+  turn: { id: string }
+}
 
-  if (!evt || typeof evt !== "object") return null
-  const obj = evt as Record<string, unknown>
-  if (obj.type !== "item.completed") return null
+const NATIVE_ITEM_TYPES = new Set([
+  "commandExecution",
+  "fileChange",
+  "mcpToolCall",
+  "collabAgentToolCall",
+  "subAgentActivity",
+  "webSearch",
+  "imageGeneration",
+  "hookPrompt",
+])
 
-  const item = obj.item as Record<string, unknown> | undefined
-  if (item?.type !== "agent_message") return null
-  return typeof item.text === "string" && item.text.length > 0 ? item.text : null
-}
+const NATIVE_METHOD_PREFIXES = [
+  "app/",
+  "command/",
+  "environment/",
+  "fs/",
+  "hook/",
+  "mcpServer/",
+  "plugin/",
+  "process/",
+  "skills/",
+  "thread/backgroundTerminals/",
+]
 
-export function extractCodexCliError(rawOutput: string): string {
-  let lastError = ""
-  for (const line of rawOutput.split(/\r?\n/)) {
-    const trimmed = line.trim()
-    if (!trimmed) continue
-    try {
-      const parsed = JSON.parse(trimmed) as {
-        type?: string
-        message?: unknown
-        error?: { message?: unknown }
-      }
-      const message = typeof parsed.error?.message === "string"
-        ? parsed.error.message
-        : typeof parsed.message === "string"
-          ? parsed.message
-          : ""
-      if (parsed.type === "turn.failed" && message) return message
-      if (parsed.type === "error" && message && !/^Reconnecting\.\.\./i.test(message)) {
-        lastError = message
-      }
-    } catch {
-      // Keep the original output as fallback below.
-    }
+export function restrictedCodexConfig(): Record<string, unknown> {
+  return {
+    features: {
+      apps: false,
+      browser_use: false,
+      browser_use_external: false,
+      browser_use_full_cdp_access: false,
+      computer_use: false,
+      image_generation: false,
+      in_app_browser: false,
+      multi_agent: false,
+      multi_agent_v2: false,
+      plugins: false,
+      remote_plugin: false,
+      shell_snapshot: false,
+      shell_tool: false,
+      skill_mcp_dependency_install: false,
+      skill_search: false,
+    },
+    web_search: "disabled",
+    project_doc_max_bytes: 0,
+    project_doc_fallback_filenames: [],
+    project_root_markers: [],
+    tools: {
+      view_image: false,
+      web_search: false,
+    },
   }
-  return lastError || rawOutput.trim()
 }
 
-function contentToText(content: string | ContentBlock[]): string {
-  if (typeof content === "string") return content
-  return content
-    .map((block) => {
-      if (block.type === "text") return block.text
-      return `[Image omitted: ${block.mediaType}]`
-    })
-    .join("\n")
+export function codexReasoningEffort(config: LlmConfig): string | null {
+  const mode = config.reasoning?.mode
+  if (!mode || mode === "auto" || mode === "off" || mode === "custom") return null
+  return mode
 }
 
-function escapePromptContent(text: string): string {
-  return text.replace(/<\/?[A-Z_][A-Z0-9_]*>/gi, (tag) =>
-    tag.replace(/</g, "&lt;").replace(/>/g, "&gt;"),
-  )
+function contentText(content: string | ContentBlock[]): string {
+  if (typeof content === "string") return content
+  return content.filter((block) => block.type === "text").map((block) => block.text).join("\n")
 }
 
-export function buildPrompt(messages: ChatMessage[]): string {
-  return messages
-    .map((message) => {
-      const role = message.role.toUpperCase()
-      return `<${role}>\n${escapePromptContent(contentToText(message.content))}\n</${role}>`
-    })
+export function buildCodexTurnInput(messages: ChatMessage[]): Array<Record<string, unknown>> {
+  const nonSystem = messages.filter((message) => message.role !== "system")
+  const text = nonSystem
+    .map((message) => `<${message.role.toUpperCase()}>\n${contentText(message.content)}\n</${message.role.toUpperCase()}>`)
     .join("\n\n")
+  const input: Array<Record<string, unknown>> = [{ type: "text", text, text_elements: [] }]
+  for (const message of nonSystem) {
+    if (!Array.isArray(message.content)) continue
+    for (const block of message.content) {
+      if (block.type !== "image") continue
+      input.push({
+        type: "image",
+        url: `data:${block.mediaType};base64,${block.dataBase64}`,
+      })
+    }
+  }
+  return input
 }
 
-type SpawnPayload = Record<string, unknown> & {
-  streamId: string
-  model: string
-  prompt: string
-  isolateLocalConfig: boolean
-  timeoutMinutes?: number
+export function codexNativeBoundaryError(
+  envelope: CodexAppServerEnvelope,
+  allowDynamicTools = false,
+): Error | null {
+  if (envelope.method === "qmai/app-server-exit") {
+    return new Error(String(envelope.params?.message || "Codex app-server 已退出"))
+  }
+  if (envelope.method && NATIVE_METHOD_PREFIXES.some((prefix) => envelope.method!.startsWith(prefix))) {
+    return new Error(`QMAI 禁止 Codex 原生能力:${envelope.method}`)
+  }
+  if (envelope.id !== undefined && envelope.method && !(allowDynamicTools && envelope.method === "item/tool/call")) {
+    return new Error(`QMAI 禁止 Codex 原生能力:${envelope.method}`)
+  }
+  if (envelope.method !== "item/started" && envelope.method !== "item/completed") return null
+  const item = envelope.params?.item
+  if (!item || typeof item !== "object") return null
+  const type = (item as Record<string, unknown>).type
+  return typeof type === "string" && NATIVE_ITEM_TYPES.has(type)
+    ? new Error(`QMAI 禁止 Codex 原生能力:${type}`)
+    : null
 }
 
 export async function streamCodexCli(
@@ -97,157 +124,140 @@ export async function streamCodexCli(
   messages: ChatMessage[],
   callbacks: StreamCallbacks,
   signal?: AbortSignal,
-  overrides?: RequestOverrides,
+  _overrides?: RequestOverrides,
 ): Promise<void> {
-  const { onToken, onDone, onError } = callbacks
-
-  if (import.meta.env?.DEV && overrides) {
-    for (const key of ["temperature", "top_p", "top_k", "max_tokens", "stop"] as const) {
-      if (overrides[key] !== undefined) {
-        // eslint-disable-next-line no-console
-        console.warn(`[codex-cli] ignoring unsupported override "${key}": CLI has no equivalent flag`)
-      }
-    }
-  }
-
-  const streamId = crypto.randomUUID()
-  let unlistenData: UnlistenFn | (() => void) | undefined
-  let unlistenDone: UnlistenFn | (() => void) | undefined
+  const client = getCodexAppServerClient()
+  let threadId = ""
+  let turnId = ""
   let finished = false
-  let aborted = signal?.aborted ?? false
-  let emittedAgentMessage = false
-  let resolveCompletion: () => void = () => {}
-  const completion = new Promise<void>((resolve) => {
-    resolveCompletion = resolve
+  let emittedText = false
+  const agentMessagePhases = new Map<string, string | null>()
+  let unregister = () => {}
+  let resolveTurn = () => {}
+  let rejectTurn = (_error: Error) => {}
+  const turnDone = new Promise<void>((resolve, reject) => {
+    resolveTurn = resolve
+    rejectTurn = reject
   })
+  const timeoutMinutes = resolveCodexCliTimeoutMinutes(config.codexCliTimeoutMinutes)
+  const timeoutMs = timeoutMinutes * 60_000
+  const timeout = setTimeout(() => {
+    if (!finished) {
+      if (threadId && turnId) void client.interrupt(threadId, turnId)
+      fail(new Error(`Codex app-server 超时(${timeoutMinutes} 分钟)`))
+    }
+  }, timeoutMs)
 
-  const unparsedLines: string[] = []
-  let unparsedSize = 0
-  function captureUnparsed(line: string) {
-    if (unparsedSize >= 4096) return
-    const trimmed = line.trim()
-    if (!trimmed) return
-    unparsedLines.push(line)
-    unparsedSize += line.length + 1
-  }
-
-  const cleanup = () => {
-    unlistenData?.()
-    unlistenDone?.()
-  }
-
-  const finishWith = (cb: () => void) => {
+  const fail = (error: Error) => {
     if (finished) return
     finished = true
-    cleanup()
-    cb()
-    resolveCompletion()
-  }
-
-  const replayAgentMessagesFromStdout = (stdout: string | undefined) => {
-    if (!stdout) return
-
-    for (const line of stdout.split(/\r?\n/)) {
-      const token = parseCodexCliLine(line)
-      if (token !== null) {
-        emittedAgentMessage = true
-        onToken(token)
-      }
-    }
-  }
-
-  const abortListener = () => {
-    aborted = true
-    void invoke("codex_cli_kill", { streamId }).catch(() => {})
-    finishWith(onDone)
+    rejectTurn(error)
   }
-  if (aborted) {
-    finishWith(onDone)
-    return
-  }
-  signal?.addEventListener("abort", abortListener)
 
   try {
-    // ── Tauri mode: use Tauri listen + invoke ──
-    unlistenData = await listen<string>(`codex-cli:${streamId}`, (event) => {
-      const token = parseCodexCliLine(event.payload)
-      if (token !== null) {
-        emittedAgentMessage = true
-        onToken(token)
-      } else {
-        captureUnparsed(event.payload)
-      }
+    await client.ensureStarted()
+    const systemPrompt = messages
+      .filter((message) => message.role === "system")
+      .map((message) => contentText(message.content))
+      .join("\n\n")
+    const started = await client.call<ThreadStartResponse>("thread/start", {
+      model: config.model.trim() || null,
+      cwd: client.isolatedCwd,
+      approvalPolicy: "never",
+      sandbox: "read-only",
+      ephemeral: true,
+      baseInstructions: systemPrompt || "You are QMAI's text generation model.",
+      developerInstructions: "Only produce the requested text. Never use native tools, shell, file changes, MCP, plugins, skills, apps, browser, or subagents.",
+      config: restrictedCodexConfig(),
     })
-    if (aborted || finished) {
-      cleanup()
-      return
+    if (started.instructionSources?.length) {
+      throw new Error(`QMAI 禁止 Codex 加载本机或项目规则:${started.instructionSources.join(", ")}`)
     }
-
-    unlistenDone = await listen<{ code: number | null; stderr: string; stdout?: string }>(
-      `codex-cli:${streamId}:done`,
-      (event) => {
-        const code = event.payload?.code
-        const stderr = event.payload?.stderr?.trim() ?? ""
-        const stdout = event.payload?.stdout ?? ""
-        if (code !== null && code !== undefined && code !== 0) {
-          const details = stderr || extractCodexCliError(stdout) || extractCodexCliError(unparsedLines.join("\n"))
-          finishWith(() =>
-            onError(new Error(
-              details
-                ? `Codex CLI exited with code ${code}:\n${details}`
-                : `Codex CLI exited with code ${code}. Run \`codex\` in a terminal to inspect the problem.`,
-            )),
-          )
-        } else {
-          if (!emittedAgentMessage) replayAgentMessagesFromStdout(stdout)
-          if (!emittedAgentMessage) {
-            const details = stdout.trim() || unparsedLines.join("\n").trim()
-            finishWith(() =>
-              onError(new Error(
-                details
-                  ? `Codex CLI completed but did not emit an agent_message. Raw output:\n${details}`
-                  : "Codex CLI completed but did not emit an agent_message. Run `codex exec --json` in a terminal to inspect the provider output.",
-              )),
-            )
-          } else {
-            finishWith(onDone)
+    threadId = started.thread.id
+    unregister = client.registerThread(threadId, {
+      onEnvelope: (envelope) => {
+        const boundaryError = codexNativeBoundaryError(envelope)
+        if (boundaryError) {
+          fail(boundaryError)
+          if (turnId) void client.interrupt(threadId, turnId)
+          return
+        }
+        if (envelope.method === "item/started") {
+          const item = envelope.params?.item as Record<string, unknown> | undefined
+          if (item?.type === "agentMessage" && typeof item.id === "string") {
+            agentMessagePhases.set(item.id, typeof item.phase === "string" ? item.phase : null)
+          }
+        } else if (envelope.method === "item/agentMessage/delta") {
+          const delta = envelope.params?.delta
+          const itemId = typeof envelope.params?.itemId === "string" ? envelope.params.itemId : ""
+          if (typeof delta === "string" && delta && agentMessagePhases.get(itemId) !== "commentary") {
+            emittedText = true
+            callbacks.onToken(delta)
+          }
+        } else if (
+          envelope.method === "item/reasoning/summaryTextDelta" ||
+          envelope.method === "item/reasoning/textDelta"
+        ) {
+          const delta = envelope.params?.delta
+          if (typeof delta === "string" && delta) callbacks.onReasoningToken?.(delta)
+        } else if (envelope.method === "thread/tokenUsage/updated") {
+          const last = (envelope.params?.tokenUsage as Record<string, unknown> | undefined)?.last as Record<string, unknown> | undefined
+          if (last) {
+            callbacks.onUsage?.({
+              inputTokens: Number(last.inputTokens) || 0,
+              outputTokens: Number(last.outputTokens) || 0,
+              totalTokens: Number(last.totalTokens) || 0,
+              cachedInputTokens: Number(last.cachedInputTokens) || 0,
+              cacheWriteInputTokens: Number(last.cacheWriteInputTokens) || 0,
+            })
+          }
+        } else if (envelope.method === "item/completed" && !emittedText) {
+          const item = envelope.params?.item as Record<string, unknown> | undefined
+          if (item?.type === "agentMessage" && item.phase !== "commentary" && typeof item.text === "string") {
+            emittedText = true
+            callbacks.onToken(item.text)
+          }
+        } else if (envelope.method === "turn/completed") {
+          const turn = envelope.params?.turn as Record<string, unknown> | undefined
+          if (turn?.status === "failed") {
+            const error = turn.error as Record<string, unknown> | undefined
+            fail(new Error(String(error?.message || "Codex app-server turn 失败")))
+          } else if (turn?.status === "interrupted") {
+            fail(new Error("操作已取消"))
+          } else if (!finished) {
+            finished = true
+            resolveTurn()
           }
         }
       },
-    )
-    if (aborted || finished) {
-      cleanup()
-      return
-    }
-
-    const payload: SpawnPayload = {
-      streamId,
-      model: config.model,
-      prompt: buildPrompt(messages),
-      isolateLocalConfig: config.localCliIsolation === true,
-      timeoutMinutes: config.codexCliTimeoutMinutes,
+    })
+    const turn = await client.call<TurnStartResponse>("turn/start", {
+      threadId,
+      input: buildCodexTurnInput(messages),
+      cwd: client.isolatedCwd,
+      approvalPolicy: "never",
+      sandboxPolicy: { type: "readOnly", networkAccess: false },
+      model: config.model.trim() || null,
+      effort: codexReasoningEffort(config),
+    })
+    turnId = turn.turn.id
+    if (finished) void client.interrupt(threadId, turnId)
+    const abort = () => {
+      if (turnId) void client.interrupt(threadId, turnId)
+      fail(new Error("操作已取消"))
     }
-    await invoke("codex_cli_spawn", payload)
-
-    if (aborted || signal?.aborted) {
-      aborted = true
-      await invoke("codex_cli_kill", { streamId }).catch(() => {})
-      finishWith(onDone)
-      return
+    signal?.addEventListener("abort", abort, { once: true })
+    if (signal?.aborted) abort()
+    try {
+      await turnDone
+    } finally {
+      signal?.removeEventListener("abort", abort)
     }
-    await completion
-  } catch (err) {
-    finishWith(() => {
-      const message = err instanceof Error ? err.message : String(err)
-      if (/not found|No such file|executable file not found/i.test(message)) {
-        onError(new Error(
-          "Codex CLI not found. Install `codex` with `npm install -g @openai/codex` or pick a different provider.",
-        ))
-      } else {
-        onError(err instanceof Error ? err : new Error(message))
-      }
-    })
+    callbacks.onDone()
+  } catch (error) {
+    callbacks.onError(error instanceof Error ? error : new Error(String(error)))
   } finally {
-    signal?.removeEventListener("abort", abortListener)
+    clearTimeout(timeout)
+    unregister()
   }
 }

+ 29 - 0
src/lib/context-hub/data-source-cache.spec.ts

@@ -76,6 +76,35 @@ describe("DataSourceCacheAdapter", () => {
     ])
   })
 
+  it("uses a versioned key for chapter outlines so stale wrong-chapter artifacts are not reused", async () => {
+    const harness = createHarness()
+    const source: DataSource<string> = { name: "chapterOutline", priority: 1, load: async () => "" }
+
+    await harness.adapter.load(source, context, async () => "第2章章纲")
+
+    expect(harness.storage.readArtifact).toHaveBeenCalledWith(
+      expect.stringMatching(/^data-source:chapterOutline:v3:/),
+    )
+    expect(harness.storage.writeArtifact).toHaveBeenCalledWith(
+      expect.stringMatching(/^data-source:chapterOutline:v3:/),
+      expect.objectContaining({ sourceName: "chapterOutline", value: "第2章章纲" }),
+    )
+  })
+
+  it("includes task text in section briefing cache keys for on-demand character selection", async () => {
+    const harness = createHarness()
+    const source: DataSource<string> = { name: "sectionBriefing", priority: 1, load: async () => "" }
+    const directLoad = vi.fn(async () => "人物速记")
+
+    await harness.adapter.load(source, context, directLoad)
+    await harness.adapter.load(source, { ...context, task: "改由另一个人物处理" }, directLoad)
+
+    expect(directLoad).toHaveBeenCalledTimes(2)
+    expect(harness.storage.readArtifact.mock.calls[0]?.[0]).not.toBe(
+      harness.storage.readArtifact.mock.calls[1]?.[0],
+    )
+  })
+
   it("marks empty values as empty and does not count them as reloaded", async () => {
     const harness = createHarness()
     const source: DataSource<string> = { name: "outline", priority: 1, load: async () => "" }

+ 12 - 2
src/lib/context-hub/data-source-cache.ts

@@ -62,10 +62,18 @@ const CHAPTER_SCOPED_SOURCES = new Set([
   "fallbackTimeline",
   "revisionFeedback",
   "cognitionText",
-  "sectionBriefing",
   "retrieval",
 ])
 
+// Bump only the affected data source when its extraction semantics change.
+// This prevents a previously cached wrong-chapter outline from surviving the fix.
+const SOURCE_CACHE_VERSIONS: Partial<Record<string, number>> = {
+  outline: 2,
+  chapterOutline: 3,
+  volumeContext: 2,
+  sectionBriefing: 2,
+}
+
 function canonicalize(value: unknown): unknown {
   if (Array.isArray(value)) return value.map(canonicalize)
   if (!value || typeof value !== "object") return value
@@ -82,7 +90,9 @@ async function sourceRequestKey(sourceName: string, context: ContextLoadContext)
     : CHAPTER_SCOPED_SOURCES.has(sourceName)
       ? { chapterNumber: context.chapterNumber ?? null, config: context.config }
       : { task: context.task, chapterNumber: context.chapterNumber ?? null, config: context.config }
-  return `data-source:${sourceName}:${await sha256Text(JSON.stringify(canonicalize(scope)))}`
+  const version = SOURCE_CACHE_VERSIONS[sourceName]
+  const versionSuffix = version ? `:v${version}` : ""
+  return `data-source:${sourceName}${versionSuffix}:${await sha256Text(JSON.stringify(canonicalize(scope)))}`
 }
 
 function dependencyStampsMatch(cached: DependencyStamp, current: DependencyStamp): boolean {

+ 5 - 0
src/lib/local-cli-config.ts

@@ -7,6 +7,9 @@ export interface LocalCliDetectResult {
   version: string | null
   path: string | null
   model?: string | null
+  appServerReady?: boolean
+  dynamicToolsReady?: boolean
+  models?: string[]
   error: string | null
 }
 
@@ -30,6 +33,8 @@ export async function resolveRuntimeLocalCliConfig(config: LlmConfig): Promise<L
     return config
   }
 
+  if (config.model.trim()) return config
+
   try {
     const detected = await detectLocalCliConfig(config.provider)
     const detectedModel = detected?.model?.trim() ?? ""

+ 9 - 21
src/lib/novel/context-data-sources.ts

@@ -10,7 +10,6 @@ import { parseChapterMeta } from "./chapter-meta"
 import { listSnapshots, loadSnapshot, type ChapterSnapshot } from "./chapter-ingest"
 import { loadRevisionFeedbackForContext, createEmptyRevisionFeedback } from "./revision-feedback"
 import { loadCognitionState, cognitionToContextText } from "./character-cognition"
-import { getChapterVolumes } from "./volume"
 import { readSoulDoc } from "./soul-doc"
 import { buildWritingStyleContext } from "./writing-style-store"
 import { buildSectionBriefing } from "./section-briefing"
@@ -25,6 +24,7 @@ import type { DataSourceCategory } from "./classification"
 import {
   readOutlineContent,
   readChapterOutlineContent,
+  readVolumeContextContent,
   searchRelevantContentUnified,
   searchGraphRelevantContent,
   selectLookbackChapterNumbers,
@@ -89,7 +89,7 @@ export const outlineDataSource: DataSource<string> = {
   name: "outline",
   priority: 1,
   async load(context: ContextLoadContext): Promise<string> {
-    return await readOutlineContent(context.projectPath)
+    return await readOutlineContent(context.projectPath, context.chapterNumber)
   },
 }
 
@@ -112,23 +112,7 @@ export const volumeContextDataSource: DataSource<string> = {
   name: "volumeContext",
   priority: 3,
   async load(context: ContextLoadContext): Promise<string> {
-    if (!context.chapterNumber) return ""
-    try {
-      const volumes = await getChapterVolumes(context.projectPath, context.chapterNumber)
-      if (volumes.length === 0) return ""
-      return volumes
-        .map(v => {
-          const parts = [`第${v.volumeNumber}卷:${v.title}`]
-          if (v.summary) parts.push(`概要:${v.summary}`)
-          if (v.chapterRangeStart !== undefined && v.chapterRangeEnd !== undefined) {
-            parts.push(`章节范围:第${v.chapterRangeStart}章 - 第${v.chapterRangeEnd}章`)
-          }
-          return parts.join("\n")
-        })
-        .join("\n\n")
-    } catch {
-      return ""
-    }
+    return await readVolumeContextContent(context.projectPath, context.chapterNumber)
   },
 }
 
@@ -492,8 +476,12 @@ export const sectionBriefingDataSource: DataSource<string> = {
   async load(context: ContextLoadContext): Promise<string> {
     if (!context.chapterNumber) return ""
     const chapterOutlineContent = await readChapterOutlineContent(context.projectPath, context.chapterNumber)
-    if (!chapterOutlineContent.trim()) return ""
-    return buildSectionBriefing(context.projectPath, context.chapterNumber, chapterOutlineContent)
+    return buildSectionBriefing(
+      context.projectPath,
+      context.chapterNumber,
+      chapterOutlineContent,
+      context.task,
+    )
   },
 }
 

+ 17 - 16
src/lib/novel/context-engine-outline-read.spec.ts

@@ -10,17 +10,24 @@ vi.mock("@/lib/search", () => ({
 }))
 
 import { listDirectory, readFile } from "@/commands/fs"
-import { searchWiki } from "@/lib/search"
 import { readOutlineContent } from "./context-engine"
 
 describe("纯 Markdown 大纲上下文读取", () => {
   beforeEach(() => {
     vi.clearAllMocks()
-    vi.mocked(searchWiki).mockResolvedValue([])
   })
 
-  it("搜索索引没有 type:outline 时从大纲目录读取并剥离历史 YAML", async () => {
+  it("只加载总纲和设定,不把章纲混入 outline", async () => {
     vi.mocked(listDirectory).mockResolvedValue([{
+      name: "大纲",
+      path: "C:/book/wiki/outlines/大纲",
+      is_dir: true,
+      children: [{
+        name: "总纲.md",
+        path: "C:/book/wiki/outlines/大纲/总纲.md",
+        is_dir: false,
+      }],
+    }, {
       name: "章纲",
       path: "C:/book/wiki/outlines/章纲",
       is_dir: true,
@@ -30,23 +37,17 @@ describe("纯 Markdown 大纲上下文读取", () => {
         is_dir: false,
       }],
     }])
-    vi.mocked(readFile).mockResolvedValue([
-      "---",
-      "type: outline",
-      "outline_type: chapter-outline",
-      "---",
-      "",
-      "# 第001章章纲",
-      "",
-      "- 主角进入旧城",
-    ].join("\n"))
+    vi.mocked(readFile).mockImplementation(async (path) => path.includes("总纲")
+      ? "---\ntype: outline\n---\n# 总纲\n主线内容"
+      : "---\ntype: outline\noutline_type: chapter-outline\n---\n# 第001章章纲\n不应混入")
 
     const result = await readOutlineContent("C:/book")
 
     expect(listDirectory).toHaveBeenCalledWith("C:/book/wiki/outlines")
-    expect(result).toContain("# 第001章章纲")
-    expect(result).toContain("主角进入旧城")
+    expect(result).toContain("# 总纲")
+    expect(result).toContain("主线内容")
+    expect(result).not.toContain("第001章章纲")
+    expect(result).not.toContain("不应混入")
     expect(result).not.toContain("type: outline")
-    expect(result).not.toContain("outline_type:")
   })
 })

+ 25 - 2
src/lib/novel/context-engine.spec.ts

@@ -1,7 +1,13 @@
 import { afterEach, beforeEach, describe, expect, it } from "vitest"
 import i18n from "@/i18n"
 import { charsPerTokenForLanguage } from "@/lib/context-budget"
-import { annotateChapterOutlineStatus, contextPackToPrompt, trimContextPack, type ContextPack } from "./context-engine"
+import {
+  annotateChapterOutlineStatus,
+  contextPackToPrompt,
+  pickChapterOutlineByNumber,
+  trimContextPack,
+  type ContextPack,
+} from "./context-engine"
 
 const basePack: ContextPack = {
   task: "生成第2章正文",
@@ -49,12 +55,29 @@ describe("annotateChapterOutlineStatus", () => {
     expect(annotateChapterOutlineStatus(content)).toBe(content)
   })
 
-  it("草稿章纲添加普通 AI 会话风险提示", () => {
+  it("草稿章纲只添加资料状态,不把状态变成生成阻断指令", () => {
     const result = annotateChapterOutlineStatus("## 基础信息\n- 当前状态:草稿\n")
 
     expect(result).toContain("章纲状态提示")
     expect(result).toContain("当前状态为「草稿」")
+    expect(result).toContain("不构成暂停生成、要求确认或向用户追问的指令")
     expect(result).toContain("不得自行补写或改写章纲")
+    expect(result).not.toContain("生成正文前应提醒用户确认")
+  })
+})
+
+describe("pickChapterOutlineByNumber", () => {
+  it("只返回目标章节,不把相邻章节当作搜索兜底", () => {
+    const candidates = [
+      {
+        path: "wiki/outlines/第237章-银河号介入.md",
+        content: "# 第237章:银河号介入\n\n后续承接:奥斯陆断线细节留第239章。",
+      },
+      { path: "wiki/outlines/第239章-批准书上的血迹.md", content: "# 第239章:批准书上的血迹" },
+    ]
+
+    expect(pickChapterOutlineByNumber(candidates, 239)).toContain("第239章")
+    expect(pickChapterOutlineByNumber(candidates.slice(0, 1), 239)).toBe("")
   })
 })
 

+ 46 - 87
src/lib/novel/context-engine.ts

@@ -2,7 +2,7 @@ import {
   charsPerTokenForLanguage,
   resolveContextPackTokenBudget,
 } from "@/lib/context-budget"
-import { listDirectory, readFile } from "@/commands/fs"
+import { readFile } from "@/commands/fs"
 import i18n from "@/i18n"
 import { searchWiki, tokenizeQuery } from "@/lib/search"
 import { normalizePath } from "@/lib/path-utils"
@@ -13,10 +13,8 @@ import { listSnapshots, loadSnapshot, type ChapterSnapshot } from "./chapter-ing
 import { buildRevisionDirectives } from "./revision-feedback"
 import { extractChapterOutlineStatus } from "./outline-quality-check"
 import { loadCognitionState, cognitionToContextText } from "./character-cognition"
-import { getChapterVolumes } from "./volume"
 import { isAuthoritativeGenerationPath, isHistoricalProjectionSnippet, novelMixedSearch } from "./search-adapter"
 import { rerankCandidates } from "@/lib/rerank"
-import type { FileNode } from "@/types/wiki"
 import {
   DataSourceRegistry,
   type ContextLoadContext,
@@ -28,7 +26,13 @@ import {
   buildNovelVectorSnippet,
   selectRelevantNovelVectorResults,
 } from "./vector-relevance"
-import { stripOutlineFrontmatter } from "./outline-markdown"
+import {
+  buildOutlineContext,
+  buildVolumeContext,
+  capOutlineSourcesToBudget,
+  loadOutlineDocumentIndex,
+  resolveChapterOutline,
+} from "./outline-context-index"
 
 const FIELD_PRIORITY: Record<string, number> = {
   sectionBriefing: 0,
@@ -428,43 +432,12 @@ function emptyPack(task: string): ContextPack {
   }
 }
 
-export async function readOutlineContent(pp: string): Promise<string> {
-  try {
-    const results = await searchWiki(pp, "outline type:outline")
-    if (results.length > 0) {
-      const contents = await Promise.all(
-        results.map(async (result) => {
-          try {
-            return stripOutlineFrontmatter(await readFile(result.path))
-          } catch {
-            return ""
-          }
-        }),
-      )
-      return joinNonEmpty(contents, "\n\n")
-    }
-  } catch {}
+export async function readOutlineContent(pp: string, chapterNumber?: number): Promise<string> {
   try {
-    const tree = await listDirectory(`${pp}/wiki/outlines`)
-    const files = flattenOutlineMarkdownFiles(tree).slice(0, 80)
-    const contents = await Promise.all(
-      files.map(async (file) => stripOutlineFrontmatter(await readFile(file.path)).trim()),
-    )
-    return joinNonEmpty(contents, "\n\n")
-  } catch {}
-  return ""
-}
-
-function flattenOutlineMarkdownFiles(nodes: FileNode[]): FileNode[] {
-  const files: FileNode[] = []
-  for (const node of nodes) {
-    if (node.is_dir) {
-      if (node.children) files.push(...flattenOutlineMarkdownFiles(node.children))
-      continue
-    }
-    if (node.name.toLowerCase().endsWith(".md")) files.push(node)
+    return buildOutlineContext(await loadOutlineDocumentIndex(pp), chapterNumber)
+  } catch {
+    return ""
   }
-  return files
 }
 
 function readFrontmatterChapterNumber(content: string): number | undefined {
@@ -505,6 +478,22 @@ function includesChapterMarker(text: string, chapterNumber: number): boolean {
     new RegExp(`chapter\\s*${chapterNumber}\\b`, "i").test(text)
 }
 
+function hasChapterHeading(content: string, chapterNumber: number): boolean {
+  const labels = chapterLabels(chapterNumber)
+  return content.split(/\r?\n/).some((line) => {
+    const compact = line.trim().replace(/\s+/g, "")
+    const heading = compact.replace(/^#{1,6}/, "")
+    if (heading === compact) return false
+    return labels.some((label) =>
+      heading === label ||
+      heading.startsWith(`${label}:`) ||
+      heading.startsWith(`${label}:`) ||
+      heading.startsWith(`${label}-`) ||
+      heading.startsWith(`${label}—`),
+    )
+  })
+}
+
 export function pickChapterOutlineByNumber(
   candidates: Array<{ path: string; content: string }>,
   chapterNumber: number,
@@ -513,50 +502,33 @@ export function pickChapterOutlineByNumber(
   if (frontmatterMatch) return frontmatterMatch.content.slice(0, 4000)
 
   const headingMatch = candidates.find((candidate) =>
-    includesChapterMarker(candidate.content, chapterNumber) || includesChapterMarker(candidate.path, chapterNumber),
+    hasChapterHeading(candidate.content, chapterNumber) || includesChapterMarker(candidate.path, chapterNumber),
   )
   if (headingMatch) return headingMatch.content.slice(0, 4000)
 
   return ""
 }
 
-async function readChapterOutlineDirect(pp: string, chapterNumber: number): Promise<string> {
+export async function readChapterOutlineContent(pp: string, chapterNumber?: number): Promise<string> {
+  if (!chapterNumber) return ""
   try {
-    const tree = await listDirectory(`${pp}/wiki/outlines`)
-    const files = flattenOutlineMarkdownFiles(tree)
-    const candidates = await Promise.all(
-      files.slice(0, 80).map(async (file) => ({
-        path: file.path,
-        content: await readFile(file.path).catch(() => ""),
-      })),
-    )
-    return pickChapterOutlineByNumber(
-      candidates.filter((candidate) => candidate.content.trim()),
-      chapterNumber,
-    )
+    const resolution = resolveChapterOutline(await loadOutlineDocumentIndex(pp), chapterNumber)
+    if (!resolution.content.trim()) return ""
+    return resolution.sourceKind === "standalone"
+      ? annotateChapterOutlineStatus(resolution.content)
+      : resolution.content
   } catch {
     return ""
   }
 }
 
-export async function readChapterOutlineContent(pp: string, chapterNumber?: number): Promise<string> {
+export async function readVolumeContextContent(pp: string, chapterNumber?: number): Promise<string> {
   if (!chapterNumber) return ""
-  const direct = await readChapterOutlineDirect(pp, chapterNumber)
-  if (direct.trim()) return annotateChapterOutlineStatus(direct)
-  const queries = [
-    `第${chapterNumber}章细纲 outline`,
-    `chapter ${chapterNumber} outline`,
-    `chapter_number:${chapterNumber} outline_type:chapter-outline`,
-  ]
-  for (const query of queries) {
-    try {
-      const results = await searchWiki(pp, query)
-      if (results.length > 0) {
-        return annotateChapterOutlineStatus(await readFile(results[0].path)).slice(0, 3000)
-      }
-    } catch {}
+  try {
+    return buildVolumeContext(await loadOutlineDocumentIndex(pp), chapterNumber)
+  } catch {
+    return ""
   }
-  return ""
 }
 
 export function annotateChapterOutlineStatus(content: string): string {
@@ -564,7 +536,7 @@ export function annotateChapterOutlineStatus(content: string): string {
   if (status === "已确认") return content
   const label = status === "未知" ? "未标明当前状态" : `当前状态为「${status}」`
   return [
-    `【章纲状态提示】该章纲${label},普通 AI 会话生成正文前应提醒用户确认是否继续使用;不得自行补写或改写章纲。`,
+    `【章纲状态提示】该章纲${label}。本标记仅说明资料状态,不构成暂停生成、要求确认或向用户追问的指令;用户已明确要求生成目标章节时,应按章纲已有内容执行,不得自行补写或改写章纲。`,
     "",
     content,
   ].join("\n")
@@ -766,22 +738,7 @@ async function readVolumeContext(
   chapterNumber: number | undefined,
 ): Promise<string> {
   if (!chapterNumber) return ""
-  try {
-    const volumes = await getChapterVolumes(pp, chapterNumber)
-    if (volumes.length === 0) return ""
-    return volumes
-      .map(v => {
-        const parts = [`第${v.volumeNumber}卷:${v.title}`]
-        if (v.summary) parts.push(`概要:${v.summary}`)
-        if (v.chapterRangeStart !== undefined && v.chapterRangeEnd !== undefined) {
-          parts.push(`章节范围:第${v.chapterRangeStart}章 - 第${v.chapterRangeEnd}章`)
-        }
-        return parts.join("\n")
-      })
-      .join("\n\n")
-  } catch {
-    return ""
-  }
+  return readVolumeContextContent(pp, chapterNumber)
 }
 
 export async function searchRelevantContent(
@@ -1264,7 +1221,9 @@ export function trimContextPack(
     const originalFieldChars = nextField.charCount
     
     if (targetContentChars > minKeepChars && nextField.charCount > targetContentChars) {
-      const trimmedContent = trimFieldContent(nextField.content, targetContentChars)
+      const trimmedContent = nextField.fieldKey === "outline" && typeof nextField.content === "string"
+        ? capOutlineSourcesToBudget(nextField.content, targetContentChars)
+        : trimFieldContent(nextField.content, targetContentChars)
       const keptContentChars = Array.isArray(trimmedContent)
         ? trimmedContent.reduce((sum, item) => sum + item.length, 0)
         : trimmedContent.length

+ 40 - 0
src/lib/novel/deep-chapter-generation.spec.ts

@@ -1673,6 +1673,46 @@ describe("runDeepChapterGeneration", () => {
     expect(thinking.join("\n")).toContain("阶段6:简单审查与去AI味")
   })
 
+  it("fails the workflow when expansion is still far below the minimum chapter length", async () => {
+    const responses = [
+      "写作任务书内容",
+      "请提供第239章章纲后再继续。",
+      "当前资料不足,无法扩写。",
+    ]
+    const deps: DeepChapterGenerationDeps = {
+      buildContextPack: vi.fn(async () => contextPack),
+      contextPackToPrompt: vi.fn(() => "上下文包内容"),
+      reviewChapter: vi.fn(async () => []),
+      streamChat: vi.fn(async (_config: LlmConfig, _messages: ChatMessage[], callbacks: StreamCallbacks) => {
+        callbacks.onToken(responses.shift() ?? "")
+        callbacks.onDone()
+      }),
+    }
+    const events: Array<{ type: string; name: string; result?: string }> = []
+
+    await expect(runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第239章", chapterNumber: 239, llmConfig },
+      { onWorkflowEvent: (event) => events.push(event) },
+      deps,
+    )).rejects.toThrow(/扩写后仅约 .*低于最低完成线/)
+
+    expect(deps.reviewChapter).not.toHaveBeenCalled()
+    expect(events).toContainEqual(expect.objectContaining({
+      type: "completed",
+      name: "chapter_draft",
+      result: expect.stringContaining("低于最低完成线"),
+    }))
+    expect(events).toContainEqual(expect.objectContaining({
+      type: "error",
+      name: "chapter_expansion",
+      result: expect.stringContaining("章节正文生成失败"),
+    }))
+    expect(events).not.toContainEqual(expect.objectContaining({
+      type: "completed",
+      name: "chapter_expansion",
+    }))
+  })
+
   it("does not force expansion after final polish even when the result is short", async () => {
     const draft = chapterText("初稿正文内容", 3000)
     const shortFinal = chapterText("最终润色后过短", 1800)

+ 19 - 23
src/lib/novel/deep-chapter-generation.ts

@@ -44,6 +44,7 @@ import {
   shouldRepairChapterPlanDeviation,
 } from "./chapter-plan-compliance";
 import { buildChapterPlanExecutionSummary } from "./chapter-plan-execution-summary";
+import { capOutlineSourcesToBudget } from "./outline-context-index";
 import {
   contractToTaskBriefText,
   fallbackParseChapterExecutionContract,
@@ -194,24 +195,6 @@ const DEEP_CHAPTER_OUTLINE_MAX_FRAC = 0.7;
  *  some room for memory/settings/search hits. */
 const DEEP_CHAPTER_REST_TOKEN_FLOOR = 2000;
 
-/**
- * Trim the (mandatory) outline to a character cap so it can never overflow the
- * context window on its own. Keeps the head — which carries the overall
- * structure — and drops the tail with an explicit truncation marker so the
- * model knows the outline was cut. Cuts on a line boundary when possible.
- */
-function capOutlineToBudget(outline: string, charCap: number): string {
-  const trimmed = outline.trim();
-  if (charCap <= 0 || trimmed.length <= charCap) return trimmed;
-
-  const marker = "\n\n【大纲过长,已按上下文窗口截断,仅保留前部】";
-  const room = Math.max(0, charCap - marker.length);
-  let head = trimmed.slice(0, room);
-  const lastBreak = head.lastIndexOf("\n");
-  if (lastBreak > room * 0.6) head = head.slice(0, lastBreak);
-  return `${head.trimEnd()}${marker}`;
-}
-
 export function shouldUseDeepChapterGeneration(
   _route: TaskRouteResult | null,
   enabled: boolean,
@@ -733,7 +716,7 @@ export async function runDeepChapterGeneration(
   const outlineCharCap = Math.floor(
     totalContextCharBudget * DEEP_CHAPTER_OUTLINE_MAX_FRAC,
   );
-  const outlineText = capOutlineToBudget(
+  const outlineText = capOutlineSourcesToBudget(
     contextPack.outline ?? "",
     outlineCharCap,
   );
@@ -926,7 +909,12 @@ export async function runDeepChapterGeneration(
           generationRequestOverrides,
           cachePrefix,
         ),
-      (value) => `正文初稿完成,约 ${countChapterChars(value)} 字。`,
+      (value) => {
+        const chars = countChapterChars(value);
+        return chars < lengthSpec.minChars
+          ? `正文初稿仅约 ${chars} 字,低于最低完成线 ${lengthSpec.minChars} 字,进入扩写补足。`
+          : `正文初稿完成,约 ${chars} 字。`;
+      },
       (value) => ({ chars: countChapterChars(value) }),
     );
     assertNotAborted(signal);
@@ -939,8 +927,8 @@ export async function runDeepChapterGeneration(
           detail: "初稿低于本章目标字数,补足场景和人物行动。",
           params: workflowBaseParams,
         },
-        () =>
-          collectModelText(
+        async () => {
+          const expanded = await collectModelText(
             writingConfig,
             [
               {
@@ -965,7 +953,15 @@ export async function runDeepChapterGeneration(
               ),
             generationRequestOverrides,
             cachePrefix,
-          ),
+          );
+          const expandedChars = countChapterChars(expanded);
+          if (expandedChars < lengthSpec.minChars) {
+            throw new Error(
+              `章节正文生成失败:扩写后仅约 ${expandedChars} 字,低于最低完成线 ${lengthSpec.minChars} 字。`,
+            );
+          }
+          return expanded;
+        },
         (value) => `正文扩写补足完成,约 ${countChapterChars(value)} 字。`,
         (value) => ({ chars: countChapterChars(value) }),
       );

+ 187 - 0
src/lib/novel/outline-context-index.spec.ts

@@ -0,0 +1,187 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+vi.mock("@/commands/fs", () => ({
+  listDirectory: vi.fn(),
+  readFile: vi.fn(),
+}))
+
+import { listDirectory, readFile } from "@/commands/fs"
+import {
+  buildOutlineContext,
+  buildRelevantCharacterBriefs,
+  buildRelevantForeshadowing,
+  capOutlineSourcesToBudget,
+  loadOutlineDocumentIndex,
+  resolveChapterOutline,
+} from "./outline-context-index"
+
+function file(path: string) {
+  return { name: path.split("/").pop()!, path, is_dir: false }
+}
+
+function directory(path: string, children: ReturnType<typeof file>[]) {
+  return { name: path.split("/").pop()!, path, is_dir: true, children }
+}
+
+const root = "/book/wiki/outlines"
+
+describe("outline context index", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it("outline 只包含总纲、目标卷和全部设定", async () => {
+    const paths = {
+      master: `${root}/大纲/总纲.md`,
+      volume1: `${root}/卷纲/第一卷.md`,
+      volume4: `${root}/卷纲/第四卷.md`,
+      chapter: `${root}/章纲/第237章.md`,
+      setting: `${root}/设定/世界观.md`,
+      character: `${root}/人物小传/阿明.md`,
+      foreshadowing: `${root}/伏笔/暗线.md`,
+    }
+    vi.mocked(listDirectory).mockResolvedValue([
+      directory(`${root}/大纲`, [file(paths.master)]),
+      directory(`${root}/卷纲`, [file(paths.volume1), file(paths.volume4)]),
+      directory(`${root}/章纲`, [file(paths.chapter)]),
+      directory(`${root}/设定`, [file(paths.setting)]),
+      directory(`${root}/人物小传`, [file(paths.character)]),
+      directory(`${root}/伏笔`, [file(paths.foreshadowing)]),
+    ])
+    const contents: Record<string, string> = {
+      [paths.master]: "# 总纲\n全书主线",
+      [paths.volume1]: "# 第一卷\n第一卷概述\n\n| 章节 | 事件 |\n| --- | --- |\n| 第1章 | 开端 |",
+      [paths.volume4]: "# 第四卷\n第四卷概述\n\n## 章序表\n| 实际章号 | 事件ID | 事件 |\n| --- | --- | --- |\n| 239 | V4-050 | 钢铁来潮 |",
+      [paths.chapter]: "# 第237章:旧账\n正文说明:第239章再回收,不能据此冒充第239章章纲。",
+      [paths.setting]: "# 世界观设定\n世界规则",
+      [paths.character]: "# 阿明\n人物秘密",
+      [paths.foreshadowing]: "# 伏笔表\n暗线内容",
+    }
+    vi.mocked(readFile).mockImplementation(async (path) => contents[String(path)] ?? "")
+
+    const index = await loadOutlineDocumentIndex("/book")
+    const result = buildOutlineContext(index, 239)
+
+    expect(result).toContain("全书主线")
+    expect(result).toContain("第四卷概述")
+    expect(result).toContain("V4-050")
+    expect(result).toContain("世界规则")
+    expect(result).not.toContain("第一卷概述")
+    expect(result).not.toContain("第237章:旧账")
+    expect(result).not.toContain("人物秘密")
+    expect(result).not.toContain("暗线内容")
+    expect(result).not.toContain("| 第1章 | 开端 |")
+
+    const capped = capOutlineSourcesToBudget(result, 900)
+    expect(capped).toContain("大纲/总纲.md")
+    expect(capped).toContain("卷纲/第四卷.md")
+    expect(capped).toContain("设定/世界观.md")
+
+    const allVolumes = buildOutlineContext(index)
+    expect(allVolumes).toContain("第一卷概述")
+    expect(allVolumes).toContain("第四卷概述")
+  })
+
+  it("第237章章纲正文提到第239章时不误选,并从目标卷纲精确兜底", async () => {
+    const chapterPath = `${root}/章纲/第237章.md`
+    const volumePath = `${root}/卷纲/第四卷.md`
+    vi.mocked(listDirectory).mockResolvedValue([
+      directory(`${root}/章纲`, [file(chapterPath)]),
+      directory(`${root}/卷纲`, [file(volumePath)]),
+    ])
+    vi.mocked(readFile).mockImplementation(async (path) => String(path) === chapterPath
+      ? "# 第237章:旧账\n本章埋下线索,到第239章回收。\n\n## 第239章\n这里只是后续提示。"
+      : [
+          "# 第四卷",
+          "## 章序表",
+          "| 实际章号 | 事件ID | 事件名 |",
+          "| --- | --- | --- |",
+          "| 237 | V4-048 | 旧账 |",
+          "| 239 | V4-050 | 钢铁来潮 |",
+          "## V4-050 钢铁来潮",
+          "第239章执行目标:完成接收链。",
+        ].join("\n"))
+
+    const result = resolveChapterOutline(await loadOutlineDocumentIndex("/book"), 239)
+
+    expect(result.sourceKind).toBe("volume")
+    expect(result.content).toContain("卷纲兜底")
+    expect(result.content).toContain("V4-050")
+    expect(result.content).toContain("完成接收链")
+    expect(result.content).not.toContain("本章埋下线索")
+  })
+
+  it("独立章纲精确命中后保留整份文档的子标题内容", async () => {
+    const chapterPath = `${root}/章纲/第239章.md`
+    vi.mocked(listDirectory).mockResolvedValue([
+      directory(`${root}/章纲`, [file(chapterPath)]),
+    ])
+    vi.mocked(readFile).mockResolvedValue("# 第239章:钢铁来潮\n\n## 场景一\n完整场景要求\n\n## 伏笔\n回收暗线")
+
+    const result = resolveChapterOutline(await loadOutlineDocumentIndex("/book"), 239)
+
+    expect(result.sourceKind).toBe("standalone")
+    expect(result.content).toContain("完整场景要求")
+    expect(result.content).toContain("回收暗线")
+  })
+
+  it("旧版根目录 type:outline 文件仍以明确的补零章标题分类", async () => {
+    const chapterPath = `${root}/legacy-outline.md`
+    vi.mocked(listDirectory).mockResolvedValue([file(chapterPath)])
+    vi.mocked(readFile).mockResolvedValue([
+      "---",
+      "type: outline",
+      "---",
+      "# 第0239章章纲",
+      "旧版章纲完整内容",
+    ].join("\n"))
+
+    const result = resolveChapterOutline(await loadOutlineDocumentIndex("/book"), 239)
+
+    expect(result.sourceKind).toBe("standalone")
+    expect(result.content).toContain("旧版章纲完整内容")
+  })
+
+  it("完整新书规划按语义拆分,人物小传仍只按命中人物加载", async () => {
+    const planningPath = `${root}/大纲/完整新书规划.md`
+    vi.mocked(listDirectory).mockResolvedValue([
+      directory(`${root}/大纲`, [file(planningPath)]),
+    ])
+    vi.mocked(readFile).mockResolvedValue([
+      "# 完整新书规划",
+      "## 总纲",
+      "总主线",
+      "## 第一卷",
+      "卷目标",
+      "### 章节规划表",
+      "| 章节 | 事件 |",
+      "| --- | --- |",
+      "| 第8章 | 入城 |",
+      "## 世界观/设定",
+      "法术规则",
+      "## 人物小传",
+      "### 林岚",
+      "林岚人物详情",
+      "### 周野",
+      "周野人物详情",
+      "## 伏笔表",
+      "| 回收章节 | 内容 |",
+      "| --- | --- |",
+      "| 第8章 | 古钥匙 |",
+    ].join("\n"))
+
+    const index = await loadOutlineDocumentIndex("/book")
+    const outline = buildOutlineContext(index, 8)
+    const characters = buildRelevantCharacterBriefs(index, "本章由林岚入城")
+    const foreshadowing = buildRelevantForeshadowing(index, 8, "处理古钥匙伏笔")
+
+    expect(outline).toContain("总主线")
+    expect(outline).toContain("卷目标")
+    expect(outline).toContain("法术规则")
+    expect(outline).not.toContain("林岚人物详情")
+    expect(outline).not.toContain("古钥匙")
+    expect(characters).toContain("林岚人物详情")
+    expect(characters).not.toContain("周野人物详情")
+    expect(foreshadowing).toContain("古钥匙")
+  })
+})

+ 679 - 0
src/lib/novel/outline-context-index.ts

@@ -0,0 +1,679 @@
+import { listDirectory, readFile } from "@/commands/fs"
+import { mapWithConcurrency } from "@/lib/async-pool"
+import { parseFrontmatter } from "@/lib/frontmatter"
+import { normalizePath } from "@/lib/path-utils"
+import type { FileNode } from "@/types/wiki"
+
+export type OutlineSegmentKind =
+  | "master"
+  | "volume"
+  | "chapter-plan"
+  | "chapter"
+  | "character"
+  | "setting"
+  | "foreshadowing"
+  | "unknown"
+
+export interface OutlineDocument {
+  path: string
+  relativePath: string
+  folder?: string
+  kind: OutlineSegmentKind
+  content: string
+  frontmatter: Record<string, unknown> | null
+}
+
+export interface OutlineSegment {
+  path: string
+  relativePath: string
+  folder?: string
+  kind: OutlineSegmentKind
+  content: string
+  volumeScopeId?: string
+  frontmatter: Record<string, unknown> | null
+}
+
+export interface OutlineDocumentIndex {
+  projectPath: string
+  documents: OutlineDocument[]
+  segments: OutlineSegment[]
+}
+
+export interface ChapterOutlineResolution {
+  content: string
+  sourceKind: "standalone" | "volume" | "chapter-plan" | "master" | "none"
+  sourcePaths: string[]
+}
+
+export interface ResolvedVolume {
+  scopeId: string
+  title: string
+  sourcePaths: string[]
+  content: string
+  score: number
+}
+
+const STANDARD_FOLDER_KINDS: Record<string, OutlineSegmentKind> = {
+  大纲: "master",
+  总大纲: "master",
+  完整新书规划: "master",
+  卷纲: "volume",
+  章纲: "chapter",
+  章节细纲: "chapter",
+  章节规划表: "chapter-plan",
+  章节计划表: "chapter-plan",
+  人物小传: "character",
+  设定: "setting",
+  世界观: "setting",
+  地点设定: "setting",
+  势力设定: "setting",
+  力量体系: "setting",
+  金手指设定: "setting",
+  背景设定: "setting",
+  地理设定: "setting",
+  组织: "setting",
+  伏笔: "foreshadowing",
+  伏笔表: "foreshadowing",
+  伏笔计划: "foreshadowing",
+}
+
+const SOURCE_START = "<!-- qmai-outline-source:start -->"
+const SOURCE_END = "<!-- qmai-outline-source:end -->"
+const INDEX_READ_CONCURRENCY = 16
+const MAX_OUTLINE_DOCUMENT_CHARS = 18_000
+const MAX_CHAPTER_CONTEXT_CHARS = 14_000
+const indexRequests = new Map<string, Promise<OutlineDocumentIndex>>()
+
+function flattenMarkdownFiles(nodes: FileNode[]): FileNode[] {
+  const files: FileNode[] = []
+  for (const node of nodes) {
+    if (node.is_dir) {
+      if (node.children) files.push(...flattenMarkdownFiles(node.children))
+      continue
+    }
+    if (node.name.toLowerCase().endsWith(".md")) files.push(node)
+  }
+  return files
+}
+
+function relativeOutlinePath(root: string, path: string): string {
+  const normalizedRoot = normalizePath(root).replace(/\/$/, "")
+  const normalizedPath = normalizePath(path)
+  return normalizedPath.startsWith(`${normalizedRoot}/`)
+    ? normalizedPath.slice(normalizedRoot.length + 1)
+    : normalizedPath.split("/").pop() ?? normalizedPath
+}
+
+function scalarText(value: unknown): string {
+  if (typeof value === "string") return value.trim()
+  if (typeof value === "number" && Number.isFinite(value)) return String(value)
+  return ""
+}
+
+function kindFromFrontmatter(frontmatter: Record<string, unknown> | null): OutlineSegmentKind {
+  if (!frontmatter) return "unknown"
+  const type = scalarText(frontmatter.type).toLowerCase()
+  const outlineType = scalarText(frontmatter.outline_type).toLowerCase()
+  if (outlineType === "chapter-outline" || scalarText(frontmatter.chapter_number)) return "chapter"
+  if (outlineType === "volume-outline" || type === "volume" || scalarText(frontmatter.volume_number)) return "volume"
+  if (outlineType === "story-outline" || outlineType === "master-outline" || type === "overview") return "master"
+  if (outlineType === "setting-outline" || type === "concept" || type === "setting") return "setting"
+  if (type === "character") return "character"
+  if (type === "foreshadowing") return "foreshadowing"
+  if (type === "outline") return "master"
+  return "unknown"
+}
+
+function kindFromHeading(title: string): OutlineSegmentKind | null {
+  const compact = title.replace(/\s+/g, "")
+  if (/^第(?:\d+|[一二三四五六七八九十百千万]+)章(?:章纲|细纲|[::、\-—]|$)/i.test(compact) || /^chapter\d+\b/i.test(compact)) {
+    return "chapter"
+  }
+  if (/章节规划表|章节计划表|章节节拍表|章序表|卷节拍表|章节列表|章节安排/.test(compact)) return "chapter-plan"
+  if (/^第(?:\d+|[一二三四五六七八九十百千万]+)卷(?:[::、\-—]|$)|卷纲|分卷大纲/.test(compact)) return "volume"
+  if (/人物小传|人物设定|角色设定|主要人物|核心主角|核心配角|人物状态变化/.test(compact)) return "character"
+  if (/伏笔表|伏笔计划|伏笔清单|伏笔设计/.test(compact)) return "foreshadowing"
+  if (/世界观|背景设定|核心设定|规则设定|力量体系|能力体系|金手指|地理设定|地点设定|组织势力|势力设定/.test(compact)) return "setting"
+  if (/完整新书规划|总纲|故事大纲|全书大纲|总体规划/.test(compact)) return "master"
+  return null
+}
+
+function classifyDocument(relativePath: string, body: string, frontmatter: Record<string, unknown> | null): OutlineSegmentKind {
+  const firstPart = relativePath.split("/").filter(Boolean)[0] ?? ""
+  const folderKind = STANDARD_FOLDER_KINDS[firstPart]
+  if (folderKind) return folderKind
+  const fmKind = kindFromFrontmatter(frontmatter)
+  const firstHeading = body.match(/^#{1,6}\s+(.+)$/m)?.[1] ?? ""
+  const headingKind = kindFromHeading(firstHeading)
+  // 旧文件常把所有大纲统一写成 type:outline;此值不应盖过明确的卷/章/设定标题。
+  if (fmKind !== "unknown" && fmKind !== "master") return fmKind
+  return headingKind ?? fmKind
+}
+
+interface HeadingState {
+  level: number
+  kind: OutlineSegmentKind
+  volumeScopeId?: string
+}
+
+function splitSemanticSegments(document: OutlineDocument): OutlineSegment[] {
+  const lines = document.content.split(/\r?\n/)
+  const segments: OutlineSegment[] = []
+  const stack: HeadingState[] = []
+  let volumeCounter = 0
+  let currentKind = document.kind
+  let currentVolumeScope = document.kind === "volume" ? `${document.path}#document` : undefined
+  let currentLines: string[] = []
+
+  const flush = () => {
+    const content = currentLines.join("\n").trim()
+    if (content) {
+      segments.push({
+        path: document.path,
+        relativePath: document.relativePath,
+        folder: document.folder,
+        kind: currentKind,
+        content,
+        volumeScopeId: currentVolumeScope,
+        frontmatter: document.frontmatter,
+      })
+    }
+    currentLines = []
+  }
+
+  for (const line of lines) {
+    const heading = line.match(/^(#{1,6})\s+(.+)$/)
+    if (!heading) {
+      currentLines.push(line)
+      continue
+    }
+
+    const level = heading[1].length
+    const explicitKind = kindFromHeading(heading[2])
+    while (stack.length > 0 && stack[stack.length - 1].level >= level) stack.pop()
+    const parent = stack[stack.length - 1]
+    const nextKind = explicitKind ?? parent?.kind ?? document.kind
+    let nextVolumeScope = parent?.volumeScopeId ?? (document.kind === "volume" ? `${document.path}#document` : undefined)
+    if (explicitKind === "volume") {
+      volumeCounter += 1
+      nextVolumeScope = `${document.path}#volume-${volumeCounter}`
+    }
+
+    // 每个标题独立成段,既能识别“完整新书规划”中的混合目标,也能按人物名精确取小传。
+    if (currentLines.length > 0) flush()
+    currentKind = nextKind
+    currentVolumeScope = nextVolumeScope
+    currentLines.push(line)
+    stack.push({ level, kind: nextKind, volumeScopeId: nextVolumeScope })
+  }
+  flush()
+  return segments
+}
+
+async function buildIndex(projectPath: string): Promise<OutlineDocumentIndex> {
+  const pp = normalizePath(projectPath)
+  const outlinesRoot = `${pp}/wiki/outlines`
+  const tree = await listDirectory(outlinesRoot)
+  const files = flattenMarkdownFiles(tree).sort((left, right) =>
+    left.path.localeCompare(right.path, "zh-Hans-CN", { numeric: true }),
+  )
+  const loadedFiles = await mapWithConcurrency(files, INDEX_READ_CONCURRENCY, async (file) => ({
+    path: file.path,
+    content: await readFile(file.path).catch(() => ""),
+  }))
+  return createOutlineDocumentIndex(pp, loadedFiles)
+}
+
+export function createOutlineDocumentIndex(
+  projectPath: string,
+  files: Array<{ path: string; content: string }>,
+): OutlineDocumentIndex {
+  const pp = normalizePath(projectPath)
+  const outlinesRoot = `${pp}/wiki/outlines`
+  const loadedDocuments = files.map((file) => {
+    const raw = file.content
+    if (!raw.trim()) return null
+    const parsed = parseFrontmatter(raw)
+    const relativePath = relativeOutlinePath(outlinesRoot, file.path)
+    const folder = relativePath.includes("/") ? relativePath.split("/")[0] : undefined
+    const content = parsed.body.trim()
+    return {
+      path: file.path,
+      relativePath,
+      folder,
+      kind: classifyDocument(relativePath, content, parsed.frontmatter),
+      content,
+      frontmatter: parsed.frontmatter as Record<string, unknown> | null,
+    } satisfies OutlineDocument
+  })
+  const documents: OutlineDocument[] = loadedDocuments.filter(
+    (document): document is Exclude<typeof document, null> => document !== null,
+  )
+
+  return {
+    projectPath: pp,
+    documents,
+    segments: documents.flatMap(splitSemanticSegments),
+  }
+}
+
+export async function loadOutlineDocumentIndex(projectPath: string): Promise<OutlineDocumentIndex> {
+  const key = normalizePath(projectPath)
+  const pending = indexRequests.get(key)
+  if (pending) return pending
+  const request = buildIndex(key).finally(() => indexRequests.delete(key))
+  indexRequests.set(key, request)
+  return request
+}
+
+function numberToChinese(value: number): string {
+  const digits = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"]
+  if (value < 10) return digits[value] ?? String(value)
+  if (value === 10) return "十"
+  if (value < 20) return `十${digits[value - 10]}`
+  if (value < 100) return `${digits[Math.floor(value / 10)]}十${value % 10 ? digits[value % 10] : ""}`
+  if (value < 1000) {
+    const hundreds = Math.floor(value / 100)
+    const rest = value % 100
+    if (rest === 0) return `${digits[hundreds]}百`
+    return `${digits[hundreds]}百${rest < 10 ? "零" : ""}${numberToChinese(rest)}`
+  }
+  return String(value)
+}
+
+function chapterLabels(chapterNumber: number): string[] {
+  return [`第${chapterNumber}章`, `第${numberToChinese(chapterNumber)}章`, `Chapter ${chapterNumber}`, `chapter ${chapterNumber}`]
+}
+
+function exactChapterHeading(content: string, chapterNumber: number): boolean {
+  const labels = chapterLabels(chapterNumber).map((label) => label.replace(/\s+/g, "").toLowerCase())
+  return content.split(/\r?\n/).some((line) => {
+    const heading = line.match(/^#{1,6}\s*(.+)$/)?.[1]?.replace(/\s+/g, "").toLowerCase()
+    if (!heading) return false
+    const arabic = heading.match(/^第0*(\d+)章(?:章纲|细纲|[::、\-—]|$)/)
+    if (arabic && Number(arabic[1]) === chapterNumber) return true
+    return labels.some((label) =>
+      heading === label || heading === `${label}章纲` || heading === `${label}细纲` ||
+      heading.startsWith(`${label}:`) || heading.startsWith(`${label}:`) || heading.startsWith(`${label}-`) || heading.startsWith(`${label}—`),
+    )
+  })
+}
+
+function pathMatchesChapter(path: string, chapterNumber: number): boolean {
+  const compact = path.replace(/\s+/g, "")
+  return new RegExp(`第0*${chapterNumber}章`).test(compact) ||
+    compact.includes(`第${numberToChinese(chapterNumber)}章`) ||
+    new RegExp(`(?:chapter|ch)[-_ ]*0*${chapterNumber}(?:\\D|$)`, "i").test(path)
+}
+
+function frontmatterChapterNumber(frontmatter: Record<string, unknown> | null): number | undefined {
+  const value = Number(scalarText(frontmatter?.chapter_number))
+  return Number.isFinite(value) && value > 0 ? value : undefined
+}
+
+function parseMarkdownTableRows(content: string): Array<{ headers: string[]; cells: string[]; raw: string }> {
+  const rows: Array<{ headers: string[]; cells: string[]; raw: string }> = []
+  const lines = content.split(/\r?\n/)
+  for (let index = 0; index < lines.length - 2; index += 1) {
+    const headerLine = lines[index].trim()
+    const divider = lines[index + 1].trim()
+    if (!headerLine.includes("|") || !/^\|?\s*:?-{3,}/.test(divider)) continue
+    const headers = headerLine.replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim())
+    let rowIndex = index + 2
+    while (rowIndex < lines.length && lines[rowIndex].includes("|")) {
+      const raw = lines[rowIndex].trim()
+      const cells = raw.replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim())
+      if (cells.some(Boolean)) rows.push({ headers, cells, raw })
+      rowIndex += 1
+    }
+    index = rowIndex - 1
+  }
+  return rows
+}
+
+function chapterCellMatches(cell: string, chapterNumber: number): boolean {
+  const compact = cell.replace(/[*_`\s]/g, "")
+  const single = compact.match(/^第?0*(\d+)章?$/)
+  if (single && Number(single[1]) === chapterNumber) return true
+  if (compact === `第${numberToChinese(chapterNumber)}章`) return true
+  const range = compact.match(/^第?(\d+)章?[-—–~至](?:第)?(\d+)章?$/)
+  if (!range) return false
+  return chapterNumber >= Number(range[1]) && chapterNumber <= Number(range[2])
+}
+
+function chapterTableRows(content: string, chapterNumber: number): string[] {
+  const chapterHeader = /章节|章号|实际章号|chapter/i
+  return parseMarkdownTableRows(content)
+    .filter((row) => row.headers.some((header) => chapterHeader.test(header)))
+    .filter((row) => row.headers.some((header, index) => chapterHeader.test(header) && chapterCellMatches(row.cells[index] ?? "", chapterNumber)))
+    .map((row) => row.raw)
+}
+
+function explicitChapterLines(content: string, chapterNumber: number): string[] {
+  const exact = new RegExp(`^(?:[-*+]\\s+|\\d+[.)]\\s+)?(?:#{1,6}\\s*)?第\\s*0*${chapterNumber}\\s*章(?:[::、\\s\\-—]|$)`, "i")
+  return content.split(/\r?\n/).filter((line) => exact.test(line.trim()))
+}
+
+function bodyRangeContains(content: string, chapterNumber: number): boolean {
+  const patterns = [
+    /章节范围[^\d]{0,12}第?\s*(\d+)\s*章?\s*[-—–~至]\s*第?\s*(\d+)\s*章?/gi,
+    /章号[^\d]{0,12}(\d+)\s*[-—–~至]\s*(\d+)/gi,
+  ]
+  return patterns.some((pattern) => {
+    for (const match of content.matchAll(pattern)) {
+      if (chapterNumber >= Number(match[1]) && chapterNumber <= Number(match[2])) return true
+    }
+    return false
+  })
+}
+
+function frontmatterRangeContains(frontmatter: Record<string, unknown> | null, chapterNumber: number): boolean {
+  const start = Number(scalarText(frontmatter?.chapter_range_start))
+  const end = Number(scalarText(frontmatter?.chapter_range_end))
+  return Number.isFinite(start) && Number.isFinite(end) && chapterNumber >= start && chapterNumber <= end
+}
+
+function volumeMatchScore(segments: OutlineSegment[], chapterNumber: number): number {
+  const content = segments.map((segment) => segment.content).join("\n\n")
+  if (chapterTableRows(content, chapterNumber).length > 0) return 100
+  if (exactChapterHeading(content, chapterNumber) || explicitChapterLines(content, chapterNumber).length > 0) return 90
+  if (segments.some((segment) => frontmatterRangeContains(segment.frontmatter, chapterNumber))) return 70
+  if (bodyRangeContains(content, chapterNumber)) return 60
+  return 0
+}
+
+function firstHeading(content: string): string {
+  return content.match(/^#{1,6}\s+(.+)$/m)?.[1]?.trim() ?? "卷纲"
+}
+
+export function resolveTargetVolumes(index: OutlineDocumentIndex, chapterNumber: number): ResolvedVolume[] {
+  const grouped = new Map<string, OutlineSegment[]>()
+  for (const segment of index.segments) {
+    if (!segment.volumeScopeId) continue
+    const list = grouped.get(segment.volumeScopeId) ?? []
+    list.push(segment)
+    grouped.set(segment.volumeScopeId, list)
+  }
+  const candidates = Array.from(grouped.entries()).map(([scopeId, segments]) => {
+    // 卷匹配和章纲兜底需要看到卷内的章节段;outline 主上下文会在 buildOutlineContext 中另行排除这些段。
+    const content = segments.map((segment) => segment.content).join("\n\n")
+    return {
+      scopeId,
+      title: firstHeading(content),
+      sourcePaths: Array.from(new Set(segments.map((segment) => segment.relativePath))),
+      content,
+      score: volumeMatchScore(segments, chapterNumber),
+    }
+  }).filter((candidate) => candidate.score > 0)
+  const bestScore = Math.max(0, ...candidates.map((candidate) => candidate.score))
+  return candidates.filter((candidate) => candidate.score === bestScore)
+}
+
+function excerptHeadTail(content: string, maxChars: number): string {
+  const trimmed = content.trim()
+  if (trimmed.length <= maxChars) return trimmed
+  const marker = "\n\n【本资料过长,中段已按上下文预算省略】\n\n"
+  const room = Math.max(0, maxChars - marker.length)
+  const headChars = Math.floor(room * 0.58)
+  return `${trimmed.slice(0, headChars).trimEnd()}${marker}${trimmed.slice(-(room - headChars)).trimStart()}`
+}
+
+function sourceBlock(kind: OutlineSegmentKind, relativePath: string, content: string): string {
+  return [
+    SOURCE_START,
+    `## 大纲来源:${relativePath}(${kind})`,
+    "",
+    excerptHeadTail(content, MAX_OUTLINE_DOCUMENT_CHARS),
+    SOURCE_END,
+  ].join("\n")
+}
+
+function uniqueSourceBlocks(sources: Array<{ kind: OutlineSegmentKind; relativePath: string; content: string }>): string[] {
+  const seen = new Set<string>()
+  const blocks: string[] = []
+  for (const source of sources) {
+    const normalized = source.content.trim()
+    if (!normalized) continue
+    const key = `${source.kind}\u0000${source.relativePath}\u0000${normalized}`
+    if (seen.has(key)) continue
+    seen.add(key)
+    blocks.push(sourceBlock(source.kind, source.relativePath, normalized))
+  }
+  return blocks
+}
+
+function groupSourcesByDocument(
+  sources: Array<{ kind: OutlineSegmentKind; relativePath: string; content: string }>,
+): Array<{ kind: OutlineSegmentKind; relativePath: string; content: string }> {
+  const grouped = new Map<string, { kind: OutlineSegmentKind; relativePath: string; contents: string[] }>()
+  for (const source of sources) {
+    const key = `${source.kind}\u0000${source.relativePath}`
+    const existing = grouped.get(key)
+    if (existing) {
+      existing.contents.push(source.content)
+    } else {
+      grouped.set(key, {
+        kind: source.kind,
+        relativePath: source.relativePath,
+        contents: [source.content],
+      })
+    }
+  }
+  return Array.from(grouped.values()).map((source) => ({
+    kind: source.kind,
+    relativePath: source.relativePath,
+    content: source.contents.map((content) => content.trim()).filter(Boolean).join("\n\n"),
+  }))
+}
+
+export function buildOutlineContext(index: OutlineDocumentIndex, chapterNumber?: number): string {
+  const master = index.segments.filter((segment) => segment.kind === "master")
+  const settings = index.segments.filter((segment) => segment.kind === "setting")
+  let volumes: OutlineSegment[] = []
+  let chapterPlans: OutlineSegment[] = []
+  if (chapterNumber) {
+    const scopeIds = new Set(resolveTargetVolumes(index, chapterNumber).map((volume) => volume.scopeId))
+    volumes = index.segments.filter((segment) => segment.volumeScopeId && scopeIds.has(segment.volumeScopeId) && segment.kind === "volume")
+    chapterPlans = index.segments.filter((segment) =>
+      segment.kind === "chapter-plan" && (!segment.volumeScopeId || scopeIds.has(segment.volumeScopeId)),
+    )
+  } else {
+    volumes = index.segments.filter((segment) => segment.kind === "volume")
+    chapterPlans = index.segments.filter((segment) => segment.kind === "chapter-plan")
+  }
+  return uniqueSourceBlocks(groupSourcesByDocument([...master, ...volumes, ...chapterPlans, ...settings])).join("\n\n")
+}
+
+function extractHeadingSection(content: string, chapterNumber: number): string {
+  const lines = content.split(/\r?\n/)
+  for (let index = 0; index < lines.length; index += 1) {
+    const heading = lines[index].match(/^(#{1,6})\s+(.+)$/)
+    if (!heading || !exactChapterHeading(lines[index], chapterNumber)) continue
+    const level = heading[1].length
+    let end = index + 1
+    while (end < lines.length) {
+      const next = lines[end].match(/^(#{1,6})\s+/)
+      if (next && next[1].length <= level) break
+      end += 1
+    }
+    return lines.slice(index, end).join("\n").trim()
+  }
+  return ""
+}
+
+function stableIdentifiers(lines: string[]): string[] {
+  const ids = new Set<string>()
+  for (const line of lines) {
+    for (const match of line.matchAll(/\b[A-Z][A-Z0-9]*-\d{2,}\b/g)) ids.add(match[0])
+  }
+  return Array.from(ids)
+}
+
+function extractIdentifierSections(content: string, identifiers: string[]): string[] {
+  if (identifiers.length === 0) return []
+  const lines = content.split(/\r?\n/)
+  const sections: string[] = []
+  for (let index = 0; index < lines.length; index += 1) {
+    const heading = lines[index].match(/^(#{1,6})\s+(.+)$/)
+    if (!heading || !identifiers.some((identifier) => heading[2].includes(identifier))) continue
+    const level = heading[1].length
+    let end = index + 1
+    while (end < lines.length) {
+      const next = lines[end].match(/^(#{1,6})\s+/)
+      if (next && next[1].length <= level) break
+      end += 1
+    }
+    sections.push(lines.slice(index, end).join("\n").trim())
+    index = end - 1
+  }
+  return sections
+}
+
+function extractStructuredChapterContent(content: string, chapterNumber: number): string {
+  const headingSection = extractHeadingSection(content, chapterNumber)
+  const rows = chapterTableRows(content, chapterNumber)
+  const lines = explicitChapterLines(content, chapterNumber)
+  const base = [headingSection, ...rows, ...lines].filter(Boolean)
+  const ids = stableIdentifiers(base)
+  const identifierSections = extractIdentifierSections(content, ids)
+  const relatedLines = ids.length > 0
+    ? content.split(/\r?\n/).filter((line) => ids.some((id) => line.includes(id)))
+    : []
+  return Array.from(new Set([...base, ...identifierSections, ...relatedLines])).join("\n\n").trim()
+}
+
+function standaloneChapterMatches(document: OutlineDocument, chapterNumber: number): boolean {
+  const titleLine = document.content.match(/^#{1,6}\s+.+$/m)?.[0] ?? ""
+  return frontmatterChapterNumber(document.frontmatter) === chapterNumber ||
+    pathMatchesChapter(document.relativePath, chapterNumber) ||
+    exactChapterHeading(titleLine, chapterNumber)
+}
+
+export function resolveChapterOutline(index: OutlineDocumentIndex, chapterNumber: number): ChapterOutlineResolution {
+  const standalone = index.documents.find((document) =>
+    document.kind === "chapter" && standaloneChapterMatches(document, chapterNumber),
+  )
+  if (standalone) {
+    return {
+      content: excerptHeadTail(standalone.content, MAX_CHAPTER_CONTEXT_CHARS),
+      sourceKind: "standalone",
+      sourcePaths: [standalone.relativePath],
+    }
+  }
+
+  const volumes = resolveTargetVolumes(index, chapterNumber)
+  const volumeExtracts = volumes.map((volume) => extractStructuredChapterContent(volume.content, chapterNumber)).filter(Boolean)
+  if (volumeExtracts.length > 0) {
+    const sourcePaths = Array.from(new Set(volumes.flatMap((volume) => volume.sourcePaths)))
+    return {
+      content: [
+        `【章纲来源:卷纲兜底】未找到第${chapterNumber}章独立章纲,以下内容从目标卷纲按章节结构精确摘录。`,
+        ...volumeExtracts,
+      ].join("\n\n").slice(0, MAX_CHAPTER_CONTEXT_CHARS),
+      sourceKind: "volume",
+      sourcePaths,
+    }
+  }
+
+  for (const kind of ["chapter-plan", "master"] as const) {
+    const matches = index.segments
+      .filter((segment) => segment.kind === kind)
+      .map((segment) => ({ segment, content: extractStructuredChapterContent(segment.content, chapterNumber) }))
+      .filter((item) => item.content)
+    if (matches.length > 0) {
+      return {
+        content: [
+          `【章纲来源:${kind === "chapter-plan" ? "章节规划表" : "总纲"}兜底】未找到第${chapterNumber}章独立章纲,以下为结构化目标摘录。`,
+          ...matches.map((item) => item.content),
+        ].join("\n\n").slice(0, MAX_CHAPTER_CONTEXT_CHARS),
+        sourceKind: kind,
+        sourcePaths: Array.from(new Set(matches.map((item) => item.segment.relativePath))),
+      }
+    }
+  }
+  return { content: "", sourceKind: "none", sourcePaths: [] }
+}
+
+export function buildVolumeContext(index: OutlineDocumentIndex, chapterNumber: number): string {
+  return resolveTargetVolumes(index, chapterNumber).map((volume) => [
+    `所属卷纲:${volume.title}`,
+    `来源:${volume.sourcePaths.join("、")}`,
+    ...chapterTableRows(volume.content, chapterNumber),
+  ].filter(Boolean).join("\n")).join("\n\n")
+}
+
+function parseMarkedSourceBlocks(outline: string): string[] {
+  const escapedStart = SOURCE_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+  const escapedEnd = SOURCE_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+  const regex = new RegExp(`${escapedStart}([\\s\\S]*?)${escapedEnd}`, "g")
+  return Array.from(outline.matchAll(regex), (match) => `${SOURCE_START}${match[1]}${SOURCE_END}`.trim())
+}
+
+export function capOutlineSourcesToBudget(outline: string, charCap: number): string {
+  const trimmed = outline.trim()
+  if (charCap <= 0 || trimmed.length <= charCap) return trimmed
+  const blocks = parseMarkedSourceBlocks(trimmed)
+  if (blocks.length === 0) return excerptHeadTail(trimmed, charCap)
+
+  const kept = new Array<string>(blocks.length)
+  const pending = new Set(blocks.map((_, index) => index))
+  let remaining = charCap - Math.max(0, blocks.length - 1) * 2
+  while (pending.size > 0 && remaining > 0) {
+    const share = Math.floor(remaining / pending.size)
+    let consumedSmallBlock = false
+    for (const index of Array.from(pending)) {
+      if (blocks[index].length <= share) {
+        kept[index] = blocks[index]
+        remaining -= blocks[index].length
+        pending.delete(index)
+        consumedSmallBlock = true
+      }
+    }
+    if (!consumedSmallBlock) {
+      for (const index of pending) kept[index] = excerptHeadTail(blocks[index], share)
+      remaining = 0
+    }
+  }
+  return kept.filter(Boolean).join("\n\n").slice(0, charCap)
+}
+
+function characterAliases(segment: OutlineSegment): string[] {
+  const aliases = new Set<string>()
+  const stem = segment.relativePath.split("/").pop()?.replace(/\.md$/i, "").replace(/^(?:角色|人物小传)[-_::]*/, "").trim()
+  if (stem) aliases.add(stem)
+  const title = scalarText(segment.frontmatter?.title)
+  const name = scalarText(segment.frontmatter?.name)
+  if (title) aliases.add(title.replace(/^(?:角色|人物小传)[-_::]*/, "").trim())
+  if (name) aliases.add(name)
+  const heading = firstHeading(segment.content).replace(/^(?:人物小传|人物设定|角色设定)[-_::]*/, "").trim()
+  if (heading && heading.length <= 24) aliases.add(heading)
+  return Array.from(aliases).filter((alias) => alias.length >= 2)
+}
+
+export function buildRelevantCharacterBriefs(index: OutlineDocumentIndex, matchingText: string): string {
+  const matches = index.segments.filter((segment) =>
+    segment.kind === "character" && characterAliases(segment).some((alias) => matchingText.includes(alias)),
+  )
+  return uniqueSourceBlocks(groupSourcesByDocument(matches)).join("\n\n")
+}
+
+export function buildRelevantForeshadowing(index: OutlineDocumentIndex, chapterNumber: number, matchingText: string): string {
+  const hintMatches = Array.from(matchingText.matchAll(/(?:伏笔|铺垫|悬念)[::]\s*([^\n]+)/gi), (match) => match[1].trim()).filter(Boolean)
+  const results: Array<{ kind: OutlineSegmentKind; relativePath: string; content: string }> = []
+  for (const segment of index.segments.filter((item) => item.kind === "foreshadowing")) {
+    const rows = parseMarkdownTableRows(segment.content).filter((row) => {
+      const chapterColumnsMatch = row.headers.some((header, index) =>
+        /章节|埋设|推进|回收|resolve|chapter/i.test(header) && chapterCellMatches(row.cells[index] ?? "", chapterNumber),
+      )
+      const hintMatch = hintMatches.some((hint) => row.raw.includes(hint))
+      return chapterColumnsMatch || hintMatch
+    }).map((row) => row.raw)
+    const lines = segment.content.split(/\r?\n/).filter((line) =>
+      (/伏笔|埋设|推进|回收/.test(line) && line.replace(/\s+/g, "").includes(`第${chapterNumber}章`)) ||
+      hintMatches.some((hint) => line.includes(hint)),
+    )
+    const content = Array.from(new Set([...rows, ...lines])).join("\n")
+    if (content.trim()) results.push({ kind: "foreshadowing", relativePath: segment.relativePath, content })
+  }
+  return uniqueSourceBlocks(results).join("\n\n")
+}

+ 37 - 4
src/lib/novel/section-briefing.ts

@@ -12,6 +12,15 @@ import {
   loadForeshadowingTracker,
   createEmptyForeshadowingStore,
 } from "./foreshadowing-tracker"
+import {
+  buildRelevantCharacterBriefs,
+  buildRelevantForeshadowing,
+  capOutlineSourcesToBudget,
+  loadOutlineDocumentIndex,
+} from "./outline-context-index"
+
+const RELEVANT_CHARACTER_BRIEFS_MAX_CHARS = 8_000
+const RELEVANT_FORESHADOWING_MAX_CHARS = 6_000
 
 /**
  * 从细纲文本中提取出场角色名
@@ -73,13 +82,14 @@ export async function buildSectionBriefing(
   projectPath: string,
   chapterNumber: number,
   chapterOutlineContent: string,
+  task = "",
 ): Promise<string> {
   const sections: string[] = []
   const trimmedOutline = chapterOutlineContent.trim()
-  if (!trimmedOutline) return ""
+  const matchingText = [task, trimmedOutline].filter(Boolean).join("\n")
 
   // ── 1. 提取出场角色并筛选角色状态 ──────────────────
-  const characterNames = extractCharacterNames(trimmedOutline)
+  const characterNames = extractCharacterNames(matchingText)
 
   if (characterNames.length > 0) {
     const charStore = await readCharacterStateMd(projectPath)
@@ -111,6 +121,29 @@ export async function buildSectionBriefing(
     }
   }
 
+  try {
+    const outlineIndex = await loadOutlineDocumentIndex(projectPath)
+    const characterBriefs = capOutlineSourcesToBudget(
+      buildRelevantCharacterBriefs(outlineIndex, matchingText),
+      RELEVANT_CHARACTER_BRIEFS_MAX_CHARS,
+    )
+    if (characterBriefs) {
+      sections.push("### 相关人物小传")
+      sections.push(characterBriefs)
+      sections.push("")
+    }
+
+    const outlineForeshadowing = capOutlineSourcesToBudget(
+      buildRelevantForeshadowing(outlineIndex, chapterNumber, matchingText),
+      RELEVANT_FORESHADOWING_MAX_CHARS,
+    )
+    if (outlineForeshadowing) {
+      sections.push("### 相关伏笔规划")
+      sections.push(outlineForeshadowing)
+      sections.push("")
+    }
+  } catch {}
+
   // ── 2. 筛选相关伏笔 ────────────────────────────────
   // 先尝试读取新版 tracking 数据
   let fStore = await loadForeshadowingTracker(projectPath).catch(() => null)
@@ -120,7 +153,7 @@ export async function buildSectionBriefing(
     fStore = mdResult.store
   }
 
-  const foreshadowingHints = extractForeshadowingHints(trimmedOutline)
+  const foreshadowingHints = extractForeshadowingHints(matchingText)
 
   const relevantForeshadowing = fStore.items.filter((f) => {
     if (f.status === "abandoned") return false
@@ -194,4 +227,4 @@ export async function buildSectionBriefing(
     "",
     ...sections,
   ].join("\n")
-}
+}

+ 94 - 0
src/lib/project-store.integration.test.ts

@@ -126,6 +126,100 @@ describe("DeepSeek window migration", () => {
   })
 })
 
+describe("Codex CLI timeout migration", () => {
+  it("rewrites old active and provider timeouts to 40 minutes once", async () => {
+    inMemoryStore.set("llmConfig", {
+      provider: "codex-cli",
+      apiKey: "",
+      model: "gpt-5.4-mini",
+      customEndpoint: "",
+      ollamaUrl: "",
+      maxContextSize: 204_800,
+      codexCliTimeoutMinutes: 20,
+    } satisfies LlmConfig)
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "gpt-5.4-mini", codexCliTimeoutMinutes: 20 },
+    } satisfies ProviderConfigs)
+
+    expect((await loadLlmConfig())?.codexCliTimeoutMinutes).toBe(40)
+    expect((await loadProviderConfigs())?.["codex-cli"]?.codexCliTimeoutMinutes).toBe(40)
+    expect((inMemoryStore.get("llmConfig") as LlmConfig).codexCliTimeoutMinutes).toBe(40)
+    expect((inMemoryStore.get("providerConfigs") as ProviderConfigs)["codex-cli"]?.codexCliTimeoutMinutes).toBe(40)
+
+    // Migration markers are now set. A later deliberate user reduction must stick.
+    inMemoryStore.set("llmConfig", {
+      ...(inMemoryStore.get("llmConfig") as LlmConfig),
+      codexCliTimeoutMinutes: 20,
+    })
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "gpt-5.4-mini", codexCliTimeoutMinutes: 20 },
+    } satisfies ProviderConfigs)
+
+    expect((await loadLlmConfig())?.codexCliTimeoutMinutes).toBe(20)
+    expect((await loadProviderConfigs())?.["codex-cli"]?.codexCliTimeoutMinutes).toBe(20)
+  })
+
+  it("fills a missing saved Codex provider timeout without changing larger values", async () => {
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "gpt-5.4-mini" },
+    } satisfies ProviderConfigs)
+    expect((await loadProviderConfigs())?.["codex-cli"]?.codexCliTimeoutMinutes).toBe(40)
+
+    inMemoryStore.clear()
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "gpt-5.4-mini", codexCliTimeoutMinutes: 60 },
+    } satisfies ProviderConfigs)
+    expect((await loadProviderConfigs())?.["codex-cli"]?.codexCliTimeoutMinutes).toBe(60)
+  })
+})
+
+describe("Codex CLI model migration", () => {
+  it("rewrites the old mini default to Terra once, then leaves the user in control", async () => {
+    inMemoryStore.set("llmConfig", {
+      provider: "codex-cli",
+      apiKey: "",
+      model: "gpt-5.4-mini",
+      customEndpoint: "",
+      ollamaUrl: "",
+      maxContextSize: 204_800,
+      codexCliTimeoutMinutes: 40,
+    } satisfies LlmConfig)
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "gpt-5.4-mini", codexCliTimeoutMinutes: 40 },
+    } satisfies ProviderConfigs)
+
+    expect((await loadLlmConfig())?.model).toBe("gpt-5.6-terra")
+    expect((await loadProviderConfigs())?.["codex-cli"]?.model).toBe("gpt-5.6-terra")
+    expect((inMemoryStore.get("llmConfig") as LlmConfig).model).toBe("gpt-5.6-terra")
+    expect((inMemoryStore.get("providerConfigs") as ProviderConfigs)["codex-cli"]?.model).toBe("gpt-5.6-terra")
+
+    // Migration markers are now set. A later deliberate legacy selection must stick.
+    inMemoryStore.set("llmConfig", {
+      ...(inMemoryStore.get("llmConfig") as LlmConfig),
+      model: "gpt-5.4-mini",
+    })
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "gpt-5.4-mini", codexCliTimeoutMinutes: 40 },
+    } satisfies ProviderConfigs)
+
+    expect((await loadLlmConfig())?.model).toBe("gpt-5.4-mini")
+    expect((await loadProviderConfigs())?.["codex-cli"]?.model).toBe("gpt-5.4-mini")
+  })
+
+  it("fills a missing Codex model but preserves an explicit non-default model", async () => {
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "", codexCliTimeoutMinutes: 40 },
+    } satisfies ProviderConfigs)
+    expect((await loadProviderConfigs())?.["codex-cli"]?.model).toBe("gpt-5.6-terra")
+
+    inMemoryStore.clear()
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "gpt-5.4", codexCliTimeoutMinutes: 40 },
+    } satisfies ProviderConfigs)
+    expect((await loadProviderConfigs())?.["codex-cli"]?.model).toBe("gpt-5.4")
+  })
+})
+
 function makeNovelConfig(overrides: Partial<NovelConfig> = {}): NovelConfig {
   return {
     contextTokenBudget: 200000,

+ 85 - 0
src/lib/project-store.ts

@@ -18,6 +18,8 @@ import {
   normalizeProviderConfigs,
   normalizeUserLlmConfig,
 } from "@/lib/llm-context-size"
+import { migrateLegacyCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
+import { migrateLegacyDefaultCodexCliModel } from "@/lib/codex-cli-model"
 import { CHAPTER_TARGET_CHARS_MAX, CHAPTER_TARGET_CHARS_MIN } from "@/lib/novel/deep-chapter-prompts"
 
 const RECENT_PROJECTS_KEY = "recentProjects"
@@ -59,10 +61,19 @@ const DEEPSEEK_WINDOW_MIGRATION_KEYS = {
   llmConfig: "deepseekWindowMigratedV1.llmConfig",
   providerConfigs: "deepseekWindowMigratedV1.providerConfigs",
 } as const
+const CODEX_TIMEOUT_MIGRATION_KEYS = {
+  llmConfig: "codexCliTimeoutMigratedV1.llmConfig",
+  providerConfigs: "codexCliTimeoutMigratedV1.providerConfigs",
+} as const
+const CODEX_MODEL_MIGRATION_KEYS = {
+  llmConfig: "codexCliModelMigratedV1.llmConfig",
+  providerConfigs: "codexCliModelMigratedV1.providerConfigs",
+} as const
 /** DeepSeek's official published context window. */
 const DEEPSEEK_OFFICIAL_CONTEXT_SIZE = 1_000_000
 /** Preset id whose configuration is known to target api.deepseek.com. */
 const DEEPSEEK_PRESET_ID = "deepseek"
+const CODEX_CLI_PRESET_ID = "codex-cli"
 
 function isDeepSeekOfficialEndpoint(endpoint: string | undefined): boolean {
   return typeof endpoint === "string" && /api\.deepseek\.com/i.test(endpoint)
@@ -95,6 +106,34 @@ async function markDeepSeekWindowMigrationDone(
   const store = await getStore()
   await store.set(DEEPSEEK_WINDOW_MIGRATION_KEYS[slot], true)
 }
+
+async function hasRunCodexTimeoutMigration(
+  slot: keyof typeof CODEX_TIMEOUT_MIGRATION_KEYS,
+): Promise<boolean> {
+  const store = await getStore()
+  return (await store.get<boolean>(CODEX_TIMEOUT_MIGRATION_KEYS[slot])) === true
+}
+
+async function markCodexTimeoutMigrationDone(
+  slot: keyof typeof CODEX_TIMEOUT_MIGRATION_KEYS,
+): Promise<void> {
+  const store = await getStore()
+  await store.set(CODEX_TIMEOUT_MIGRATION_KEYS[slot], true)
+}
+
+async function hasRunCodexModelMigration(
+  slot: keyof typeof CODEX_MODEL_MIGRATION_KEYS,
+): Promise<boolean> {
+  const store = await getStore()
+  return (await store.get<boolean>(CODEX_MODEL_MIGRATION_KEYS[slot])) === true
+}
+
+async function markCodexModelMigrationDone(
+  slot: keyof typeof CODEX_MODEL_MIGRATION_KEYS,
+): Promise<void> {
+  const store = await getStore()
+  await store.set(CODEX_MODEL_MIGRATION_KEYS[slot], true)
+}
 const AI_CHAT_MODEL_KEY = "aiChatModel"
 const AI_OUTLINE_MODEL_KEY = "aiOutlineModel"
 let aiOutlineModelSaveRevision = 0
@@ -122,6 +161,24 @@ export async function loadLlmConfig(): Promise<LlmConfig | null> {
     }
     await markDeepSeekWindowMigrationDone("llmConfig")
   }
+  if (!(await hasRunCodexTimeoutMigration("llmConfig"))) {
+    if (normalized.provider === "codex-cli") {
+      const codexCliTimeoutMinutes = migrateLegacyCodexCliTimeoutMinutes(
+        normalized.codexCliTimeoutMinutes,
+      )
+      if (codexCliTimeoutMinutes !== normalized.codexCliTimeoutMinutes) {
+        normalized = { ...normalized, codexCliTimeoutMinutes }
+      }
+    }
+    await markCodexTimeoutMigrationDone("llmConfig")
+  }
+  if (!(await hasRunCodexModelMigration("llmConfig"))) {
+    if (normalized.provider === "codex-cli") {
+      const model = migrateLegacyDefaultCodexCliModel(normalized.model)
+      if (model !== normalized.model) normalized = { ...normalized, model }
+    }
+    await markCodexModelMigrationDone("llmConfig")
+  }
   if (normalized !== saved) await store.set(LLM_CONFIG_KEY, normalized)
   return normalized
 }
@@ -188,6 +245,34 @@ export async function loadProviderConfigs(): Promise<ProviderConfigs | null> {
     }
     await markDeepSeekWindowMigrationDone("providerConfigs")
   }
+  if (!(await hasRunCodexTimeoutMigration("providerConfigs"))) {
+    const codex = normalized[CODEX_CLI_PRESET_ID]
+    if (codex) {
+      const codexCliTimeoutMinutes = migrateLegacyCodexCliTimeoutMinutes(
+        codex.codexCliTimeoutMinutes,
+      )
+      if (codexCliTimeoutMinutes !== codex.codexCliTimeoutMinutes) {
+        normalized = {
+          ...normalized,
+          [CODEX_CLI_PRESET_ID]: { ...codex, codexCliTimeoutMinutes },
+        }
+      }
+    }
+    await markCodexTimeoutMigrationDone("providerConfigs")
+  }
+  if (!(await hasRunCodexModelMigration("providerConfigs"))) {
+    const codex = normalized[CODEX_CLI_PRESET_ID]
+    if (codex) {
+      const model = migrateLegacyDefaultCodexCliModel(codex.model)
+      if (model !== codex.model) {
+        normalized = {
+          ...normalized,
+          [CODEX_CLI_PRESET_ID]: { ...codex, model },
+        }
+      }
+    }
+    await markCodexModelMigrationDone("providerConfigs")
+  }
   if (normalized !== saved) await store.set(PROVIDER_CONFIGS_KEY, normalized)
   return normalized
 }

+ 25 - 3
src/lib/settings-model-list.spec.ts

@@ -120,9 +120,12 @@ describe("settings model list", () => {
   it("reads the configured local Codex CLI model from Tauri detection", async () => {
     vi.mocked(invoke).mockResolvedValueOnce({
       installed: true,
-      version: "codex-cli 0.137.0",
+      version: "codex-cli 0.146.1",
       path: "C:/Users/Administrator/AppData/Roaming/npm/codex.cmd",
-      model: "gpt-5.4",
+      model: "gpt-5.6-terra",
+      appServerReady: true,
+      dynamicToolsReady: true,
+      models: ["gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.6-luna"],
       error: null,
     })
 
@@ -134,6 +137,25 @@ describe("settings model list", () => {
     }))
 
     expect(invoke).toHaveBeenCalledWith("codex_cli_detect")
-    expect(result.models).toEqual(["gpt-5.4"])
+    expect(result.models).toEqual(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"])
+  })
+
+  it("rejects a Codex CLI without app-server dynamic tools", async () => {
+    vi.mocked(invoke).mockResolvedValueOnce({
+      installed: true,
+      version: "codex-cli 0.120.0",
+      path: "/usr/local/bin/codex",
+      appServerReady: false,
+      dynamicToolsReady: false,
+      models: [],
+      error: "当前 Codex CLI 不支持 QMAI 主 Agent,请升级 Codex CLI。",
+    })
+
+    const { fetchLlmModelList } = await import("./settings-model-list")
+    await expect(fetchLlmModelList(customConfig({
+      provider: "codex-cli",
+      apiKey: "",
+      model: "",
+    }))).rejects.toThrow("请升级 Codex CLI")
   })
 })

+ 14 - 1
src/lib/settings-model-list.ts

@@ -168,9 +168,22 @@ async function fetchModelList(url: string, headers: Record<string, string>, _cur
 
 async function fetchLocalCliModel(config: LlmConfig): Promise<LlmModelListResult> {
   const explicitModel = config.model.trim()
-  if (explicitModel) return { models: [explicitModel] }
+  if (explicitModel && config.provider !== "codex-cli") return { models: [explicitModel] }
 
   const detect = await detectLocalCliConfig(config.provider)
+  if (config.provider === "codex-cli") {
+    if (!detect?.appServerReady || !detect.dynamicToolsReady) {
+      throw new Error(detect?.error || "当前 Codex CLI 不支持 QMAI 主 Agent,请升级 Codex CLI。")
+    }
+    const models = Array.from(new Set([
+      ...(explicitModel ? [explicitModel] : []),
+      ...(detect.models ?? []),
+    ])).filter(Boolean)
+    if (models.length === 0) {
+      throw new Error("Codex app-server 未返回可用模型,请检查本机 Codex 登录状态。")
+    }
+    return toModelListResult(models)
+  }
   const localModel = detect?.model?.trim() ?? ""
   if (!localModel) {
     throw new Error("当前本地 CLI 未配置默认模型,请先在本地 CLI 中设置模型,或在软件里手动填写模型。")

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác