Просмотр исходного кода

merge dev into v13-refactor

# Conflicts:
#	packages/core/src/agents/architect.ts
#	packages/core/src/index.ts
#	packages/studio/src/api/server.ts
Ma 6 месяцев назад
Родитель
Сommit
e57d342725
100 измененных файлов с 10622 добавлено и 578 удалено
  1. 3 1
      .github/workflows/ci.yml
  2. 4 1
      .gitignore
  3. 116 0
      CHANGELOG.md
  4. 661 21
      LICENSE
  5. 37 22
      README.en.md
  6. 19 20
      README.ja.md
  7. 44 22
      README.md
  8. BIN
      assets/wechat-group-v6.jpg
  9. 2 2
      package.json
  10. 12 5
      packages/cli/package.json
  11. 90 0
      packages/cli/src/__tests__/cli-integration.test.ts
  12. 4 2
      packages/cli/src/__tests__/daemon.test.ts
  13. 35 0
      packages/cli/src/__tests__/doctor.test.ts
  14. 142 0
      packages/cli/src/__tests__/interact-command.test.ts
  15. 107 0
      packages/cli/src/__tests__/interaction-tools.test.ts
  16. 54 0
      packages/cli/src/__tests__/project-bootstrap.test.ts
  17. 4 2
      packages/cli/src/__tests__/publish-package.test.ts
  18. 82 0
      packages/cli/src/__tests__/revision-command.test.ts
  19. 65 21
      packages/cli/src/__tests__/studio-runtime.test.ts
  20. 22 8
      packages/cli/src/__tests__/studio.test.ts
  21. 20 0
      packages/cli/src/__tests__/tui-activity-state.test.ts
  22. 123 0
      packages/cli/src/__tests__/tui-agent-session.test.ts
  23. 27 0
      packages/cli/src/__tests__/tui-chat-depth.test.ts
  24. 44 0
      packages/cli/src/__tests__/tui-chat-draft.test.ts
  25. 86 0
      packages/cli/src/__tests__/tui-command.test.ts
  26. 46 0
      packages/cli/src/__tests__/tui-composer-caret.test.ts
  27. 40 0
      packages/cli/src/__tests__/tui-composer-display.test.ts
  28. 163 0
      packages/cli/src/__tests__/tui-control-flow.test.ts
  29. 219 0
      packages/cli/src/__tests__/tui-dashboard.test.tsx
  30. 29 0
      packages/cli/src/__tests__/tui-effects-i18n.test.ts
  31. 22 0
      packages/cli/src/__tests__/tui-i18n.test.ts
  32. 10 0
      packages/cli/src/__tests__/tui-input-chrome.test.ts
  33. 40 0
      packages/cli/src/__tests__/tui-input-history.test.ts
  34. 55 0
      packages/cli/src/__tests__/tui-layout.test.ts
  35. 47 0
      packages/cli/src/__tests__/tui-local-commands.test.ts
  36. 38 0
      packages/cli/src/__tests__/tui-output.test.ts
  37. 81 0
      packages/cli/src/__tests__/tui-session-store.test.ts
  38. 18 0
      packages/cli/src/__tests__/tui-setup-i18n.test.ts
  39. 36 0
      packages/cli/src/__tests__/tui-slash-autocomplete.test.ts
  40. 135 7
      packages/cli/src/commands/doctor.ts
  41. 13 106
      packages/cli/src/commands/export.ts
  42. 8 111
      packages/cli/src/commands/init.ts
  43. 109 0
      packages/cli/src/commands/interact.ts
  44. 4 1
      packages/cli/src/commands/revise.ts
  45. 108 30
      packages/cli/src/commands/studio.ts
  46. 18 0
      packages/cli/src/commands/tui.ts
  47. 76 7
      packages/cli/src/commands/write.ts
  48. 2 67
      packages/cli/src/index.ts
  49. 0 7
      packages/cli/src/localization.ts
  50. 96 0
      packages/cli/src/program.ts
  51. 142 0
      packages/cli/src/project-bootstrap.ts
  52. 64 0
      packages/cli/src/tui/__tests__/markdown.test.ts
  53. 41 0
      packages/cli/src/tui/activity-state.ts
  54. 98 0
      packages/cli/src/tui/agent-input.ts
  55. 72 0
      packages/cli/src/tui/ansi.ts
  56. 185 0
      packages/cli/src/tui/app.ts
  57. 22 0
      packages/cli/src/tui/chat-depth.ts
  58. 42 0
      packages/cli/src/tui/chat-draft.ts
  59. 22 0
      packages/cli/src/tui/composer-caret.ts
  60. 26 0
      packages/cli/src/tui/composer-display.ts
  61. 164 0
      packages/cli/src/tui/dashboard-model.ts
  62. 587 0
      packages/cli/src/tui/dashboard.tsx
  63. 546 0
      packages/cli/src/tui/effects.ts
  64. 327 0
      packages/cli/src/tui/i18n.ts
  65. 69 0
      packages/cli/src/tui/input-history.ts
  66. 55 0
      packages/cli/src/tui/local-commands.ts
  67. 64 0
      packages/cli/src/tui/markdown.ts
  68. 39 0
      packages/cli/src/tui/output.ts
  69. 6 0
      packages/cli/src/tui/session-store.ts
  70. 351 0
      packages/cli/src/tui/setup.ts
  71. 67 0
      packages/cli/src/tui/slash-autocomplete.ts
  72. 17 0
      packages/cli/src/tui/theme.ts
  73. 48 0
      packages/cli/src/tui/tools.ts
  74. 2 2
      packages/cli/src/utils.ts
  75. 1 0
      packages/cli/tsconfig.json
  76. 6 4
      packages/core/package.json
  77. 98 0
      packages/core/src/__tests__/agent-system-prompt.test.ts
  78. 163 0
      packages/core/src/__tests__/agent-tools.test.ts
  79. 189 0
      packages/core/src/__tests__/architect.test.ts
  80. 109 0
      packages/core/src/__tests__/book-session-store.test.ts
  81. 108 0
      packages/core/src/__tests__/book-session.test.ts
  82. 46 0
      packages/core/src/__tests__/chapter-persistence.test.ts
  83. 3 0
      packages/core/src/__tests__/chapter-state-recovery.test.ts
  84. 48 0
      packages/core/src/__tests__/chapter-truth-validation.test.ts
  85. 153 1
      packages/core/src/__tests__/config-loader.test.ts
  86. 102 0
      packages/core/src/__tests__/config-migration.test.ts
  87. 386 0
      packages/core/src/__tests__/draft-directive-parser.test.ts
  88. 190 0
      packages/core/src/__tests__/edit-controller.test.ts
  89. 170 0
      packages/core/src/__tests__/interaction-chat-tokens.test.ts
  90. 155 0
      packages/core/src/__tests__/interaction-models.test.ts
  91. 223 0
      packages/core/src/__tests__/interaction-nl-router.test.ts
  92. 633 0
      packages/core/src/__tests__/interaction-runtime.test.ts
  93. 269 0
      packages/core/src/__tests__/interaction-tools.test.ts
  94. 240 6
      packages/core/src/__tests__/pipeline-runner.test.ts
  95. 241 0
      packages/core/src/__tests__/project-interaction.test.ts
  96. 452 102
      packages/core/src/__tests__/provider.test.ts
  97. 95 0
      packages/core/src/__tests__/secrets.test.ts
  98. 174 0
      packages/core/src/__tests__/service-resolver.test.ts
  99. 32 0
      packages/core/src/__tests__/state-manager.test.ts
  100. 64 0
      packages/core/src/__tests__/state-reducer.test.ts

+ 3 - 1
.github/workflows/ci.yml

@@ -8,9 +8,11 @@ on:
 
 jobs:
   build-and-test:
-    runs-on: ubuntu-latest
+    runs-on: ${{ matrix.os }}
     strategy:
+      fail-fast: false
       matrix:
+        os: [ubuntu-latest, windows-latest]
         node-version: [20, 22]
 
     steps:

+ 4 - 1
.gitignore

@@ -13,7 +13,9 @@ test-project/
 .venv*/
 .gstack/
 .worktrees/
-docs/
+docs/*
+!docs/plans/
+!docs/plans/**
 novel-to-comic-research.docx
 .pnpm-store/
 tmp/
@@ -21,3 +23,4 @@ autoresearch/
 .playwright-cli/
 .superpowers/
 _*.md
+docs/plans/

+ 116 - 0
CHANGELOG.md

@@ -1,5 +1,121 @@
 # Changelog
 
+## v1.3.0
+
+### Release Focus
+
+Studio 2.0 正式发布。`inkos` 现在默认直接启动 Studio,本地 Web 工作台成为主入口;TUI 保留为 `inkos tui`。
+
+### 新功能
+
+- **Studio 2.0 默认入口**:`inkos` 直接启动 Studio,首页、服务商管理、写作工作台统一为新的主交互入口
+- **自定义 OpenAI-compatible 服务**:Studio 现支持自定义 `baseUrl`、协议类型(`chat` / `responses`)与流式开关,兼容更多中转站和聚合网关
+- **配置来源切换**:Studio 新增 `.env` 与 Studio 配置的显式切换,不再只能被目录里的 `INKOS_LLM_*` 被动覆盖
+- **原生 custom transport**:对 `custom` 服务新增原生 fetch 请求链,减少对 SDK 路径的单点依赖,提升兼容性
+
+### 改进
+
+- **服务测试更真实**:服务页测试不再只测 `/models`,还会执行最小生成探测,避免“测试连接通过但聊天失败”的假阳性
+- **服务保存流程优化**:保存成功后自动返回服务商管理页,顶部首页和返回入口更醒目
+- **密钥回填**:服务详情页会重新加载已保存的 key,避免重新打开后误以为 key 丢失
+- **错误可见性增强**:Studio 聊天不再用 `Acknowledged.` 掩盖空回复,会直接显示真实上游错误
+
+### Bug Fixes
+
+- 修复 `llm.services + defaultModel + secrets` 与运行时加载契约不一致的问题
+- 修复 `custom:*` 服务在测试连接、模型列表与 `/api/v1/agent` 之间链路不一致的问题
+- 修复 `inkos` 启动 Studio 时因未设置默认模型而直接抛出 `llm.model` 校验错误
+- 修复自定义服务非流式 / SSE 返回被误当作普通 JSON 解析的问题
+
+## v1.2.0
+
+### Release Focus
+
+统一交互内核——TUI、Studio、`inkos interact`、OpenClaw Skill 共享同一套自然语言理解和执行运行时。
+
+### 新功能
+
+- **共享交互运行时**(`packages/core/src/interaction/`):自然语言路由器(15+ intent)、会话管理、编辑事务控制器、事件追踪、阶段遥测
+- **Ink TUI 仪表盘**:`inkos` 直接进入全屏 Ink + React 仪表盘,对话式创作,slash 命令 Tab 补全,主题动效(writing/auditing/revising/planning 各有独立动画),i18n 中英双语
+- **Studio 助手面板**:右侧 AI 助手接入共享交互内核,自然语言操作书籍(写章、改名、审计、导出),SSE 实时状态推送,执行阶段图标
+- **对话式建书**:通过 Studio 助手自然语言对话逐步构思书籍概念、设定、目标章数,草稿就绪后一键创建
+- **全书实体改名**:`把林烬改成张三` / `/rename 林烬 => 张三`,全量扫描章节 + 真相文件一次替换
+- **单章文本替换**:`/replace 5 旧文本 => 新文本`,精确修补指定章节
+- **`inkos interact --json`**:共享交互 JSON 入口,返回 request / response / session / events,供 OpenClaw 和外部 Agent 直接调用
+- **Thinking 模型温度夹制**(PR #174):kimi-k2.5 等 thinking 模型自动 temperature=1,兼容 per-call 温度调参,每模型只 warn 一次
+
+### 改进
+
+- Studio ChatBar 去重:`executeCommand()` 提取公共逻辑,消除 handleSubmit/handleQuickCommand 80 行重复
+- Studio ChatBar SSE effect 用 `loadingRef` 替代 stale closure
+- Studio 下拉菜单 z-index 修复:移除 paper-sheet 的 transform(消除 stacking context),菜单打开时 card 提升 z-50
+- Studio agent 响应修复:使用 `result.responseText` 而非 `session.messages.at(-1)`
+- TUI 主题扩展:语义色(成功/错误/活跃/空闲)+ 角色色(用户/助手/系统)
+- TUI 状态徽标:✓ 完成 / ✗ 失败 / ✎ 写作 / ◇ 规划 / ◈ 等待决策
+- TUI i18n 修复:`stageLabels` 移入 TuiCopy,消除 hardcoded 状态字符串
+- Studio 死代码清理(PR #176):移除未使用的 shadcn 组件、`dotenv`、`shadcn`、`tw-animate-css`、`class-variance-authority`,-2800 行
+
+### Bug Fixes
+
+- Studio ChatBar 助手回复丢失:session 历史覆盖导致 response 被静默丢弃
+- Studio BookMenu 下拉被下层 card 遮挡:fadeIn 动画的 transform 创建 stacking context
+- Studio GenreManager 用 `window.confirm` 替换为 `ConfirmDialog`
+- Studio BookDetail Nav `toTruth` 类型断言 hack 修复
+- Studio ChapterReader/Dashboard approve/reject 缺失错误处理
+- ChatBar curly quote 编码导致 esbuild 解析失败
+
+---
+
+## v1.1.1
+
+### Release Focus
+
+- 回退到稳定的 `v6 + bugfix` 主线,替换掉不稳定的 `v8` 最新版本
+
+### Bug Fixes
+
+- **#151** — Architect section 解析支持 `book-rules` / `Book Rules` / 全角冒号等标题漂移,不再因 `book_rules` 区块轻微变形而创建失败
+- **#152** — State validator 改为 fail-closed:空响应直接报错,并恢复多行 JSON 平衡提取,避免 `passed` 字段丢失时被误判
+- **#154** — 后写规则增加正文章节号指称检测,拦截 `第33章` / `Chapter 33` 一类叙述
+- **#155** — `repair-state` 支持对最新 `state-degraded` 章节进行同章重算,不再报 `delta chapter N goes backwards`
+
+### Improvements
+
+- `ai-tells` / `sensitive-words` 增加中英双语规则路径,英文书修订链不再混入中文 issue
+- import / continuation / series 的 prompt 与语言传递补齐,foundation reviewer 结果能更稳定回灌
+- reviser 修订链重新接入 `hookDebtBlock`,局部修订时能看到 hook 债务证据
+
+---
+
+## v1.1.0
+
+写作管线全面升级。通过 Meta-Harness 方法论驱动的多轮 autoresearch 实验,从零模式质量从 75 分提升至 92 分,同人模式从 39 分提升至 82+ 分。
+
+### 新功能
+
+- **Foundation Reviewer**:建书时新增独立审核 Agent,5 维度百分制打分(原作 DNA 保留、新叙事空间、核心冲突、开篇节奏、节奏可行性),不达 80 分自动驳回并将审核意见反馈给 Architect 重新生成
+- **新时空要求**:同人模式(canon/au/ooc/cp)必须设计原创分岔点,不允许复述原作剧情
+- **Hook Seed Excerpt**:伏笔回收时,Composer 从 chapter_summaries 提取原始种子场景的原文片段注入 Writer 上下文,替代了复杂的 lifecycle pressure 系统
+- **Review Reject 回滚**:`inkos review reject` 回滚 state 到被拒章节之前的快照,丢弃下游章节和记忆索引
+- **State Validation Recovery**:state 校验失败自动重试 settler,仍失败则降级保存,支持 `inkos write repair-state` 手动修复
+- **Audit Drift 隔离**:审计纠偏写入独立的 `audit_drift.md`,不再追加到 `current_state.md`
+- **标题坍缩修复**:检测近期标题主题聚集,从正文提取新关键词重生标题
+- **Hook 预算提示**:活跃伏笔 ≥10 时显示预算警告,引导优先回收旧债
+- **章节结尾摘要**:提取最近 3 章结尾句注入上下文,防止结构性重复
+- **情绪/节奏检测**:mood 单调和标题聚集检测,序列级 warning 不计入修订 blockingCount
+- **同人风格提取**:`fanfic init` 和 `import chapters` 自动生成 style_guide.md + style_profile.json
+- **Governed 路径补全**:续写/同人的 parent_canon.md 和 fanfic_canon.md 通过 Governed 路径注入 Writer
+- **自定义 HTTP Headers**:`INKOS_LLM_HEADERS` 环境变量注入自定义 HTTP 头
+
+### Bug Fixes
+
+- 章节号污染修复:叙事文本中的数字不再被误解析为章节进度
+- hook 排序修复:mustAdvance 从降序修正为升序(选最久未推进的)
+- Outline 匹配修复:支持章节范围格式,防止 Chapter 1 误匹配 Chapter 10
+- approve 不覆盖快照、style 提取 graceful degrade、Studio 热加载 LLM 配置、主题持久化
+
+---
+
 ## v1.0.2
 
 ### Bug Fixes

+ 661 - 21
LICENSE

@@ -1,21 +1,661 @@
-MIT License
-
-Copyright (c) 2026 InkOS Contributors
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+                    GNU AFFERO GENERAL PUBLIC LICENSE
+                       Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+  A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate.  Many developers of free software are heartened and
+encouraged by the resulting cooperation.  However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+  The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community.  It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server.  Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+  An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals.  This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU Affero General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Remote Network Interaction; Use with the GNU General Public License.
+
+  Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software.  This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time.  Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source.  For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code.  There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.

+ 37 - 22
README.en.md

@@ -3,13 +3,14 @@
   <img src="assets/inkos-text.svg" width="240" height="65" alt="InkOS">
 </p>
 
-<h1 align="center">Autonomous Novel Writing CLI AI Agent</h1>
+<h1 align="center">Autonomous Novel Writing AI Agent</h1>
 
 <p align="center">
   <a href="https://www.npmjs.com/package/@actalk/inkos"><img src="https://img.shields.io/npm/v/@actalk/inkos.svg?color=cb3837&logo=npm" alt="npm version"></a>
-  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
-  <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg" alt="Node.js"></a>
-  <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.x-3178C6.svg?logo=typescript&logoColor=white" alt="TypeScript"></a>
+  <a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL-3.0"></a>
+  <a href="https://github.com/Narcooo/inkos/stargazers"><img src="https://img.shields.io/github/stars/Narcooo/inkos?style=flat&logo=github&color=yellow" alt="GitHub stars"></a>
+  <a href="https://www.npmjs.com/package/@actalk/inkos"><img src="https://img.shields.io/npm/dm/@actalk/inkos?color=cb3837&logo=npm&label=downloads" alt="npm downloads"></a>
+  <a href="https://clawhub.ai/narcooo/inkos"><img src="https://img.shields.io/badge/🦞%20ClawHub-Skill-FF6B35?labelColor=1a1a1a" alt="ClawHub Skill"></a>
 </p>
 
 <p align="center">
@@ -18,9 +19,11 @@
 
 ---
 
-Open-source CLI AI Agent that autonomously writes, audits, and revises novels — with human review gates that keep you in control. Supports LitRPG, Progression Fantasy, Isekai, Romantasy, Sci-Fi, and more. Continuation, spinoff, fanfic, and style imitation workflows built in.
+Open-source AI Agent that autonomously writes, audits, and revises novels — with human review gates that keep you in control. Supports LitRPG, Progression Fantasy, Isekai, Romantasy, Sci-Fi, and more. Continuation, spinoff, fanfic, and style imitation workflows built in.
 
-**InkOS Studio is here!** — run `inkos studio` to launch a local web workbench. Book management, chapter review & editing, real-time writing progress, market radar, analytics, AI detection, style analysis, genre management, daemon control, truth file editing — everything the CLI does, now visual.
+**InkOS Studio 2.0 is here!** — run `inkos` to launch the local web workbench. Book management, chapter review & editing, real-time writing progress, market radar, analytics, AI detection, style analysis, genre management, daemon control, truth file editing — everything the CLI does, now visual.
+
+**InkOS TUI is here!** — run `inkos tui` to launch a full-screen interactive dashboard. Conversational creation, natural-language book operations, slash command autocomplete, themed animations — TUI, Studio, and OpenClaw share the same interaction kernel.
 
 **Native English novel writing now supported!** — 10 built-in English genre profiles with dedicated pacing rules, fatigue word lists, and audit dimensions. Set `--lang en` and go.
 
@@ -42,7 +45,21 @@ clawhub install inkos          # Install from ClawHub
 
 If you installed via npm or cloned the repo, `skills/SKILL.md` is already included — 🦞 can read it directly without a separate ClawHub install.
 
-Once installed, Claw can invoke InkOS atomic commands and control-surface operations (`plan chapter`/`compose chapter`/`draft`/`audit`/`revise`/`write next`) via `exec`, with `--json` output for structured decision-making. The recommended flow is: update `author_intent.md` or `current_focus.md`, run `plan` / `compose`, then decide whether to call `draft` or the full `write next` pipeline. You can also browse it on [ClawHub](https://clawhub.ai) by searching `inkos`.
+Once installed, Claw should prefer the shared interaction entry:
+
+```bash
+inkos interact --json --message "continue the current book, but keep the pacing tighter"
+```
+
+This routes through the same conversation executor used by the project TUI, so OpenClaw, TUI, and Studio stay on the same control brain. The JSON payload includes:
+- parsed request
+- assistant response text
+- updated interaction session
+- execution state
+- pending decision
+- recent events
+
+Atomic commands (`plan chapter` / `compose chapter` / `draft` / `audit` / `revise` / `write next`) are still available, but they are now lower-level tools rather than the preferred OpenClaw entry. You can also browse it on [ClawHub](https://clawhub.ai) by searching `inkos`.
 
 ### Configure
 
@@ -102,22 +119,18 @@ inkos config show-models        # View current routing
 
 Agents without explicit overrides fall back to the global model.
 
-### v0.6 Update
+### v1.2 Update
 
-**Structured State + Hook Governance + Length Governance**
+**Unified Interaction Kernel + TUI Dashboard + Studio Assistant**
 
-Addresses three systemic long-form writing problems: **context bloat after 20+ chapters causing slowdowns and 400 errors** (Settler full injection → JSON delta + selective retrieval), **hooks only accumulate, never resolve, ~0% payoff rate** (Planner scheduling + Settler blind spot fix + audit debt tracking), **word count deviation 50%+ and normalizer destroying chapters** (LengthSpec + safety net).
-
-- Pipeline upgraded to 10 agents: adds Planner, Composer, Observer, Reflector, Normalizer
-- Truth files moved to `story/state/*.json` (Zod validated); Settler outputs JSON delta instead of full markdown; legacy books auto-migrate
-- SQLite temporal memory database on Node 22+ for relevance-based retrieval
-- Planner generates `hookAgenda` to schedule hook advancement and payoff; Settler working set expanded to cover dormant debt
-- New `mention` semantics prevents fake hook advancement; `analyzeHookHealth` audits hook debt; `evaluateHookAdmission` blocks duplicate hooks
-- Length governance: `LengthSpec` + Normalizer single-pass correction with safety net against destructive normalization
-- User `INKOS_LLM_MAX_TOKENS` acts as global cap; reserved keys in `llm.extra` auto-stripped
-- Cross-chapter repetition detection, dialogue-driven guidance, English variance brief, multi-character scene resistance
-- Chapter summary dedup, ESM node:sqlite fix, consolidate full-width parenthesis support
-- Bilingual CLI output and logging
+- **Shared Interaction Runtime**: TUI, Studio, `inkos interact`, and OpenClaw Skill share a single NL understanding + execution kernel, supporting 15+ intents (write, revise, rewrite, rename, export, switch book, etc.)
+- **Ink TUI Dashboard**: `inkos` launches a full-screen interactive dashboard (Ink + React) with conversational creation, slash command autocomplete, themed animations, and bilingual i18n
+- **Studio Assistant Panel**: right-side AI assistant panel connects to the shared interaction kernel — natural language book operations (rename, write, audit, export) with real-time execution status
+- **Conversational Book Creation**: brainstorm book settings through natural language dialogue, one-click create when draft is ready
+- **Book-wide Entity Rename**: `rename Lin Jin to Zhang San` or `/rename Lin Jin => Zhang San` — scans all chapters + truth files in one pass
+- **`inkos interact`**: shared interaction JSON endpoint for OpenClaw / external agent integration
+- **Thinking Model Temperature Clamp**: kimi-k2.5 and similar thinking models auto-clamped to temperature=1, compatible with per-call temperature overrides
+- **Studio Dead Code Cleanup**: removed unused shadcn components and dependencies, -2800 lines
 
 ### Write Your First Book
 
@@ -391,6 +404,8 @@ inkos agent "Create a progression fantasy about a mage who can only use one spel
 
 Contributions welcome. Open an issue or PR.
 
+Development is moving quickly. More features and writing-quality improvements will keep landing. Feedback, feature requests, and project follow-up are all welcome. The goal is to build the strongest AI novel-writing Agent.
+
 ```bash
 pnpm install
 pnpm dev          # Watch mode for all packages
@@ -420,4 +435,4 @@ pnpm typecheck    # Type-check without emitting
 
 ## License
 
-[MIT](LICENSE)
+[AGPL-3.0](LICENSE)

+ 19 - 20
README.ja.md

@@ -3,13 +3,14 @@
   <img src="assets/inkos-text.svg" width="240" height="65" alt="InkOS">
 </p>
 
-<h1 align="center">自律型小説執筆 CLI AIエージェント</h1>
+<h1 align="center">自律型小説執筆 AIエージェント</h1>
 
 <p align="center">
   <a href="https://www.npmjs.com/package/@actalk/inkos"><img src="https://img.shields.io/npm/v/@actalk/inkos.svg?color=cb3837&logo=npm" alt="npm version"></a>
-  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
-  <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg" alt="Node.js"></a>
-  <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.x-3178C6.svg?logo=typescript&logoColor=white" alt="TypeScript"></a>
+  <a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL-3.0"></a>
+  <a href="https://github.com/Narcooo/inkos/stargazers"><img src="https://img.shields.io/github/stars/Narcooo/inkos?style=flat&logo=github&color=yellow" alt="GitHub stars"></a>
+  <a href="https://www.npmjs.com/package/@actalk/inkos"><img src="https://img.shields.io/npm/dm/@actalk/inkos?color=cb3837&logo=npm&label=downloads" alt="npm downloads"></a>
+  <a href="https://clawhub.ai/narcooo/inkos"><img src="https://img.shields.io/badge/🦞%20ClawHub-Skill-FF6B35?labelColor=1a1a1a" alt="ClawHub Skill"></a>
 </p>
 
 <p align="center">
@@ -20,7 +21,9 @@
 
 小説の執筆・監査・修正を自律的に行うオープンソースCLIエージェント。人間によるレビューゲートにより、常にコントロールを維持できます。LitRPG、プログレッションファンタジー、異世界転生、ロマンタジー、SF など多数のジャンルに対応。続編・スピンオフ・二次創作・文体模倣ワークフローを内蔵。
 
-**InkOS Studio 正式リリース!** — `inkos studio` でローカルWebワークベンチを起動。書籍管理、章のレビュー&編集、リアルタイム執筆進捗、市場レーダー、アナリティクス、AI検出、スタイル分析、ジャンル管理、デーモン制御、真実ファイル編集 — CLIの全機能をビジュアルで利用可能。
+**InkOS Studio 2.0 正式リリース!** — `inkos` を実行してローカル Web ワークベンチを起動。書籍管理、章のレビュー&編集、リアルタイム執筆進捗、市場レーダー、アナリティクス、AI検出、スタイル分析、ジャンル管理、デーモン制御、真実ファイル編集 — CLI の全機能をビジュアルで利用可能。
+
+**InkOS TUI 正式リリース!** — `inkos tui` を実行してフルスクリーンのインタラクティブダッシュボードを起動。会話型創作、自然言語での書籍操作、スラッシュコマンド自動補完、テーマアニメーション——TUI、Studio、OpenClaw が同一のインタラクションカーネルを共有。
 
 **英語ネイティブ小説執筆に対応!** — 10種類の英語ジャンルプロファイルを内蔵し、専用のペーシングルール、疲労語リスト、監査ディメンションを搭載。`--lang en` を設定するだけですぐに始められます。
 
@@ -102,22 +105,18 @@ inkos config show-models        # 現在のルーティングを表示
 
 明示的なオーバーライドがないエージェントはグローバルモデルにフォールバックします。
 
-### v0.6 アップデート
-
-**構造化ステート + フック管理 + 文字数管理**
+### v1.2 アップデート
 
-長編執筆における3つのシステム的問題に対処:**20章超でのコンテキスト肥大化によるスローダウンと400エラー**(Settler全量注入 → JSONデルタ + 選択的取得)、**フックが蓄積するだけで解決せず、回収率がほぼ0%**(Plannerスケジューリング + Settler盲点修正 + 監査デット追跡)、**文字数乖離50%超とNormalizerによる章破壊**(LengthSpec + セーフティネット)。
+**統一インタラクションカーネル + TUIダッシュボード + Studioアシスタント**
 
-- パイプラインが10エージェントにアップグレード:Planner、Composer、Observer、Reflector、Normalizerを追加
-- 真実ファイルが `story/state/*.json` に移行(Zodバリデーション済み);SettlerはフルMarkdownではなくJSONデルタを出力;既存書籍は自動マイグレーション
-- Node 22+ でSQLite時系列メモリデータベースを使用した関連性ベースの取得
-- Plannerが `hookAgenda` を生成してフックの進行と回収をスケジューリング;Settlerのワーキングセットを拡張して休眠中のデットもカバー
-- 新しい `mention` セマンティクスで偽のフック進行を防止;`analyzeHookHealth` がフックデットを監査;`evaluateHookAdmission` が重複フックをブロック
-- 文字数管理:`LengthSpec` + Normalizerの1パス補正とセーフティネットで破壊的正規化を防止
-- ユーザー設定の `INKOS_LLM_MAX_TOKENS` がグローバルキャップとして機能;`llm.extra` の予約キーは自動除去
-- クロスチャプター反復検出、対話駆動ガイダンス、英語バリアンスブリーフ、マルチキャラクターシーン耐性
-- チャプターサマリーの重複排除、ESM node:sqlite修正、全角括弧サポートの統合
-- バイリンガルCLI出力とログ
+- **共有インタラクションランタイム**:TUI、Studio、`inkos interact`、OpenClaw Skillが単一のNL理解+実行カーネルを共有、15以上のインテント(執筆、修正、書き直し、リネーム、エクスポート、書籍切り替え等)をサポート
+- **Ink TUIダッシュボード**:`inkos` でフルスクリーンの対話型ダッシュボード(Ink + React)を起動、会話型創作、スラッシュコマンド自動補完、テーマアニメーション、i18nバイリンガル対応
+- **Studioアシスタントパネル**:右側AIアシスタントパネルが共有インタラクションカーネルに接続——自然言語で書籍操作(リネーム、執筆、監査、エクスポート)、リアルタイム実行状態表示
+- **会話型ブック作成**:自然言語の対話で書籍設定を段階的にブレスト、ドラフト完成後ワンクリック作成
+- **全書エンティティリネーム**:`rename 林烬 to 張三` または `/rename 林烬 => 張三`——全章+真実ファイルを一括スキャン&置換
+- **`inkos interact`**:共有インタラクションJSONエンドポイント、OpenClaw/外部Agentから直接呼び出し可能
+- **Thinkingモデル温度クランプ**:kimi-k2.5等のthinkingモデルをtemperature=1に自動固定、per-call温度オーバーライドと互換
+- **Studio不要コード削除**:未使用のshadcnコンポーネントと依存関係を削除、-2800行
 
 ### 最初の本を書く
 
@@ -400,4 +399,4 @@ pnpm typecheck    # 出力なしで型チェック
 
 ## ライセンス
 
-[MIT](LICENSE)
+[AGPL-3.0](LICENSE)

+ 44 - 22
README.md

@@ -3,13 +3,14 @@
   <img src="assets/inkos-text.svg" width="240" height="65" alt="InkOS">
 </p>
 
-<h1 align="center">Autonomous Novel Writing CLI AI Agent<br><sub>自动化小说写作 CLI AI Agent</sub></h1>
+<h1 align="center">Autonomous Novel Writing AI Agent<br><sub>自动化小说写作 AI Agent</sub></h1>
 
 <p align="center">
   <a href="https://www.npmjs.com/package/@actalk/inkos"><img src="https://img.shields.io/npm/v/@actalk/inkos.svg?color=cb3837&logo=npm" alt="npm version"></a>
-  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
-  <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg" alt="Node.js"></a>
-  <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.x-3178C6.svg?logo=typescript&logoColor=white" alt="TypeScript"></a>
+  <a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL-3.0"></a>
+  <a href="https://github.com/Narcooo/inkos/stargazers"><img src="https://img.shields.io/github/stars/Narcooo/inkos?style=flat&logo=github&color=yellow" alt="GitHub stars"></a>
+  <a href="https://www.npmjs.com/package/@actalk/inkos"><img src="https://img.shields.io/npm/dm/@actalk/inkos?color=cb3837&logo=npm&label=downloads" alt="npm downloads"></a>
+  <a href="https://clawhub.ai/narcooo/inkos"><img src="https://img.shields.io/badge/🦞%20ClawHub-Skill-FF6B35?labelColor=1a1a1a" alt="ClawHub Skill"></a>
 </p>
 
 <p align="center">
@@ -20,10 +21,21 @@
 
 AI Agent 自主写小说——写、审、改,全程接管。覆盖玄幻、仙侠、都市、科幻等多种风格,支持续写、番外、同人、仿写等创作形式。人工审核门控确保你始终掌控全局。已发布为 [OpenClaw](https://clawhub.ai/narcooo/inkos) skill。
 
-**InkOS Studio 正式发布!** — `inkos studio` 启动本地 Web 工作台。书籍管理、章节审阅编辑、实时写作进度、市场雷达、数据分析、AI 检测、文风分析、题材管理、守护进程控制、真相文件编辑——CLI 能做的,Studio 全部可视化。
+**InkOS Studio 2.0 正式发布!** — 直接运行 `inkos` 启动本地 Web 工作台。书籍管理、章节审阅编辑、实时写作进度、市场雷达、数据分析、AI 检测、文风分析、题材管理、守护进程控制、真相文件编辑——CLI 能做的,Studio 全部可视化。
+
+**InkOS TUI 正式发布!** — 运行 `inkos tui` 进入全屏交互仪表盘。对话式创作、自然语言操作书籍、slash 命令补全、主题动效——TUI、Studio、OpenClaw 共享同一套交互内核。
 
 **Native English novel writing now supported!** Set `--lang en` to write in English. See [English README](README.en.md) for details.
 
+## 欢迎交流
+
+> 当前更新相对频繁,后续会持续新增功能与优化写作效果。
+> 欢迎加群反馈问题、提出需求,也欢迎关注项目动态 — 我们的目标是做最强的基于小说的内容生态创作 AI Agent。
+
+<p align="center">
+  <img src="assets/wechat-group-v6.jpg" width="300" alt="微信交流群">
+</p>
+
 ## 快速开始
 
 ### 安装
@@ -42,7 +54,21 @@ clawhub install inkos          # 从 ClawHub 安装 InkOS Skill
 
 通过 npm 安装或克隆本项目时,`skills/SKILL.md` 已包含在内,🦞 可直接读取——无需额外从 ClawHub 安装。
 
-安装后,Claw 可通过 `exec` 调用 InkOS 的原子命令和控制面操作(`plan chapter`/`compose chapter`/`draft`/`audit`/`revise`/`write next`),`--json` 输出结构化数据供 Claw 解析决策。推荐流程是先更新 `author_intent.md` 或 `current_focus.md`,再 `plan` / `compose`,最后决定是否 `draft` 或完整 `write next`。也可以在 [ClawHub](https://clawhub.ai) 搜索 `inkos` 在线查看。
+安装后,Claw 应优先通过共享交互入口调用 InkOS:
+
+```bash
+inkos interact --json --message "继续当前书,但把节奏再收紧一点"
+```
+
+这条入口直接走和项目 TUI 相同的交互执行内核,因此 OpenClaw、TUI、Studio 共用同一套控制脑。返回的 JSON 包含:
+- 解析后的 request
+- assistant 文本回复
+- 更新后的 interaction session
+- execution state
+- pending decision
+- recent events
+
+`plan chapter` / `compose chapter` / `draft` / `audit` / `revise` / `write next` 这些原子命令仍然保留,但更适合作为底层工具,而不是 OpenClaw 的首选入口。也可以在 [ClawHub](https://clawhub.ai) 搜索 `inkos` 在线查看。
 
 ### 配置
 
@@ -98,22 +124,18 @@ inkos config show-models        # 查看当前路由
 
 未单独配置的 Agent 自动使用全局模型。
 
-### v0.6 更新
-
-**结构化状态 + 伏笔治理 + 字数治理**
+### v1.2 更新
 
-重点解决三个长篇写作的系统性问题:**20+ 章后上下文膨胀导致写作变慢甚至 400 报错**(Settler 全量注入 → JSON delta + 选择性检索)、**伏笔只加不收、回收率接近 0%**(Planner 排班 + Settler 盲区修补 + 审计追债)、**字数偏差 50%+ 且 normalizer 可能毁章**(LengthSpec + 安全网)。
+**统一交互内核 + TUI 仪表盘 + Studio 助手**
 
-- 管线升级为 10-agent:新增 Planner、Composer、Observer、Reflector、Normalizer
-- 真相文件迁移到 `story/state/*.json`(Zod 校验),Settler 输出 JSON delta 而非全量 markdown,旧书自动迁移
-- Node 22+ 启用 SQLite 时序记忆数据库,按相关性检索历史事实
-- Planner 生成 `hookAgenda` 排班伏笔推进与回收,Settler working set 扩展覆盖 dormant debt
-- hookOps 新增 `mention` 语义防止假推进,`analyzeHookHealth` 审计伏笔债务,`evaluateHookAdmission` 拦截重复伏笔
-- 字数治理:`LengthSpec` + Normalizer 单 pass 修正,安全网防止归一化毁章
-- 用户 `INKOS_LLM_MAX_TOKENS` 作为全局上限生效,`llm.extra` 保留键自动过滤
-- 跨章重复检测、对话驱动引导、English variance brief、多角色场景阻力要求
-- 章节摘要去重、ESM node:sqlite 修复、consolidate 全角括号兼容
-- 双语 CLI 输出和日志
+- **共享交互运行时**:TUI、Studio、`inkos interact`、OpenClaw Skill 共用同一套自然语言理解 + 执行内核,支持 15+ 种意图(写作、修订、重写、改名、导出、切换书籍等)
+- **Ink TUI 仪表盘**:`inkos` 直接进入全屏交互式仪表盘(Ink + React),对话式创作体验,slash 命令自动补全,主题动效,i18n 双语
+- **Studio 助手面板**:右侧 AI 助手面板接入共享交互内核,支持自然语言操作书籍——改名、写章、审计、导出等,实时显示执行状态
+- **对话式建书**:通过自然语言对话逐步构思书籍设定,草稿就绪后一键创建
+- **全书实体改名**:`把林烬改成张三` 或 `/rename 林烬 => 张三`,全量扫描章节 + 真相文件,一次替换
+- **`inkos interact`**:共享交互 JSON 入口,OpenClaw / 外部 Agent 可直接调用
+- **Thinking 模型温度夹制**:kimi-k2.5 等 thinking 模型自动强制 temperature=1,兼容 per-call 温度调参
+- **Studio 死代码清理**:移除未使用的 shadcn 组件和依赖,-2800 行
 
 ### 写第一本书
 
@@ -393,7 +415,7 @@ pnpm test         # 运行测试
 pnpm typecheck    # 类型检查
 ```
 
-## 📈 Star History
+## Star History
 
 <a href="https://www.star-history.com/#Narcooo/inkos&type=date&legend=top-left">
  <picture>
@@ -415,4 +437,4 @@ pnpm typecheck    # 类型检查
 
 ## 许可证
 
-[MIT](LICENSE)
+[AGPL-3.0](LICENSE)

BIN
assets/wechat-group-v6.jpg


+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "inkos",
-  "version": "1.0.2",
+  "version": "1.3.0",
   "private": true,
   "description": "Autonomous AI novel writing CLI agent — 10-agent pipeline that writes, audits, and revises novels with continuity tracking",
   "keywords": [
@@ -15,7 +15,7 @@
     "openclaw-skill",
     "cli"
   ],
-  "license": "MIT",
+  "license": "AGPL-3.0-only",
   "repository": {
     "type": "git",
     "url": "https://github.com/Narcooo/inkos"

+ 12 - 5
packages/cli/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@actalk/inkos",
-  "version": "1.0.2",
+  "version": "1.3.0",
   "description": "Autonomous AI novel writing CLI agent — 10-agent pipeline that writes, audits, and revises novels with continuity tracking. Supports LitRPG, Progression Fantasy, Isekai, Romantasy, Sci-Fi and more.",
   "keywords": [
     "ai-novel-writing",
@@ -27,7 +27,7 @@
     "dist",
     "!dist/__tests__"
   ],
-  "license": "MIT",
+  "license": "AGPL-3.0-only",
   "repository": {
     "type": "git",
     "url": "https://github.com/Narcooo/inkos.git",
@@ -48,11 +48,18 @@
     "commander": "^13.0.0",
     "dotenv": "^16.4.0",
     "epub-gen-memory": "^1.0.10",
-    "marked": "^15.0.0"
+    "ink": "^7.0.0",
+    "ink-text-input": "^6.0.0",
+    "marked": "^15.0.12",
+    "marked-terminal": "^7.3.0",
+    "react": "^19.2.4"
   },
   "devDependencies": {
+    "@types/marked-terminal": "^6.1.1",
+    "@types/node": "^22.0.0",
+    "@types/react": "^19.2.14",
+    "ink-testing-library": "^4.0.0",
     "typescript": "^5.8.0",
-    "vitest": "^3.0.0",
-    "@types/node": "^22.0.0"
+    "vitest": "^3.0.0"
   }
 }

+ 90 - 0
packages/cli/src/__tests__/cli-integration.test.ts

@@ -191,6 +191,55 @@ describe("CLI integration", () => {
     });
   });
 
+  describe("inkos interact", () => {
+    it("returns structured JSON for shared interaction mode switches", async () => {
+      const initialized = await stat(join(projectDir, "inkos.json")).then(() => true).catch(() => false);
+      if (!initialized) run(["init"]);
+      await writeFile(
+        join(projectDir, ".env"),
+        Object.entries(failingLlmEnv).map(([key, value]) => `${key}=${value}`).join("\n"),
+        "utf-8",
+      );
+      const output = run(["interact", "--json", "--message", "切换到全自动"]);
+      const data = JSON.parse(output);
+
+      expect(data.request.intent).toBe("switch_mode");
+      expect(data.request.mode).toBe("auto");
+      expect(data.session.automationMode).toBe("auto");
+    });
+
+    it("binds the requested book when interact is called with --book", async () => {
+      const initialized = await stat(join(projectDir, "inkos.json")).then(() => true).catch(() => false);
+      if (!initialized) run(["init"]);
+      await writeFile(
+        join(projectDir, ".env"),
+        Object.entries(failingLlmEnv).map(([key, value]) => `${key}=${value}`).join("\n"),
+        "utf-8",
+      );
+      const state = new StateManager(projectDir);
+      await state.saveBookConfig("harbor", {
+        id: "harbor",
+        title: "Harbor",
+        platform: "tomato",
+        genre: "other",
+        status: "active",
+        targetChapters: 20,
+        chapterWordCount: 3000,
+        createdAt: "2026-04-07T00:00:00.000Z",
+        updatedAt: "2026-04-07T00:00:00.000Z",
+      });
+
+      const output = run(["interact", "--json", "--book", "harbor", "--message", "/books"]);
+      const data = JSON.parse(output);
+
+      expect(data.session.activeBookId).toBe("harbor");
+
+      // Clean up harbor book and session so subsequent tests start with an empty project
+      await rm(join(projectDir, "books", "harbor"), { recursive: true, force: true });
+      await rm(join(projectDir, ".inkos-session.json"), { force: true }).catch(() => {});
+    });
+  });
+
   describe("inkos config set-model", () => {
     it("rejects raw API keys passed to --api-key-env", async () => {
       const { exitCode, stderr } = runStderr([
@@ -590,6 +639,46 @@ describe("CLI integration", () => {
       expect(`${stdout}\n${stderr}`).toContain("legacy format");
     });
 
+    it("fails rewrite before deleting chapters when the rollback snapshot is missing", async () => {
+      const bookId = "rewrite-missing-snapshot";
+      const bookDir = join(projectDir, "books", bookId);
+      const storyDir = join(bookDir, "story");
+      const chaptersDir = join(bookDir, "chapters");
+
+      await mkdir(chaptersDir, { recursive: true });
+      await mkdir(storyDir, { recursive: true });
+      await writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: bookId,
+          title: "Rewrite Missing Snapshot",
+          platform: "other",
+          genre: "other",
+          status: "active",
+          targetChapters: 10,
+          chapterWordCount: 2200,
+          createdAt: "2026-03-22T00:00:00.000Z",
+          updatedAt: "2026-03-22T00:00:00.000Z",
+        }, null, 2),
+        "utf-8",
+      );
+      await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
+      await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
+      await writeFile(join(chaptersDir, "0001_ch1.md"), "# Chapter 1\n\nContent 1", "utf-8");
+      await writeFile(join(chaptersDir, "0002_ch2.md"), "# Chapter 2\n\nContent 2", "utf-8");
+      await writeFile(join(chaptersDir, "index.json"), JSON.stringify([
+        { number: 1, title: "Ch1", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
+        { number: 2, title: "Ch2", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
+      ], null, 2), "utf-8");
+
+      const { exitCode, stdout, stderr } = runStderr(["write", "rewrite", bookId, "2", "--force"], {
+        env: failingLlmEnv,
+      });
+      expect(exitCode).not.toBe(0);
+      expect(`${stdout}\n${stderr}`).toContain("missing snapshot for chapter 1");
+      await expect(readFile(join(chaptersDir, "0002_ch2.md"), "utf-8")).resolves.toContain("Content 2");
+    });
+
     it("keeps next chapter at 2 after rewrite 2 trims later chapters, even if regeneration fails", async () => {
       const state = new StateManager(projectDir);
       const bookId = "rewrite-cli";
@@ -650,6 +739,7 @@ describe("CLI integration", () => {
       });
       expect(exitCode).not.toBe(0);
       expect(`${stdout}\n${stderr}`).toContain("Regenerating chapter 2");
+      expect(`${stdout}\n${stderr}`).not.toContain("resolved to 3");
 
       const next = await state.getNextChapterNumber(bookId);
       expect(next).toBe(2);

+ 4 - 2
packages/cli/src/__tests__/daemon.test.ts

@@ -1,4 +1,5 @@
 import { beforeEach, describe, expect, it, vi } from "vitest";
+import { join } from "node:path";
 
 const readFileMock = vi.fn();
 const writeFileMock = vi.fn();
@@ -83,8 +84,9 @@ describe("daemon command", () => {
       upCommand.parseAsync(["node", "up", "--quiet"]),
     ).rejects.toMatchObject({ code: 1 });
 
-    expect(writeFileMock).toHaveBeenCalledWith("/project/inkos.pid", expect.any(String), "utf-8");
-    expect(unlinkMock).toHaveBeenCalledWith("/project/inkos.pid");
+    const pidPath = join("/project", "inkos.pid");
+    expect(writeFileMock).toHaveBeenCalledWith(pidPath, expect.any(String), "utf-8");
+    expect(unlinkMock).toHaveBeenCalledWith(pidPath);
 
     exitSpy.mockRestore();
   });

+ 35 - 0
packages/cli/src/__tests__/doctor.test.ts

@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { buildDoctorModelCandidates, resolveDoctorModelsBaseUrl } from "../commands/doctor.js";
+
+describe("doctor model candidate probing", () => {
+  it("keeps the configured model first, then tries discovered models without duplicates", () => {
+    const candidates = buildDoctorModelCandidates("google/gemma-4-26b-4b", [
+      { id: "google/gemma-4-26b-4b", name: "google/gemma-4-26b-4b" },
+      { id: "google/gemma-4-27b-it", name: "google/gemma-4-27b-it" },
+      { id: "gemini-2.5-flash", name: "gemini-2.5-flash" },
+    ]);
+
+    expect(candidates[0]).toBe("google/gemma-4-26b-4b");
+    expect(candidates[1]).toBe("google/gemma-4-27b-it");
+    expect(candidates[2]).toBe("gemini-2.5-flash");
+    expect(new Set(candidates).size).toBe(candidates.length);
+  });
+
+  it("falls back to common compatibility candidates when /models is unavailable", () => {
+    const candidates = buildDoctorModelCandidates("missing-model", []);
+    expect(candidates[0]).toBe("missing-model");
+    expect(candidates).toContain("gpt-5.4");
+    expect(candidates).toContain("MiniMax-M2.7");
+    expect(candidates).toContain("gemini-2.5-flash");
+  });
+
+  it("uses the service-specific models endpoint when the preset defines one", () => {
+    const modelsBaseUrl = resolveDoctorModelsBaseUrl(
+      "bailian",
+      "https://dashscope.aliyuncs.com/apps/anthropic",
+      (service) => service === "bailian" ? "https://dashscope.aliyuncs.com/compatible-mode/v1" : undefined,
+    );
+
+    expect(modelsBaseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
+  });
+});

+ 142 - 0
packages/cli/src/__tests__/interact-command.test.ts

@@ -0,0 +1,142 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createProgram } from "../program.js";
+
+describe("interact command", () => {
+  const originalArgv = process.argv;
+  let projectRoot: string;
+  let stdoutSpy: ReturnType<typeof vi.spyOn> | {
+    mockClear: () => void;
+    mock: { calls: Array<ReadonlyArray<unknown>> };
+  };
+
+  beforeEach(async () => {
+    stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-interact-cli-"));
+    await mkdir(join(projectRoot, "books", "harbor"), { recursive: true });
+    await writeFile(join(projectRoot, "books", "harbor", "book.json"), "{}", "utf-8");
+    stdoutSpy.mockClear();
+    process.argv = originalArgv;
+  });
+
+  afterEach(() => {
+    process.argv = originalArgv;
+    vi.restoreAllMocks();
+  });
+
+  it("routes natural language through the shared executor and prints plain text by default", async () => {
+    const runInteraction = vi.fn(async () => ({
+      request: { intent: "write_next" },
+      responseText: "Continuing harbor.",
+      session: {
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [{ role: "assistant", content: "Continuing harbor.", timestamp: 1 }],
+        events: [{ kind: "task.completed", status: "completed", timestamp: 1 }],
+      },
+    }));
+
+    const program = createProgram({
+      runInteraction,
+      readInteractionInput: async () => "",
+    });
+
+    await program.parseAsync(["interact", "continue", "--book", "harbor"], {
+      from: "user",
+    });
+
+    expect(runInteraction).toHaveBeenCalledWith(
+      expect.objectContaining({
+        projectRoot: process.cwd(),
+        input: "continue",
+        activeBookId: "harbor",
+      }),
+    );
+    expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining("Continuing harbor."));
+  });
+
+  it("emits structured JSON when --json is used", async () => {
+    const runInteraction = vi.fn(async () => ({
+      request: { intent: "switch_mode", mode: "auto" },
+      responseText: "Switched to auto.",
+      session: {
+        activeBookId: "harbor",
+        automationMode: "auto",
+        messages: [],
+        events: [{ kind: "task.completed", status: "completed", timestamp: 1 }],
+      },
+    }));
+
+    const program = createProgram({
+      runInteraction,
+      readInteractionInput: async () => "",
+    });
+
+    await program.parseAsync(["interact", "切换到全自动", "--book", "harbor", "--json"], {
+      from: "user",
+    });
+
+    const output = stdoutSpy.mock.calls.map((call) => String(call[0])).join("");
+    const parsed = JSON.parse(output);
+    expect(parsed.request.intent).toBe("switch_mode");
+    expect(parsed.responseText).toBe("Switched to auto.");
+    expect(parsed.session.automationMode).toBe("auto");
+  });
+
+  it("accepts --message as an explicit OpenClaw-friendly input channel", async () => {
+    const runInteraction = vi.fn(async () => ({
+      request: { intent: "continue_book" },
+      responseText: "Continuing via --message.",
+      session: {
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      },
+    }));
+
+    const program = createProgram({
+      runInteraction,
+      readInteractionInput: async () => "",
+    });
+
+    await program.parseAsync(["interact", "--book", "harbor", "--message", "continue current book"], {
+      from: "user",
+    });
+
+    expect(runInteraction).toHaveBeenCalledWith(
+      expect.objectContaining({
+        input: "continue current book",
+        activeBookId: "harbor",
+      }),
+    );
+  });
+
+  it("reads the message from stdin when no args are provided", async () => {
+    const runInteraction = vi.fn(async () => ({
+      request: { intent: "explain_status" },
+      responseText: "Explaining status.",
+      session: {
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      },
+    }));
+
+    const program = createProgram({
+      runInteraction,
+      readInteractionInput: async () => "why did it stop?",
+    });
+
+    await program.parseAsync(["interact"], { from: "user" });
+
+    expect(runInteraction).toHaveBeenCalledWith(
+      expect.objectContaining({
+        input: "why did it stop?",
+      }),
+    );
+  });
+});

+ 107 - 0
packages/cli/src/__tests__/interaction-tools.test.ts

@@ -0,0 +1,107 @@
+import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import { createInteractionToolsFromDeps } from "../tui/tools.js";
+
+const chapterResult = {
+  chapterNumber: 1,
+  title: "Draft",
+  wordCount: 1200,
+  revised: false,
+  status: "ready-for-review" as const,
+  auditResult: {
+    passed: true,
+    issues: [],
+    summary: "ok",
+  },
+};
+
+const reviseResult = {
+  chapterNumber: 3,
+  wordCount: 1200,
+  fixedIssues: [],
+  applied: true,
+  status: "ready-for-review" as const,
+};
+
+let projectRoot: string;
+
+describe("interaction tools adapter", () => {
+  beforeAll(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-interaction-tools-"));
+    await mkdir(join(projectRoot, "books", "harbor", "story"), { recursive: true });
+  });
+
+  afterAll(async () => {
+    // tmpdir cleanup omitted
+  });
+
+  it("delegates writeNextChapter and reviseDraft to the pipeline", async () => {
+    const pipeline = {
+      writeNextChapter: vi.fn(async () => chapterResult),
+      reviseDraft: vi.fn(async () => reviseResult),
+    };
+    const state = {
+      ensureControlDocuments: vi.fn(async () => {}),
+      bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadBookConfig: vi.fn(async () => ({
+        id: "harbor",
+        title: "Harbor",
+        platform: "other" as const,
+        genre: "other",
+        status: "outlining" as const,
+        targetChapters: 200,
+        chapterWordCount: 3000,
+        createdAt: "2026-04-10T00:00:00.000Z",
+        updatedAt: "2026-04-10T00:00:00.000Z",
+      })),
+      loadChapterIndex: vi.fn(async () => []),
+      saveChapterIndex: vi.fn(async () => undefined),
+      listBooks: vi.fn(async () => ["harbor"]),
+    };
+
+    const tools = createInteractionToolsFromDeps(projectRoot, pipeline, state);
+
+    await tools.writeNextChapter("harbor");
+    await tools.reviseDraft("harbor", 3, "rewrite");
+
+    expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor");
+    expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "rewrite");
+  });
+
+  it("writes current_focus and author_intent through the canonical story paths", async () => {
+    const pipeline = {
+      writeNextChapter: vi.fn(async () => chapterResult),
+      reviseDraft: vi.fn(async () => reviseResult),
+    };
+    const state = {
+      ensureControlDocuments: vi.fn(async () => {}),
+      bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadBookConfig: vi.fn(async () => ({
+        id: "harbor",
+        title: "Harbor",
+        platform: "other" as const,
+        genre: "other",
+        status: "outlining" as const,
+        targetChapters: 200,
+        chapterWordCount: 3000,
+        createdAt: "2026-04-10T00:00:00.000Z",
+        updatedAt: "2026-04-10T00:00:00.000Z",
+      })),
+      loadChapterIndex: vi.fn(async () => []),
+      saveChapterIndex: vi.fn(async () => undefined),
+      listBooks: vi.fn(async () => ["harbor"]),
+    };
+
+    const tools = createInteractionToolsFromDeps(projectRoot, pipeline, state);
+
+    await tools.updateCurrentFocus("harbor", "# Current Focus\n\nBring focus back to the old case.\n");
+    await tools.updateAuthorIntent("harbor", "# Author Intent\n\nWrite a cold harbor mystery.\n");
+
+    await expect(readFile(join(projectRoot, "books", "harbor", "story", "current_focus.md"), "utf-8"))
+      .resolves.toContain("Bring focus back to the old case");
+    await expect(readFile(join(projectRoot, "books", "harbor", "story", "author_intent.md"), "utf-8"))
+      .resolves.toContain("Write a cold harbor mystery");
+  });
+});

+ 54 - 0
packages/cli/src/__tests__/project-bootstrap.test.ts

@@ -0,0 +1,54 @@
+import { beforeEach, afterEach, describe, expect, it } from "vitest";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+describe("project bootstrap", () => {
+  const originalHome = process.env.HOME;
+  let tempDir: string;
+
+  beforeEach(async () => {
+    tempDir = await mkdtemp(join(tmpdir(), "inkos-bootstrap-"));
+    process.env.HOME = tempDir;
+  });
+
+  afterEach(async () => {
+    process.env.HOME = originalHome;
+    await rm(tempDir, { recursive: true, force: true });
+  });
+
+  it("creates a minimal Studio-first project when none exists", async () => {
+    const { ensureProjectDirectoryInitialized } = await import("../project-bootstrap.js");
+
+    const initialized = await ensureProjectDirectoryInitialized(tempDir, { language: "zh" });
+
+    expect(initialized).toBe(true);
+    const config = JSON.parse(await readFile(join(tempDir, "inkos.json"), "utf-8"));
+    expect(config.name).toMatch(/^inkos-bootstrap-/);
+    expect(config.version).toBe("0.1.0");
+    expect(config.llm.configSource).toBe("studio");
+    expect(config.llm.service).toBe("custom");
+    expect(config.llm.model).toBe("");
+    expect(config.llm.baseUrl).toBe("");
+    await expect(readFile(join(tempDir, ".nvmrc"), "utf-8")).resolves.toBe("22\n");
+    await expect(readFile(join(tempDir, ".node-version"), "utf-8")).resolves.toBe("22\n");
+  });
+
+  it("does not overwrite support files when auto-initializing", async () => {
+    await writeFile(join(tempDir, ".env"), "EXISTING=1\n", "utf-8");
+    await writeFile(join(tempDir, ".gitignore"), "CUSTOM\n", "utf-8");
+
+    const { ensureProjectDirectoryInitialized } = await import("../project-bootstrap.js");
+    await ensureProjectDirectoryInitialized(tempDir, { language: "zh" });
+
+    await expect(readFile(join(tempDir, ".env"), "utf-8")).resolves.toBe("EXISTING=1\n");
+    await expect(readFile(join(tempDir, ".gitignore"), "utf-8")).resolves.toBe("CUSTOM\n");
+  });
+
+  it("returns false when the directory is already an InkOS project", async () => {
+    await writeFile(join(tempDir, "inkos.json"), "{}\n", "utf-8");
+    const { ensureProjectDirectoryInitialized } = await import("../project-bootstrap.js");
+
+    await expect(ensureProjectDirectoryInitialized(tempDir, { language: "zh" })).resolves.toBe(false);
+  });
+});

+ 4 - 2
packages/cli/src/__tests__/publish-package.test.ts

@@ -17,10 +17,12 @@ const sourceStudioPackageJsonPromise = readFile(resolve(studioDir, "package.json
 );
 
 async function packPackage(packageDir: string, packDir: string) {
-  execFileSync("npm", ["pack", "--pack-destination", packDir], {
+  const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
+  execFileSync(npmCmd, ["pack", "--pack-destination", packDir], {
     cwd: packageDir,
     env: process.env,
     encoding: "utf-8",
+    shell: process.platform === "win32",
   });
 
   const tgzFiles = (await readdir(packDir)).filter((name) => name.endsWith(".tgz"));
@@ -235,7 +237,7 @@ describe.sequential("publish packaging", () => {
     }
   });
 
-  it("packs the studio runtime entry alongside the built frontend", { timeout: 30_000 }, async () => {
+  it("packs the studio runtime entry alongside the built frontend", { timeout: 60_000 }, async () => {
     const packDir = await mkdtemp(join(tmpdir(), "inkos-studio-pack-"));
 
     try {

+ 82 - 0
packages/cli/src/__tests__/revision-command.test.ts

@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const reviseDraftMock = vi.fn();
+const resyncChapterArtifactsMock = vi.fn();
+const buildPipelineConfigMock = vi.fn();
+const loadBookConfigMock = vi.fn();
+const logMock = vi.fn();
+const logErrorMock = vi.fn();
+
+vi.mock("@actalk/inkos-core", () => ({
+  DEFAULT_REVISE_MODE: "spot-fix",
+  PipelineRunner: class {
+    reviseDraft = reviseDraftMock;
+    resyncChapterArtifacts = resyncChapterArtifactsMock;
+  },
+  StateManager: class {
+    async loadBookConfig() {
+      return loadBookConfigMock();
+    }
+  },
+}));
+
+vi.mock("../utils.js", () => ({
+  loadConfig: vi.fn(async () => ({ llm: {} })),
+  buildPipelineConfig: buildPipelineConfigMock,
+  findProjectRoot: vi.fn(() => "/project"),
+  resolveBookId: vi.fn(async (bookId?: string) => bookId ?? "auto-book"),
+  getLegacyMigrationHint: vi.fn(async () => undefined),
+  resolveContext: vi.fn(),
+  log: logMock,
+  logError: logErrorMock,
+}));
+
+vi.mock("../localization.js", () => ({
+  formatWriteNextResultLines: vi.fn(() => ["ok"]),
+  resolveCliLanguage: vi.fn(() => "zh"),
+}));
+
+describe("revision-related CLI commands", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    reviseDraftMock.mockResolvedValue({
+      chapterNumber: 3,
+      wordCount: 1200,
+      fixedIssues: [],
+      applied: true,
+      status: "ready-for-review",
+    });
+    resyncChapterArtifactsMock.mockResolvedValue({
+      chapterNumber: 3,
+      title: "Synced Chapter",
+      wordCount: 1200,
+      revised: false,
+      status: "ready-for-review",
+      auditResult: { passed: true, issues: [], summary: "synced" },
+    });
+    buildPipelineConfigMock.mockReturnValue({});
+    loadBookConfigMock.mockResolvedValue({ language: "zh" });
+  });
+
+  it("passes one-off brief into revise command pipeline config", async () => {
+    const { reviseCommand } = await import("../commands/revise.js");
+
+    await reviseCommand.parseAsync(["node", "revise", "demo-book", "3", "--mode", "rewrite", "--brief", "把注意力拉回师债主线。"], { from: "node" });
+
+    expect(buildPipelineConfigMock).toHaveBeenCalledWith(expect.anything(), "/project", {
+      externalContext: "把注意力拉回师债主线。",
+    });
+    expect(reviseDraftMock).toHaveBeenCalledWith("demo-book", 3, "rewrite");
+  });
+
+  it("exposes write sync and passes brief into pipeline config", async () => {
+    const { writeCommand } = await import("../commands/write.js");
+
+    await writeCommand.parseAsync(["node", "write", "sync", "demo-book", "3", "--brief", "以师债线为准同步状态。"], { from: "node" });
+
+    expect(buildPipelineConfigMock).toHaveBeenCalledWith(expect.anything(), "/project", {
+      externalContext: "以师债线为准同步状态。",
+    });
+    expect(resyncChapterArtifactsMock).toHaveBeenCalledWith("demo-book", 3);
+  });
+});

+ 65 - 21
packages/cli/src/__tests__/studio-runtime.test.ts

@@ -1,5 +1,5 @@
 import { beforeEach, describe, expect, it, vi } from "vitest";
-import { dirname, resolve } from "node:path";
+import { dirname, join, resolve } from "node:path";
 import { fileURLToPath } from "node:url";
 
 const accessMock = vi.fn();
@@ -16,12 +16,31 @@ describe("studio runtime resolution", () => {
     vi.resetModules();
   });
 
+  const tsSourceEntry = join("/repo", "packages", "studio", "src", "api", "index.ts");
+  const tsxLoader = join("/repo", "packages", "studio", "node_modules", "tsx", "dist", "loader.mjs");
+  const projectBuiltEntry = join(
+    "/repo",
+    "test-project",
+    "node_modules",
+    "@actalk",
+    "inkos-studio",
+    "dist",
+    "api",
+    "index.js",
+  );
+  const cliBuiltEntry = join(
+    cliPackageRoot,
+    "node_modules",
+    "@actalk",
+    "inkos-studio",
+    "dist",
+    "api",
+    "index.js",
+  );
+
   it("prefers the repository-local tsx loader for monorepo sources", async () => {
     accessMock.mockImplementation(async (path: string) => {
-      if (
-        path === "/repo/packages/studio/src/api/index.ts" ||
-        path === "/repo/packages/studio/node_modules/tsx/dist/loader.mjs"
-      ) {
+      if (path === tsSourceEntry || path === tsxLoader) {
         return;
       }
       throw new Error(`missing: ${path}`);
@@ -31,20 +50,21 @@ describe("studio runtime resolution", () => {
     const launch = await resolveStudioLaunch("/repo/test-project");
 
     expect(launch).toEqual({
-      studioEntry: "/repo/packages/studio/src/api/index.ts",
+      studioEntry: tsSourceEntry,
       command: "node",
-      args: [
-        "--import",
-        "/repo/packages/studio/node_modules/tsx/dist/loader.mjs",
-        "/repo/packages/studio/src/api/index.ts",
-        "/repo/test-project",
-      ],
+      args: ["--import", tsxLoader, tsSourceEntry, "/repo/test-project"],
     });
   });
 
+  it("uses a file URL for the tsx loader on Windows absolute paths", async () => {
+    const { toNodeImportSpecifier } = await import("../commands/studio.js");
+    expect(toNodeImportSpecifier("D:\\repo\\packages\\studio\\node_modules\\tsx\\dist\\loader.mjs"))
+      .toBe("file:///D:/repo/packages/studio/node_modules/tsx/dist/loader.mjs");
+  });
+
   it("finds monorepo packages/studio sources from a project directory", async () => {
     accessMock.mockImplementation(async (path: string) => {
-      if (path === "/repo/packages/studio/src/api/index.ts") {
+      if (path === tsSourceEntry) {
         return;
       }
       throw new Error(`missing: ${path}`);
@@ -54,15 +74,15 @@ describe("studio runtime resolution", () => {
     const launch = await resolveStudioLaunch("/repo/test-project");
 
     expect(launch).toEqual({
-      studioEntry: "/repo/packages/studio/src/api/index.ts",
+      studioEntry: tsSourceEntry,
       command: "npx",
-      args: ["tsx", "/repo/packages/studio/src/api/index.ts", "/repo/test-project"],
+      args: ["tsx", tsSourceEntry, "/repo/test-project"],
     });
   });
 
   it("uses node for built JavaScript entries", async () => {
     accessMock.mockImplementation(async (path: string) => {
-      if (path === "/repo/test-project/node_modules/@actalk/inkos-studio/dist/api/index.js") {
+      if (path === projectBuiltEntry) {
         return;
       }
       throw new Error(`missing: ${path}`);
@@ -72,15 +92,15 @@ describe("studio runtime resolution", () => {
     const launch = await resolveStudioLaunch("/repo/test-project");
 
     expect(launch).toEqual({
-      studioEntry: "/repo/test-project/node_modules/@actalk/inkos-studio/dist/api/index.js",
+      studioEntry: projectBuiltEntry,
       command: "node",
-      args: ["/repo/test-project/node_modules/@actalk/inkos-studio/dist/api/index.js", "/repo/test-project"],
+      args: [projectBuiltEntry, "/repo/test-project"],
     });
   });
 
   it("falls back to the CLI installation's bundled studio runtime", async () => {
     accessMock.mockImplementation(async (path: string) => {
-      if (path === `${cliPackageRoot}/node_modules/@actalk/inkos-studio/dist/api/index.js`) {
+      if (path === cliBuiltEntry) {
         return;
       }
       throw new Error(`missing: ${path}`);
@@ -90,9 +110,33 @@ describe("studio runtime resolution", () => {
     const launch = await resolveStudioLaunch("/repo/test-project");
 
     expect(launch).toEqual({
-      studioEntry: `${cliPackageRoot}/node_modules/@actalk/inkos-studio/dist/api/index.js`,
+      studioEntry: cliBuiltEntry,
       command: "node",
-      args: [`${cliPackageRoot}/node_modules/@actalk/inkos-studio/dist/api/index.js`, "/repo/test-project"],
+      args: [cliBuiltEntry, "/repo/test-project"],
+    });
+  });
+
+  it("returns a browser launch spec for macOS", async () => {
+    const { resolveBrowserLaunch } = await import("../commands/studio.js");
+    expect(resolveBrowserLaunch("darwin", "http://localhost:4567")).toEqual({
+      command: "open",
+      args: ["http://localhost:4567"],
+    });
+  });
+
+  it("returns a browser launch spec for Windows", async () => {
+    const { resolveBrowserLaunch } = await import("../commands/studio.js");
+    expect(resolveBrowserLaunch("win32", "http://localhost:4567")).toEqual({
+      command: "cmd",
+      args: ["/c", "start", "", "http://localhost:4567"],
+    });
+  });
+
+  it("returns a browser launch spec for Linux", async () => {
+    const { resolveBrowserLaunch } = await import("../commands/studio.js");
+    expect(resolveBrowserLaunch("linux", "http://localhost:4567")).toEqual({
+      command: "xdg-open",
+      args: ["http://localhost:4567"],
     });
   });
 });

+ 22 - 8
packages/cli/src/__tests__/studio.test.ts

@@ -1,4 +1,5 @@
 import { beforeEach, describe, expect, it, vi } from "vitest";
+import { join } from "node:path";
 
 const accessMock = vi.fn();
 const spawnMock = vi.fn(() => ({
@@ -9,6 +10,9 @@ const logErrorMock = vi.fn();
 
 vi.mock("node:fs/promises", () => ({
   access: accessMock,
+  mkdir: vi.fn(),
+  readFile: vi.fn(async () => ""),
+  writeFile: vi.fn(),
 }));
 
 vi.mock("node:child_process", () => ({
@@ -28,19 +32,20 @@ describe("studio command", () => {
   });
 
   it("launches TypeScript sources through tsx in monorepo mode", async () => {
+    const tsEntry = join("/project", "packages", "studio", "src", "api", "index.ts");
     accessMock.mockImplementation(async (path: string) => {
-      if (path === "/project/packages/studio/src/api/index.ts") {
+      if (path === tsEntry) {
         return;
       }
       throw new Error(`missing: ${path}`);
     });
 
-    const { studioCommand } = await import("../commands/studio.js");
-    await studioCommand.parseAsync(["node", "studio", "--port", "9001"]);
+    const { createStudioCommand } = await import("../commands/studio.js");
+    await createStudioCommand().parseAsync(["node", "studio", "--port", "9001"]);
 
     expect(spawnMock).toHaveBeenCalledWith(
       "npx",
-      ["tsx", "/project/packages/studio/src/api/index.ts", "/project"],
+      ["tsx", tsEntry, "/project"],
       expect.objectContaining({
         cwd: "/project",
         stdio: "inherit",
@@ -50,19 +55,28 @@ describe("studio command", () => {
   });
 
   it("launches built JavaScript entries through node", async () => {
+    const jsEntry = join(
+      "/project",
+      "node_modules",
+      "@actalk",
+      "inkos-studio",
+      "dist",
+      "api",
+      "index.js",
+    );
     accessMock.mockImplementation(async (path: string) => {
-      if (path === "/project/node_modules/@actalk/inkos-studio/dist/api/index.js") {
+      if (path === jsEntry) {
         return;
       }
       throw new Error(`missing: ${path}`);
     });
 
-    const { studioCommand } = await import("../commands/studio.js");
-    await studioCommand.parseAsync(["node", "studio", "--port", "4567"]);
+    const { createStudioCommand } = await import("../commands/studio.js");
+    await createStudioCommand().parseAsync(["node", "studio", "--port", "4567"]);
 
     expect(spawnMock).toHaveBeenCalledWith(
       "node",
-      ["/project/node_modules/@actalk/inkos-studio/dist/api/index.js", "/project"],
+      [jsEntry, "/project"],
       expect.objectContaining({
         cwd: "/project",
         stdio: "inherit",

+ 20 - 0
packages/cli/src/__tests__/tui-activity-state.test.ts

@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { describeActivityState } from "../tui/activity-state.js";
+import { getTuiCopy } from "../tui/i18n.js";
+import { WARM_ACCENT } from "../tui/theme.js";
+
+describe("tui activity state", () => {
+  it("maps chat-like intents to thinking", () => {
+    const copy = getTuiCopy("en");
+    expect(describeActivityState("chat", copy)).toMatchObject({ label: "thinking", accent: WARM_ACCENT });
+    expect(describeActivityState("explain_status", copy)).toMatchObject({ label: "checking", accent: WARM_ACCENT });
+  });
+
+  it("maps writing and review intents to task-specific labels", () => {
+    const zhCopy = getTuiCopy("zh-CN");
+    expect(describeActivityState("write_next", zhCopy)).toMatchObject({ label: "写作中", accent: WARM_ACCENT });
+    expect(describeActivityState("revise_chapter", zhCopy)).toMatchObject({ label: "审阅中", accent: WARM_ACCENT });
+    expect(describeActivityState("rewrite_chapter", zhCopy)).toMatchObject({ label: "审阅中", accent: WARM_ACCENT });
+    expect(describeActivityState("chat", zhCopy).intervalMs).toBeGreaterThanOrEqual(180);
+  });
+});

+ 123 - 0
packages/cli/src/__tests__/tui-agent-session.test.ts

@@ -0,0 +1,123 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { createProjectSession, loadProjectSession } from "../tui/session-store.js";
+
+const {
+  runAgentSessionMock,
+  loadConfigMock,
+  buildPipelineConfigMock,
+} = vi.hoisted(() => ({
+  runAgentSessionMock: vi.fn(),
+  loadConfigMock: vi.fn(),
+  buildPipelineConfigMock: vi.fn(),
+}));
+
+vi.mock("@actalk/inkos-core", async () => {
+  const actual = await vi.importActual<typeof import("@actalk/inkos-core")>("@actalk/inkos-core");
+  class PipelineRunnerMock {
+    constructor(_config: unknown) {}
+  }
+  return {
+    ...actual,
+    createLLMClient: vi.fn(() => ({
+      _piModel: {
+        id: "gpt-5.4",
+        name: "gpt-5.4",
+        api: "openai-completions",
+        provider: "openai",
+        baseUrl: "https://right.codes/codex/v1",
+        reasoning: false,
+        input: ["text"],
+        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+        contextWindow: 128000,
+        maxTokens: 8192,
+      },
+      _apiKey: "secret",
+    })),
+    PipelineRunner: PipelineRunnerMock as any,
+    runAgentSession: runAgentSessionMock,
+  };
+});
+
+vi.mock("../utils.js", () => ({
+  loadConfig: loadConfigMock,
+  buildPipelineConfig: buildPipelineConfigMock,
+}));
+
+describe("tui agent session bridge", () => {
+  let projectRoot: string;
+
+  beforeEach(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-tui-agent-"));
+    vi.clearAllMocks();
+    loadConfigMock.mockResolvedValue({
+      llm: {
+        provider: "openai",
+        model: "gpt-5.4",
+        baseUrl: "https://right.codes/codex/v1",
+        apiFormat: "chat",
+        stream: false,
+      },
+      language: "zh",
+    });
+    buildPipelineConfigMock.mockReturnValue({});
+  });
+
+  afterEach(async () => {
+    await rm(projectRoot, { recursive: true, force: true });
+  });
+
+  it("runs agent chat and persists raw assistant output into the tui session", async () => {
+    runAgentSessionMock.mockResolvedValue({
+      responseText: "这是 agent 直接返回的回复。",
+      messages: [
+        { role: "user", content: "帮我整理这一章" },
+        { role: "assistant", content: "这是 agent 直接返回的回复。", thinking: "internal" },
+      ],
+    });
+
+    const { processTuiAgentInput } = await import("../tui/agent-input.js");
+    const session = {
+      ...createProjectSession(projectRoot),
+      activeBookId: "harbor",
+      messages: [
+        { role: "user" as const, content: "旧问题", timestamp: 1 },
+        { role: "assistant" as const, content: "旧回答", timestamp: 2 },
+      ],
+    };
+
+    const result = await processTuiAgentInput({
+      projectRoot,
+      input: "帮我整理这一章",
+      session,
+      activeBookId: "harbor",
+    });
+
+    expect(runAgentSessionMock).toHaveBeenCalledWith(
+      expect.objectContaining({
+        sessionId: session.sessionId,
+        bookId: "harbor",
+        projectRoot,
+      }),
+      "帮我整理这一章",
+      [
+        { role: "user", content: "旧问题" },
+        { role: "assistant", content: "旧回答" },
+      ],
+    );
+    expect(result.responseText).toBe("这是 agent 直接返回的回复。");
+    expect(result.session.messages.at(-1)).toEqual(expect.objectContaining({
+      role: "assistant",
+      content: "这是 agent 直接返回的回复。",
+      thinking: "internal",
+    }));
+
+    const persisted = await loadProjectSession(projectRoot);
+    expect(persisted.messages.at(-1)).toEqual(expect.objectContaining({
+      role: "assistant",
+      content: "这是 agent 直接返回的回复。",
+    }));
+  });
+});

+ 27 - 0
packages/cli/src/__tests__/tui-chat-depth.test.ts

@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { resolveChatDepthProfile } from "../tui/chat-depth.js";
+
+describe("tui chat depth", () => {
+  it("maps light/normal/deep to stable chat options", () => {
+    expect(resolveChatDepthProfile("light")).toEqual({
+      depth: "light",
+      temperature: 0.3,
+      maxTokens: 160,
+      label: "light",
+    });
+    const normal = resolveChatDepthProfile("normal");
+    expect(normal).toEqual({
+      depth: "normal",
+      temperature: 0.4,
+      label: "normal",
+    });
+    // normal depth must NOT include maxTokens — let the model decide
+    expect(normal.maxTokens).toBeUndefined();
+    expect(resolveChatDepthProfile("deep")).toEqual({
+      depth: "deep",
+      temperature: 0.45,
+      maxTokens: 420,
+      label: "deep",
+    });
+  });
+});

+ 44 - 0
packages/cli/src/__tests__/tui-chat-draft.test.ts

@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import type { InteractionSession } from "@actalk/inkos-core";
+import {
+  appendStreamingAssistantChunk,
+  createOptimisticUserMessageSession,
+} from "../tui/chat-draft.js";
+
+function createSession(): InteractionSession {
+  return {
+    sessionId: "session-1",
+    projectRoot: "/tmp/inkos-demo",
+    activeBookId: "harbor",
+    automationMode: "semi",
+    messages: [],
+    events: [],
+    draftRounds: [],
+  };
+}
+
+describe("tui chat draft", () => {
+  it("optimistically appends the user message before the model returns", () => {
+    const next = createOptimisticUserMessageSession(createSession(), "continue current book", 100);
+    expect(next.messages).toEqual([
+      {
+        role: "user",
+        content: "continue current book",
+        timestamp: 100,
+      },
+    ]);
+  });
+
+  it("creates and extends a streaming assistant draft message", () => {
+    const afterUser = createOptimisticUserMessageSession(createSession(), "hi", 100);
+    const afterFirstChunk = appendStreamingAssistantChunk(afterUser, "hello", 101);
+    expect(afterFirstChunk.messages.at(-1)).toEqual({
+      role: "assistant",
+      content: "hello",
+      timestamp: 101,
+    });
+
+    const afterSecondChunk = appendStreamingAssistantChunk(afterFirstChunk, " world", 101);
+    expect(afterSecondChunk.messages.at(-1)?.content).toBe("hello world");
+  });
+});

+ 86 - 0
packages/cli/src/__tests__/tui-command.test.ts

@@ -0,0 +1,86 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { realpathSync } from "node:fs";
+import { createProgram } from "../program.js";
+
+describe("tui command", () => {
+  const originalArgv = process.argv;
+  const originalCwd = process.cwd();
+  let tempDir: string;
+
+  beforeEach(async () => {
+    tempDir = await mkdtemp(join(tmpdir(), "inkos-default-studio-"));
+    process.chdir(tempDir);
+  });
+
+  afterEach(async () => {
+    process.argv = originalArgv;
+    process.chdir(originalCwd);
+    vi.restoreAllMocks();
+    await rm(tempDir, { recursive: true, force: true });
+  });
+
+  it("launches Studio when no subcommand is provided", async () => {
+    const launchStudio = vi.fn(async () => {});
+    const program = createProgram({ launchStudio });
+
+    await program.parseAsync([], { from: "user" });
+
+    expect(launchStudio).toHaveBeenCalledTimes(1);
+    expect(launchStudio).toHaveBeenCalledWith(process.cwd(), "4567");
+  });
+
+  it("auto-initializes a minimal project before launching Studio by default", async () => {
+    const launchStudio = vi.fn(async () => {});
+    const program = createProgram({ launchStudio });
+
+    await program.parseAsync([], { from: "user" });
+
+    const config = JSON.parse(await readFile(join(tempDir, "inkos.json"), "utf-8"));
+    expect(config.name).toMatch(/^inkos-default-studio-/);
+    expect(config.llm.configSource).toBe("studio");
+    expect(config.llm.model).toBe("");
+    expect(config.llm.baseUrl).toBe("");
+    expect(launchStudio).toHaveBeenCalledTimes(1);
+    const [calledRoot, calledPort] = launchStudio.mock.calls[0] as unknown as [string, string];
+    expect(realpathSync(calledRoot)).toBe(realpathSync(tempDir));
+    expect(calledPort).toBe("4567");
+  });
+
+  it("does not overwrite an existing .env during automatic Studio init", async () => {
+    await writeFile(join(tempDir, ".env"), "EXISTING_ENV=1\n", "utf-8");
+    const launchStudio = vi.fn(async () => {});
+    const program = createProgram({ launchStudio });
+
+    await program.parseAsync([], { from: "user" });
+
+    await expect(readFile(join(tempDir, ".env"), "utf-8")).resolves.toBe("EXISTING_ENV=1\n");
+  });
+
+  it("launches the TUI when the explicit tui command is used", async () => {
+    const launchTui = vi.fn(async () => {});
+    const launchStudio = vi.fn(async () => {});
+    const program = createProgram({ launchTui, launchStudio });
+
+    await program.parseAsync(["tui"], { from: "user" });
+
+    expect(launchTui).toHaveBeenCalledTimes(1);
+    expect(launchStudio).not.toHaveBeenCalled();
+  });
+
+  it("auto-initializes a minimal project before launching the explicit studio command", async () => {
+    const launchStudio = vi.fn(async () => {});
+    const program = createProgram({ launchStudio });
+
+    await program.parseAsync(["studio"], { from: "user" });
+
+    const config = JSON.parse(await readFile(join(tempDir, "inkos.json"), "utf-8"));
+    expect(config.llm.configSource).toBe("studio");
+    expect(launchStudio).toHaveBeenCalledTimes(1);
+    const [calledRoot, calledPort] = launchStudio.mock.calls[0] as unknown as [string, string];
+    expect(realpathSync(calledRoot)).toBe(realpathSync(tempDir));
+    expect(calledPort).toBe("4567");
+  });
+});

+ 46 - 0
packages/cli/src/__tests__/tui-composer-caret.test.ts

@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { resolveComposerCaretState } from "../tui/composer-caret.js";
+
+describe("tui composer caret", () => {
+  it("keeps the empty composer caret visible without animation", () => {
+    expect(resolveComposerCaretState({
+      inputValue: "",
+      isSubmitting: false,
+      blinkTick: 0,
+    })).toEqual({
+      visible: true,
+      shouldAnimate: false,
+    });
+  });
+
+  it("keeps the typed composer caret visible without animation", () => {
+    expect(resolveComposerCaretState({
+      inputValue: "continue",
+      isSubmitting: false,
+      blinkTick: 0,
+    })).toEqual({
+      visible: true,
+      shouldAnimate: false,
+    });
+
+    expect(resolveComposerCaretState({
+      inputValue: "continue",
+      isSubmitting: false,
+      blinkTick: 1,
+    })).toEqual({
+      visible: true,
+      shouldAnimate: false,
+    });
+  });
+
+  it("hides the caret while submitting", () => {
+    expect(resolveComposerCaretState({
+      inputValue: "continue",
+      isSubmitting: true,
+      blinkTick: 0,
+    })).toEqual({
+      visible: false,
+      shouldAnimate: false,
+    });
+  });
+});

+ 40 - 0
packages/cli/src/__tests__/tui-composer-display.test.ts

@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+import { renderComposerDisplay } from "../tui/composer-display.js";
+
+describe("tui composer display", () => {
+  it("renders placeholder when empty", () => {
+    expect(renderComposerDisplay("", "Ask InkOS", false)).toEqual({
+      textBeforeCursor: "Ask InkOS",
+      textAfterCursor: "",
+      cursor: "",
+      isPlaceholder: true,
+    });
+  });
+
+  it("keeps the initial caret before the placeholder text", () => {
+    expect(renderComposerDisplay("", "Ask InkOS", true)).toEqual({
+      textBeforeCursor: "",
+      textAfterCursor: "Ask InkOS",
+      cursor: "│",
+      isPlaceholder: true,
+    });
+  });
+
+  it("renders plain input text with a blinking bar cursor when active", () => {
+    expect(renderComposerDisplay("continue", "Ask InkOS", true)).toEqual({
+      textBeforeCursor: "continue",
+      textAfterCursor: "",
+      cursor: "│",
+      isPlaceholder: false,
+    });
+  });
+
+  it("hides the cursor between blink frames", () => {
+    expect(renderComposerDisplay("continue", "Ask InkOS", false)).toEqual({
+      textBeforeCursor: "continue",
+      textAfterCursor: "",
+      cursor: "",
+      isPlaceholder: false,
+    });
+  });
+});

+ 163 - 0
packages/cli/src/__tests__/tui-control-flow.test.ts

@@ -0,0 +1,163 @@
+import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import { createProjectSession, persistProjectSession } from "../tui/session-store.js";
+import { processTuiInput } from "../tui/app.js";
+
+let projectRoot: string;
+
+describe("tui control flow", () => {
+  beforeAll(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-tui-control-"));
+    await mkdir(join(projectRoot, "books", "harbor"), { recursive: true });
+    await writeFile(join(projectRoot, "books", "harbor", "book.json"), "{}", "utf-8");
+    await writeFile(join(projectRoot, "inkos.json"), JSON.stringify({ language: "zh" }), "utf-8");
+  });
+
+  afterAll(async () => {
+    // tmpdir cleanup omitted
+  });
+
+  it("routes continue through the active project book", async () => {
+    await persistProjectSession(projectRoot, {
+      ...createProjectSession(projectRoot),
+      activeBookId: "harbor",
+    });
+
+    const tools = {
+      listBooks: vi.fn(async () => ["harbor"]),
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+      writeTruthFile: vi.fn(async () => ({ ok: true })),
+    };
+
+    const result = await processTuiInput(projectRoot, "continue", tools);
+
+    expect(tools.writeNextChapter).toHaveBeenCalledWith("harbor");
+    expect(result.session.activeBookId).toBe("harbor");
+    expect(result.session.currentExecution?.status).toBe("waiting_human");
+    expect(result.session.pendingDecision?.kind).toBe("review-next-step");
+    expect(result.session.pendingDecision?.summary).toContain("等待");
+    expect(result.session.messages.at(0)?.role).toBe("user");
+    expect(result.session.messages.at(-1)?.role).toBe("assistant");
+  });
+
+  it("routes focus updates through the interaction runtime", async () => {
+    await persistProjectSession(projectRoot, {
+      ...createProjectSession(projectRoot),
+      activeBookId: "harbor",
+    });
+
+    const tools = {
+      listBooks: vi.fn(async () => ["harbor"]),
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+      writeTruthFile: vi.fn(async () => ({ ok: true })),
+    };
+
+    await processTuiInput(projectRoot, "把 focus 拉回旧案线", tools);
+
+    expect(tools.updateCurrentFocus).toHaveBeenCalledWith("harbor", "把 focus 拉回旧案线");
+  });
+
+  it("persists automation mode changes through the session", async () => {
+    await persistProjectSession(projectRoot, {
+      ...createProjectSession(projectRoot),
+      activeBookId: "harbor",
+    });
+
+    const tools = {
+      listBooks: vi.fn(async () => ["harbor"]),
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+      writeTruthFile: vi.fn(async () => ({ ok: true })),
+    };
+
+    const result = await processTuiInput(projectRoot, "切换到全自动", tools);
+
+    expect(result.session.automationMode).toBe("auto");
+  });
+
+  it("routes truth-file edits through the shared control flow", async () => {
+    await persistProjectSession(projectRoot, {
+      ...createProjectSession(projectRoot),
+      activeBookId: "harbor",
+    });
+
+    const tools = {
+      listBooks: vi.fn(async () => ["harbor"]),
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+      writeTruthFile: vi.fn(async () => ({ ok: true })),
+    };
+
+    await processTuiInput(projectRoot, "/truth current_focus.md Bring it back", tools);
+
+    expect(tools.writeTruthFile).toHaveBeenCalledWith(
+      "harbor",
+      "current_focus.md",
+      "Bring it back",
+    );
+  });
+
+  it("binds the active book through /open in multi-book projects", async () => {
+    await mkdir(join(projectRoot, "books", "beta"), { recursive: true });
+    await writeFile(join(projectRoot, "books", "beta", "book.json"), "{}", "utf-8");
+    await persistProjectSession(projectRoot, createProjectSession(projectRoot));
+
+    const tools = {
+      listBooks: vi.fn(async () => ["harbor", "beta"]),
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+      writeTruthFile: vi.fn(async () => ({ ok: true })),
+    };
+
+    const result = await processTuiInput(projectRoot, "/open beta", tools);
+
+    expect(result.session.activeBookId).toBe("beta");
+    expect(result.session.messages.at(-1)?.content).toContain("beta");
+  });
+
+  it("formats interactive summaries in English when the project language is en", async () => {
+    await writeFile(join(projectRoot, "inkos.json"), JSON.stringify({ language: "en" }), "utf-8");
+    await persistProjectSession(projectRoot, createProjectSession(projectRoot));
+
+    const tools = {
+      listBooks: vi.fn(async () => ["harbor", "beta"]),
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+      writeTruthFile: vi.fn(async () => ({ ok: true })),
+    };
+
+    const result = await processTuiInput(projectRoot, "/open beta", tools);
+
+    expect(result.session.messages.at(-1)?.content).toBe("Active book: beta");
+
+    await writeFile(join(projectRoot, "inkos.json"), JSON.stringify({ language: "zh" }), "utf-8");
+  });
+});

+ 219 - 0
packages/cli/src/__tests__/tui-dashboard.test.tsx

@@ -0,0 +1,219 @@
+import React from "react";
+import { render } from "ink-testing-library";
+import { describe, expect, it } from "vitest";
+import type { InteractionSession } from "@actalk/inkos-core";
+
+function createSession(): InteractionSession {
+  return {
+    sessionId: "session-1",
+    projectRoot: "/tmp/inkos-demo",
+    activeBookId: "harbor",
+    activeChapterNumber: 12,
+    automationMode: "semi",
+    currentExecution: {
+      status: "writing",
+      bookId: "harbor",
+      chapterNumber: 12,
+      stageLabel: "writing chapter",
+    },
+    pendingDecision: {
+      kind: "review",
+      bookId: "harbor",
+      chapterNumber: 12,
+      summary: "Review chapter 12 before publishing.",
+    },
+    messages: [
+      { role: "user", content: "continue current book", timestamp: 1 },
+      { role: "assistant", content: "Working on chapter 12.", timestamp: 2 },
+    ],
+    events: [
+      {
+        kind: "task.started",
+        timestamp: 3,
+        status: "writing",
+        bookId: "harbor",
+        chapterNumber: 12,
+        detail: "Preparing chapter 12.",
+      },
+    ],
+    draftRounds: [],
+  };
+}
+
+describe("ink dashboard", () => {
+  it("renders a codex-like single column with compact status and composer", async () => {
+    const mod = await import("../tui/dashboard.js");
+
+    const { lastFrame } = render(
+      <mod.InkTuiDashboard
+        locale="en"
+        projectName="inkos-demo"
+        activeBookTitle="Night Harbor Echo"
+        modelLabel="gpt-5.4 (openai)"
+        session={createSession()}
+        inputValue=""
+        isSubmitting={false}
+      />,
+    );
+
+    const frame = lastFrame() ?? "";
+    expect(frame).toContain("inkos-demo");
+    expect(frame).toContain("Night Harbor Echo");
+    expect(frame).toContain("gpt-5.4 (openai)");
+    expect(frame).toContain("writing chapter");
+    expect(frame).toContain("│");
+    expect(frame).toContain("continue current book");
+    expect(frame).not.toContain("You  continue current book");
+    expect(frame).not.toContain("Header");
+    expect(frame).not.toContain("Conversation");
+    expect(frame).not.toContain("Status");
+    expect(frame).not.toContain("Composer");
+  });
+
+  it("places the initial caret before the placeholder text", async () => {
+    const mod = await import("../tui/dashboard.js");
+
+    const { lastFrame } = render(
+      <mod.InkTuiDashboard
+        locale="en"
+        projectName="inkos-demo"
+        activeBookTitle="Night Harbor Echo"
+        modelLabel="gpt-5.4 (openai)"
+        session={createSession()}
+        inputValue=""
+        isSubmitting={false}
+        showComposerCursor
+      />,
+    );
+
+    const frame = lastFrame() ?? "";
+    expect(frame).toContain("│");
+  });
+
+  it("renders the compact status strip directly above the composer", async () => {
+    const mod = await import("../tui/dashboard.js");
+
+    const { lastFrame } = render(
+      <mod.InkTuiDashboard
+        locale="en"
+        projectName="inkos-demo"
+        activeBookTitle="Night Harbor Echo"
+        modelLabel="gpt-5.4 (openai)"
+        session={createSession()}
+        inputValue="continue"
+        isSubmitting
+      />,
+    );
+
+    const frame = lastFrame() ?? "";
+    expect(frame).toContain("writing chapter");
+    expect(frame).toContain("continue");
+  });
+
+  it("renders a slash autocomplete dropdown under the composer", async () => {
+    const mod = await import("../tui/dashboard.js");
+
+    const { lastFrame } = render(
+      <mod.InkTuiDashboard
+        locale="en"
+        projectName="inkos-demo"
+        activeBookTitle="Night Harbor Echo"
+        modelLabel="gpt-5.4 (openai)"
+        session={createSession()}
+        inputValue="/c"
+        isSubmitting={false}
+        slashSuggestions={["/clear", "/config"]}
+        selectedSlashIndex={1}
+      />,
+    );
+
+    const frame = lastFrame() ?? "";
+    expect(frame).toContain("/clear");
+    expect(frame).toContain("/config");
+    expect(frame.indexOf("/clear")).toBeGreaterThan(frame.indexOf("› /c"));
+  });
+
+  it("renders a low-frequency bar cursor in the active composer", async () => {
+    const mod = await import("../tui/dashboard.js");
+
+    const { lastFrame } = render(
+      <mod.InkTuiDashboard
+        locale="en"
+        projectName="inkos-demo"
+        activeBookTitle="Night Harbor Echo"
+        modelLabel="gpt-5.4 (openai)"
+        session={createSession()}
+        inputValue="continue"
+        isSubmitting={false}
+        showComposerCursor
+      />,
+    );
+
+    const frame = lastFrame() ?? "";
+    expect(frame).toContain("continue");
+    expect(frame).toContain("│");
+  });
+
+  it("defaults dashboard chrome to Chinese when locale is zh-CN", async () => {
+    const mod = await import("../tui/dashboard.js");
+
+    const { lastFrame } = render(
+      <mod.InkTuiDashboard
+        locale="zh-CN"
+        projectName="inkos-demo"
+        activeBookTitle="夜港回声"
+        modelLabel="gpt-5.4 (openai)"
+        session={createSession()}
+        inputValue=""
+        isSubmitting={false}
+      />,
+    );
+
+    const frame = lastFrame() ?? "";
+    expect(frame).toContain("项目 inkos-demo");
+    expect(frame).toContain("作品 夜港回声");
+    expect(frame).toContain("深度 标准");
+    expect(frame).toContain("项目");
+  });
+
+  it("surfaces the shared creation draft when no active book exists yet", async () => {
+    const mod = await import("../tui/dashboard.js");
+
+    const draftSession: InteractionSession = {
+      ...createSession(),
+      activeBookId: undefined,
+      activeChapterNumber: undefined,
+      currentExecution: {
+        status: "planning",
+        stageLabel: "developing book draft",
+      },
+      pendingDecision: undefined,
+      creationDraft: {
+        concept: "港风商战悬疑,主角从灰产洗白。",
+        title: "夜港账本",
+        nextQuestion: "你更想写长篇连载,还是十来章能收住?",
+        missingFields: ["targetChapters"],
+        readyToCreate: false,
+      },
+      messages: [
+        { role: "user", content: "我想写个港风商战悬疑。", timestamp: 1 },
+        { role: "assistant", content: "先把这本书的大概方向收住。", timestamp: 2 },
+      ],
+      events: [],
+    };
+
+    const { lastFrame } = render(
+      <mod.InkTuiDashboard
+        locale="zh-CN"
+        projectName="inkos-demo"
+        modelLabel="gpt-5.4 (openai)"
+        session={draftSession}
+        inputValue=""
+        isSubmitting={false}
+      />,
+    );
+
+    const frame = lastFrame() ?? "";
+    expect(frame).toContain("草稿 夜港账本");
+  });
+});

+ 29 - 0
packages/cli/src/__tests__/tui-effects-i18n.test.ts

@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { stripAnsi } from "../tui/ansi.js";
+import { buildStyledHelpSections, formatStyledStatusLines, intentToBadge } from "../tui/effects.js";
+
+describe("tui effects i18n", () => {
+  it("builds localized help sections", () => {
+    const zhSections = buildStyledHelpSections("zh-CN");
+    const enSections = buildStyledHelpSections("en");
+
+    expect(zhSections[0]?.title).toBe("写作");
+    expect(zhSections[1]?.commands[0]?.[1]).toContain("列出");
+    expect(enSections[0]?.title).toBe("Writing");
+  });
+
+  it("localizes intent badges and status labels", () => {
+    expect(stripAnsi(intentToBadge("write_next", "zh-CN"))).toContain("写作");
+
+    const zhLines = formatStyledStatusLines("zh-CN", {
+      mode: "semi",
+      bookId: "harbor",
+      status: "writing",
+      events: [{ kind: "task.started", detail: "Preparing chapter 3.", status: "running" }],
+    });
+
+    expect(zhLines.join("\n")).toContain("模式");
+    expect(zhLines.join("\n")).toContain("半自动");
+    expect(zhLines.join("\n")).toContain("作品");
+  });
+});

+ 22 - 0
packages/cli/src/__tests__/tui-i18n.test.ts

@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { formatModeLabel, getTuiCopy, normalizeStageLabel, resolveTuiLocale } from "../tui/i18n.js";
+
+describe("tui i18n", () => {
+  it("defaults to Chinese and supports explicit English override", () => {
+    expect(resolveTuiLocale({})).toBe("zh-CN");
+    expect(resolveTuiLocale({ INKOS_TUI_LOCALE: "en" })).toBe("en");
+    expect(resolveTuiLocale({ LANG: "en_US.UTF-8" })).toBe("en");
+    expect(resolveTuiLocale({}, "en")).toBe("en");
+  });
+
+  it("normalizes common activity labels for Chinese chrome", () => {
+    const copy = getTuiCopy("zh-CN");
+    expect(normalizeStageLabel("writing chapter", copy)).toBe("写作中");
+    expect(normalizeStageLabel("thinking ...", copy)).toBe("思考中");
+    expect(normalizeStageLabel("idle", copy)).toBe("就绪");
+    expect(normalizeStageLabel("waiting_human", copy)).toBe("等待你的决定");
+    expect(normalizeStageLabel("completed", copy)).toBe("已完成");
+    expect(formatModeLabel("semi", copy)).toBe("半自动");
+    expect(formatModeLabel("auto", copy)).toBe("自动");
+  });
+});

+ 10 - 0
packages/cli/src/__tests__/tui-input-chrome.test.ts

@@ -0,0 +1,10 @@
+import { describe, expect, it } from "vitest";
+import { inputPromptPrefix } from "../tui/effects.js";
+import { stripAnsi } from "../tui/ansi.js";
+
+describe("tui input chrome", () => {
+  it("produces a prompt prefix with the › indicator", () => {
+    const prefix = stripAnsi(inputPromptPrefix());
+    expect(prefix).toContain("›");
+  });
+});

+ 40 - 0
packages/cli/src/__tests__/tui-input-history.test.ts

@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+import {
+  buildInputHistory,
+  moveHistoryCursor,
+  type InputHistoryState,
+} from "../tui/input-history.js";
+
+describe("tui input history", () => {
+  it("builds unique history from user messages only", () => {
+    const history = buildInputHistory([
+      { role: "user", content: "first", timestamp: 1 },
+      { role: "assistant", content: "reply", timestamp: 2 },
+      { role: "user", content: "second", timestamp: 3 },
+      { role: "user", content: "second", timestamp: 4 },
+      { role: "user", content: "third", timestamp: 5 },
+    ]);
+
+    expect(history).toEqual(["first", "second", "third"]);
+  });
+
+  it("moves up through history and restores draft when moving back down", () => {
+    const entries = ["first", "second", "third"];
+    const initial: InputHistoryState = { cursor: null, draft: "" };
+
+    const firstUp = moveHistoryCursor(entries, initial, "drafting", "up");
+    expect(firstUp.value).toBe("third");
+    expect(firstUp.state).toEqual({ cursor: 2, draft: "drafting" });
+
+    const secondUp = moveHistoryCursor(entries, firstUp.state, firstUp.value, "up");
+    expect(secondUp.value).toBe("second");
+    expect(secondUp.state.cursor).toBe(1);
+
+    const down = moveHistoryCursor(entries, secondUp.state, secondUp.value, "down");
+    expect(down.value).toBe("third");
+
+    const finalDown = moveHistoryCursor(entries, down.state, down.value, "down");
+    expect(finalDown.value).toBe("drafting");
+    expect(finalDown.state.cursor).toBeNull();
+  });
+});

+ 55 - 0
packages/cli/src/__tests__/tui-layout.test.ts

@@ -0,0 +1,55 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { renderTuiFrame } from "../tui/app.js";
+import { drawInputHint } from "../tui/effects.js";
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+describe("tui layout", () => {
+  it("renders a codex-like single-column workspace preview", () => {
+    const frame = renderTuiFrame({
+      locale: "zh-CN",
+      projectName: "inkos-demo",
+      activeBookTitle: undefined,
+      automationMode: "semi",
+      status: "idle",
+    });
+
+    expect(frame).toContain("项目 inkos-demo");
+    expect(frame).toContain("阶段 就绪");
+    expect(frame).toContain("模式 半自动");
+    expect(frame).not.toContain("Header");
+    expect(frame).not.toContain("Conversation");
+    expect(frame).not.toContain("Status");
+    expect(frame).not.toContain("Composer");
+    expect(frame).toContain("告诉 InkOS");
+  });
+
+  it("keeps the two-line status strip above the composer preview", () => {
+    const frame = renderTuiFrame({
+      locale: "en",
+      projectName: "inkos-demo",
+      activeBookTitle: "Night Harbor Echo",
+      automationMode: "auto",
+      status: "writing",
+      messages: ["user: continue", "assistant: Completed write_next for harbor."],
+      events: ["task.completed: Completed write_next for harbor."],
+    });
+
+    expect(frame).toContain("Night Harbor Echo");
+    expect(frame).toContain("writing");
+    expect(frame).toContain("user: continue");
+    expect(frame).toContain("task.completed: Completed write_next for harbor.");
+    expect(frame.indexOf("task.completed: Completed write_next for harbor.")).toBeLessThan(frame.indexOf("Ask InkOS"));
+    expect(frame.indexOf("Mode auto")).toBeLessThan(frame.indexOf("Ask InkOS"));
+  });
+
+  it("does not add blank lines before the readline prompt", () => {
+    const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+    drawInputHint();
+
+    expect(logSpy).not.toHaveBeenCalled();
+  });
+});

+ 47 - 0
packages/cli/src/__tests__/tui-local-commands.test.ts

@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+import { classifyLocalTuiCommand, parseDepthCommand } from "../tui/local-commands.js";
+
+describe("tui local commands", () => {
+  it("recognizes help aliases", () => {
+    expect(classifyLocalTuiCommand("/help")).toBe("help");
+    expect(classifyLocalTuiCommand("help")).toBe("help");
+    expect(classifyLocalTuiCommand("帮助")).toBe("help");
+  });
+
+  it("recognizes status aliases", () => {
+    expect(classifyLocalTuiCommand("/status")).toBe("status");
+    expect(classifyLocalTuiCommand("status")).toBe("status");
+    expect(classifyLocalTuiCommand("状态")).toBe("status");
+  });
+
+  it("recognizes quit aliases", () => {
+    expect(classifyLocalTuiCommand("/quit")).toBe("quit");
+    expect(classifyLocalTuiCommand("/exit")).toBe("quit");
+    expect(classifyLocalTuiCommand("quit")).toBe("quit");
+    expect(classifyLocalTuiCommand("exit")).toBe("quit");
+    expect(classifyLocalTuiCommand("bye")).toBe("quit");
+    expect(classifyLocalTuiCommand("退出")).toBe("quit");
+  });
+
+  it("recognizes config and clear aliases", () => {
+    expect(classifyLocalTuiCommand("/config")).toBe("config");
+    expect(classifyLocalTuiCommand("配置")).toBe("config");
+    expect(classifyLocalTuiCommand("/clear")).toBe("clear");
+    expect(classifyLocalTuiCommand("清屏")).toBe("clear");
+  });
+
+  it("returns undefined for normal chat input", () => {
+    expect(classifyLocalTuiCommand("hi")).toBeUndefined();
+    expect(classifyLocalTuiCommand("continue current book")).toBeUndefined();
+  });
+
+  it("parses depth commands", () => {
+    expect(parseDepthCommand("/depth deep")).toBe("deep");
+    expect(parseDepthCommand("depth light")).toBe("light");
+    expect(parseDepthCommand("/depth normal")).toBe("normal");
+    expect(parseDepthCommand("深度 轻量")).toBe("light");
+    expect(parseDepthCommand("/深度 标准")).toBe("normal");
+    expect(parseDepthCommand("深度 深入")).toBe("deep");
+    expect(parseDepthCommand("/depth weird")).toBeUndefined();
+  });
+});

+ 38 - 0
packages/cli/src/__tests__/tui-output.test.ts

@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { formatTuiResult } from "../tui/output.js";
+
+describe("tui output", () => {
+  it("summarizes a completed write flow", () => {
+    expect(formatTuiResult({
+      intent: "write_next",
+      status: "completed",
+      bookId: "harbor",
+    })).toContain("harbor");
+  });
+
+  it("summarizes a mode switch", () => {
+    expect(formatTuiResult({
+      intent: "switch_mode",
+      status: "completed",
+      mode: "auto",
+      locale: "en",
+    })).toContain("auto");
+  });
+
+  it("prefers explicit response text when provided", () => {
+    expect(formatTuiResult({
+      intent: "explain_status",
+      status: "completed",
+      responseText: "Current status: harbor is at repairing chapter 3.",
+    })).toBe("Current status: harbor is at repairing chapter 3.");
+  });
+
+  it("renders Chinese summaries when locale is zh-CN", () => {
+    expect(formatTuiResult({
+      intent: "select_book",
+      status: "completed",
+      bookId: "harbor",
+      locale: "zh-CN",
+    })).toBe("当前作品:harbor");
+  });
+});

+ 81 - 0
packages/cli/src/__tests__/tui-session-store.test.ts

@@ -0,0 +1,81 @@
+import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import {
+  createProjectSession,
+  loadProjectSession,
+  persistProjectSession,
+  resolveSessionActiveBook,
+} from "../tui/session-store.js";
+
+let projectRoot: string;
+
+describe("tui session store", () => {
+  beforeAll(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-tui-session-"));
+    await mkdir(join(projectRoot, "books"), { recursive: true });
+  });
+
+  afterAll(async () => {
+    // no cleanup needed, tmpdir
+  });
+
+  it("creates a default project session", () => {
+    const session = createProjectSession(projectRoot);
+    expect(session.projectRoot).toBe(projectRoot);
+    expect(session.automationMode).toBe("semi");
+    expect(session.messages).toEqual([]);
+  });
+
+  it("persists and reloads the session", async () => {
+    const session = {
+      ...createProjectSession(projectRoot),
+      activeBookId: "night-harbor",
+      automationMode: "auto" as const,
+    };
+
+    await persistProjectSession(projectRoot, session);
+    const reloaded = await loadProjectSession(projectRoot);
+
+    expect(reloaded.activeBookId).toBe("night-harbor");
+    expect(reloaded.automationMode).toBe("auto");
+  });
+
+  it("resolves active book from session when it still exists", async () => {
+    await writeFile(join(projectRoot, "books", "night-harbor", "book.json"), "{}", "utf-8").catch(async () => {
+      await mkdir(join(projectRoot, "books", "night-harbor"), { recursive: true });
+      await writeFile(join(projectRoot, "books", "night-harbor", "book.json"), "{}", "utf-8");
+    });
+
+    const session = {
+      ...createProjectSession(projectRoot),
+      activeBookId: "night-harbor",
+    };
+
+    expect(await resolveSessionActiveBook(projectRoot, session)).toBe("night-harbor");
+  });
+
+  it("falls back to the only book in the project", async () => {
+    await mkdir(join(projectRoot, "books", "single-book"), { recursive: true });
+    await writeFile(join(projectRoot, "books", "single-book", "book.json"), "{}", "utf-8");
+
+    const singleRoot = await mkdtemp(join(tmpdir(), "inkos-tui-single-"));
+    await mkdir(join(singleRoot, "books", "single-book"), { recursive: true });
+    await writeFile(join(singleRoot, "books", "single-book", "book.json"), "{}", "utf-8");
+
+    const session = createProjectSession(singleRoot);
+    expect(await resolveSessionActiveBook(singleRoot, session)).toBe("single-book");
+  });
+
+  it("returns undefined when multiple books exist and no valid active binding is stored", async () => {
+    const multiRoot = await mkdtemp(join(tmpdir(), "inkos-tui-multi-"));
+    await mkdir(join(multiRoot, "books", "book-a"), { recursive: true });
+    await mkdir(join(multiRoot, "books", "book-b"), { recursive: true });
+    await writeFile(join(multiRoot, "books", "book-a", "book.json"), "{}", "utf-8");
+    await writeFile(join(multiRoot, "books", "book-b", "book.json"), "{}", "utf-8");
+
+    const session = createProjectSession(multiRoot);
+    expect(await resolveSessionActiveBook(multiRoot, session)).toBeUndefined();
+  });
+});

+ 18 - 0
packages/cli/src/__tests__/tui-setup-i18n.test.ts

@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+import { buildAutoInitMessages, buildInteractiveSetupCopy } from "../tui/setup.js";
+
+describe("tui setup i18n", () => {
+  it("builds Chinese setup copy by default", () => {
+    const copy = buildInteractiveSetupCopy("zh-CN");
+    expect(copy.title).toBe("模型配置");
+    expect(copy.subtitle).toContain("配置模型服务");
+    expect(copy.steps.provider).toBe("服务提供方");
+    expect(copy.steps.scope).toBe("保存范围");
+    expect(copy.scopeChoices.project).toBe("当前目录");
+  });
+
+  it("builds localized auto-init messages", () => {
+    expect(buildAutoInitMessages("山海", "zh-CN").initializing).toContain("正在初始化项目:山海");
+    expect(buildAutoInitMessages("harbor", "en").initialized).toContain("Project initialized");
+  });
+});

+ 36 - 0
packages/cli/src/__tests__/tui-slash-autocomplete.test.ts

@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import {
+  applySlashSuggestion,
+  getSlashSuggestions,
+  getNextSlashSelection,
+  SLASH_COMMANDS,
+} from "../tui/slash-autocomplete.js";
+
+describe("tui slash autocomplete", () => {
+  it("filters slash commands by prefix", () => {
+    expect(getSlashSuggestions("/st", SLASH_COMMANDS)).toEqual(["/status"]);
+    expect(getSlashSuggestions("/w", SLASH_COMMANDS)).toEqual(["/write"]);
+    expect(getSlashSuggestions("/o", SLASH_COMMANDS)).toEqual(["/open <book>"]);
+    expect(getSlashSuggestions("/d", SLASH_COMMANDS)).toEqual(["/draft", "/discard", "/depth <light|normal|deep>"]);
+    expect(getSlashSuggestions("/cr", SLASH_COMMANDS)).toEqual(["/create"]);
+  });
+
+  it("does not suggest anything for non-slash input", () => {
+    expect(getSlashSuggestions("status", SLASH_COMMANDS)).toEqual([]);
+    expect(getSlashSuggestions("", SLASH_COMMANDS)).toEqual([]);
+  });
+
+  it("cycles the active suggestion index", () => {
+    expect(getNextSlashSelection(0, 3, "down")).toBe(1);
+    expect(getNextSlashSelection(2, 3, "down")).toBe(0);
+    expect(getNextSlashSelection(0, 3, "up")).toBe(2);
+  });
+
+  it("applies the selected suggestion to the composer input", () => {
+    expect(applySlashSuggestion("/st", ["/status"], 0)).toBe("/status");
+    expect(applySlashSuggestion("/o", ["/open <book>"], 0)).toBe("/open ");
+    expect(applySlashSuggestion("/d", ["/depth <light|normal|deep>"], 0)).toBe("/depth ");
+    expect(applySlashSuggestion("/dr", ["/draft"], 0)).toBe("/draft");
+    expect(applySlashSuggestion("/cr", ["/create"], 0)).toBe("/create");
+  });
+});

+ 135 - 7
packages/cli/src/commands/doctor.ts

@@ -8,6 +8,87 @@ import {
   inspectNodeRuntimePinFiles,
 } from "../runtime-requirements.js";
 
+function buildDoctorProbePlans(
+  preferredApiFormat: "chat" | "responses" | undefined,
+  preferredStream: boolean | undefined,
+): Array<{ apiFormat: "chat" | "responses"; stream: boolean }> {
+  const plans: Array<{ apiFormat: "chat" | "responses"; stream: boolean }> = [];
+  const seen = new Set<string>();
+  const push = (apiFormat: "chat" | "responses", stream: boolean) => {
+    const key = `${apiFormat}:${stream ? "1" : "0"}`;
+    if (seen.has(key)) return;
+    seen.add(key);
+    plans.push({ apiFormat, stream });
+  };
+
+  if (preferredApiFormat) {
+    push(preferredApiFormat, preferredStream ?? false);
+    push(preferredApiFormat, !(preferredStream ?? false));
+  }
+  const alternate = preferredApiFormat === "responses" ? "chat" : "responses";
+  push(alternate, false);
+  push(alternate, true);
+  push("chat", false);
+  push("chat", true);
+  push("responses", false);
+  push("responses", true);
+  return plans;
+}
+
+export function buildDoctorModelCandidates(
+  preferredModel: string | undefined,
+  discoveredModels: Array<{ id: string; name: string }>,
+): string[] {
+  const seen = new Set<string>();
+  const candidates: string[] = [];
+  const push = (value: string | undefined | null) => {
+    if (!value || value.trim().length === 0) return;
+    const model = value.trim();
+    if (seen.has(model)) return;
+    seen.add(model);
+    candidates.push(model);
+  };
+
+  push(preferredModel);
+  for (const model of discoveredModels) push(model.id);
+  push("gpt-5.4");
+  push("gpt-4o");
+  push("claude-sonnet-4-6");
+  push("MiniMax-M2.7");
+  push("kimi-k2.5");
+  push("gemini-2.5-flash");
+  return candidates;
+}
+
+export function resolveDoctorModelsBaseUrl(
+  service: string | undefined,
+  baseUrl: string,
+  resolveServiceModelsBaseUrl: (service: string) => string | undefined,
+): string {
+  if (!service || service.length === 0) {
+    return baseUrl;
+  }
+  return resolveServiceModelsBaseUrl(service) ?? baseUrl;
+}
+
+async function fetchDoctorModels(
+  modelsBaseUrl: string,
+  apiKey: string,
+): Promise<Array<{ id: string; name: string }>> {
+  const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
+  try {
+    const res = await fetch(modelsUrl, {
+      headers: { Authorization: `Bearer ${apiKey}` },
+      signal: AbortSignal.timeout(10_000),
+    });
+    if (!res.ok) return [];
+    const json = await res.json() as { data?: Array<{ id: string }> };
+    return (json.data ?? []).map((model) => ({ id: model.id, name: model.id }));
+  } catch {
+    return [];
+  }
+}
+
 export const doctorCommand = new Command("doctor")
   .description("Check environment and project health")
   .option("--repair-node-runtime", "Write .nvmrc and .node-version pinned to Node 22 for this project")
@@ -149,7 +230,7 @@ export const doctorCommand = new Command("doctor")
 
     // 6. API connectivity test
     try {
-      const { createLLMClient, chatCompletion, LLMConfigSchema, isApiKeyOptionalForEndpoint } = await import("@actalk/inkos-core");
+      const { createLLMClient, chatCompletion, LLMConfigSchema, isApiKeyOptionalForEndpoint, resolveServiceModelsBaseUrl } = await import("@actalk/inkos-core");
       const { loadConfig } = await import("../utils.js");
 
       let llmConfig;
@@ -188,17 +269,64 @@ export const doctorCommand = new Command("doctor")
           detail: `provider=${llmConfig.provider} model=${llmConfig.model} stream=${llmConfig.stream ?? true} baseUrl=${llmConfig.baseUrl}`,
         });
 
-        const client = createLLMClient(llmConfig);
         log("\n  [..] Testing API connectivity...");
-        const response = await chatCompletion(client, llmConfig.model, [
-          { role: "user", content: "Say OK" },
-        ], { maxTokens: 16 });
+
+        let connected = false;
+        let detectedDetail = "";
+        let lastError = "Unknown error";
+        const modelsBaseUrl = resolveDoctorModelsBaseUrl(
+          typeof llmConfig.service === "string" ? llmConfig.service : undefined,
+          llmConfig.baseUrl,
+          resolveServiceModelsBaseUrl,
+        );
+        const discoveredModels = (llmConfig.apiKey && modelsBaseUrl)
+          ? await fetchDoctorModels(modelsBaseUrl, llmConfig.apiKey)
+          : [];
+        const modelCandidates = (llmConfig.provider === "openai" || discoveredModels.length > 0)
+          ? buildDoctorModelCandidates(llmConfig.model, discoveredModels)
+          : [llmConfig.model];
+        const plans = llmConfig.provider === "openai"
+          ? buildDoctorProbePlans(llmConfig.apiFormat, llmConfig.stream)
+          : [{ apiFormat: (llmConfig.apiFormat ?? "chat") as "chat" | "responses", stream: llmConfig.stream ?? true }];
+
+        for (const model of modelCandidates) {
+          for (const plan of plans) {
+            try {
+              const client = createLLMClient({
+                ...llmConfig,
+                model,
+                apiFormat: plan.apiFormat,
+                stream: plan.stream,
+              });
+              const response = await chatCompletion(client, model, [
+                { role: "user", content: "Say OK" },
+              ], { maxTokens: 16 });
+
+              connected = true;
+              detectedDetail = `OK (model: ${model}, apiFormat=${plan.apiFormat}, stream=${plan.stream}, tokens: ${response.usage.totalTokens})`;
+              break;
+            } catch (error) {
+              lastError = error instanceof Error ? error.message : String(error);
+            }
+          }
+          if (connected) {
+            break;
+          }
+        }
 
         checks.push({
           name: "API Connectivity",
-          ok: true,
-          detail: `OK (model: ${llmConfig.model}, tokens: ${response.usage.totalTokens})`,
+          ok: connected,
+          detail: connected ? detectedDetail : lastError.split("\n")[0]!,
         });
+
+        if (!connected && llmConfig.provider === "openai") {
+          checks.push({
+            name: "  Hint",
+            ok: false,
+            detail: "当前已自动尝试 chat/responses 与流式开关组合;如果仍失败,问题更可能在模型名、baseUrl 路径或服务商兼容性本身。",
+          });
+        }
       }
     } catch (e) {
       const errMsg = String(e);

+ 13 - 106
packages/cli/src/commands/export.ts

@@ -1,7 +1,6 @@
 import { Command } from "commander";
-import { StateManager } from "@actalk/inkos-core";
-import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
-import { dirname, join } from "node:path";
+import { StateManager, writeExportArtifact } from "@actalk/inkos-core";
+import { join } from "node:path";
 import { findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const exportCommand = new Command("export")
@@ -17,62 +16,23 @@ export const exportCommand = new Command("export")
       const bookId = await resolveBookId(bookIdArg, root);
       const state = new StateManager(root);
 
-      const book = await state.loadBookConfig(bookId);
-      const index = await state.loadChapterIndex(bookId);
-      const bookDir = state.bookDir(bookId);
-      const chaptersDir = join(bookDir, "chapters");
-
-      const chapters = opts.approvedOnly
-        ? index.filter((ch) => ch.status === "approved")
-        : index;
-
-      if (chapters.length === 0) {
-        throw new Error("No chapters to export.");
-      }
-
-      if (opts.format === "epub") {
-        await exportEpub(book, chapters, chaptersDir, bookId, root, opts);
-        return;
-      }
-
-      const parts: string[] = [];
-
-      if (opts.format === "md") {
-        parts.push(`# ${book.title}\n`);
-        parts.push(`---\n`);
-      } else {
-        parts.push(`${book.title}\n\n`);
-      }
-
-      for (const ch of chapters) {
-        const paddedNum = String(ch.number).padStart(4, "0");
-        const files = await readdir(chaptersDir);
-        const match = files.find((f) => f.startsWith(paddedNum));
-        if (!match) continue;
-
-        const content = await readFile(join(chaptersDir, match), "utf-8");
-        parts.push(content);
-        parts.push("\n\n");
-      }
-
-      const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
-
-      const outputPath =
-        opts.output ?? join(root, `${bookId}_export.${opts.format}`);
-      await mkdir(dirname(outputPath), { recursive: true });
-      await writeFile(outputPath, parts.join("\n"), "utf-8");
+      const result = await writeExportArtifact(state, bookId, {
+        format: opts.format as "txt" | "md" | "epub",
+        approvedOnly: Boolean(opts.approvedOnly),
+        outputPath: opts.output ?? join(root, `${bookId}_export.${opts.format}`),
+      });
 
       if (opts.json) {
         log(JSON.stringify({
           bookId,
-          chaptersExported: chapters.length,
-          totalWords,
-          format: opts.format,
-          outputPath,
+          chaptersExported: result.chaptersExported,
+          totalWords: result.totalWords,
+          format: result.format,
+          outputPath: result.outputPath,
         }, null, 2));
       } else {
-        log(`Exported ${chapters.length} chapters (${totalWords} words)`);
-        log(`Output: ${outputPath}`);
+        log(`Exported ${result.chaptersExported} chapters (${result.totalWords} words)`);
+        log(`Output: ${result.outputPath}`);
       }
     } catch (e) {
       if (opts.json) {
@@ -83,56 +43,3 @@ export const exportCommand = new Command("export")
       process.exit(1);
     }
   });
-
-async function exportEpub(
-  book: { readonly title: string; readonly language?: string },
-  chapters: ReadonlyArray<{ readonly number: number; readonly wordCount: number }>,
-  chaptersDir: string,
-  bookId: string,
-  root: string,
-  opts: { readonly output?: string; readonly json?: boolean },
-): Promise<void> {
-  const { marked } = await import("marked");
-  const { EPub } = await import("epub-gen-memory");
-
-  const epubChapters: Array<{ title: string; content: string }> = [];
-
-  for (const ch of chapters) {
-    const paddedNum = String(ch.number).padStart(4, "0");
-    const files = await readdir(chaptersDir);
-    const match = files.find((f) => f.startsWith(paddedNum));
-    if (!match) continue;
-
-    const markdown = await readFile(join(chaptersDir, match), "utf-8");
-    const html = await marked.parse(markdown);
-    // Extract title from first heading or fall back to filename
-    const titleMatch = markdown.match(/^#\s+(.+)/m);
-    const title = titleMatch?.[1] ?? match.replace(/\.md$/, "");
-
-    epubChapters.push({ title, content: html });
-  }
-
-  const epubInstance = new EPub(
-    { title: book.title, lang: book.language === "en" ? "en" : "zh-CN" },
-    epubChapters,
-  );
-  const epubBuffer: Buffer = await epubInstance.genEpub();
-
-  const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
-  const outputPath = opts.output ?? join(root, `${bookId}.epub`);
-  await mkdir(dirname(outputPath), { recursive: true });
-  await writeFile(outputPath, epubBuffer);
-
-  if (opts.json) {
-    log(JSON.stringify({
-      bookId,
-      chaptersExported: chapters.length,
-      totalWords,
-      format: "epub",
-      outputPath,
-    }, null, 2));
-  } else {
-    log(`Exported ${chapters.length} chapters (${totalWords} words) to EPUB`);
-    log(`Output: ${outputPath}`);
-  }
-}

+ 8 - 111
packages/cli/src/commands/init.ts

@@ -1,16 +1,8 @@
 import { Command } from "commander";
-import { access, readFile, writeFile, mkdir } from "node:fs/promises";
-import { join, basename, resolve } from "node:path";
-import { log, logError, GLOBAL_ENV_PATH } from "../utils.js";
-
-async function hasGlobalConfig(): Promise<boolean> {
-  try {
-    const content = await readFile(GLOBAL_ENV_PATH, "utf-8");
-    return content.includes("INKOS_LLM_API_KEY=") && !content.includes("your-api-key-here");
-  } catch {
-    return false;
-  }
-}
+import { mkdir } from "node:fs/promises";
+import { resolve } from "node:path";
+import { log, logError } from "../utils.js";
+import { initializeProjectDirectory } from "../project-bootstrap.js";
 
 export const initCommand = new Command("init")
   .description("Initialize an InkOS project (current directory by default)")
@@ -18,108 +10,13 @@ export const initCommand = new Command("init")
   .option("--lang <language>", "Default writing language: zh (Chinese) or en (English)", "zh")
   .action(async (name: string | undefined, opts: { lang?: string }) => {
     const projectDir = name ? resolve(process.cwd(), name) : process.cwd();
-    const projectName = basename(projectDir);
 
     try {
       await mkdir(projectDir, { recursive: true });
-
-      // Check if inkos.json already exists
-      const configPath = join(projectDir, "inkos.json");
-      try {
-        await access(configPath);
-        throw new Error(`inkos.json already exists in ${projectDir}. Use a different directory or delete the existing project.`);
-      } catch (e) {
-        if (e instanceof Error && e.message.includes("already exists")) throw e;
-        // File doesn't exist, good
-      }
-
-      await mkdir(join(projectDir, "books"), { recursive: true });
-      await mkdir(join(projectDir, "radar"), { recursive: true });
-
-      const config = {
-        name: projectName,
-        version: "0.1.0",
-        language: opts.lang ?? "zh",
-        llm: {
-          provider: process.env.INKOS_LLM_PROVIDER ?? "openai",
-          baseUrl: process.env.INKOS_LLM_BASE_URL ?? "",
-          model: process.env.INKOS_LLM_MODEL ?? "",
-        },
-        notify: [],
-        daemon: {
-          schedule: {
-            radarCron: "0 */6 * * *",
-            writeCron: "*/15 * * * *",
-          },
-          maxConcurrentBooks: 3,
-        },
-      };
-
-      await writeFile(
-        join(projectDir, "inkos.json"),
-        JSON.stringify(config, null, 2),
-        "utf-8",
-      );
-      await Promise.all([
-        writeFile(join(projectDir, ".nvmrc"), "22\n", "utf-8"),
-        writeFile(join(projectDir, ".node-version"), "22\n", "utf-8"),
-      ]);
-
-      const global = await hasGlobalConfig();
-
-      if (global) {
-        await writeFile(
-          join(projectDir, ".env"),
-          [
-            "# Project-level LLM overrides (optional)",
-            "# Global config at ~/.inkos/.env will be used by default.",
-            "# Uncomment below to override for this project only:",
-            "# INKOS_LLM_PROVIDER=openai",
-            "# INKOS_LLM_BASE_URL=",
-            "# INKOS_LLM_API_KEY=",
-            "# INKOS_LLM_MODEL=",
-            "",
-            "# Web search (optional):",
-            "# TAVILY_API_KEY=tvly-xxxxx",
-          ].join("\n"),
-          "utf-8",
-        );
-      } else {
-        await writeFile(
-          join(projectDir, ".env"),
-          [
-            "# LLM Configuration",
-            "# Tip: Run 'inkos config set-global' to set once for all projects.",
-            "# Provider: openai (OpenAI / compatible proxy), anthropic (Anthropic native)",
-            "INKOS_LLM_PROVIDER=openai",
-            "INKOS_LLM_BASE_URL=",
-            "INKOS_LLM_API_KEY=",
-            "INKOS_LLM_MODEL=",
-            "",
-            "# Optional parameters (defaults shown):",
-            "# INKOS_LLM_TEMPERATURE=0.7",
-            "# INKOS_LLM_MAX_TOKENS=8192",
-            "# INKOS_LLM_THINKING_BUDGET=0          # Anthropic extended thinking budget",
-            "# INKOS_LLM_API_FORMAT=chat             # chat (default) or responses (OpenAI Responses API)",
-            "",
-            "# Web search (optional, for auditor era-research):",
-            "# TAVILY_API_KEY=tvly-xxxxx              # Free at tavily.com (1000 searches/month)",
-            "",
-            "# Anthropic example:",
-            "# INKOS_LLM_PROVIDER=anthropic",
-            "# INKOS_LLM_PROVIDER=anthropic",
-            "# INKOS_LLM_BASE_URL=",
-            "# INKOS_LLM_MODEL=",
-          ].join("\n"),
-          "utf-8",
-        );
-      }
-
-      await writeFile(
-        join(projectDir, ".gitignore"),
-        [".env", "node_modules/", ".DS_Store"].join("\n"),
-        "utf-8",
-      );
+      await initializeProjectDirectory(projectDir, {
+        language: (opts.lang === "en" ? "en" : "zh"),
+        overwriteSupportFiles: true,
+      });
 
       log(`Project initialized at ${projectDir}`);
       log("");

+ 109 - 0
packages/cli/src/commands/interact.ts

@@ -0,0 +1,109 @@
+import { Command } from "commander";
+import {
+  processProjectInteractionInput,
+  type InteractionRuntimeTools,
+} from "@actalk/inkos-core";
+import { createInteractionTools } from "../tui/tools.js";
+
+export interface InteractCommandHooks {
+  readonly runInteraction?: (params: {
+    readonly projectRoot: string;
+    readonly input: string;
+    readonly activeBookId?: string;
+    readonly tools: InteractionRuntimeTools;
+  }) => Promise<{
+    readonly request: unknown;
+    readonly responseText?: string;
+    readonly session: {
+      readonly automationMode: string;
+      readonly activeBookId?: string;
+      readonly currentExecution?: unknown;
+      readonly pendingDecision?: unknown;
+      readonly messages: ReadonlyArray<unknown>;
+      readonly events: ReadonlyArray<unknown>;
+    };
+  }>;
+  readonly createTools?: (projectRoot: string) => Promise<InteractionRuntimeTools>;
+  readonly readInput?: () => Promise<string>;
+}
+
+async function readInteractionInput(
+  args: ReadonlyArray<string>,
+  explicitMessage: string | undefined,
+  readInput?: () => Promise<string>,
+): Promise<string> {
+  const explicit = explicitMessage?.trim();
+  if (explicit) {
+    return explicit;
+  }
+
+  const inline = args.join(" ").trim();
+  if (inline) {
+    return inline;
+  }
+
+  if (readInput) {
+    const provided = (await readInput()).trim();
+    if (provided) {
+      return provided;
+    }
+  }
+
+  if (!process.stdin.isTTY) {
+    const chunks: Buffer[] = [];
+    for await (const chunk of process.stdin) {
+      chunks.push(chunk as Buffer);
+    }
+    const piped = Buffer.concat(chunks).toString("utf-8").trim();
+    if (piped) {
+      return piped;
+    }
+  }
+
+  throw new Error("Interaction message is required. Pass text arguments or pipe input via stdin.");
+}
+
+export function createInteractCommand(hooks: InteractCommandHooks = {}): Command {
+  return new Command("interact")
+    .description("Run a shared natural-language interaction against the current project")
+    .argument("[message...]", "Natural-language message")
+    .option("--message <text>", "Explicit natural-language message")
+    .option("--book <bookId>", "Bind a specific active book for this interaction")
+    .option("--json", "Emit structured JSON for external agents")
+    .action(async (messageArgs: ReadonlyArray<string>, opts) => {
+      const input = await readInteractionInput(messageArgs, opts.message, hooks.readInput);
+      const projectRoot = process.cwd();
+      const tools = hooks.createTools
+        ? await hooks.createTools(projectRoot)
+        : hooks.runInteraction
+          ? ({} as InteractionRuntimeTools)
+          : await createInteractionTools(projectRoot);
+      const runInteraction = hooks.runInteraction ?? processProjectInteractionInput;
+      const result = await runInteraction({
+        projectRoot,
+        input,
+        activeBookId: opts.book,
+        tools,
+      });
+
+      if (opts.json) {
+        process.stdout.write(`${JSON.stringify({
+          request: result.request,
+          responseText: result.responseText,
+          session: result.session,
+          currentExecution: result.session.currentExecution ?? null,
+          pendingDecision: result.session.pendingDecision ?? null,
+          events: result.session.events,
+        }, null, 2)}\n`);
+        return;
+      }
+
+      const text = result.responseText
+        ?? (result.session.messages.at(-1) && "content" in (result.session.messages.at(-1) as Record<string, unknown>)
+          ? String((result.session.messages.at(-1) as Record<string, unknown>).content)
+          : "");
+      if (text) {
+        process.stdout.write(`${text}\n`);
+      }
+    });
+}

+ 4 - 1
packages/cli/src/commands/revise.ts

@@ -7,6 +7,7 @@ export const reviseCommand = new Command("revise")
   .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .argument("[chapter]", "Chapter number (defaults to latest)")
   .option("--mode <mode>", "Revise mode: spot-fix, polish, rewrite, rework, anti-detect", DEFAULT_REVISE_MODE)
+  .option("--brief <text>", "One-off creative guidance for this revise/rewrite only")
   .option("--json", "Output JSON")
   .action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => {
     try {
@@ -23,7 +24,9 @@ export const reviseCommand = new Command("revise")
         chapterNumber = chapterStr ? parseInt(chapterStr, 10) : undefined;
       }
 
-      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root, {
+        externalContext: opts.brief,
+      }));
 
       const mode = opts.mode as ReviseMode;
       if (!opts.json) log(`Revising "${bookId}"${chapterNumber ? ` chapter ${chapterNumber}` : " (latest)"} [mode: ${mode}]...`);

+ 108 - 30
packages/cli/src/commands/studio.ts

@@ -3,7 +3,8 @@ import { findProjectRoot, log, logError } from "../utils.js";
 import { spawn } from "node:child_process";
 import { dirname, join } from "node:path";
 import { access } from "node:fs/promises";
-import { fileURLToPath } from "node:url";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { ensureProjectDirectoryInitialized } from "../project-bootstrap.js";
 
 export interface StudioLaunchSpec {
   readonly studioEntry: string;
@@ -11,6 +12,20 @@ export interface StudioLaunchSpec {
   readonly args: string[];
 }
 
+export interface BrowserLaunchSpec {
+  readonly command: string;
+  readonly args: string[];
+}
+
+export interface StudioCommandHooks {
+  readonly launchStudio?: (projectRoot: string, port: string) => Promise<void> | void;
+}
+
+async function prepareStudioRoot(root: string): Promise<{ readonly root: string; readonly initialized: boolean }> {
+  const initialized = await ensureProjectDirectoryInitialized(root, { language: "zh" });
+  return { root, initialized };
+}
+
 async function firstAccessiblePath(paths: readonly string[]): Promise<string | undefined> {
   for (const path of paths) {
     try {
@@ -25,6 +40,26 @@ async function firstAccessiblePath(paths: readonly string[]): Promise<string | u
 
 const cliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
 
+export function toNodeImportSpecifier(path: string): string {
+  if (/^[A-Za-z]:[\\/]/.test(path)) {
+    return `file:///${path.replace(/\\/g, "/")}`;
+  }
+  return path;
+}
+
+export function resolveBrowserLaunch(
+  platform: NodeJS.Platform,
+  url: string,
+): BrowserLaunchSpec {
+  if (platform === "darwin") {
+    return { command: "open", args: [url] };
+  }
+  if (platform === "win32") {
+    return { command: "cmd", args: ["/c", "start", "", url] };
+  }
+  return { command: "xdg-open", args: [url] };
+}
+
 export async function resolveStudioLaunch(root: string): Promise<StudioLaunchSpec | null> {
   const sourceEntry = await firstAccessiblePath([
     join(root, "packages", "studio", "src", "api", "index.ts"),
@@ -40,7 +75,7 @@ export async function resolveStudioLaunch(root: string): Promise<StudioLaunchSpe
       return {
         studioEntry: sourceEntry,
         command: "node",
-        args: ["--import", localTsxLoader, sourceEntry, root],
+        args: ["--import", toNodeImportSpecifier(localTsxLoader), sourceEntry, root],
       };
     }
 
@@ -80,37 +115,80 @@ export async function resolveStudioLaunch(root: string): Promise<StudioLaunchSpe
   return null;
 }
 
-export const studioCommand = new Command("studio")
+export async function launchStudioWorkbench(root: string, port: string): Promise<void> {
+  const prepared = await prepareStudioRoot(root);
+  const url = prepared.initialized
+    ? `http://localhost:${port}#/services`
+    : `http://localhost:${port}`;
+  const launch = await resolveStudioLaunch(root);
+
+  if (!launch) {
+    logError(
+      "InkOS Studio not found. If you cloned the repo, run:\n" +
+      "  cd packages/studio && pnpm install && pnpm build\n" +
+      "Then run 'inkos studio' from the project root.",
+    );
+    process.exit(1);
+  }
+
+  log(`Starting InkOS Studio on ${url}`);
+
+  const child = spawn(launch.command, launch.args, {
+    cwd: root,
+    stdio: "inherit",
+    env: { ...process.env, INKOS_STUDIO_PORT: port },
+  });
+
+  child.on("error", (e) => {
+    logError(`Failed to start studio: ${e.message}`);
+    process.exit(1);
+  });
+
+  const browserLaunch = resolveBrowserLaunch(process.platform, url);
+  const browser = spawn(browserLaunch.command, browserLaunch.args, {
+    cwd: root,
+    stdio: "ignore",
+    detached: true,
+  });
+  browser.on("error", () => {
+    // Best effort only — server startup should not fail just because browser open failed.
+  });
+  if (typeof browser.unref === "function") {
+    browser.unref();
+  }
+
+  child.on("exit", (code) => {
+    process.exit(code ?? 0);
+  });
+}
+
+export async function launchStudioEntry(
+  root: string,
+  port: string,
+  hooks: StudioCommandHooks = {},
+): Promise<void> {
+  const prepared = await prepareStudioRoot(root);
+  if (prepared.initialized) {
+    log(`No inkos.json found in ${root}. Initialized a minimal InkOS project for Studio.`);
+  }
+
+  if (hooks.launchStudio) {
+    await hooks.launchStudio(prepared.root, port);
+    return;
+  }
+
+  await launchStudioWorkbench(prepared.root, port);
+}
+
+export function createStudioCommand(hooks: StudioCommandHooks = {}): Command {
+  return new Command("studio")
   .description("Start InkOS Studio web workbench")
   .option("-p, --port <port>", "Server port", "4567")
   .action(async (opts) => {
     const root = findProjectRoot();
     const port = opts.port;
-    const launch = await resolveStudioLaunch(root);
-
-    if (!launch) {
-      logError(
-        "InkOS Studio not found. If you cloned the repo, run:\n" +
-        "  cd packages/studio && pnpm install && pnpm build\n" +
-        "Then run 'inkos studio' from the project root.",
-      );
-      process.exit(1);
-    }
-
-    log(`Starting InkOS Studio on http://localhost:${port}`);
-
-    const child = spawn(launch.command, launch.args, {
-      cwd: root,
-      stdio: "inherit",
-      env: { ...process.env, INKOS_STUDIO_PORT: port },
-    });
-
-    child.on("error", (e) => {
-      logError(`Failed to start studio: ${e.message}`);
-      process.exit(1);
-    });
-
-    child.on("exit", (code) => {
-      process.exit(code ?? 0);
-    });
+    await launchStudioEntry(root, port, hooks);
   });
+}
+
+export const studioCommand = createStudioCommand();

+ 18 - 0
packages/cli/src/commands/tui.ts

@@ -0,0 +1,18 @@
+import { Command } from "commander";
+import { launchTui } from "../tui/app.js";
+
+export interface TuiCommandHooks {
+  readonly launchTui?: (projectRoot: string) => Promise<void> | void;
+}
+
+export function createTuiCommand(hooks: TuiCommandHooks = {}): Command {
+  return new Command("tui")
+    .description("Open the InkOS project workspace TUI")
+    .action(async () => {
+      if (hooks.launchTui) {
+        await hooks.launchTui(process.cwd());
+        return;
+      }
+      await launchTui(process.cwd());
+    });
+}

+ 76 - 7
packages/cli/src/commands/write.ts

@@ -1,6 +1,6 @@
 import { Command } from "commander";
 import { PipelineRunner, StateManager } from "@actalk/inkos-core";
-import { readdir, unlink } from "node:fs/promises";
+import { readdir, stat, unlink } from "node:fs/promises";
 import { join } from "node:path";
 import { createInterface } from "node:readline";
 import { loadConfig, buildPipelineConfig, findProjectRoot, getLegacyMigrationHint, resolveContext, resolveBookId, log, logError } from "../utils.js";
@@ -91,6 +91,7 @@ writeCommand
   .argument("<args...>", "Book ID (optional) and chapter number")
   .option("--force", "Skip confirmation prompt")
   .option("--words <n>", "Words per chapter (overrides book config)")
+  .option("--brief <text>", "One-off creative guidance for this rewrite only")
   .option("--json", "Output JSON")
   .action(async (args: ReadonlyArray<string>, opts) => {
     try {
@@ -125,6 +126,11 @@ writeCommand
       const state = new StateManager(root);
       const bookDir = state.bookDir(bookId);
       const chaptersDir = join(bookDir, "chapters");
+      const restoreFrom = chapter - 1;
+      const restoreSnapshotDir = join(bookDir, "story", "snapshots", String(restoreFrom));
+      await stat(restoreSnapshotDir).catch(() => {
+        throw new Error(`Cannot rewrite chapter ${chapter}: missing snapshot for chapter ${restoreFrom}`);
+      });
       const migrationHint = await getLegacyMigrationHint(root, bookId);
       if (migrationHint && !opts.json) {
         log(`[migration] ${migrationHint}`);
@@ -155,12 +161,15 @@ writeCommand
       }
 
       // Restore state to previous chapter's end-state (chapter 1 uses snapshot-0 from initBook)
-      const restoreFrom = chapter - 1;
       const restored = await state.restoreState(bookId, restoreFrom);
-      if (restored) {
-        if (!opts.json) log(`State restored from chapter ${restoreFrom} snapshot.`);
-      } else {
-        if (!opts.json) log(`Warning: no snapshot for chapter ${restoreFrom}. Using current state.`);
+      if (!restored) {
+        throw new Error(`Cannot rewrite chapter ${chapter}: failed to restore snapshot for chapter ${restoreFrom}`);
+      }
+      if (!opts.json) log(`State restored from chapter ${restoreFrom} snapshot.`);
+
+      const nextChapter = await state.getNextChapterNumber(bookId);
+      if (nextChapter !== chapter) {
+        throw new Error(`Cannot rewrite chapter ${chapter}: expected next chapter to be ${chapter}, but resolved to ${nextChapter}`);
       }
 
       if (!opts.json) log(`Regenerating chapter ${chapter}...`);
@@ -168,7 +177,9 @@ writeCommand
       const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
 
       const config = await loadConfig();
-      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root, {
+        externalContext: opts.brief,
+      }));
 
       const result = await pipeline.writeNextChapter(bookId, wordCount);
       const book = await state.loadBookConfig(bookId);
@@ -199,6 +210,64 @@ writeCommand
     }
   });
 
+writeCommand
+  .command("sync")
+  .description("Rebuild truth files and SQLite indexes from the latest edited chapter body")
+  .argument("<args...>", "Book ID (optional) and chapter number")
+  .option("--brief <text>", "One-off guidance for how to interpret the edited chapter while syncing")
+  .option("--json", "Output JSON")
+  .action(async (args: ReadonlyArray<string>, opts) => {
+    try {
+      const root = findProjectRoot();
+
+      let bookId: string;
+      let chapter: number;
+      if (args.length === 1) {
+        chapter = parseInt(args[0]!, 10);
+        if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[0]}"`);
+        bookId = await resolveBookId(undefined, root);
+      } else if (args.length === 2) {
+        chapter = parseInt(args[1]!, 10);
+        if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[1]}"`);
+        bookId = await resolveBookId(args[0], root);
+      } else {
+        throw new Error("Usage: inkos write sync [book-id] <chapter>");
+      }
+
+      const state = new StateManager(root);
+      const book = await state.loadBookConfig(bookId);
+      const language = resolveCliLanguage(book.language);
+      const config = await loadConfig();
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root, {
+        externalContext: opts.brief,
+      }));
+      const result = await pipeline.resyncChapterArtifacts(bookId, chapter);
+
+      if (opts.json) {
+        log(JSON.stringify(result, null, 2));
+      } else {
+        for (const line of formatWriteNextResultLines(language, {
+          chapterNumber: result.chapterNumber,
+          title: result.title,
+          wordCount: result.wordCount,
+          auditPassed: result.auditResult.passed,
+          revised: result.revised,
+          status: result.status,
+          issues: result.auditResult.issues,
+        })) {
+          log(line);
+        }
+      }
+    } catch (e) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to sync chapter artifacts: ${e}`);
+      }
+      process.exit(1);
+    }
+  });
+
 writeCommand
   .command("repair-state")
   .description("Rebuild truth files for a persisted state-degraded chapter without rewriting body text")

+ 2 - 67
packages/cli/src/index.ts

@@ -1,70 +1,5 @@
 #!/usr/bin/env node
 
-import { createRequire } from "node:module";
-import { Command } from "commander";
-import { initCommand } from "./commands/init.js";
-import { configCommand } from "./commands/config.js";
-import { bookCommand } from "./commands/book.js";
-import { writeCommand } from "./commands/write.js";
-import { reviewCommand } from "./commands/review.js";
-import { statusCommand } from "./commands/status.js";
-import { radarCommand } from "./commands/radar.js";
-import { upCommand, downCommand } from "./commands/daemon.js";
-import { doctorCommand } from "./commands/doctor.js";
-import { exportCommand } from "./commands/export.js";
-import { draftCommand } from "./commands/draft.js";
-import { auditCommand } from "./commands/audit.js";
-import { reviseCommand } from "./commands/revise.js";
-import { agentCommand } from "./commands/agent.js";
-import { planCommand } from "./commands/plan.js";
-import { composeCommand } from "./commands/compose.js";
-import { genreCommand } from "./commands/genre.js";
-import { updateCommand } from "./commands/update.js";
-import { detectCommand } from "./commands/detect.js";
-import { styleCommand } from "./commands/style.js";
-import { analyticsCommand } from "./commands/analytics.js";
-import { evalCommand } from "./commands/eval.js";
-import { importCommand } from "./commands/import.js";
-import { fanficCommand } from "./commands/fanfic.js";
-import { studioCommand } from "./commands/studio.js";
-import { consolidateCommand } from "./commands/consolidate.js";
+import { runProgram } from "./program.js";
 
-const require = createRequire(import.meta.url);
-const { version } = require("../package.json") as { version: string };
-
-const program = new Command();
-
-program
-  .name("inkos")
-  .description("InkOS — Multi-agent novel production system")
-  .version(version);
-
-program.addCommand(initCommand);
-program.addCommand(configCommand);
-program.addCommand(bookCommand);
-program.addCommand(writeCommand);
-program.addCommand(reviewCommand);
-program.addCommand(statusCommand);
-program.addCommand(radarCommand);
-program.addCommand(upCommand);
-program.addCommand(downCommand);
-program.addCommand(doctorCommand);
-program.addCommand(exportCommand);
-program.addCommand(draftCommand);
-program.addCommand(auditCommand);
-program.addCommand(reviseCommand);
-program.addCommand(agentCommand);
-program.addCommand(planCommand);
-program.addCommand(composeCommand);
-program.addCommand(genreCommand);
-program.addCommand(updateCommand);
-program.addCommand(detectCommand);
-program.addCommand(styleCommand);
-program.addCommand(analyticsCommand);
-program.addCommand(evalCommand);
-program.addCommand(importCommand);
-program.addCommand(fanficCommand);
-program.addCommand(studioCommand);
-program.addCommand(consolidateCommand);
-
-program.parse();
+await runProgram();

+ 0 - 7
packages/cli/src/localization.ts

@@ -34,13 +34,6 @@ export function resolveCliLanguage(language?: string): CliLanguage {
   return language === "en" ? "en" : "zh";
 }
 
-export function formatBookCreateResume(language: CliLanguage, bookId: string): string {
-  return localize(language, {
-    zh: `继续未完成的书籍创建:「${bookId}」...`,
-    en: `Resuming incomplete book creation for "${bookId}"...`,
-  });
-}
-
 export function formatBookCreateCreating(
   language: CliLanguage,
   title: string,

+ 96 - 0
packages/cli/src/program.ts

@@ -0,0 +1,96 @@
+import { createRequire } from "node:module";
+import { Command } from "commander";
+import { initCommand } from "./commands/init.js";
+import { configCommand } from "./commands/config.js";
+import { bookCommand } from "./commands/book.js";
+import { writeCommand } from "./commands/write.js";
+import { reviewCommand } from "./commands/review.js";
+import { statusCommand } from "./commands/status.js";
+import { radarCommand } from "./commands/radar.js";
+import { upCommand, downCommand } from "./commands/daemon.js";
+import { doctorCommand } from "./commands/doctor.js";
+import { exportCommand } from "./commands/export.js";
+import { draftCommand } from "./commands/draft.js";
+import { auditCommand } from "./commands/audit.js";
+import { reviseCommand } from "./commands/revise.js";
+import { agentCommand } from "./commands/agent.js";
+import { planCommand } from "./commands/plan.js";
+import { composeCommand } from "./commands/compose.js";
+import { genreCommand } from "./commands/genre.js";
+import { updateCommand } from "./commands/update.js";
+import { detectCommand } from "./commands/detect.js";
+import { styleCommand } from "./commands/style.js";
+import { analyticsCommand } from "./commands/analytics.js";
+import { evalCommand } from "./commands/eval.js";
+import { importCommand } from "./commands/import.js";
+import { fanficCommand } from "./commands/fanfic.js";
+import { createStudioCommand, launchStudioEntry } from "./commands/studio.js";
+import { consolidateCommand } from "./commands/consolidate.js";
+import { createInteractCommand, type InteractCommandHooks } from "./commands/interact.js";
+import { createTuiCommand } from "./commands/tui.js";
+import { launchTui } from "./tui/app.js";
+
+const require = createRequire(import.meta.url);
+const { version } = require("../package.json") as { version: string };
+
+export interface ProgramHooks {
+  readonly launchTui?: (projectRoot: string) => Promise<void> | void;
+  readonly launchStudio?: (projectRoot: string, port: string) => Promise<void> | void;
+  readonly runInteraction?: InteractCommandHooks["runInteraction"];
+  readonly readInteractionInput?: InteractCommandHooks["readInput"];
+}
+
+export function createProgram(hooks: ProgramHooks = {}): Command {
+  const program = new Command();
+
+  program
+    .name("inkos")
+    .description("InkOS — Multi-agent novel production system")
+    .version(version)
+    .action(async () => {
+      await launchStudioEntry(process.cwd(), "4567", { launchStudio: hooks.launchStudio });
+    });
+
+  program.addCommand(initCommand);
+  program.addCommand(configCommand);
+  program.addCommand(bookCommand);
+  program.addCommand(writeCommand);
+  program.addCommand(reviewCommand);
+  program.addCommand(statusCommand);
+  program.addCommand(radarCommand);
+  program.addCommand(upCommand);
+  program.addCommand(downCommand);
+  program.addCommand(doctorCommand);
+  program.addCommand(exportCommand);
+  program.addCommand(draftCommand);
+  program.addCommand(auditCommand);
+  program.addCommand(reviseCommand);
+  program.addCommand(agentCommand);
+  program.addCommand(planCommand);
+  program.addCommand(composeCommand);
+  program.addCommand(genreCommand);
+  program.addCommand(updateCommand);
+  program.addCommand(detectCommand);
+  program.addCommand(styleCommand);
+  program.addCommand(analyticsCommand);
+  program.addCommand(evalCommand);
+  program.addCommand(importCommand);
+  program.addCommand(fanficCommand);
+  program.addCommand(createStudioCommand({ launchStudio: hooks.launchStudio }));
+  program.addCommand(consolidateCommand);
+  program.addCommand(createInteractCommand({
+    runInteraction: hooks.runInteraction,
+    readInput: hooks.readInteractionInput,
+  }));
+  program.addCommand(createTuiCommand({ launchTui: hooks.launchTui }));
+
+  return program;
+}
+
+export async function runProgram(
+  argv: string[] = process.argv,
+  hooks: ProgramHooks = {},
+): Promise<void> {
+  const program = createProgram(hooks);
+  await program.parseAsync(argv);
+}

+ 142 - 0
packages/cli/src/project-bootstrap.ts

@@ -0,0 +1,142 @@
+import { access, mkdir, readFile, writeFile } from "node:fs/promises";
+import { basename, join } from "node:path";
+import { GLOBAL_ENV_PATH } from "./utils.js";
+
+export interface ProjectBootstrapOptions {
+  readonly language?: "zh" | "en";
+  readonly overwriteSupportFiles?: boolean;
+}
+
+async function hasGlobalConfig(): Promise<boolean> {
+  try {
+    const content = await readFile(GLOBAL_ENV_PATH, "utf-8");
+    return content.includes("INKOS_LLM_API_KEY=") && !content.includes("your-api-key-here");
+  } catch {
+    return false;
+  }
+}
+
+async function exists(path: string): Promise<boolean> {
+  try {
+    await access(path);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+async function writeMaybe(path: string, content: string, overwrite: boolean): Promise<void> {
+  if (!overwrite && await exists(path)) {
+    return;
+  }
+  await writeFile(path, content, "utf-8");
+}
+
+function buildProjectConfig(projectDir: string, language: "zh" | "en") {
+  return {
+    name: basename(projectDir),
+    version: "0.1.0" as const,
+    language,
+    llm: {
+      provider: "openai" as const,
+      service: "custom",
+      configSource: "studio" as const,
+      baseUrl: "",
+      model: "",
+      apiFormat: "chat" as const,
+      stream: true,
+    },
+    notify: [],
+    inputGovernanceMode: "v2" as const,
+    daemon: {
+      schedule: {
+        radarCron: "0 */6 * * *",
+        writeCron: "*/15 * * * *",
+      },
+      maxConcurrentBooks: 3,
+    },
+  };
+}
+
+function buildProjectEnvTemplate(globalConfigured: boolean): string {
+  if (globalConfigured) {
+    return [
+      "# Project-level LLM overrides (optional)",
+      "# Global config at ~/.inkos/.env will be used by default.",
+      "# Switch Studio to '使用 Studio 配置' if you want per-project service settings.",
+      "# Uncomment below to override for this project only:",
+      "# INKOS_LLM_PROVIDER=openai",
+      "# INKOS_LLM_BASE_URL=",
+      "# INKOS_LLM_API_KEY=",
+      "# INKOS_LLM_MODEL=",
+      "",
+      "# Web search (optional):",
+      "# TAVILY_API_KEY=tvly-xxxxx",
+      "",
+    ].join("\n");
+  }
+
+  return [
+    "# Optional project-level LLM overrides",
+    "# Studio can manage provider / model / key without editing this file.",
+    "# Uncomment only if you want this directory to force env-based config:",
+    "# INKOS_LLM_PROVIDER=openai",
+    "# INKOS_LLM_BASE_URL=",
+    "# INKOS_LLM_API_KEY=",
+    "# INKOS_LLM_MODEL=",
+    "# INKOS_LLM_API_FORMAT=chat",
+    "# INKOS_LLM_STREAM=true",
+    "",
+    "# Web search (optional):",
+    "# TAVILY_API_KEY=tvly-xxxxx",
+    "",
+  ].join("\n");
+}
+
+export async function initializeProjectDirectory(
+  projectDir: string,
+  options: ProjectBootstrapOptions = {},
+): Promise<void> {
+  const language = options.language ?? "zh";
+  const overwriteSupportFiles = options.overwriteSupportFiles ?? true;
+  const configPath = join(projectDir, "inkos.json");
+
+  if (await exists(configPath)) {
+    throw new Error(`inkos.json already exists in ${projectDir}. Use a different directory or delete the existing project.`);
+  }
+
+  await mkdir(projectDir, { recursive: true });
+  await mkdir(join(projectDir, "books"), { recursive: true });
+  await mkdir(join(projectDir, "radar"), { recursive: true });
+
+  await writeFile(
+    configPath,
+    JSON.stringify(buildProjectConfig(projectDir, language), null, 2),
+    "utf-8",
+  );
+
+  const globalConfigured = await hasGlobalConfig();
+
+  await Promise.all([
+    writeMaybe(join(projectDir, ".env"), buildProjectEnvTemplate(globalConfigured), overwriteSupportFiles),
+    writeMaybe(join(projectDir, ".gitignore"), [".env", "node_modules/", ".DS_Store"].join("\n"), overwriteSupportFiles),
+    writeMaybe(join(projectDir, ".nvmrc"), "22\n", overwriteSupportFiles),
+    writeMaybe(join(projectDir, ".node-version"), "22\n", overwriteSupportFiles),
+  ]);
+}
+
+export async function ensureProjectDirectoryInitialized(
+  projectDir: string,
+  options: Omit<ProjectBootstrapOptions, "overwriteSupportFiles"> = {},
+): Promise<boolean> {
+  const configPath = join(projectDir, "inkos.json");
+  if (await exists(configPath)) {
+    return false;
+  }
+
+  await initializeProjectDirectory(projectDir, {
+    language: options.language,
+    overwriteSupportFiles: false,
+  });
+  return true;
+}

+ 64 - 0
packages/cli/src/tui/__tests__/markdown.test.ts

@@ -0,0 +1,64 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { renderMarkdown } from "../markdown.js";
+
+describe("renderMarkdown", () => {
+  const origTermProgram = process.env.TERM_PROGRAM;
+  afterEach(() => {
+    if (origTermProgram === undefined) delete process.env.TERM_PROGRAM;
+    else process.env.TERM_PROGRAM = origTermProgram;
+  });
+  it("converts **text** to ANSI bold", () => {
+    const result = renderMarkdown("这是 **加粗** 文本");
+    // Should contain ANSI bold on/off around 加粗
+    expect(result).toContain("\x1b[1m加粗\x1b[22m");
+    // Should NOT contain raw ** markers
+    expect(result).not.toContain("**");
+  });
+
+  it("converts * bullets to · bullets", () => {
+    const result = renderMarkdown("* 第一项\n* 第二项");
+    expect(result).toContain("· 第一项");
+    expect(result).toContain("· 第二项");
+    // No raw * bullets remaining
+    expect(result).not.toMatch(/^\s*\* /m);
+  });
+
+  it("renders bold inside list items", () => {
+    const result = renderMarkdown("* **昨日**: 总统套房\n* **今日**: 桥洞");
+    expect(result).toContain("\x1b[1m昨日\x1b[22m");
+    expect(result).toContain("\x1b[1m今日\x1b[22m");
+    expect(result).toContain("·");
+    expect(result).not.toContain("**");
+  });
+
+  it("does not leak bold across lines", () => {
+    const result = renderMarkdown("打磨**冲突场景**,还是梳理**心理转变弧线**。");
+    // Each bold region should be self-contained
+    const boldRegions = [...result.matchAll(/\x1b\[1m(.*?)\x1b\[22m/g)];
+    expect(boldRegions).toHaveLength(2);
+    expect(boldRegions[0][1]).toBe("冲突场景");
+    expect(boldRegions[1][1]).toBe("心理转变弧线");
+  });
+
+  it("renders tables with box-drawing characters", () => {
+    const result = renderMarkdown(
+      "| 属性 | 值 |\n|------|----|\n| 住所 | 桥洞 |",
+    );
+    expect(result).toContain("┌");
+    expect(result).toContain("│");
+    expect(result).toContain("└");
+  });
+
+  it("does not contain full-reset codes that override parent Ink color", () => {
+    // \x1b[0m resets ALL terminal attributes including color set by Ink's <Text>.
+    // renderMarkdown must not emit it, so the parent <Text color={...}> stays in effect.
+    const result = renderMarkdown("这是 **加粗** 文本");
+    expect(result).not.toContain("\x1b[0m");
+  });
+
+  it("falls back to raw text on parse error", () => {
+    // renderMarkdown should never throw; it returns raw text on failure
+    const result = renderMarkdown("plain text");
+    expect(result).toContain("plain text");
+  });
+});

+ 41 - 0
packages/cli/src/tui/activity-state.ts

@@ -0,0 +1,41 @@
+import type { InteractionIntentType } from "@actalk/inkos-core";
+import type { TuiCopy } from "./i18n.js";
+
+export interface ActivityState {
+  readonly label: string;
+  readonly frames: readonly string[];
+  readonly accent: string;
+  readonly intervalMs: number;
+}
+
+import { WARM_ACCENT } from "./theme.js";
+
+const DOTS = ["·  ", "·· ", "···", " ··", "  ·"] as const;
+const WAVE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴"] as const;
+const PULSE = ["◜", "◠", "◝", "◞", "◡", "◟"] as const;
+
+export function describeActivityState(
+  intent: InteractionIntentType | "unknown",
+  copy: Pick<TuiCopy, "activity">,
+): ActivityState {
+  switch (intent) {
+    case "write_next":
+    case "continue_book":
+      return { label: copy.activity.writing, frames: WAVE, accent: WARM_ACCENT, intervalMs: 180 };
+    case "revise_chapter":
+    case "rewrite_chapter":
+      return { label: copy.activity.reviewing, frames: WAVE, accent: WARM_ACCENT, intervalMs: 180 };
+    case "update_focus":
+    case "update_author_intent":
+    case "edit_truth":
+      return { label: copy.activity.updating, frames: PULSE, accent: WARM_ACCENT, intervalMs: 220 };
+    case "list_books":
+    case "select_book":
+    case "switch_mode":
+    case "explain_status":
+      return { label: copy.activity.checking, frames: DOTS, accent: WARM_ACCENT, intervalMs: 220 };
+    case "chat":
+    default:
+      return { label: copy.activity.thinking, frames: DOTS, accent: WARM_ACCENT, intervalMs: 220 };
+  }
+}

+ 98 - 0
packages/cli/src/tui/agent-input.ts

@@ -0,0 +1,98 @@
+import {
+  appendInteractionMessage,
+  clearPendingDecision,
+  createLLMClient,
+  runAgentSession,
+  type InteractionSession,
+} from "@actalk/inkos-core";
+import { persistProjectSession } from "./session-store.js";
+import { buildPipelineConfig, loadConfig } from "../utils.js";
+
+export async function processTuiAgentInput(params: {
+  readonly projectRoot: string;
+  readonly input: string;
+  readonly session: InteractionSession;
+  readonly activeBookId?: string;
+  readonly onTextDelta?: (text: string) => void;
+}) {
+  const config = await loadConfig({ requireApiKey: false, projectRoot: params.projectRoot });
+  const client = createLLMClient(config.llm);
+  const pipeline = new (await import("@actalk/inkos-core")).PipelineRunner(
+    buildPipelineConfig(config, params.projectRoot, { quiet: true }),
+  );
+  const userTimestamp = Date.now();
+  const resolvedBookId = params.activeBookId ?? params.session.activeBookId ?? null;
+  const initialMessages = params.session.messages
+    .filter((message) => message.role === "user" || message.role === "assistant")
+    .map((message) => ({ role: message.role, content: message.content }));
+
+  let nextSession = appendInteractionMessage(clearPendingDecision({
+    ...params.session,
+    ...(resolvedBookId ? { activeBookId: resolvedBookId } : {}),
+    currentExecution: {
+      status: "planning",
+      ...(resolvedBookId ? { bookId: resolvedBookId } : {}),
+      ...(params.session.activeChapterNumber ? { chapterNumber: params.session.activeChapterNumber } : {}),
+      stageLabel: "agent",
+    },
+  }), {
+    role: "user",
+    content: params.input,
+    timestamp: userTimestamp,
+  });
+
+  const result = await runAgentSession(
+    {
+      sessionId: params.session.sessionId,
+      bookId: resolvedBookId,
+      language: config.language ?? "zh",
+      pipeline,
+      projectRoot: params.projectRoot,
+      model: client._piModel
+        ? client._piModel
+        : { provider: config.llm.provider ?? "openai", modelId: config.llm.model },
+      apiKey: client._apiKey,
+      onEvent: (event: any) => {
+        if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
+          params.onTextDelta?.(event.assistantMessageEvent.delta);
+        }
+      },
+    },
+    params.input,
+    initialMessages,
+  );
+
+  if (result.responseText?.trim()) {
+    const lastAssistant = result.messages.filter((message: any) => message.role === "assistant").pop() as { thinking?: string } | undefined;
+    nextSession = appendInteractionMessage({
+      ...nextSession,
+      currentExecution: {
+        status: "completed",
+        ...(resolvedBookId ? { bookId: resolvedBookId } : {}),
+        ...(params.session.activeChapterNumber ? { chapterNumber: params.session.activeChapterNumber } : {}),
+        stageLabel: "agent",
+      },
+    }, {
+      role: "assistant",
+      content: result.responseText,
+      ...(lastAssistant?.thinking ? { thinking: lastAssistant.thinking } : {}),
+      timestamp: userTimestamp + 1,
+    });
+  } else {
+    nextSession = {
+      ...nextSession,
+      currentExecution: {
+        status: "completed",
+        ...(resolvedBookId ? { bookId: resolvedBookId } : {}),
+        ...(params.session.activeChapterNumber ? { chapterNumber: params.session.activeChapterNumber } : {}),
+        stageLabel: "agent",
+      },
+    };
+  }
+
+  await persistProjectSession(params.projectRoot, nextSession);
+  return {
+    responseText: result.responseText,
+    session: nextSession,
+  };
+}

+ 72 - 0
packages/cli/src/tui/ansi.ts

@@ -0,0 +1,72 @@
+/* ── ANSI terminal helpers ── zero dependencies ── */
+
+export const reset = "\x1b[0m";
+export const bold = "\x1b[1m";
+export const dim = "\x1b[2m";
+export const italic = "\x1b[3m";
+
+export const red = "\x1b[31m";
+export const green = "\x1b[32m";
+export const yellow = "\x1b[33m";
+export const blue = "\x1b[34m";
+export const magenta = "\x1b[35m";
+export const cyan = "\x1b[36m";
+export const white = "\x1b[37m";
+export const gray = "\x1b[90m";
+export const brightRed = "\x1b[91m";
+export const brightGreen = "\x1b[92m";
+export const brightYellow = "\x1b[93m";
+export const brightBlue = "\x1b[94m";
+export const brightMagenta = "\x1b[95m";
+export const brightCyan = "\x1b[96m";
+export const brightWhite = "\x1b[97m";
+
+export const bgCyan = "\x1b[46m";
+export const bgBlue = "\x1b[44m";
+export const bgMagenta = "\x1b[45m";
+export const bgGreen = "\x1b[42m";
+export const bgYellow = "\x1b[43m";
+export const bgRed = "\x1b[41m";
+export const bgGray = "\x1b[100m";
+
+export const clearScreen = "\x1b[2J\x1b[H";
+export const showCursor = "\x1b[?25h";
+export const hideCursor = "\x1b[?25l";
+export const clearLine = "\x1b[2K\r";
+export const saveCursor = "\x1b[s";
+export const restoreCursor = "\x1b[u";
+
+export function c(text: string, ...codes: string[]): string {
+  return `${codes.join("")}${text}${reset}`;
+}
+
+export function termWidth(): number {
+  return process.stdout.columns ?? 80;
+}
+
+export function stripAnsi(s: string): string {
+  return s.replace(/\x1b\[[0-9;]*m/g, "");
+}
+
+export function hr(char = "─"): string {
+  return char.repeat(Math.min(termWidth(), 60));
+}
+
+export function box(lines: string[], width = 56): string {
+  const top = `╭${"─".repeat(width - 2)}╮`;
+  const bot = `╰${"─".repeat(width - 2)}╯`;
+  const rows = lines.map((line) => {
+    const visible = stripAnsi(line);
+    const pad = Math.max(0, width - 2 - visible.length);
+    return `│${line}${" ".repeat(pad)}│`;
+  });
+  return [top, ...rows, bot].join("\n");
+}
+
+export function badge(text: string, bg: string, fg: string = brightWhite): string {
+  return `${bg}${fg}${bold} ${text} ${reset}`;
+}
+
+export function sleep(ms: number): Promise<void> {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}

+ 185 - 0
packages/cli/src/tui/app.ts

@@ -0,0 +1,185 @@
+import { access } from "node:fs/promises";
+import { basename, dirname, join } from "node:path";
+import {
+  appendInteractionMessage,
+  processProjectInteractionInput,
+  type InteractionRuntimeTools,
+} from "@actalk/inkos-core";
+import { render } from "ink";
+import React from "react";
+import { InkTuiApp } from "./dashboard.js";
+import { formatModeLabel, getTuiCopy, normalizeStageLabel, resolveTuiLocale, type TuiLocale } from "./i18n.js";
+import { formatTuiResult } from "./output.js";
+import { loadProjectSession, persistProjectSession } from "./session-store.js";
+import { detectModelInfo, detectProjectLanguage, ensureProject, interactiveLlmSetup } from "./setup.js";
+import { createInteractionTools } from "./tools.js";
+import { animateStartup } from "./effects.js";
+
+export interface TuiFrameState {
+  readonly locale?: TuiLocale;
+  readonly projectName: string;
+  readonly activeBookTitle?: string;
+  readonly automationMode: string;
+  readonly status: string;
+  readonly messages?: ReadonlyArray<string>;
+  readonly events?: ReadonlyArray<string>;
+}
+
+export function renderTuiFrame(state: TuiFrameState): string {
+  const locale = state.locale ?? resolveTuiLocale();
+  const copy = getTuiCopy(locale);
+  const lines = [
+    `${copy.labels.project} ${state.projectName}`,
+    `${copy.labels.stage} ${normalizeStageLabel(state.status, copy)}`,
+    `${copy.labels.mode} ${formatModeLabel(state.automationMode, copy)}`,
+    `${copy.labels.book} ${state.activeBookTitle ?? copy.labels.none}`,
+    "",
+    ...(state.messages?.length
+      ? state.messages.slice(-6).map((message) => `- ${message}`)
+      : [`- (${copy.labels.none})`]),
+    "",
+    state.events?.length
+      ? state.events.slice(-1).map((event) => `${copy.labels.recent} ${event}`)[0]!
+      : `${copy.labels.recent} (${copy.labels.none})`,
+    "",
+    copy.composer.placeholder,
+    "> ",
+  ];
+  return lines.join("\n");
+}
+
+async function readVersion(): Promise<string> {
+  try {
+    const { readFile } = await import("node:fs/promises");
+    const { join, dirname } = await import("node:path");
+    const { fileURLToPath } = await import("node:url");
+    const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
+    const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf-8"));
+    return pkg.version ?? "dev";
+  } catch {
+    return "dev";
+  }
+}
+
+export async function processTuiInput(
+  projectRoot: string,
+  input: string,
+  tools: InteractionRuntimeTools,
+) {
+  const projectLanguage = await detectProjectLanguage(projectRoot);
+  const locale = resolveTuiLocale(process.env, projectLanguage);
+  const result = await processProjectInteractionInput({
+    projectRoot,
+    input,
+    tools,
+  });
+  const summary = formatTuiResult({
+    intent: result.request.intent,
+    status: result.session.currentExecution?.status ?? "completed",
+    bookId: result.session.activeBookId,
+    mode: result.request.mode,
+    responseText: result.responseText,
+    locale,
+  });
+  const nextSession = appendInteractionMessage(result.session, {
+    role: "assistant",
+    content: summary,
+    timestamp: Date.now(),
+  });
+  await persistProjectSession(projectRoot, nextSession);
+  return { ...result, session: nextSession };
+}
+
+async function resolveProjectRoot(cwd: string): Promise<string> {
+  // If CWD is a book directory (contains book.json), walk up to the actual project root.
+  // Structure: <projectRoot>/books/<bookId>/book.json
+  try {
+    await access(join(cwd, "book.json"));
+    const parent = dirname(cwd);
+    if (basename(parent) === "books") {
+      return dirname(parent);
+    }
+  } catch {
+    // not a book directory
+  }
+  return cwd;
+}
+
+export async function launchTui(
+  projectRoot: string,
+  toolsOverride?: InteractionRuntimeTools,
+): Promise<void> {
+  projectRoot = await resolveProjectRoot(projectRoot);
+  const { hasLlmConfig } = await ensureProject(projectRoot);
+  const projectLanguage = await detectProjectLanguage(projectRoot);
+  const locale = resolveTuiLocale(process.env, projectLanguage);
+  const copy = getTuiCopy(locale);
+
+  if (!hasLlmConfig) {
+    console.log();
+    console.log(copy.notes.noLlmConfig);
+    console.log(copy.notes.setupProvider);
+    await interactiveLlmSetup(projectRoot);
+  }
+
+  if (!process.stdin.isTTY || !process.stdout.isTTY) {
+    return;
+  }
+
+  const session = await loadProjectSession(projectRoot);
+  const modelInfo = await detectModelInfo(projectRoot);
+  const modelLabel = modelInfo
+    ? `${modelInfo.model && modelInfo.model !== "unknown" ? modelInfo.model : copy.labels.unknown} (${modelInfo.provider})`
+    : copy.labels.notConfigured;
+  const version = await readVersion();
+  const chatStreamBridge: { onTextDelta?: (text: string) => void } = {};
+
+  let tools: InteractionRuntimeTools;
+  try {
+    tools = toolsOverride ?? (await createInteractionTools(projectRoot, {
+      onChatTextDelta: (text) => {
+        chatStreamBridge.onTextDelta?.(text);
+      },
+      onDraftTextDelta: (text) => {
+        chatStreamBridge.onTextDelta?.(text);
+      },
+    }));
+  } catch (error) {
+    const message = error instanceof Error ? error.message : String(error);
+    console.error(copy.notes.toolInitFailed(message));
+    console.error(copy.notes.toolInitHint);
+    return;
+  }
+
+  const originalEmitWarning = process.emitWarning;
+  process.emitWarning = (() => {}) as typeof process.emitWarning;
+  const originalStderrWrite = process.stderr.write.bind(process.stderr);
+  process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => {
+    const text = typeof chunk === "string" ? chunk : chunk.toString();
+    if (text.includes("ExperimentalWarning") || text.includes("--trace-warnings")) {
+      return true;
+    }
+    return (originalStderrWrite as Function)(chunk, ...args);
+  };
+
+  try {
+    await animateStartup(version, basename(projectRoot), session.activeBookId, modelInfo);
+
+    const app = render(
+      React.createElement(InkTuiApp, {
+        locale,
+        projectRoot,
+        projectName: basename(projectRoot),
+        modelLabel,
+        initialSession: session,
+        tools,
+        chatStreamBridge,
+      }),
+      { exitOnCtrlC: true },
+    );
+    await app.waitUntilExit();
+  } finally {
+    process.emitWarning = originalEmitWarning;
+    process.stderr.write = originalStderrWrite;
+  }
+}

+ 22 - 0
packages/cli/src/tui/chat-depth.ts

@@ -0,0 +1,22 @@
+export type ChatDepth = "light" | "normal" | "deep";
+
+export interface ChatDepthProfile {
+  readonly depth: ChatDepth;
+  readonly temperature: number;
+  readonly maxTokens?: number;
+  readonly label: string;
+}
+
+export function resolveChatDepthProfile(depth: ChatDepth): ChatDepthProfile {
+  switch (depth) {
+    case "light":
+      return { depth, temperature: 0.3, maxTokens: 160, label: "light" };
+    case "deep":
+      return { depth, temperature: 0.45, maxTokens: 420, label: "deep" };
+    case "normal":
+    default:
+      // No maxTokens — let the model decide its own output length.
+      // Only /depth light|deep explicitly caps tokens.
+      return { depth: "normal", temperature: 0.4, label: "normal" };
+  }
+}

+ 42 - 0
packages/cli/src/tui/chat-draft.ts

@@ -0,0 +1,42 @@
+import {
+  appendInteractionMessage,
+  type InteractionSession,
+} from "@actalk/inkos-core";
+
+export function createOptimisticUserMessageSession(
+  session: InteractionSession,
+  input: string,
+  timestamp: number = Date.now(),
+): InteractionSession {
+  return appendInteractionMessage(session, {
+    role: "user",
+    content: input,
+    timestamp,
+  });
+}
+
+export function appendStreamingAssistantChunk(
+  session: InteractionSession,
+  chunk: string,
+  timestamp: number = Date.now(),
+): InteractionSession {
+  if (!chunk) {
+    return session;
+  }
+
+  const lastMessage = session.messages.at(-1);
+  if (lastMessage?.role === "assistant" && lastMessage.timestamp === timestamp) {
+    return {
+      ...session,
+      messages: session.messages.map((message, index) => index === session.messages.length - 1
+        ? { ...message, content: message.content + chunk }
+        : message),
+    };
+  }
+
+  return appendInteractionMessage(session, {
+    role: "assistant",
+    content: chunk,
+    timestamp,
+  });
+}

+ 22 - 0
packages/cli/src/tui/composer-caret.ts

@@ -0,0 +1,22 @@
+export interface ComposerCaretState {
+  readonly visible: boolean;
+  readonly shouldAnimate: boolean;
+}
+
+export function resolveComposerCaretState(params: {
+  readonly inputValue: string;
+  readonly isSubmitting: boolean;
+  readonly blinkTick: number;
+}): ComposerCaretState {
+  if (params.isSubmitting) {
+    return {
+      visible: false,
+      shouldAnimate: false,
+    };
+  }
+
+  return {
+    visible: true,
+    shouldAnimate: false,
+  };
+}

+ 26 - 0
packages/cli/src/tui/composer-display.ts

@@ -0,0 +1,26 @@
+export function renderComposerDisplay(
+  inputValue: string,
+  placeholder: string,
+  showCursor: boolean,
+): {
+  readonly textBeforeCursor: string;
+  readonly textAfterCursor: string;
+  readonly cursor: string;
+  readonly isPlaceholder: boolean;
+} {
+  if (!inputValue) {
+    return {
+      textBeforeCursor: showCursor ? "" : placeholder,
+      textAfterCursor: showCursor ? placeholder : "",
+      cursor: showCursor ? "│" : "",
+      isPlaceholder: true,
+    };
+  }
+
+  return {
+    textBeforeCursor: inputValue,
+    textAfterCursor: "",
+    cursor: showCursor ? "│" : "",
+    isPlaceholder: false,
+  };
+}

+ 164 - 0
packages/cli/src/tui/dashboard-model.ts

@@ -0,0 +1,164 @@
+import type {
+  ExecutionStatus,
+  InteractionEvent,
+  InteractionMessage,
+  InteractionSession,
+} from "@actalk/inkos-core";
+import { formatModeLabel, normalizeStageLabel, type TuiCopy } from "./i18n.js";
+
+export interface DashboardMessageRow {
+  readonly key: string;
+  readonly label: string;
+  readonly role: InteractionMessage["role"];
+  readonly content: string;
+}
+
+export interface DashboardEventRow {
+  readonly key: string;
+  readonly status: ExecutionStatus;
+  readonly summary: string;
+}
+
+export interface DashboardViewModel {
+  readonly projectName: string;
+  readonly activeBookTitle?: string;
+  readonly modelLabel: string;
+  readonly modeLabel: string;
+  readonly executionStatus: ExecutionStatus;
+  readonly executionLabel: string;
+  readonly headerLine: string;
+  readonly statusPrimaryLine: string;
+  readonly statusSecondaryLine: string;
+  readonly messageRows: ReadonlyArray<DashboardMessageRow>;
+  readonly eventRows: ReadonlyArray<DashboardEventRow>;
+  readonly pendingDecisionSummary?: string;
+  readonly composerPlaceholder: string;
+  readonly composerHelper: string;
+  readonly composerStatus: string;
+  readonly errorText?: string;
+}
+
+export interface BuildDashboardViewModelParams {
+  readonly projectName: string;
+  readonly activeBookTitle?: string;
+  readonly modelLabel: string;
+  readonly depthLabel?: string;
+  readonly copy: TuiCopy;
+  readonly session: InteractionSession;
+  readonly isSubmitting: boolean;
+  readonly lastError?: string;
+  readonly sinceTimestamp?: number;
+  readonly terminalRows?: number;
+  readonly scrollOffset?: number;
+}
+
+export function buildDashboardViewModel(params: BuildDashboardViewModelParams): DashboardViewModel {
+  const status = params.session.currentExecution?.status ?? "idle";
+  const executionLabel = normalizeStageLabel(params.session.currentExecution?.stageLabel ?? status, params.copy);
+  const modeLabel = formatModeLabel(params.session.automationMode, params.copy);
+  const bookLabel = params.activeBookTitle ?? params.session.activeBookId ?? params.copy.labels.none;
+  const draftTitle = params.session.creationDraft?.title;
+  const draftQuestion = params.session.creationDraft?.nextQuestion;
+  const sinceTimestamp = params.sinceTimestamp ?? 0;
+  const terminalRows = params.terminalRows ?? process.stdout.rows ?? 24;
+  const conversationLimit = Math.max(4, terminalRows - 10);
+
+  const filteredMessages = params.session.messages
+    .filter((message) => message.timestamp >= sinceTimestamp);
+  const scrollOffset = params.scrollOffset ?? 0;
+  const endIndex = filteredMessages.length - scrollOffset;
+  const startIndex = Math.max(0, endIndex - conversationLimit);
+  const messageRows = filteredMessages
+    .slice(startIndex, endIndex > 0 ? endIndex : undefined)
+    .map((message, index) => ({
+      key: `${message.timestamp}-${index}`,
+      label: roleLabel(message.role, params.copy),
+      role: message.role,
+      content: message.content,
+    }));
+
+  const eventRows = params.session.events
+    .filter((event) => event.timestamp >= sinceTimestamp)
+    .slice(-3)
+    .map((event, index) => ({
+      key: `${event.timestamp}-${index}`,
+      status: event.status,
+      summary: summarizeEvent(event, params.copy),
+    }));
+
+  const latestEventSummary = eventRows[eventRows.length - 1]?.summary;
+
+  return {
+    projectName: params.projectName,
+    activeBookTitle: params.activeBookTitle ?? params.session.activeBookId,
+    modelLabel: params.modelLabel,
+    modeLabel,
+    executionStatus: status,
+    executionLabel,
+    headerLine: [
+      `${params.copy.labels.project} ${params.projectName}`,
+      `${params.copy.labels.book} ${bookLabel}`,
+      draftTitle ? `${params.copy.labels.draft} ${draftTitle}` : undefined,
+      `${params.copy.labels.depth} ${params.depthLabel ?? params.copy.depthLabels.normal}`,
+      `${params.copy.labels.session} ${params.session.sessionId.slice(-4)}`,
+      params.copy.labels.messageCount(params.session.messages.length),
+    ].filter(Boolean).join(" · "),
+    statusPrimaryLine: `${params.copy.labels.stage} ${executionLabel} · ${params.copy.labels.model} ${params.modelLabel}`,
+    statusSecondaryLine: params.lastError
+      ? `${params.copy.labels.error} · ${compactInline(params.lastError)}`
+      : params.isSubmitting && latestEventSummary
+        ? `${params.copy.labels.recent} · ${latestEventSummary}`
+      : params.session.pendingDecision?.summary
+          ? `${params.copy.labels.pending} · ${compactInline(params.session.pendingDecision.summary)}`
+        : draftQuestion
+          ? `${params.copy.labels.draft} · ${compactInline(draftQuestion)}`
+        : draftTitle
+          ? `${params.copy.labels.draft} · ${draftTitle}`
+        : latestEventSummary
+            ? `${params.copy.labels.recent} · ${latestEventSummary}`
+            : `${params.copy.labels.ready} · ${bookLabel}`,
+    messageRows,
+    eventRows,
+    pendingDecisionSummary: params.session.pendingDecision?.summary,
+    composerPlaceholder: params.copy.composer.placeholder,
+    composerHelper: params.copy.composer.helper,
+    composerStatus: params.isSubmitting
+      ? params.copy.composer.submitting
+      : params.lastError
+        ? params.copy.composer.failed
+        : params.copy.composer.ready,
+    errorText: params.lastError,
+  };
+}
+
+function roleLabel(role: InteractionMessage["role"], copy: TuiCopy): string {
+  switch (role) {
+    case "user":
+      return copy.roles.user;
+    case "assistant":
+      return copy.roles.assistant;
+    case "system":
+      return copy.roles.system;
+    default:
+      return role;
+  }
+}
+
+function summarizeEvent(event: InteractionEvent, copy: TuiCopy): string {
+  const base = compactInline(event.detail?.trim() || event.kind);
+  if (event.bookId && event.chapterNumber !== undefined) {
+    const chapterLabel = copy.locale === "zh-CN"
+      ? `第 ${event.chapterNumber} 章`
+      : `ch.${event.chapterNumber}`;
+    return `${base} (${event.bookId} ${chapterLabel})`;
+  }
+  if (event.bookId) {
+    return `${base} (${event.bookId})`;
+  }
+  return base;
+}
+
+function compactInline(value: string): string {
+  const singleLine = value.replace(/\s+/g, " ").trim();
+  return singleLine.length > 72 ? singleLine.slice(0, 69) + "..." : singleLine;
+}

+ 587 - 0
packages/cli/src/tui/dashboard.tsx

@@ -0,0 +1,587 @@
+import React, { useEffect, useRef, useState } from "react";
+import {
+  appendInteractionMessage,
+  processProjectInteractionInput,
+  routeNaturalLanguageIntent,
+  type InteractionIntentType,
+  type InteractionRuntimeTools,
+  type InteractionSession,
+} from "@actalk/inkos-core";
+import { Box, Text, useApp, useInput } from "ink";
+import { processTuiAgentInput } from "./agent-input.js";
+import { describeActivityState } from "./activity-state.js";
+import { resolveComposerCaretState } from "./composer-caret.js";
+import { resolveChatDepthProfile, type ChatDepth } from "./chat-depth.js";
+import { appendStreamingAssistantChunk, createOptimisticUserMessageSession } from "./chat-draft.js";
+import { renderComposerDisplay } from "./composer-display.js";
+import { renderMarkdown } from "./markdown.js";
+import { formatTuiResult } from "./output.js";
+import { buildDashboardViewModel, type DashboardMessageRow } from "./dashboard-model.js";
+import { buildInputHistory, moveHistoryCursor } from "./input-history.js";
+import { formatModeLabel, getTuiCopy, normalizeStageLabel, type TuiLocale } from "./i18n.js";
+import { loadProjectSession, persistProjectSession, resolveSessionActiveBook } from "./session-store.js";
+import { classifyLocalTuiCommand, parseDepthCommand } from "./local-commands.js";
+import {
+  applySlashSuggestion,
+  getNextSlashSelection,
+  getSlashSuggestions,
+  SLASH_COMMANDS,
+} from "./slash-autocomplete.js";
+import {
+  WARM_ACCENT, WARM_BORDER, WARM_MUTED, WARM_REPLY,
+  STATUS_SUCCESS, STATUS_ERROR, STATUS_ACTIVE, STATUS_IDLE,
+  ROLE_USER, ROLE_SYSTEM,
+  isAppleTerminal,
+} from "./theme.js";
+
+function shouldUseLegacyTuiInteraction(intent: InteractionIntentType): boolean {
+  switch (intent) {
+    case "list_books":
+    case "show_book_draft":
+    case "discard_book_draft":
+    case "select_book":
+    case "pause_book":
+    case "switch_mode":
+      return true;
+    default:
+      return false;
+  }
+}
+
+export interface InkTuiDashboardProps {
+  readonly locale: TuiLocale;
+  readonly projectName: string;
+  readonly activeBookTitle?: string;
+  readonly modelLabel: string;
+  readonly depthLabel?: string;
+  readonly session: InteractionSession;
+  readonly inputValue: string;
+  readonly isSubmitting: boolean;
+  readonly sinceTimestamp?: number;
+  readonly lastError?: string;
+  readonly slashSuggestions?: ReadonlyArray<string>;
+  readonly selectedSlashIndex?: number;
+  readonly showComposerCursor?: boolean;
+  readonly scrollOffset?: number;
+  readonly onInputChange?: (value: string) => void;
+  readonly onSubmit?: (value: string) => void;
+}
+
+export interface InkTuiAppProps {
+  readonly locale: TuiLocale;
+  readonly projectRoot: string;
+  readonly projectName: string;
+  readonly modelLabel: string;
+  readonly initialSession: InteractionSession;
+  readonly tools: InteractionRuntimeTools;
+  readonly chatStreamBridge?: {
+    onTextDelta?: (text: string) => void;
+    getChatRequestOptions?: () => {
+      readonly temperature?: number;
+      readonly maxTokens?: number;
+    };
+  };
+}
+
+export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element {
+  const copy = getTuiCopy(props.locale);
+  const model = buildDashboardViewModel({
+    copy,
+    projectName: props.projectName,
+    activeBookTitle: props.activeBookTitle,
+    modelLabel: props.modelLabel,
+    depthLabel: props.depthLabel,
+    session: props.session,
+    isSubmitting: props.isSubmitting,
+    lastError: props.lastError,
+    sinceTimestamp: props.sinceTimestamp,
+    scrollOffset: props.scrollOffset,
+  });
+  const activeAccent = props.isSubmitting ? WARM_ACCENT : statusColor(model.executionStatus);
+  const composer = renderComposerDisplay(props.inputValue, model.composerPlaceholder, props.showComposerCursor ?? false);
+
+  const separatorWidth = Math.max(20, (process.stdout.columns ?? 60) - 8);
+  const thinRule = "─".repeat(separatorWidth);
+
+  return (
+    <Box flexDirection="column" width="100%" paddingX={2}>
+      {/* Header bar */}
+      <Text color={WARM_MUTED}>{model.headerLine}</Text>
+      <Text color={WARM_BORDER}>{thinRule}</Text>
+
+      {/* Conversation area */}
+      <Box flexDirection="column" marginTop={1} flexGrow={1}>
+        {model.messageRows.length > 0 ? (
+          model.messageRows.map((row) => <ConversationRow key={row.key} row={row} />)
+        ) : (
+          <Box marginY={1}>
+            <Text color={WARM_MUTED} italic>  {copy.composer.emptyConversation}</Text>
+          </Box>
+        )}
+      </Box>
+
+      {/* Composer area */}
+      <Box flexDirection="column" marginTop={1}>
+        <Text color={WARM_BORDER}>{thinRule}</Text>
+
+        {/* Composer input */}
+        <Box
+          marginTop={1}
+          flexDirection="column"
+          width="100%"
+          borderStyle="round"
+          borderColor={props.isSubmitting ? STATUS_ACTIVE : WARM_BORDER}
+          paddingX={1}
+        >
+          <Box>
+            <Text color={props.isSubmitting ? STATUS_ACTIVE : WARM_ACCENT} bold>
+              ›{" "}
+            </Text>
+            {composer.textBeforeCursor ? (
+              <Text color={composer.isPlaceholder ? WARM_MUTED : WARM_REPLY}>
+                {composer.textBeforeCursor}
+              </Text>
+            ) : null}
+            {composer.cursor ? (
+              <Text color={props.isSubmitting ? STATUS_ACTIVE : WARM_ACCENT}>
+                {composer.cursor}
+              </Text>
+            ) : null}
+            {composer.textAfterCursor ? (
+              <Text color={composer.isPlaceholder ? WARM_MUTED : WARM_REPLY}>
+                {composer.textAfterCursor}
+              </Text>
+            ) : null}
+          </Box>
+          {/* Slash command suggestions */}
+          {props.slashSuggestions && props.slashSuggestions.length > 0 ? (
+            <Box flexDirection="column" marginTop={1} borderTop borderColor={WARM_BORDER}>
+              {props.slashSuggestions.slice(0, 6).map((suggestion, index) => {
+                const isSelected = index === (props.selectedSlashIndex ?? 0);
+                return (
+                  <Box key={suggestion}>
+                    <Text color={isSelected ? WARM_ACCENT : WARM_BORDER}>{isSelected ? "› " : "  "}</Text>
+                    <Text color={isSelected ? WARM_REPLY : WARM_MUTED} bold={isSelected}>
+                      {suggestion}
+                    </Text>
+                  </Box>
+                );
+              })}
+            </Box>
+          ) : null}
+        </Box>
+        <Box marginTop={1}>
+          <ExecutionBadge status={model.executionStatus} color={activeAccent} />
+          <Text color={activeAccent}> {model.statusPrimaryLine}</Text>
+        </Box>
+      </Box>
+    </Box>
+  );
+}
+
+export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
+  const { exit } = useApp();
+  const copy = getTuiCopy(props.locale);
+  const [session, setSession] = useState(props.initialSession);
+  const [inputValue, setInputValue] = useState("");
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [lastError, setLastError] = useState<string | undefined>();
+  const [sinceTimestamp, setSinceTimestamp] = useState<number | undefined>();
+  const [selectedSlashIndex, setSelectedSlashIndex] = useState(0);
+  const [scrollOffset, setScrollOffset] = useState(0);
+  const [historyState, setHistoryState] = useState<{ cursor: number | null; draft: string }>({
+    cursor: null,
+    draft: "",
+  });
+  const [activityIntent, setActivityIntent] = useState<InteractionIntentType | "unknown">("unknown");
+  const [activityFrameIndex, setActivityFrameIndex] = useState(0);
+  const [chatDepth, setChatDepth] = useState<ChatDepth>("normal");
+  const assistantDraftTimestampRef = useRef<number | null>(null);
+  const submitLockRef = useRef(false);
+  const slashSuggestions = getSlashSuggestions(inputValue, SLASH_COMMANDS);
+  const inputHistory = buildInputHistory(session.messages);
+  const activity = describeActivityState(activityIntent, copy);
+  const chatDepthProfile = resolveChatDepthProfile(chatDepth);
+  const composerCaret = resolveComposerCaretState({
+    inputValue,
+    isSubmitting,
+    blinkTick: 0,
+  });
+
+  useEffect(() => {
+    if (!isSubmitting) {
+      setActivityFrameIndex(0);
+      return;
+    }
+
+    const timer = setInterval(() => {
+      setActivityFrameIndex((current) => (current + 1) % activity.frames.length);
+    }, activity.intervalMs);
+    return () => clearInterval(timer);
+  }, [activity.frames.length, activity.intervalMs, isSubmitting]);
+
+  if (props.chatStreamBridge) {
+    props.chatStreamBridge.getChatRequestOptions = () => ({
+      temperature: chatDepthProfile.temperature,
+      maxTokens: chatDepthProfile.maxTokens,
+    });
+  }
+
+  props.chatStreamBridge && (props.chatStreamBridge.onTextDelta = (text: string) => {
+    const timestamp = assistantDraftTimestampRef.current;
+    if (timestamp === null) {
+      return;
+    }
+
+    setSession((current) => appendStreamingAssistantChunk(current, text, timestamp));
+  });
+
+  useInput((_input, key) => {
+    if (key.escape) {
+      exit();
+      return;
+    }
+
+    if (slashSuggestions.length > 0 && key.tab) {
+      setInputValue(applySlashSuggestion(inputValue, slashSuggestions, selectedSlashIndex));
+      setSelectedSlashIndex(0);
+      return;
+    }
+
+    if (key.backspace || key.delete) {
+      setInputValue((current) => {
+        // Use Intl.Segmenter for grapheme-aware backspace (handles CJK, emoji, etc.)
+        const segments = [...new Intl.Segmenter().segment(current)];
+        segments.pop();
+        return segments.map((s) => s.segment).join("");
+      });
+      setSelectedSlashIndex(0);
+      return;
+    }
+
+    if (slashSuggestions.length > 0 && key.downArrow) {
+      setSelectedSlashIndex((current) => getNextSlashSelection(current, slashSuggestions.length, "down"));
+      return;
+    }
+
+    if (slashSuggestions.length > 0 && key.upArrow) {
+      setSelectedSlashIndex((current) => getNextSlashSelection(current, slashSuggestions.length, "up"));
+      return;
+    }
+
+    // Page Up / Page Down for conversation scrolling
+    // Raw sequences: Page Up = \x1b[5~ , Page Down = \x1b[6~
+    if (_input === "\x1b[5~") {
+      const maxOffset = Math.max(0, session.messages.length - 4);
+      setScrollOffset((cur) => Math.min(maxOffset, cur + 3));
+      return;
+    }
+    if (_input === "\x1b[6~") {
+      setScrollOffset((cur) => Math.max(0, cur - 3));
+      return;
+    }
+
+    if (key.downArrow) {
+      const next = moveHistoryCursor(inputHistory, historyState, inputValue, "down");
+      setHistoryState(next.state);
+      setInputValue(next.value);
+      return;
+    }
+
+    if (key.upArrow) {
+      const next = moveHistoryCursor(inputHistory, historyState, inputValue, "up");
+      setHistoryState(next.state);
+      setInputValue(next.value);
+      return;
+    }
+
+    if (key.return) {
+      void handleSubmit(inputValue);
+      return;
+    }
+
+    if (_input && !_input.includes("\r") && !_input.includes("\n") && !key.ctrl && !key.meta) {
+      setInputValue((current) => current + _input);
+      setSelectedSlashIndex(0);
+    }
+  });
+
+  const appendSystemNote = (content: string) => {
+    setLastError(undefined);
+    setSession((current) => appendInteractionMessage(current, {
+      role: "system",
+      content,
+      timestamp: Date.now(),
+    }));
+  };
+
+  const handleSubmit = async (rawValue: string) => {
+    const input = rawValue.trim();
+    if (!input || isSubmitting || submitLockRef.current) {
+      return;
+    }
+    submitLockRef.current = true;
+
+    try {
+      const localCommand = classifyLocalTuiCommand(input);
+      const depthCommand = parseDepthCommand(input);
+      if (localCommand) {
+        setInputValue("");
+
+        if (localCommand === "quit") {
+          exit();
+          return;
+        }
+
+        if (localCommand === "help") {
+          appendSystemNote(copy.notes.help);
+          return;
+        }
+
+        if (localCommand === "status") {
+          const stage = normalizeStageLabel(
+            session.currentExecution?.stageLabel ?? session.currentExecution?.status ?? "idle",
+            copy,
+          );
+          appendSystemNote(copy.notes.status(stage, formatModeLabel(session.automationMode, copy)));
+          return;
+        }
+
+        if (localCommand === "clear") {
+          setLastError(undefined);
+          setSinceTimestamp(Date.now());
+          return;
+        }
+
+        if (localCommand === "config") {
+          appendSystemNote(copy.notes.config);
+          return;
+        }
+      }
+
+      if (depthCommand) {
+        setInputValue("");
+        setChatDepth(depthCommand);
+        appendSystemNote(copy.notes.depthSet(copy.depthLabels[depthCommand]));
+        return;
+      }
+
+      const activeBookId = await resolveSessionActiveBook(props.projectRoot, session);
+      const routed = routeNaturalLanguageIntent(input, {
+        activeBookId,
+        hasCreationDraft: Boolean(session.creationDraft),
+        hasFailed: session.currentExecution?.status === "failed",
+      });
+      const userTimestamp = Date.now();
+      const assistantDraftTimestamp = (routed.intent === "chat" || routed.intent === "develop_book")
+        ? userTimestamp + 1 : null;
+      assistantDraftTimestampRef.current = assistantDraftTimestamp;
+      setActivityIntent(routed.intent);
+      setIsSubmitting(true);
+      setLastError(undefined);
+      setInputValue("");
+      setScrollOffset(0);
+      setHistoryState({ cursor: null, draft: "" });
+      setSession((current) => createOptimisticUserMessageSession(current, input, userTimestamp));
+
+      if (routed.intent === "develop_book" && !session.creationDraft) {
+        appendSystemNote(copy.notes.newBookGuide);
+      }
+
+      if (shouldUseLegacyTuiInteraction(routed.intent)) {
+        const result = await processProjectInteractionInput({
+          projectRoot: props.projectRoot,
+          input,
+          tools: props.tools,
+          activeBookId,
+        });
+        const summary = formatTuiResult({
+          intent: result.request.intent,
+          status: result.session.currentExecution?.status ?? "completed",
+          bookId: result.session.activeBookId,
+          mode: result.request.mode,
+          responseText: result.responseText,
+          locale: props.locale,
+        });
+        const nextSession = appendInteractionMessage(result.session, {
+          role: "assistant",
+          content: summary,
+          timestamp: Date.now(),
+        });
+        await persistProjectSession(props.projectRoot, nextSession);
+        setSession(nextSession);
+      } else {
+        const result = await processTuiAgentInput({
+          projectRoot: props.projectRoot,
+          input,
+          session,
+          activeBookId,
+          onTextDelta: (text) => {
+            props.chatStreamBridge?.onTextDelta?.(text);
+          },
+        });
+        setSession(result.session);
+      }
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      const failedSession = await loadProjectSession(props.projectRoot);
+      setSession(failedSession);
+      setLastError(message);
+    } finally {
+      assistantDraftTimestampRef.current = null;
+      setIsSubmitting(false);
+      setActivityIntent("unknown");
+      submitLockRef.current = false;
+    }
+  };
+
+  const activitySession = isSubmitting
+    ? {
+        ...session,
+        currentExecution: {
+          status: "planning" as const,
+          bookId: session.activeBookId,
+          chapterNumber: session.activeChapterNumber,
+          stageLabel: `${activity.label} ${activity.frames[activityFrameIndex] ?? ""}`.trim(),
+        },
+      }
+    : session;
+
+  return (
+    <InkTuiDashboard
+      locale={props.locale}
+      projectName={props.projectName}
+      activeBookTitle={activitySession.activeBookId}
+      modelLabel={props.modelLabel}
+      depthLabel={copy.depthLabels[chatDepth]}
+      session={activitySession}
+      inputValue={inputValue}
+      isSubmitting={isSubmitting}
+      sinceTimestamp={sinceTimestamp}
+      lastError={lastError}
+      slashSuggestions={slashSuggestions}
+      selectedSlashIndex={selectedSlashIndex}
+      showComposerCursor={composerCaret.visible}
+      scrollOffset={scrollOffset}
+      onInputChange={(value) => {
+        setInputValue(value);
+        setSelectedSlashIndex(0);
+        setHistoryState((current) => current.cursor === null ? current : { cursor: null, draft: value });
+      }}
+      onSubmit={(value) => {
+        void handleSubmit(value);
+      }}
+    />
+  );
+}
+
+function ConversationRow(props: { readonly row: DashboardMessageRow }): React.JSX.Element {
+  const { role, content } = props.row;
+
+  // Terminal.app: use the same simple layout as the main branch to avoid
+  // triggering CoreGraphics crashes from complex Box nesting + ANSI codes.
+  if (isAppleTerminal) {
+    const prefix = role === "user" ? "│ " : role === "system" ? "· " : "◆ ";
+    const color = role === "user" ? ROLE_USER : role === "system" ? ROLE_SYSTEM : WARM_REPLY;
+    return (
+      <Box marginBottom={1}>
+        <Text color={role === "assistant" ? WARM_ACCENT : color}>{prefix}</Text>
+        <Text color={color}>{content}</Text>
+      </Box>
+    );
+  }
+
+  if (role === "user") {
+    return (
+      <Box flexDirection="row" marginBottom={1}>
+        <Box minWidth={2}><Text color={ROLE_USER}>│</Text></Box>
+        <Box flexDirection="column" flexShrink={1}><Text color={ROLE_USER}>{content}</Text></Box>
+      </Box>
+    );
+  }
+
+  if (role === "system") {
+    return (
+      <Box flexDirection="row" marginBottom={1}>
+        <Box minWidth={2}><Text color={ROLE_SYSTEM}>·</Text></Box>
+        <Box flexDirection="column" flexShrink={1}><Text color={ROLE_SYSTEM}>{content}</Text></Box>
+      </Box>
+    );
+  }
+
+  // assistant — render markdown (bold, tables, code, etc.)
+  return (
+    <Box flexDirection="row" marginBottom={1}>
+      <Box minWidth={2}><Text color={WARM_ACCENT}>◆</Text></Box>
+      <Box flexDirection="column" flexShrink={1}><Text color={WARM_REPLY}>{renderMarkdown(content)}</Text></Box>
+    </Box>
+  );
+}
+
+function ExecutionBadge(props: { readonly status: string; readonly color?: string }): React.JSX.Element {
+  const icon = statusIcon(props.status);
+  return (
+    <Text color={props.color ?? statusColor(props.status)} bold>
+      {icon}
+    </Text>
+  );
+}
+
+function statusIcon(status: string): string {
+  switch (status) {
+    case "completed":
+      return "✓";
+    case "failed":
+      return "✗";
+    case "blocked":
+    case "waiting_human":
+      return "◈";
+    case "writing":
+      return "✎";
+    case "planning":
+    case "composing":
+      return "◇";
+    case "repairing":
+    case "persisting":
+      return "◉";
+    default:
+      return "●";
+  }
+}
+
+function MutedText(props: { readonly children: React.ReactNode }): React.JSX.Element {
+  return <Text color={WARM_MUTED}>{props.children}</Text>;
+}
+
+function messageColor(role: DashboardMessageRow["role"]): string {
+  switch (role) {
+    case "user":
+      return WARM_MUTED;
+    case "assistant":
+      return WARM_REPLY;
+    case "system":
+      return WARM_ACCENT;
+    default:
+      return WARM_REPLY;
+  }
+}
+
+function statusColor(status: string): string {
+  switch (status) {
+    case "completed":
+      return STATUS_SUCCESS;
+    case "failed":
+      return STATUS_ERROR;
+    case "blocked":
+    case "waiting_human":
+      return WARM_ACCENT;
+    case "writing":
+    case "repairing":
+    case "planning":
+    case "composing":
+    case "persisting":
+      return STATUS_ACTIVE;
+    default:
+      return STATUS_IDLE;
+  }
+}

+ 546 - 0
packages/cli/src/tui/effects.ts

@@ -0,0 +1,546 @@
+/* ── TUI animation effects ── */
+
+import {
+  c, bold, dim, italic,
+  cyan, green, yellow, blue, magenta, red, gray, white,
+  brightCyan, brightGreen, brightYellow, brightBlue, brightMagenta, brightWhite,
+  bgCyan, bgBlue, bgMagenta, bgGreen, bgYellow, bgRed, bgGray,
+  clearLine, hideCursor, showCursor, reset,
+  badge, sleep, stripAnsi, box,
+} from "./ansi.js";
+import { formatModeLabel, getTuiCopy, normalizeStageLabel, resolveTuiLocale, type TuiLocale } from "./i18n.js";
+
+/* ── Operation themes ── */
+
+export interface OperationTheme {
+  readonly icon: string;
+  readonly color: string;
+  readonly brightColor: string;
+  readonly bg: string;
+  readonly label: string;
+  readonly frames: ReadonlyArray<string>;
+}
+
+export interface StyledHelpSection {
+  readonly title: string;
+  readonly commands: ReadonlyArray<readonly [string, string]>;
+}
+
+const WAVE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
+const PULSE_FRAMES = ["◜", "◠", "◝", "◞", "◡", "◟"];
+const DOTS_FRAMES = ["·  ", "·· ", "···", " ··", "  ·", "   "];
+const SCAN_FRAMES = ["▱▱▱▱▱", "▰▱▱▱▱", "▰▰▱▱▱", "▰▰▰▱▱", "▰▰▰▰▱", "▰▰▰▰▰", "▱▰▰▰▰", "▱▱▰▰▰", "▱▱▱▰▰", "▱▱▱▱▰"];
+const WRITE_FRAMES = ["✎", "✎·", "✎··", "✎···", "✎····", "✎···", "✎··", "✎·"];
+
+export const THEMES: Record<string, OperationTheme> = {
+  thinking: {
+    icon: "◇",
+    color: cyan,
+    brightColor: brightCyan,
+    bg: bgCyan,
+    label: "thinking",
+    frames: DOTS_FRAMES,
+  },
+  writing: {
+    icon: "✎",
+    color: magenta,
+    brightColor: brightMagenta,
+    bg: bgMagenta,
+    label: "writing",
+    frames: WRITE_FRAMES,
+  },
+  auditing: {
+    icon: "◉",
+    color: yellow,
+    brightColor: brightYellow,
+    bg: bgYellow,
+    label: "auditing",
+    frames: SCAN_FRAMES,
+  },
+  revising: {
+    icon: "✂",
+    color: blue,
+    brightColor: brightBlue,
+    bg: bgBlue,
+    label: "revising",
+    frames: WAVE_FRAMES,
+  },
+  planning: {
+    icon: "◈",
+    color: cyan,
+    brightColor: brightCyan,
+    bg: bgCyan,
+    label: "planning",
+    frames: PULSE_FRAMES,
+  },
+  composing: {
+    icon: "❖",
+    color: green,
+    brightColor: brightGreen,
+    bg: bgGreen,
+    label: "composing",
+    frames: PULSE_FRAMES,
+  },
+  loading: {
+    icon: "◌",
+    color: gray,
+    brightColor: white,
+    bg: bgGray,
+    label: "loading",
+    frames: WAVE_FRAMES,
+  },
+};
+
+/* ── Animated spinner with themed operations ── */
+
+export class ThemedSpinner {
+  private interval: ReturnType<typeof setInterval> | undefined;
+  private frame = 0;
+  private elapsed = 0;
+  private theme: OperationTheme;
+
+  constructor(themeName = "thinking") {
+    this.theme = THEMES[themeName] ?? THEMES["thinking"]!;
+  }
+
+  start(label?: string): void {
+    const displayLabel = label ?? localizeThemeLabel(this.theme.label, resolveTuiLocale());
+    this.frame = 0;
+    this.elapsed = 0;
+    process.stdout.write(hideCursor);
+
+    this.interval = setInterval(() => {
+      this.elapsed += 120;
+      const f = this.theme.frames[this.frame % this.theme.frames.length]!;
+      const icon = c(this.theme.icon, this.theme.color);
+      const anim = c(f, this.theme.brightColor);
+      const text = c(displayLabel, dim);
+      const time = this.elapsed >= 3000
+        ? c(` ${formatElapsed(this.elapsed)}`, gray)
+        : "";
+      process.stdout.write(`${clearLine}  ${icon} ${text} ${anim}${time}`);
+      this.frame++;
+    }, 120);
+  }
+
+  update(label: string): void {
+    if (!this.interval) return;
+    this.stop();
+    this.start(label);
+  }
+
+  succeed(message?: string): void {
+    this.clear();
+    if (message) {
+      console.log(`  ${c("✓", brightGreen, bold)} ${message}`);
+    }
+  }
+
+  fail(message?: string): void {
+    this.clear();
+    if (message) {
+      console.log(`  ${c("✗", red, bold)} ${message}`);
+    }
+  }
+
+  stop(): void {
+    this.clear();
+  }
+
+  private clear(): void {
+    if (this.interval) {
+      clearInterval(this.interval);
+      this.interval = undefined;
+    }
+    process.stdout.write(`${clearLine}${showCursor}`);
+  }
+}
+
+/* ── ASCII Art Logo ── */
+
+const ASCII_LOGO = [
+  " ██╗███╗   ██╗██╗  ██╗ ██████╗ ███████╗",
+  " ██║████╗  ██║██║ ██╔╝██╔═══██╗██╔════╝",
+  " ██║██╔██╗ ██║█████╔╝ ██║   ██║███████╗",
+  " ██║██║╚██╗██║██╔═██╗ ██║   ██║╚════██║",
+  " ██║██║ ╚████║██║  ██╗╚██████╔╝███████║",
+  " ╚═╝╚═╝  ╚═══╝╚═╝  ╚═╝ ╚═════╝ ╚══════╝",
+];
+
+/* ── Input area ── */
+
+/* ── Input chrome ── */
+
+export function inputPromptPrefix(): string {
+  return `  ${c("›", cyan)} `;
+}
+
+export function drawInputHint(): void {
+  // Keep the prompt anchored to the next line; extra blank lines confuse
+  // terminal UIs that render readline prompts inside a framed input block.
+}
+
+export function printInputSeparator(): void {
+  const w = Math.min(process.stdout.columns ?? 60, 60);
+  console.log(c("  " + "─".repeat(w - 4), gray));
+}
+
+/* ── Startup animation ── */
+
+export interface StartupModelInfo {
+  readonly provider: string;
+  readonly model: string;
+}
+
+export async function animateStartup(version: string, projectName: string, bookTitle?: string, modelInfo?: StartupModelInfo): Promise<void> {
+  const isTTY = process.stdout.isTTY;
+
+  if (isTTY) {
+    console.log();
+    process.stdout.write(hideCursor);
+
+    // Animate ASCII logo line by line
+    for (let i = 0; i < ASCII_LOGO.length; i++) {
+      const line = ASCII_LOGO[i]!;
+      const shade = i < 2 ? brightCyan : i < 4 ? cyan : dim + cyan;
+      process.stdout.write(`  ${c(line, shade)}\n`);
+      await sleep(40);
+    }
+
+    // Version line
+    console.log(c(`  v${version}`, dim));
+    await sleep(100);
+
+    // Project info strip
+    const infoParts = [
+      c("◇", cyan) + " " + c(projectName, white, bold),
+    ];
+    if (bookTitle) {
+      infoParts.push(c("◇", cyan) + " " + c(bookTitle, brightCyan));
+    }
+    if (modelInfo?.model && modelInfo.model !== "unknown") {
+      infoParts.push(c("◇", cyan) + " " + c(`${modelInfo.model} (${modelInfo.provider})`, dim));
+    }
+    console.log();
+    for (const part of infoParts) {
+      console.log(`  ${part}`);
+      await sleep(60);
+    }
+
+    process.stdout.write(showCursor);
+  } else {
+    console.log();
+    for (const line of ASCII_LOGO) {
+      console.log(`  ${c(line, brightCyan)}`);
+    }
+    console.log(c(`  v${version}`, dim));
+  }
+  console.log();
+}
+
+/* ── Typewriter effect ── */
+
+async function typewrite(text: string, charDelay = 12): Promise<void> {
+  const chars = text.split("");
+  let i = 0;
+  let insideEscape = false;
+
+  for (const ch of chars) {
+    process.stdout.write(ch);
+    if (ch === "\x1b") insideEscape = true;
+    if (insideEscape) {
+      if (ch === "m") insideEscape = false;
+      continue;
+    }
+    i++;
+    if (i % 2 === 0) await sleep(charDelay);
+  }
+  process.stdout.write("\n");
+}
+
+/* ── Result display ── */
+
+export function formatResultCard(content: string, intent?: string): string {
+  const lines: string[] = [];
+
+  if (intent) {
+    const intentBadge = intentToBadge(intent);
+    lines.push(`  ${intentBadge}`);
+    lines.push("");
+  }
+
+  for (const line of content.split("\n")) {
+    lines.push(`  ${line}`);
+  }
+
+  return lines.join("\n");
+}
+
+export function intentToBadge(intent: string, locale: TuiLocale = resolveTuiLocale()): string {
+  const labels = locale === "en"
+    ? {
+        write_next: " WRITE ",
+        revise_chapter: " REVISE ",
+        rewrite_chapter: " REWRITE ",
+        update_focus: " FOCUS ",
+        explain_status: " STATUS ",
+        explain_failure: " DEBUG ",
+        pause_book: " PAUSE ",
+        list_books: " BOOKS ",
+        select_book: " SELECT ",
+        switch_mode: " MODE ",
+        rename_entity: " RENAME ",
+        patch_chapter_text: " PATCH ",
+        edit_truth: " TRUTH ",
+      }
+    : {
+        write_next: " 写作 ",
+        revise_chapter: " 修订 ",
+        rewrite_chapter: " 重写 ",
+        update_focus: " 焦点 ",
+        explain_status: " 状态 ",
+        explain_failure: " 调试 ",
+        pause_book: " 暂停 ",
+        list_books: " 作品 ",
+        select_book: " 选择 ",
+        switch_mode: " 模式 ",
+        rename_entity: " 改名 ",
+        patch_chapter_text: " 修补 ",
+        edit_truth: " 真相 ",
+      };
+  const backgrounds: Record<string, string> = {
+    write_next: bgMagenta,
+    revise_chapter: bgBlue,
+    rewrite_chapter: bgBlue,
+    update_focus: bgCyan,
+    explain_status: bgGray,
+    explain_failure: bgRed,
+    pause_book: bgYellow,
+    list_books: bgGray,
+    select_book: bgGreen,
+    switch_mode: bgCyan,
+    rename_entity: bgYellow,
+    patch_chapter_text: bgBlue,
+    edit_truth: bgGreen,
+  };
+  const label = labels[intent as keyof typeof labels] ?? ` ${intent} `;
+  const bg = backgrounds[intent] ?? bgGray;
+  return badge(label, bg);
+}
+
+/* ── Intent to spinner theme ── */
+
+export function intentToTheme(intent: string): string {
+  const map: Record<string, string> = {
+    write_next: "writing",
+    revise_chapter: "revising",
+    rewrite_chapter: "revising",
+    update_focus: "composing",
+    explain_status: "loading",
+    explain_failure: "thinking",
+    pause_book: "loading",
+    list_books: "loading",
+    select_book: "loading",
+    switch_mode: "loading",
+    rename_entity: "composing",
+    patch_chapter_text: "revising",
+    edit_truth: "composing",
+  };
+  return map[intent] ?? "thinking";
+}
+
+/* ── Help display ── */
+
+export function printStyledHelp(): void {
+  const locale = resolveTuiLocale();
+  const sections = buildStyledHelpSections(locale);
+  const footer = buildHelpFooter(locale);
+
+  console.log();
+  for (const section of sections) {
+    console.log(`  ${c(section.title, bold, cyan)}`);
+    for (const [cmd, desc] of section.commands) {
+      const cmdStr = c(cmd, green);
+      const descStr = c(desc, dim);
+      const padding = " ".repeat(Math.max(1, 24 - stripAnsi(cmd).length));
+      console.log(`    ${cmdStr}${padding}${descStr}`);
+    }
+    console.log();
+  }
+  console.log(c(`  ${footer.title}`, dim));
+  for (const example of footer.examples) {
+    console.log(c(`  ${example}`, dim, italic));
+  }
+  console.log();
+}
+
+/* ── Status display ── */
+
+export function printStyledStatus(params: {
+  readonly mode: string;
+  readonly bookId?: string;
+  readonly status: string;
+  readonly events: ReadonlyArray<{ readonly kind: string; readonly detail?: string; readonly status: string }>;
+}): void {
+  const locale = resolveTuiLocale();
+  console.log();
+  for (const line of formatStyledStatusLines(locale, params)) {
+    console.log(line);
+  }
+  console.log();
+}
+
+export function formatStyledStatusLines(
+  locale: TuiLocale,
+  params: {
+    readonly mode: string;
+    readonly bookId?: string;
+    readonly status: string;
+    readonly events: ReadonlyArray<{ readonly kind: string; readonly detail?: string; readonly status: string }>;
+  },
+): string[] {
+  const copy = getTuiCopy(locale);
+  const modeColors: Record<string, string> = {
+    auto: green,
+    semi: yellow,
+    manual: blue,
+  };
+  const modeColor = modeColors[params.mode] ?? gray;
+  const statusColors: Record<string, string> = {
+    idle: gray,
+    running: cyan,
+    writing: magenta,
+    auditing: yellow,
+    completed: green,
+    failed: red,
+    waiting_human: brightYellow,
+  };
+  const statusColor = statusColors[params.status] ?? gray;
+  const modeLabel = copy.labels.mode;
+  const bookLabel = copy.labels.book;
+  const statusLabel = copy.labels.stage;
+  const recentLabel = copy.labels.recent;
+  const lines = [
+    `  ${c("◇", cyan)} ${c(modeLabel, gray)}     ${c(formatModeLabel(params.mode, copy), modeColor, bold)}`,
+    `  ${c("◇", cyan)} ${c(bookLabel, gray)}     ${params.bookId ? c(params.bookId, brightWhite) : c(copy.labels.none, dim)}`,
+    `  ${c("◇", cyan)} ${c(statusLabel, gray)}   ${c(normalizeStageLabel(params.status, copy), statusColor)}`,
+  ];
+  if (params.events.length > 0) {
+    lines.push(`  ${c("◇", cyan)} ${c(recentLabel, gray)}`);
+    for (const ev of params.events.slice(-3)) {
+      const icon = ev.status === "completed" ? c("✓", green) : c("·", gray);
+      lines.push(`        ${icon} ${c(`${ev.kind}`, dim)} ${c(ev.detail ?? "", gray)}`);
+    }
+  }
+  return lines;
+}
+
+/* ── Utilities ── */
+
+function formatElapsed(ms: number): string {
+  const s = Math.floor(ms / 1000);
+  if (s < 60) return `${s}s`;
+  const m = Math.floor(s / 60);
+  return `${m}m${s % 60}s`;
+}
+
+export function buildStyledHelpSections(locale: TuiLocale = resolveTuiLocale()): StyledHelpSection[] {
+  if (locale === "en") {
+    return [
+      {
+        title: "Writing",
+        commands: [
+          ["/write", "Write the next chapter (full pipeline)"],
+          ["/rewrite <n>", "Rewrite chapter N from scratch"],
+        ],
+      },
+      {
+        title: "Navigation",
+        commands: [
+          ["/books", "List all books"],
+          ["/open <book>", "Select active book"],
+          ["/status", "Show current status"],
+        ],
+      },
+      {
+        title: "Control",
+        commands: [
+          ["/mode <auto|semi|manual>", "Switch automation mode"],
+          ["/focus <text>", "Update current focus"],
+        ],
+      },
+      {
+        title: "Session",
+        commands: [
+          ["/clear", "Clear screen"],
+          ["/help", "Show this help"],
+          ["/quit", "Exit InkOS TUI"],
+        ],
+      },
+    ];
+  }
+
+  return [
+    {
+      title: "写作",
+      commands: [
+        ["/write", "完整跑一轮下一章写作"],
+        ["/rewrite <n>", "从头重写第 N 章"],
+      ],
+    },
+    {
+      title: "导航",
+      commands: [
+        ["/books", "列出全部作品"],
+        ["/open <book>", "切换当前作品"],
+        ["/status", "查看当前状态"],
+      ],
+    },
+    {
+      title: "控制",
+      commands: [
+        ["/mode <auto|semi|manual>", "切换自动化模式"],
+        ["/focus <text>", "更新当前焦点"],
+      ],
+    },
+    {
+      title: "会话",
+      commands: [
+        ["/clear", "清空当前屏幕"],
+        ["/help", "显示帮助"],
+        ["/quit", "退出 InkOS TUI"],
+      ],
+    },
+  ];
+}
+
+function buildHelpFooter(locale: TuiLocale): { readonly title: string; readonly examples: readonly string[] } {
+  if (locale === "en") {
+    return {
+      title: "Natural language also works:",
+      examples: ['"continue writing" "write next chapter" "pause" "rename Lin Jin to Zhang San"'],
+    };
+  }
+
+  return {
+    title: "自然语言同样可用:",
+    examples: ['"继续写" "写下一章" "暂停" "把林烬改成张三"'],
+  };
+}
+
+function localizeThemeLabel(label: string, locale: TuiLocale): string {
+  if (locale === "en") {
+    return label;
+  }
+
+  const labels: Record<string, string> = {
+    thinking: "思考中",
+    writing: "写作中",
+    auditing: "审计中",
+    revising: "修订中",
+    planning: "规划中",
+    composing: "生成中",
+    loading: "加载中",
+  };
+  return labels[label] ?? label;
+}

+ 327 - 0
packages/cli/src/tui/i18n.ts

@@ -0,0 +1,327 @@
+import type { ChatDepth } from "./chat-depth.js";
+
+export type TuiLocale = "zh-CN" | "en";
+
+export interface TuiCopy {
+  readonly locale: TuiLocale;
+  readonly labels: {
+    readonly project: string;
+    readonly book: string;
+    readonly depth: string;
+    readonly session: string;
+    readonly messageCount: (count: number) => string;
+    readonly stage: string;
+    readonly mode: string;
+    readonly model: string;
+    readonly error: string;
+    readonly recent: string;
+    readonly pending: string;
+    readonly draft: string;
+    readonly ready: string;
+    readonly none: string;
+    readonly notConfigured: string;
+    readonly unknown: string;
+  };
+  readonly modeLabels: Record<string, string>;
+  readonly composer: {
+    readonly placeholder: string;
+    readonly emptyConversation: string;
+    readonly helper: string;
+    readonly submitting: string;
+    readonly failed: string;
+    readonly ready: string;
+  };
+  readonly notes: {
+    readonly help: string;
+    readonly status: (stage: string, mode: string) => string;
+    readonly config: string;
+    readonly depthSet: (depthLabel: string) => string;
+    readonly newBookGuide: string;
+    readonly noLlmConfig: string;
+    readonly setupProvider: string;
+    readonly toolInitFailed: (message: string) => string;
+    readonly toolInitHint: string;
+  };
+  readonly roles: {
+    readonly user: string;
+    readonly assistant: string;
+    readonly system: string;
+  };
+  readonly activity: Record<"thinking" | "checking" | "writing" | "reviewing" | "updating", string>;
+  readonly stageLabels: {
+    readonly completed: string;
+    readonly failed: string;
+    readonly blocked: string;
+    readonly waitingHuman: string;
+    readonly pausedByUser: string;
+    readonly readyToContinue: string;
+  };
+  readonly depthLabels: Record<ChatDepth, string>;
+  readonly results: {
+    readonly modeSwitched: (mode: string) => string;
+    readonly booksListed: string;
+    readonly activeBook: (bookId: string) => string;
+    readonly completed: (intent: string) => string;
+    readonly intentLabels: Partial<Record<string, string>>;
+  };
+}
+
+const ZH_CN: TuiCopy = {
+  locale: "zh-CN",
+  labels: {
+    project: "项目",
+    book: "作品",
+    depth: "深度",
+    session: "会话",
+    messageCount: (count) => `${count} 条消息`,
+    stage: "阶段",
+    mode: "模式",
+    model: "模型",
+    error: "错误",
+    recent: "最近",
+    pending: "待确认",
+    draft: "草稿",
+    ready: "就绪",
+    none: "无",
+    notConfigured: "未配置",
+    unknown: "未知",
+  },
+  modeLabels: {
+    auto: "自动",
+    semi: "半自动",
+    manual: "手动",
+  },
+  composer: {
+    placeholder: "告诉 InkOS 要写什么、修改什么,或解释什么…",
+    emptyConversation: "先告诉 InkOS 你要做什么。",
+    helper: "回车发送 • /new 输入你的想法,自动构建新书 • /draft • /create • /write • /books • /open • /mode • /depth • /help",
+    submitting: "处理中…",
+    failed: "上次请求失败",
+    ready: "就绪",
+  },
+  notes: {
+    help: "可用命令:/new(输入想法,自动构建新书)、/draft、/create、/discard、/write、/books、/open、/mode、/rewrite、/focus、/truth、/rename、/replace、/export、/status、/clear、/depth、/quit。也支持直接输入自然语言。",
+    status: (stage, mode) => `当前状态:${stage}(${mode})。`,
+    config: "当前 Ink 仪表盘里还不支持交互式 /config。请使用 inkos config set-global。",
+    depthSet: (depthLabel) => `思考深度已切换为 ${depthLabel}。`,
+    newBookGuide: "开始构思新书。直接描述你的想法——题材、世界观、主角、核心冲突都可以。AI 会逐步引导你完善草案,随时用 /draft 查看进度,/create 建书。",
+    noLlmConfig: "未发现 LLM 配置。",
+    setupProvider: "先配置 API 提供方。",
+    toolInitFailed: (message) => `初始化 TUI 工具失败:${message}`,
+    toolInitHint: "请检查 .env,或运行:inkos config set-global",
+  },
+  roles: {
+    user: "你",
+    assistant: "InkOS",
+    system: "系统",
+  },
+  activity: {
+    thinking: "思考中",
+    checking: "检查中",
+    writing: "写作中",
+    reviewing: "审阅中",
+    updating: "更新中",
+  },
+  stageLabels: {
+    completed: "已完成",
+    failed: "失败",
+    blocked: "已阻塞",
+    waitingHuman: "等待你的决定",
+    pausedByUser: "已由用户暂停",
+    readyToContinue: "可继续执行",
+  },
+  depthLabels: {
+    light: "轻量",
+    normal: "标准",
+    deep: "深入",
+  },
+  results: {
+    modeSwitched: (mode) => `已切换到 ${mode} 模式。`,
+    booksListed: "已列出作品。",
+    activeBook: (bookId) => `当前作品:${bookId}`,
+    completed: (intent) => `已完成 ${intent}`,
+    intentLabels: {
+      write_next: "已写完下一章",
+      revise_chapter: "已修订章节",
+      rewrite_chapter: "已重写章节",
+      update_focus: "已更新焦点",
+      explain_status: "状态说明",
+      explain_failure: "失败说明",
+      pause_book: "已暂停作品",
+      rename_entity: "已重命名实体",
+      patch_chapter_text: "已修补正文",
+      edit_truth: "已更新真相文件",
+    },
+  },
+};
+
+const EN: TuiCopy = {
+  locale: "en",
+  labels: {
+    project: "Project",
+    book: "Book",
+    depth: "Depth",
+    session: "Session",
+    messageCount: (count) => `${count} msgs`,
+    stage: "Stage",
+    mode: "Mode",
+    model: "Model",
+    error: "Error",
+    recent: "Recent",
+    pending: "Pending",
+    draft: "Draft",
+    ready: "Ready",
+    none: "none",
+    notConfigured: "not configured",
+    unknown: "unknown",
+  },
+  modeLabels: {
+    auto: "auto",
+    semi: "semi",
+    manual: "manual",
+  },
+  composer: {
+    placeholder: "Ask InkOS to write, revise, or explain…",
+    emptyConversation: "Start by asking InkOS what to do.",
+    helper: "Enter to send • /new describe your idea to start a book • /draft • /create • /write • /books • /open • /mode • /depth • /help",
+    submitting: "Submitting…",
+    failed: "Last request failed",
+    ready: "Ready",
+  },
+  notes: {
+    help: "Commands: /new (describe your idea to start a book), /draft, /create, /discard, /write, /books, /open, /mode, /rewrite, /focus, /truth, /rename, /replace, /export, /status, /clear, /depth, /quit. Natural language still works.",
+    status: (stage, mode) => `Status: ${stage} (${mode}).`,
+    config: "Interactive /config is not available inside the Ink dashboard yet. Use inkos config set-global.",
+    depthSet: (depthLabel) => `Thinking depth set to ${depthLabel}.`,
+    newBookGuide: "Starting a new book. Describe your idea — genre, world, protagonist, core conflict, anything. The AI will guide you step by step. Use /draft to check progress, /create to finalize.",
+    noLlmConfig: "No LLM configuration found.",
+    setupProvider: "Let's set up your API provider first.",
+    toolInitFailed: (message) => `Failed to initialize TUI tools: ${message}`,
+    toolInitHint: "Check your .env or run: inkos config set-global",
+  },
+  roles: {
+    user: "You",
+    assistant: "InkOS",
+    system: "System",
+  },
+  activity: {
+    thinking: "thinking",
+    checking: "checking",
+    writing: "writing",
+    reviewing: "reviewing",
+    updating: "updating",
+  },
+  stageLabels: {
+    completed: "completed",
+    failed: "failed",
+    blocked: "blocked",
+    waitingHuman: "waiting for your decision",
+    pausedByUser: "paused by user",
+    readyToContinue: "ready to continue",
+  },
+  depthLabels: {
+    light: "light",
+    normal: "normal",
+    deep: "deep",
+  },
+  results: {
+    modeSwitched: (mode) => `Mode switched to ${mode}.`,
+    booksListed: "Books listed.",
+    activeBook: (bookId) => `Active book: ${bookId}`,
+    completed: (intent) => `Completed ${intent}`,
+    intentLabels: {
+      write_next: "Chapter written",
+      revise_chapter: "Chapter revised",
+      rewrite_chapter: "Chapter rewritten",
+      update_focus: "Focus updated",
+      explain_status: "Status",
+      explain_failure: "Explanation",
+      pause_book: "Book paused",
+      rename_entity: "Entity renamed",
+      patch_chapter_text: "Text patched",
+      edit_truth: "Truth file updated",
+    },
+  },
+};
+
+export function resolveTuiLocale(
+  env: NodeJS.ProcessEnv = process.env,
+  preferredLanguage?: string,
+): TuiLocale {
+  const requested = normalizeLocale(env.INKOS_TUI_LOCALE ?? env.INKOS_LOCALE);
+  if (requested) {
+    return requested;
+  }
+
+  const preferred = normalizeLocale(preferredLanguage);
+  if (preferred) {
+    return preferred;
+  }
+
+  const detected = normalizeLocale(env.LC_ALL ?? env.LC_MESSAGES ?? env.LANG);
+  return detected ?? "zh-CN";
+}
+
+export function getTuiCopy(locale: TuiLocale): TuiCopy {
+  return locale === "en" ? EN : ZH_CN;
+}
+
+export function normalizeStageLabel(label: string, copy: TuiCopy): string {
+  const normalized = label.trim().toLowerCase();
+  if (!normalized) {
+    return label;
+  }
+
+  const replacements: Array<[RegExp, string]> = [
+    [/^thinking\b/i, copy.activity.thinking],
+    [/^checking\b/i, copy.activity.checking],
+    [/^writing\b/i, copy.activity.writing],
+    [/^reviewing\b/i, copy.activity.reviewing],
+    [/^updating\b/i, copy.activity.updating],
+    [/^completed\b/i, copy.stageLabels.completed],
+    [/^failed\b/i, copy.stageLabels.failed],
+    [/^blocked\b/i, copy.stageLabels.blocked],
+    [/^waiting_human\b/i, copy.stageLabels.waitingHuman],
+    [/^paused by user\b/i, copy.stageLabels.pausedByUser],
+    [/^ready to continue\b/i, copy.stageLabels.readyToContinue],
+  ];
+
+  for (const [pattern, value] of replacements) {
+    if (pattern.test(label)) {
+      // For English, keep the original label (already in English);
+      // for other locales, use the translated value
+      return copy.locale === "en" ? label : value;
+    }
+  }
+
+  if (normalized === "idle") {
+    return copy.labels.ready;
+  }
+
+  return label;
+}
+
+export function formatModeLabel(mode: string, copy: TuiCopy): string {
+  return copy.modeLabels[mode] ?? mode;
+}
+
+function normalizeLocale(value: string | undefined): TuiLocale | undefined {
+  if (!value) {
+    return undefined;
+  }
+
+  const normalized = value.trim().toLowerCase();
+  if (!normalized || normalized === "auto") {
+    return undefined;
+  }
+
+  if (normalized.startsWith("zh")) {
+    return "zh-CN";
+  }
+
+  if (normalized.startsWith("en")) {
+    return "en";
+  }
+
+  return undefined;
+}

+ 69 - 0
packages/cli/src/tui/input-history.ts

@@ -0,0 +1,69 @@
+import type { InteractionMessage } from "@actalk/inkos-core";
+
+export interface InputHistoryState {
+  readonly cursor: number | null;
+  readonly draft: string;
+}
+
+export type InputHistoryDirection = "up" | "down";
+
+export function buildInputHistory(messages: ReadonlyArray<InteractionMessage>): string[] {
+  const result: string[] = [];
+
+  for (const message of messages) {
+    if (message.role !== "user") {
+      continue;
+    }
+
+    const value = message.content.trim();
+    if (!value || result[result.length - 1] === value) {
+      continue;
+    }
+    result.push(value);
+  }
+
+  return result;
+}
+
+export function moveHistoryCursor(
+  entries: ReadonlyArray<string>,
+  state: InputHistoryState,
+  currentValue: string,
+  direction: InputHistoryDirection,
+): { state: InputHistoryState; value: string } {
+  if (entries.length === 0) {
+    return { state, value: currentValue };
+  }
+
+  if (direction === "up") {
+    if (state.cursor === null) {
+      return {
+        state: { cursor: entries.length - 1, draft: currentValue },
+        value: entries[entries.length - 1]!,
+      };
+    }
+
+    const nextCursor = Math.max(0, state.cursor - 1);
+    return {
+      state: { ...state, cursor: nextCursor },
+      value: entries[nextCursor]!,
+    };
+  }
+
+  if (state.cursor === null) {
+    return { state, value: currentValue };
+  }
+
+  if (state.cursor >= entries.length - 1) {
+    return {
+      state: { cursor: null, draft: state.draft },
+      value: state.draft,
+    };
+  }
+
+  const nextCursor = state.cursor + 1;
+  return {
+    state: { ...state, cursor: nextCursor },
+    value: entries[nextCursor]!,
+  };
+}

+ 55 - 0
packages/cli/src/tui/local-commands.ts

@@ -0,0 +1,55 @@
+import type { ChatDepth } from "./chat-depth.js";
+
+export type LocalTuiCommand = "help" | "status" | "quit" | "clear" | "config";
+
+export function classifyLocalTuiCommand(input: string): LocalTuiCommand | undefined {
+  const value = input.trim();
+
+  if (/^\/help$/i.test(value) || /^(help|帮助)$/i.test(value)) {
+    return "help";
+  }
+
+  if (/^\/status$/i.test(value) || /^(status|状态)$/i.test(value)) {
+    return "status";
+  }
+
+  if (/^\/clear$/i.test(value) || /^清屏$/i.test(value)) {
+    return "clear";
+  }
+
+  if (/^\/config$/i.test(value) || /^(config|配置)$/i.test(value)) {
+    return "config";
+  }
+
+  if (/^\/quit$/i.test(value) || /^\/exit$/i.test(value) || /^(quit|exit|bye|退出)$/i.test(value)) {
+    return "quit";
+  }
+
+  return undefined;
+}
+
+export function parseDepthCommand(input: string): ChatDepth | undefined {
+  const value = input.trim().toLowerCase();
+  const englishMatch = value.match(/^\/?depth\s+(light|normal|deep)$/);
+  if (englishMatch?.[1]) {
+    return englishMatch[1] as ChatDepth;
+  }
+
+  const chineseMatch = input.trim().match(/^\/?深度\s+(浅|轻量|标准|普通|深|深入)$/);
+  if (!chineseMatch?.[1]) {
+    return undefined;
+  }
+
+  switch (chineseMatch[1]) {
+    case "浅":
+    case "轻量":
+      return "light";
+    case "深":
+    case "深入":
+      return "deep";
+    case "标准":
+    case "普通":
+    default:
+      return "normal";
+  }
+}

+ 64 - 0
packages/cli/src/tui/markdown.ts

@@ -0,0 +1,64 @@
+import { Marked } from "marked";
+import { markedTerminal } from "marked-terminal";
+import { isAppleTerminal } from "./theme.js";
+
+const marked = new Marked();
+marked.use(markedTerminal({
+  // Terminal width minus paddingX(2*2) + prefix(2) + margin(2) = 8, plus extra 2 for safety
+  width: Math.min(process.stdout.columns ?? 80, 100) - 10,
+  reflowText: true,
+  showSectionPrefix: false,
+  tab: 2,
+  // Preserve inline formatting through reflow: keep ** markers as plain text
+  // so reflowText won't split ANSI codes across lines. Post-process converts them.
+  listitem: (text: string) => text,
+  strong: (text: string) => `**${text}**`,
+  // cli-table3 defaults table headers to red; disable to inherit parent color
+  tableOptions: { style: { head: [] } },
+}) as never);
+
+const BOLD_ON = "\x1b[1m";
+const BOLD_OFF = "\x1b[22m";
+
+/** Strip ALL ANSI escape sequences from a string. */
+function stripAnsi(text: string): string {
+  // eslint-disable-next-line no-control-regex
+  return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
+}
+
+/**
+ * Full post-processing for terminals with ANSI support (iTerm2, etc.):
+ * 1. Strip \x1b[0m (full reset) that overrides Ink's <Text color>
+ * 2. Replace `* ` bullets with `· `
+ * 3. Convert `**text**` markers to ANSI bold
+ */
+function postProcess(text: string): string {
+  return text
+    .replace(/\x1b\[0m/g, "")
+    .replace(/^(\s*)\* /gm, "$1· ")
+    .replace(/\*\*(.+?)\*\*/g, `${BOLD_ON}$1${BOLD_OFF}`);
+}
+
+/**
+ * Terminal.app post-processing: use marked-terminal for layout (tables,
+ * lists, indentation) but strip ALL ANSI codes. Box-drawing characters
+ * (┌─┐│└┘) are plain Unicode and survive the strip.
+ */
+function postProcessPlain(text: string): string {
+  return stripAnsi(text)
+    .replace(/^(\s*)\* /gm, "$1· ")
+    .replace(/\*\*(.+?)\*\*/g, "$1");
+}
+
+export function renderMarkdown(text: string): string {
+  try {
+    const rendered = marked.parse(text);
+    if (typeof rendered !== "string") {
+      return text;
+    }
+    const trimmed = rendered.replace(/\n+$/, "");
+    return isAppleTerminal ? postProcessPlain(trimmed) : postProcess(trimmed);
+  } catch {
+    return text;
+  }
+}

+ 39 - 0
packages/cli/src/tui/output.ts

@@ -0,0 +1,39 @@
+import type { AutomationMode, ExecutionStatus, InteractionIntentType } from "@actalk/inkos-core";
+import { formatModeLabel, getTuiCopy, resolveTuiLocale, type TuiLocale } from "./i18n.js";
+
+export function formatTuiResult(params: {
+  readonly intent: InteractionIntentType;
+  readonly status: ExecutionStatus;
+  readonly bookId?: string;
+  readonly mode?: AutomationMode;
+  readonly responseText?: string;
+  readonly locale?: TuiLocale;
+}): string {
+  const copy = getTuiCopy(params.locale ?? resolveTuiLocale());
+
+  if (params.responseText?.trim()) {
+    return params.responseText.trim();
+  }
+
+  if (params.intent === "switch_mode" && params.mode) {
+    return copy.results.modeSwitched(formatModeLabel(params.mode, copy));
+  }
+
+  if (params.intent === "list_books") {
+    return copy.results.booksListed;
+  }
+
+  if (params.intent === "select_book" && params.bookId) {
+    return copy.results.activeBook(params.bookId);
+  }
+
+  if (params.bookId) {
+    return `${intentLabel(params.intent, copy)} — ${params.bookId}`;
+  }
+
+  return intentLabel(params.intent, copy);
+}
+
+function intentLabel(intent: InteractionIntentType, copy: ReturnType<typeof getTuiCopy>): string {
+  return copy.results.intentLabels[intent] ?? copy.results.completed(intent);
+}

+ 6 - 0
packages/cli/src/tui/session-store.ts

@@ -0,0 +1,6 @@
+export {
+  createProjectSession,
+  loadProjectSession,
+  persistProjectSession,
+  resolveSessionActiveBook,
+} from "@actalk/inkos-core";

+ 351 - 0
packages/cli/src/tui/setup.ts

@@ -0,0 +1,351 @@
+/* ── Auto-init & environment detection for TUI ── */
+
+import { access, mkdir, readFile, writeFile } from "node:fs/promises";
+import { join, basename } from "node:path";
+import readline from "node:readline/promises";
+import {
+  c, bold, dim, italic,
+  cyan, green, yellow, gray, red,
+  brightCyan, brightGreen, brightWhite,
+} from "./ansi.js";
+import { resolveTuiLocale, type TuiLocale } from "./i18n.js";
+import { GLOBAL_ENV_PATH } from "../utils.js";
+
+const PROVIDERS = ["openai", "anthropic", "custom"] as const;
+
+interface SetupResult {
+  readonly projectRoot: string;
+  readonly hasLlmConfig: boolean;
+}
+
+export interface InteractiveSetupCopy {
+  readonly title: string;
+  readonly subtitle: string;
+  readonly steps: {
+    readonly provider: string;
+    readonly baseUrl: string;
+    readonly apiKey: string;
+    readonly model: string;
+    readonly scope: string;
+  };
+  readonly hints: {
+    readonly provider: string;
+    readonly baseUrl: string;
+    readonly model: string;
+    readonly scope: string;
+  };
+  readonly defaults: {
+    readonly provider: string;
+    readonly baseUrl: string;
+    readonly scope: string;
+  };
+  readonly scopeChoices: {
+    readonly global: string;
+    readonly project: string;
+  };
+  readonly savedTo: string;
+}
+
+export function buildInteractiveSetupCopy(locale: TuiLocale): InteractiveSetupCopy {
+  if (locale === "en") {
+    return {
+      title: "LLM Setup",
+      subtitle: "Configure your model provider to start writing.",
+      steps: {
+        provider: "Provider",
+        baseUrl: "Base URL",
+        apiKey: "API Key",
+        model: "Model",
+        scope: "Save scope",
+      },
+      hints: {
+        provider: "openai / anthropic / custom (OpenAI-compatible proxy)",
+        baseUrl: "Your API endpoint",
+        model: "e.g. gpt-4o, claude-sonnet-4-20250514, deepseek-chat",
+        scope: "global = all projects, project = this directory only",
+      },
+      defaults: {
+        provider: "openai",
+        baseUrl: "(default)",
+        scope: "[global]",
+      },
+      scopeChoices: {
+        global: "all projects",
+        project: "this directory",
+      },
+      savedTo: "Saved to",
+    };
+  }
+
+  return {
+    title: "模型配置",
+    subtitle: "配置模型服务后即可开始使用。",
+    steps: {
+      provider: "服务提供方",
+      baseUrl: "接口地址",
+      apiKey: "API 密钥",
+      model: "模型",
+      scope: "保存范围",
+    },
+    hints: {
+      provider: "openai / anthropic / custom(兼容 OpenAI 的代理)",
+      baseUrl: "你的 API 入口地址",
+      model: "例如 gpt-5.4、claude-sonnet-4-20250514、deepseek-chat",
+      scope: "global = 所有项目,project = 仅当前目录",
+    },
+    defaults: {
+      provider: "openai",
+      baseUrl: "(默认)",
+      scope: "[global]",
+    },
+    scopeChoices: {
+      global: "所有项目",
+      project: "当前目录",
+    },
+    savedTo: "已保存到",
+  };
+}
+
+export function buildAutoInitMessages(projectName: string, locale: TuiLocale): {
+  readonly initializing: string;
+  readonly initialized: string;
+  readonly envTemplateHeader: string;
+} {
+  if (locale === "en") {
+    return {
+      initializing: `Initializing project in ${projectName}/ ...`,
+      initialized: "Project initialized",
+      envTemplateHeader: "# LLM Configuration — run inkos tui to configure interactively",
+    };
+  }
+
+  return {
+    initializing: `正在初始化项目:${projectName}/ ...`,
+    initialized: "项目已初始化",
+    envTemplateHeader: "# LLM 配置 —— 运行 inkos tui 进行交互式配置",
+  };
+}
+
+export async function ensureProject(cwd: string): Promise<SetupResult> {
+  const configPath = join(cwd, "inkos.json");
+  const hasConfig = await fileExists(configPath);
+
+  if (!hasConfig) {
+    await autoInit(cwd);
+  }
+
+  const hasLlm = await hasLlmConfig(cwd);
+  return { projectRoot: cwd, hasLlmConfig: hasLlm };
+}
+
+export async function interactiveLlmSetup(
+  projectRoot: string,
+): Promise<void> {
+  const projectLanguage = await detectProjectLanguage(projectRoot);
+  const locale = resolveTuiLocale(process.env, projectLanguage);
+  const copy = buildInteractiveSetupCopy(locale);
+  const rl = readline.createInterface({
+    input: process.stdin,
+    output: process.stdout,
+  });
+
+  try {
+    console.log();
+    console.log(`  ${c("◈", brightCyan)} ${c(copy.title, bold, brightWhite)}`);
+    console.log(c(`  ${copy.subtitle}`, dim));
+    console.log();
+
+    // Provider
+    console.log(`  ${c("1", cyan)}  ${c(copy.steps.provider, gray)}`);
+    console.log(c(`     ${copy.hints.provider}`, dim));
+    const providerInput = await rl.question(`     ${c("❯", cyan)} `);
+    const provider = PROVIDERS.includes(providerInput.trim() as typeof PROVIDERS[number])
+      ? providerInput.trim()
+      : copy.defaults.provider;
+    console.log(`     ${c("✓", brightGreen)} ${provider}`);
+    console.log();
+
+    // Base URL
+    console.log(`  ${c("2", cyan)}  ${c(copy.steps.baseUrl, gray)}`);
+    console.log(c(`     ${copy.hints.baseUrl}`, dim));
+    const baseUrl = await rl.question(`     ${c("❯", cyan)} `);
+    console.log(`     ${c("✓", brightGreen)} ${baseUrl.trim() || copy.defaults.baseUrl}`);
+    console.log();
+
+    // API Key
+    console.log(`  ${c("3", cyan)}  ${c(copy.steps.apiKey, gray)}`);
+    const apiKey = await rl.question(`     ${c("❯", cyan)} `);
+    const maskedKey = apiKey.trim().length > 8
+      ? apiKey.trim().slice(0, 4) + "···" + apiKey.trim().slice(-4)
+      : "···";
+    console.log(`     ${c("✓", brightGreen)} ${maskedKey}`);
+    console.log();
+
+    // Model
+    console.log(`  ${c("4", cyan)}  ${c(copy.steps.model, gray)}`);
+    console.log(c(`     ${copy.hints.model}`, dim));
+    const model = await rl.question(`     ${c("❯", cyan)} `);
+    console.log(`     ${c("✓", brightGreen)} ${model.trim()}`);
+    console.log();
+
+    // Scope
+    console.log(`  ${c("5", cyan)}  ${c(copy.steps.scope, gray)}`);
+    console.log(c(`     ${copy.hints.scope}`, dim));
+    const scope = await rl.question(`     ${c("❯", cyan)} ${c(copy.defaults.scope, dim)} `);
+    const useGlobal = scope.trim().toLowerCase() !== "project";
+
+    const envContent = [
+      `INKOS_LLM_PROVIDER=${provider}`,
+      `INKOS_LLM_BASE_URL=${baseUrl.trim()}`,
+      `INKOS_LLM_API_KEY=${apiKey.trim()}`,
+      `INKOS_LLM_MODEL=${model.trim()}`,
+    ].join("\n");
+
+    if (useGlobal) {
+      const globalDir = join(GLOBAL_ENV_PATH, "..");
+      await mkdir(globalDir, { recursive: true });
+      await writeFile(GLOBAL_ENV_PATH, envContent + "\n", "utf-8");
+      console.log();
+      console.log(`  ${c("✓", brightGreen, bold)} ${c(copy.savedTo, dim)} ${c(GLOBAL_ENV_PATH, gray)}`);
+    } else {
+      await writeFile(join(projectRoot, ".env"), envContent + "\n", "utf-8");
+      console.log();
+      console.log(`  ${c("✓", brightGreen, bold)} ${c(copy.savedTo, dim)} ${c(".env", gray)}`);
+    }
+    console.log();
+  } finally {
+    rl.close();
+  }
+}
+
+async function autoInit(cwd: string): Promise<void> {
+  const projectName = basename(cwd);
+  const locale = resolveTuiLocale();
+  const messages = buildAutoInitMessages(projectName, locale);
+  console.log();
+  console.log(`  ${c("◌", cyan)} ${c(messages.initializing, dim)}`);
+
+  await mkdir(join(cwd, "books"), { recursive: true });
+  await mkdir(join(cwd, "radar"), { recursive: true });
+
+  const config = {
+    name: projectName,
+    version: "0.1.0",
+    language: "zh",
+    llm: {
+      provider: process.env.INKOS_LLM_PROVIDER ?? "openai",
+      baseUrl: process.env.INKOS_LLM_BASE_URL ?? "",
+      model: process.env.INKOS_LLM_MODEL ?? "",
+    },
+    notify: [],
+    daemon: {
+      schedule: {
+        radarCron: "0 */6 * * *",
+        writeCron: "*/15 * * * *",
+      },
+      maxConcurrentBooks: 3,
+    },
+  };
+
+  await writeFile(
+    join(cwd, "inkos.json"),
+    JSON.stringify(config, null, 2),
+    "utf-8",
+  );
+
+  const hasGlobal = await hasGlobalConfig();
+  if (!hasGlobal) {
+    await writeFile(
+      join(cwd, ".env"),
+      [
+        messages.envTemplateHeader,
+        "INKOS_LLM_PROVIDER=openai",
+        "INKOS_LLM_BASE_URL=",
+        "INKOS_LLM_API_KEY=",
+        "INKOS_LLM_MODEL=",
+      ].join("\n"),
+      "utf-8",
+    );
+  }
+
+  await writeFile(
+    join(cwd, ".gitignore"),
+    [".env", "node_modules/", ".DS_Store"].join("\n"),
+    "utf-8",
+  );
+
+  console.log(`  ${c("✓", brightGreen, bold)} ${c(messages.initialized, dim)}`);
+}
+
+async function hasLlmConfig(projectRoot: string): Promise<boolean> {
+  const projectEnv = join(projectRoot, ".env");
+  if (await checkEnvForKey(projectEnv)) return true;
+  return checkEnvForKey(GLOBAL_ENV_PATH);
+}
+
+async function hasGlobalConfig(): Promise<boolean> {
+  return checkEnvForKey(GLOBAL_ENV_PATH);
+}
+
+async function checkEnvForKey(envPath: string): Promise<boolean> {
+  try {
+    const content = await readFile(envPath, "utf-8");
+    const match = content.match(/INKOS_LLM_API_KEY=(.+)/);
+    return !!match && match[1]!.trim().length > 0 && !match[1]!.includes("your-api-key");
+  } catch {
+    return false;
+  }
+}
+
+export interface ModelInfo {
+  readonly provider: string;
+  readonly model: string;
+  readonly baseUrl: string;
+}
+
+export async function detectModelInfo(projectRoot: string): Promise<ModelInfo | undefined> {
+  const paths = [join(projectRoot, ".env"), GLOBAL_ENV_PATH];
+  for (const p of paths) {
+    const info = await parseEnvModel(p);
+    if (info) return info;
+  }
+  return undefined;
+}
+
+export async function detectProjectLanguage(projectRoot: string): Promise<string | undefined> {
+  try {
+    const raw = await readFile(join(projectRoot, "inkos.json"), "utf-8");
+    const parsed = JSON.parse(raw) as { language?: string };
+    return parsed.language;
+  } catch {
+    return undefined;
+  }
+}
+
+async function parseEnvModel(envPath: string): Promise<ModelInfo | undefined> {
+  try {
+    const content = await readFile(envPath, "utf-8");
+    const get = (key: string) => {
+      const m = content.match(new RegExp(`^${key}=(.+)$`, "m"));
+      return m?.[1]?.trim() ?? "";
+    };
+    const key = get("INKOS_LLM_API_KEY");
+    if (!key || key.includes("your-api-key")) return undefined;
+    return {
+      provider: get("INKOS_LLM_PROVIDER") || "openai",
+      model: get("INKOS_LLM_MODEL") || "unknown",
+      baseUrl: get("INKOS_LLM_BASE_URL") || "",
+    };
+  } catch {
+    return undefined;
+  }
+}
+
+async function fileExists(path: string): Promise<boolean> {
+  try {
+    await access(path);
+    return true;
+  } catch {
+    return false;
+  }
+}

+ 67 - 0
packages/cli/src/tui/slash-autocomplete.ts

@@ -0,0 +1,67 @@
+export const SLASH_COMMANDS = [
+  "/new 输入你的想法",
+  "/draft",
+  "/create",
+  "/discard",
+  "/write",
+  "/books",
+  "/open <book>",
+  "/mode <auto|semi|manual>",
+  "/rewrite <n>",
+  "/focus <text>",
+  "/truth <file> <content>",
+  "/rename <from> => <to>",
+  "/replace <n> <from> => <to>",
+  "/export [txt|md|epub]",
+  "/help",
+  "/status",
+  "/clear",
+  "/depth <light|normal|deep>",
+  "/quit",
+  "/exit",
+] as const;
+
+export type SlashNavigationDirection = "up" | "down";
+
+export function getSlashSuggestions(input: string, commands: readonly string[]): string[] {
+  const value = input.trim();
+  if (!value.startsWith("/")) {
+    return [];
+  }
+
+  return commands.filter((command) => slashCommandStem(command).startsWith(value));
+}
+
+export function getNextSlashSelection(
+  currentIndex: number,
+  suggestionCount: number,
+  direction: SlashNavigationDirection,
+): number {
+  if (suggestionCount <= 0) {
+    return 0;
+  }
+
+  if (direction === "down") {
+    return (currentIndex + 1) % suggestionCount;
+  }
+
+  return (currentIndex - 1 + suggestionCount) % suggestionCount;
+}
+
+export function applySlashSuggestion(
+  _input: string,
+  suggestions: readonly string[],
+  selectedIndex: number,
+): string {
+  const suggestion = suggestions[selectedIndex] ?? "";
+  return slashSuggestionInsertion(suggestion);
+}
+
+function slashCommandStem(command: string): string {
+  return command.match(/^\/\S+/)?.[0] ?? command;
+}
+
+function slashSuggestionInsertion(suggestion: string): string {
+  const stem = slashCommandStem(suggestion);
+  return suggestion === stem ? stem : `${stem} `;
+}

+ 17 - 0
packages/cli/src/tui/theme.ts

@@ -0,0 +1,17 @@
+/** Whether the terminal is macOS Terminal.app. */
+export const isAppleTerminal = process.env.TERM_PROGRAM === "Apple_Terminal";
+
+export const WARM_ACCENT = "#c88a56";
+export const WARM_MUTED = "#8f8374";
+export const WARM_REPLY = "#f0e6d8";
+export const WARM_BORDER = "#6b6156";
+
+// Semantic status colors
+export const STATUS_SUCCESS = "#7ec87e";
+export const STATUS_ERROR = "#e06060";
+export const STATUS_ACTIVE = "#d4a76a";
+export const STATUS_IDLE = "#7a7268";
+
+// Role colors
+export const ROLE_USER = "#a8c4d4";
+export const ROLE_SYSTEM = "#b8a8d0";

+ 48 - 0
packages/cli/src/tui/tools.ts

@@ -0,0 +1,48 @@
+import {
+  PipelineRunner,
+  StateManager,
+  createInteractionToolsFromDeps,
+  type InteractionRuntimeTools,
+} from "@actalk/inkos-core";
+import { buildPipelineConfig, loadConfig } from "../utils.js";
+
+type CliPipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
+type CliStateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadBookConfig" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
+type CliInteractionToolHooks = {
+  readonly onChatTextDelta?: (text: string) => void;
+  readonly onDraftTextDelta?: (text: string) => void;
+  readonly getChatRequestOptions?: () => {
+    readonly temperature?: number;
+    readonly maxTokens?: number;
+  };
+};
+
+export function createCliInteractionToolsFromDeps(
+  pipeline: CliPipelineLike,
+  state: CliStateLike,
+  hooks?: CliInteractionToolHooks,
+): InteractionRuntimeTools {
+  return createInteractionToolsFromDeps(pipeline, state, hooks);
+}
+
+// Backward-compatible export for the current CLI tests during the extraction phase.
+export function createInteractionToolsFromDepsCompat(
+  _projectRoot: string,
+  pipeline: CliPipelineLike,
+  state: CliStateLike,
+  hooks?: CliInteractionToolHooks,
+): InteractionRuntimeTools {
+  return createInteractionToolsFromDeps(pipeline, state, hooks);
+}
+
+export { createInteractionToolsFromDepsCompat as createInteractionToolsFromDeps };
+
+export async function createInteractionTools(
+  projectRoot: string,
+  hooks?: CliInteractionToolHooks,
+): Promise<InteractionRuntimeTools> {
+  const config = await loadConfig({ projectRoot });
+  const pipeline = new PipelineRunner(buildPipelineConfig(config, projectRoot));
+  const state = new StateManager(projectRoot);
+  return createInteractionToolsFromDeps(pipeline, state, hooks);
+}

+ 2 - 2
packages/cli/src/utils.ts

@@ -31,8 +31,8 @@ export function findProjectRoot(): string {
   return process.cwd();
 }
 
-export async function loadConfig(options?: { readonly requireApiKey?: boolean }): Promise<ProjectConfig> {
-  return loadProjectConfig(findProjectRoot(), options);
+export async function loadConfig(options?: { readonly requireApiKey?: boolean; readonly projectRoot?: string }): Promise<ProjectConfig> {
+  return loadProjectConfig(options?.projectRoot ?? findProjectRoot(), options);
 }
 
 export function createClient(config: ProjectConfig) {

+ 1 - 0
packages/cli/tsconfig.json

@@ -1,6 +1,7 @@
 {
   "extends": "../../tsconfig.json",
   "compilerOptions": {
+    "jsx": "react-jsx",
     "outDir": "dist",
     "rootDir": "src"
   },

+ 6 - 4
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@actalk/inkos-core",
-  "version": "1.0.2",
+  "version": "1.3.0",
   "description": "InkOS core engine — multi-agent novel writing pipeline with 33-dimension continuity audit, style cloning, and de-AI-ification",
   "keywords": [
     "ai-novel-writing",
@@ -28,7 +28,7 @@
     "!dist/__tests__",
     "genres"
   ],
-  "license": "MIT",
+  "license": "AGPL-3.0-only",
   "repository": {
     "type": "git",
     "url": "https://github.com/Narcooo/inkos.git",
@@ -44,10 +44,12 @@
     "typecheck": "tsc --noEmit"
   },
   "dependencies": {
-    "@anthropic-ai/sdk": "^0.78.0",
+    "@mariozechner/pi-agent-core": "^0.67.1",
+    "@mariozechner/pi-ai": "^0.67.1",
+    "@sinclair/typebox": "^0.34.49",
     "dotenv": "^16.4.0",
+    "epub-gen-memory": "^1.0.10",
     "js-yaml": "^4.1.1",
-    "openai": "^4.80.0",
     "zod": "^3.24.0"
   },
   "devDependencies": {

+ 98 - 0
packages/core/src/__tests__/agent-system-prompt.test.ts

@@ -0,0 +1,98 @@
+import { describe, expect, it } from "vitest";
+import { buildAgentSystemPrompt } from "../agent/agent-system-prompt.js";
+
+describe("buildAgentSystemPrompt", () => {
+  describe("no book (creation flow)", () => {
+    it("Chinese prompt includes info collection workflow", () => {
+      const prompt = buildAgentSystemPrompt(null, "zh");
+      expect(prompt).toContain("建书助手");
+      expect(prompt).toContain("收集信息");
+      expect(prompt).toContain("题材");
+      expect(prompt).toContain("世界观");
+      expect(prompt).toContain("主角");
+      expect(prompt).toContain("核心冲突");
+      expect(prompt).toContain("architect");
+      expect(prompt).toContain("sub_agent");
+      expect(prompt).toContain("title");
+    });
+
+    it("English prompt includes info collection workflow", () => {
+      const prompt = buildAgentSystemPrompt(null, "en");
+      expect(prompt).toContain("book creation");
+      expect(prompt).toContain("architect");
+      expect(prompt).toContain("Genre");
+      expect(prompt).toContain("Protagonist");
+      expect(prompt).toContain("Core conflict");
+      expect(prompt).toContain("title");
+    });
+
+    it("Chinese prompt forbids emoji", () => {
+      const prompt = buildAgentSystemPrompt(null, "zh");
+      expect(prompt).toContain("不要在回复中添加表情符号");
+    });
+
+    it("English prompt forbids emoji", () => {
+      const prompt = buildAgentSystemPrompt(null, "en");
+      expect(prompt).toContain("Do NOT use emoji");
+    });
+
+    it("no-book prompt does NOT mention read/edit/grep/ls", () => {
+      const prompt = buildAgentSystemPrompt(null, "zh");
+      expect(prompt).not.toMatch(/\bread\b.*读取/);
+      expect(prompt).not.toContain("edit");
+    });
+  });
+
+  describe("with book (writing flow)", () => {
+    it("Chinese prompt includes deterministic writing tools except architect", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      expect(prompt).toContain("my-book");
+      expect(prompt).toContain("sub_agent");
+      expect(prompt).toContain("writer");
+      expect(prompt).toContain("auditor");
+      expect(prompt).toContain("reviser");
+      expect(prompt).toContain("read");
+      expect(prompt).toContain("revise_chapter");
+      expect(prompt).toContain("write_truth_file");
+      expect(prompt).toContain("rename_entity");
+      expect(prompt).toContain("patch_chapter_text");
+      expect(prompt).toContain("grep");
+      expect(prompt).toContain("ls");
+    });
+
+    it("with-book prompt steers high-risk edits to dedicated deterministic tools", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      expect(prompt).toContain("改设定/改真相文件");
+      expect(prompt).toContain("write_truth_file");
+      expect(prompt).toContain("用户要求重写/精修已有章节");
+      expect(prompt).toContain("revise_chapter");
+      expect(prompt).not.toContain("edit");
+    });
+
+    it("Chinese prompt warns NOT to call architect", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      expect(prompt).toContain("不要调用 architect");
+    });
+
+    it("English prompt warns NOT to call architect", () => {
+      const prompt = buildAgentSystemPrompt("novel", "en");
+      expect(prompt).toContain("Do NOT call architect");
+    });
+
+    it("Chinese with-book prompt forbids emoji", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      expect(prompt).toContain("不要在回复中添加表情符号");
+    });
+
+    it("English with-book prompt forbids emoji", () => {
+      const prompt = buildAgentSystemPrompt("novel", "en");
+      expect(prompt).toContain("Do NOT use emoji");
+    });
+
+    it("with-book prompt does NOT list architect as available", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      // architect 不在可用工具列表里
+      expect(prompt).not.toMatch(/agent="architect"/);
+    });
+  });
+});

+ 163 - 0
packages/core/src/__tests__/agent-tools.test.ts

@@ -0,0 +1,163 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { StateManager } from "../state/manager.js";
+import {
+  createSubAgentTool,
+  createPatchChapterTextTool,
+  createRenameEntityTool,
+  createReviseChapterTool,
+  createWriteTruthFileTool,
+} from "../agent/agent-tools.js";
+
+describe("agent deterministic writing tools", () => {
+  let root: string;
+  let state: StateManager;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-agent-tools-"));
+    state = new StateManager(root);
+
+    await state.saveBookConfig("harbor", {
+      id: "harbor",
+      title: "Harbor",
+      platform: "tomato",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 3000,
+      createdAt: "2026-04-16T00:00:00.000Z",
+      updatedAt: "2026-04-16T00:00:00.000Z",
+    });
+
+    await mkdir(join(state.bookDir("harbor"), "story", "runtime"), { recursive: true });
+    await mkdir(join(state.bookDir("harbor"), "chapters"), { recursive: true });
+    await writeFile(join(state.bookDir("harbor"), "story", "story_bible.md"), "# Story Bible\n\nLin Yue guards the jade seal.\n", "utf-8");
+    await writeFile(join(state.bookDir("harbor"), "chapters", "0003_Storm.md"), "# 第3章 风暴\n\nLin Yue kept the jade seal hidden.\n", "utf-8");
+    await state.saveChapterIndex("harbor", [{
+      number: 3,
+      title: "风暴",
+      status: "ready-for-review",
+      wordCount: 120,
+      createdAt: "2026-04-16T00:00:00.000Z",
+      updatedAt: "2026-04-16T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+    }]);
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("writes truth files through the deterministic tool path", async () => {
+    const tool = createWriteTruthFileTool({} as never, root, "harbor");
+
+    const result = await tool.execute("tool-1", {
+      fileName: "story_bible.md",
+      content: "# Story Bible\n\nLin Yue now distrusts the guild.\n",
+    });
+
+    expect(result.content[0]?.type).toBe("text");
+    await expect(readFile(join(state.bookDir("harbor"), "story", "story_bible.md"), "utf-8"))
+      .resolves.toContain("distrusts the guild");
+  });
+
+  it("routes chapter rewrite requests through reviseDraft with the requested mode", async () => {
+    const pipeline = {
+      reviseDraft: vi.fn(async () => ({
+        chapterNumber: 3,
+        wordCount: 1300,
+        fixedIssues: [],
+        applied: true,
+        status: "ready-for-review" as const,
+      })),
+    };
+    const tool = createReviseChapterTool(pipeline as never, "harbor");
+
+    const result = await tool.execute("tool-2", {
+      chapterNumber: 3,
+      mode: "rewrite",
+    });
+
+    expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "rewrite");
+    expect(result.content[0]?.type).toBe("text");
+  });
+
+  it("renames entities through the deterministic edit controller", async () => {
+    const tool = createRenameEntityTool({} as never, root, "harbor");
+
+    await tool.execute("tool-3", {
+      oldValue: "Lin Yue",
+      newValue: "Lin Yan",
+    });
+
+    await expect(readFile(join(state.bookDir("harbor"), "story", "story_bible.md"), "utf-8"))
+      .resolves.toContain("Lin Yan");
+    await expect(readFile(join(state.bookDir("harbor"), "chapters", "0003_Storm.md"), "utf-8"))
+      .resolves.toContain("Lin Yan");
+  });
+
+  it("patches chapter text through the deterministic edit controller", async () => {
+    const tool = createPatchChapterTextTool({} as never, root, "harbor");
+
+    await tool.execute("tool-4", {
+      chapterNumber: 3,
+      targetText: "jade seal hidden",
+      replacementText: "jade seal locked beneath the altar",
+    });
+
+    await expect(readFile(join(state.bookDir("harbor"), "chapters", "0003_Storm.md"), "utf-8"))
+      .resolves.toContain("locked beneath the altar");
+    await expect(state.loadChapterIndex("harbor")).resolves.toEqual([
+      expect.objectContaining({
+        number: 3,
+        status: "audit-failed",
+        auditIssues: expect.arrayContaining([
+          expect.stringContaining("Manual text edit requires review"),
+        ]),
+      }),
+    ]);
+  });
+
+  it("requires an explicit title when the architect sub-agent creates a book", async () => {
+    const pipeline = {
+      initBook: vi.fn(async () => undefined),
+    };
+    const tool = createSubAgentTool(pipeline as never, null);
+
+    const result = await tool.execute("tool-5", {
+      agent: "architect",
+      instruction: "写一本港风商战小说",
+    });
+
+    expect(result.content[0]?.type).toBe("text");
+    if (result.content[0]?.type === "text") {
+      expect(result.content[0].text).toContain("title is required");
+    }
+    expect(pipeline.initBook).not.toHaveBeenCalled();
+  });
+
+  it("passes the explicit architect title straight into initBook", async () => {
+    const pipeline = {
+      initBook: vi.fn(async () => undefined),
+    };
+    const tool = createSubAgentTool(pipeline as never, null);
+
+    await tool.execute("tool-6", {
+      agent: "architect",
+      title: "夜港账本",
+      instruction: "写一本港风商战小说",
+    });
+
+    expect(pipeline.initBook).toHaveBeenCalledWith(
+      expect.objectContaining({
+        title: "夜港账本",
+      }),
+      expect.objectContaining({
+        externalContext: "写一本港风商战小说",
+      }),
+    );
+  });
+});

+ 189 - 0
packages/core/src/__tests__/architect.test.ts

@@ -522,4 +522,193 @@ describe("ArchitectAgent", () => {
 
     await expect(agent.generateFoundation(book)).rejects.toThrow(/book_rules/i);
   });
+
+  it("passes maxTokens 16384 when generating foundation", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "max-tokens-book",
+      title: "Max Tokens Book",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 2200,
+      language: "zh",
+      createdAt: "2026-03-29T00:00:00.000Z",
+      updatedAt: "2026-03-29T00:00:00.000Z",
+    };
+
+    const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# 故事圣经",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION: current_state ===",
+          "# 当前状态",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    await agent.generateFoundation(book);
+
+    expect(chatSpy).toHaveBeenCalledWith(
+      expect.any(Array),
+      expect.objectContaining({ temperature: 0.8, maxTokens: 16384 }),
+    );
+  });
+
+  it("passes maxTokens 16384 when generating foundation from import", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "import-max-tokens-book",
+      title: "Import Max Tokens Book",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 2200,
+      language: "zh",
+      createdAt: "2026-03-29T00:00:00.000Z",
+      updatedAt: "2026-03-29T00:00:00.000Z",
+    };
+
+    const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# 故事圣经",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION: current_state ===",
+          "# 当前状态",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    await agent.generateFoundationFromImport(book, "第一章正文");
+
+    expect(chatSpy).toHaveBeenCalledWith(
+      expect.any(Array),
+      expect.objectContaining({ temperature: 0.5, maxTokens: 16384 }),
+    );
+  });
+
+  it("passes maxTokens 16384 when generating fanfic foundation", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "fanfic-max-tokens-book",
+      title: "Fanfic Max Tokens Book",
+      platform: "other",
+      genre: "fanfic",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 2200,
+      language: "zh",
+      createdAt: "2026-03-29T00:00:00.000Z",
+      updatedAt: "2026-03-29T00:00:00.000Z",
+    };
+
+    const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# 故事圣经",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION: current_state ===",
+          "# 当前状态",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    await agent.generateFanficFoundation(book, "正典文本", "canon");
+
+    expect(chatSpy).toHaveBeenCalledWith(
+      expect.any(Array),
+      expect.objectContaining({ temperature: 0.7, maxTokens: 16384 }),
+    );
+  });
 });

+ 109 - 0
packages/core/src/__tests__/book-session-store.test.ts

@@ -0,0 +1,109 @@
+import { describe, expect, it, beforeEach, afterEach } from "vitest";
+import { mkdtemp, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import {
+  loadBookSession,
+  persistBookSession,
+  listBookSessions,
+  findOrCreateBookSession,
+} from "../interaction/book-session-store.js";
+import { createBookSession, appendBookSessionMessage } from "../interaction/session.js";
+
+describe("book-session-store", () => {
+  let tempDir: string;
+
+  beforeEach(async () => {
+    tempDir = await mkdtemp(join(tmpdir(), "inkos-test-"));
+  });
+
+  afterEach(async () => {
+    await rm(tempDir, { recursive: true, force: true });
+  });
+
+  describe("persistBookSession + loadBookSession", () => {
+    it("round-trips a session", async () => {
+      const session = createBookSession("my-book");
+      await persistBookSession(tempDir, session);
+      const loaded = await loadBookSession(tempDir, session.sessionId);
+      expect(loaded).not.toBeNull();
+      expect(loaded!.sessionId).toBe(session.sessionId);
+      expect(loaded!.bookId).toBe("my-book");
+    });
+
+    it("returns null for non-existent session", async () => {
+      const loaded = await loadBookSession(tempDir, "nonexistent");
+      expect(loaded).toBeNull();
+    });
+
+    it("persists messages", async () => {
+      let session = createBookSession("book");
+      session = appendBookSessionMessage(session, { role: "user" as const, content: "test", timestamp: 100 });
+      await persistBookSession(tempDir, session);
+      const loaded = await loadBookSession(tempDir, session.sessionId);
+      expect(loaded!.messages).toHaveLength(1);
+      expect(loaded!.messages[0].content).toBe("test");
+    });
+  });
+
+  describe("listBookSessions", () => {
+    it("returns empty for no sessions", async () => {
+      const list = await listBookSessions(tempDir, "no-book");
+      expect(list).toEqual([]);
+    });
+
+    it("filters by bookId", async () => {
+      const s1 = createBookSession("book-a");
+      const s2 = createBookSession("book-b");
+      const s3 = createBookSession("book-a");
+      await persistBookSession(tempDir, s1);
+      await persistBookSession(tempDir, s2);
+      await persistBookSession(tempDir, s3);
+
+      const listA = await listBookSessions(tempDir, "book-a");
+      expect(listA).toHaveLength(2);
+      expect(listA.every((s) => s.bookId === "book-a")).toBe(true);
+
+      const listB = await listBookSessions(tempDir, "book-b");
+      expect(listB).toHaveLength(1);
+    });
+
+    it("sorts by updatedAt descending", async () => {
+      const s1 = { ...createBookSession("book"), updatedAt: 100 };
+      const s2 = { ...createBookSession("book"), updatedAt: 300 };
+      const s3 = { ...createBookSession("book"), updatedAt: 200 };
+      await persistBookSession(tempDir, s1);
+      await persistBookSession(tempDir, s2);
+      await persistBookSession(tempDir, s3);
+
+      const list = await listBookSessions(tempDir, "book");
+      expect(list[0].updatedAt).toBe(300);
+      expect(list[1].updatedAt).toBe(200);
+      expect(list[2].updatedAt).toBe(100);
+    });
+
+    it("lists null bookId sessions", async () => {
+      const s = createBookSession(null);
+      await persistBookSession(tempDir, s);
+      const list = await listBookSessions(tempDir, null);
+      expect(list).toHaveLength(1);
+    });
+  });
+
+  describe("findOrCreateBookSession", () => {
+    it("creates new if none exist", async () => {
+      const session = await findOrCreateBookSession(tempDir, "new-book");
+      expect(session.bookId).toBe("new-book");
+      // Verify it was persisted
+      const loaded = await loadBookSession(tempDir, session.sessionId);
+      expect(loaded).not.toBeNull();
+    });
+
+    it("returns existing if found", async () => {
+      const existing = createBookSession("book");
+      await persistBookSession(tempDir, existing);
+      const found = await findOrCreateBookSession(tempDir, "book");
+      expect(found.sessionId).toBe(existing.sessionId);
+    });
+  });
+});

+ 108 - 0
packages/core/src/__tests__/book-session.test.ts

@@ -0,0 +1,108 @@
+import { describe, expect, it } from "vitest";
+import {
+  BookSessionSchema,
+  GlobalSessionSchema,
+  createBookSession,
+  appendBookSessionMessage,
+} from "../interaction/session.js";
+
+describe("BookSession", () => {
+  describe("BookSessionSchema", () => {
+    it("parses a valid session", () => {
+      const raw = {
+        sessionId: "123-abc",
+        bookId: "my-book",
+        messages: [],
+        draftRounds: [],
+        events: [],
+        createdAt: 1000,
+        updatedAt: 1000,
+      };
+      const result = BookSessionSchema.parse(raw);
+      expect(result.sessionId).toBe("123-abc");
+      expect(result.bookId).toBe("my-book");
+    });
+
+    it("accepts null bookId for draft sessions", () => {
+      const raw = {
+        sessionId: "123-abc",
+        bookId: null,
+        messages: [],
+        draftRounds: [],
+        events: [],
+        createdAt: 1000,
+        updatedAt: 1000,
+      };
+      const result = BookSessionSchema.parse(raw);
+      expect(result.bookId).toBeNull();
+    });
+
+    it("defaults empty arrays", () => {
+      const raw = {
+        sessionId: "123-abc",
+        bookId: null,
+        createdAt: 1000,
+        updatedAt: 1000,
+      };
+      const result = BookSessionSchema.parse(raw);
+      expect(result.messages).toEqual([]);
+      expect(result.draftRounds).toEqual([]);
+      expect(result.events).toEqual([]);
+    });
+  });
+
+  describe("GlobalSessionSchema", () => {
+    it("parses with defaults", () => {
+      const result = GlobalSessionSchema.parse({});
+      expect(result.automationMode).toBe("semi");
+      expect(result.activeBookId).toBeUndefined();
+    });
+
+    it("parses with values", () => {
+      const result = GlobalSessionSchema.parse({ activeBookId: "book-1", automationMode: "auto" });
+      expect(result.activeBookId).toBe("book-1");
+      expect(result.automationMode).toBe("auto");
+    });
+  });
+
+  describe("createBookSession", () => {
+    it("creates session with bookId", () => {
+      const session = createBookSession("my-book");
+      expect(session.bookId).toBe("my-book");
+      expect(session.sessionId).toBeTruthy();
+      expect(session.messages).toEqual([]);
+      expect(session.createdAt).toBeGreaterThan(0);
+      expect(session.updatedAt).toBe(session.createdAt);
+    });
+
+    it("creates session with null bookId", () => {
+      const session = createBookSession(null);
+      expect(session.bookId).toBeNull();
+    });
+
+    it("generates unique sessionIds", () => {
+      const a = createBookSession("book");
+      const b = createBookSession("book");
+      expect(a.sessionId).not.toBe(b.sessionId);
+    });
+  });
+
+  describe("appendBookSessionMessage", () => {
+    it("appends message and updates timestamp", () => {
+      const session = createBookSession("book");
+      const msg = { role: "user" as const, content: "hello", timestamp: Date.now() };
+      const updated = appendBookSessionMessage(session, msg);
+      expect(updated.messages).toHaveLength(1);
+      expect(updated.messages[0].content).toBe("hello");
+      expect(updated.updatedAt).toBeGreaterThanOrEqual(session.updatedAt);
+    });
+
+    it("sorts messages by timestamp", () => {
+      let session = createBookSession("book");
+      session = appendBookSessionMessage(session, { role: "user" as const, content: "second", timestamp: 200 });
+      session = appendBookSessionMessage(session, { role: "assistant" as const, content: "first", timestamp: 100 });
+      expect(session.messages[0].content).toBe("first");
+      expect(session.messages[1].content).toBe("second");
+    });
+  });
+});

+ 46 - 0
packages/core/src/__tests__/chapter-persistence.test.ts

@@ -149,4 +149,50 @@ describe("persistChapterArtifacts", () => {
     expect(snapshotState).not.toHaveBeenCalled();
     expect(syncCurrentStateFactHistory).not.toHaveBeenCalled();
   });
+
+  it("replaces existing entry for the same chapter number instead of appending", async () => {
+    const saveChapterIndex = vi.fn().mockResolvedValue(undefined);
+    const existingEntry: ChapterMeta = {
+      number: 1,
+      title: "Old Title",
+      status: "drafted",
+      wordCount: 500,
+      createdAt: "2026-01-01T00:00:00.000Z",
+      updatedAt: "2026-01-01T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+    };
+
+    await persistChapterArtifacts({
+      chapterNumber: 1,
+      chapterTitle: "New Title",
+      status: "ready-for-review",
+      auditResult: createAuditResult(),
+      finalWordCount: 2000,
+      lengthWarnings: [],
+      degradedIssues: [],
+      tokenUsage: ZERO_USAGE,
+      loadChapterIndex: async () => [existingEntry],
+      saveChapter: vi.fn().mockResolvedValue(undefined),
+      saveTruthFiles: vi.fn().mockResolvedValue(undefined),
+      saveChapterIndex,
+      markBookActiveIfNeeded: vi.fn().mockResolvedValue(undefined),
+      persistAuditDriftGuidance: vi.fn().mockResolvedValue(undefined),
+      snapshotState: vi.fn().mockResolvedValue(undefined),
+      syncCurrentStateFactHistory: vi.fn().mockResolvedValue(undefined),
+      logSnapshotStage: vi.fn(),
+      now: () => "2026-04-01T00:00:00.000Z",
+    });
+
+    const savedIndex = saveChapterIndex.mock.calls[0][0] as ChapterMeta[];
+    // Must have exactly 1 entry, not 2
+    expect(savedIndex).toHaveLength(1);
+    expect(savedIndex[0].number).toBe(1);
+    expect(savedIndex[0].title).toBe("New Title");
+    expect(savedIndex[0].wordCount).toBe(2000);
+    expect(savedIndex[0].status).toBe("ready-for-review");
+    // Must preserve original createdAt
+    expect(savedIndex[0].createdAt).toBe("2026-01-01T00:00:00.000Z");
+    expect(savedIndex[0].updatedAt).toBe("2026-04-01T00:00:00.000Z");
+  });
 });

+ 3 - 0
packages/core/src/__tests__/chapter-state-recovery.test.ts

@@ -126,6 +126,9 @@ describe("chapter-state-recovery", () => {
     expect(result.kind).toBe("recovered");
     expect(capturedFeedback).toContain("上一次状态结算未通过校验");
     expect(capturedFeedback).toContain("铜牌位置与正文矛盾");
+    expect(writer.settleChapterState).toHaveBeenCalledWith(expect.objectContaining({
+      allowReapply: true,
+    }));
     expect(logWarn).toHaveBeenCalledWith(expect.objectContaining({
       zh: expect.stringContaining("仅重试结算层"),
     }));

+ 48 - 0
packages/core/src/__tests__/chapter-truth-validation.test.ts

@@ -125,6 +125,54 @@ describe("validateChapterTruthPersistence", () => {
     expect(logger.warn).toHaveBeenCalledWith("  [unsupported_change] 正文写铜牌在怀里,但 state 说未携带。");
   });
 
+  it("degrades gracefully when validator throws (e.g. LLM returned empty response)", async () => {
+    const validator = {
+      validate: vi.fn().mockRejectedValue(new Error("LLM returned empty response")),
+    };
+    const writer = {
+      settleChapterState: vi.fn(),
+    };
+    const logWarn = vi.fn();
+    const logger = { warn: vi.fn() };
+
+    const result = await validateChapterTruthPersistence({
+      writer,
+      validator,
+      book: BOOK,
+      bookDir: "/tmp/book",
+      chapterNumber: 1,
+      title: "Test Chapter",
+      content: "Chapter content.",
+      persistenceOutput: createWriterOutput({
+        updatedState: "new state",
+        updatedHooks: "new hooks",
+        updatedLedger: "new ledger",
+      }),
+      auditResult: createAuditResult(),
+      previousTruth: {
+        oldState: "old state",
+        oldHooks: "old hooks",
+        oldLedger: "old ledger",
+      },
+      language: "zh",
+      logWarn,
+      logger,
+    });
+
+    expect(result.chapterStatus).toBe("state-degraded");
+    expect(result.persistenceOutput.updatedState).toBe("old state");
+    expect(result.persistenceOutput.updatedHooks).toBe("old hooks");
+    expect(result.persistenceOutput.updatedLedger).toBe("old ledger");
+    expect(result.degradedIssues).toEqual([
+      expect.objectContaining({
+        severity: "warning",
+        category: "state-validation",
+      }),
+    ]);
+    // Should NOT have attempted settlement retry
+    expect(writer.settleChapterState).not.toHaveBeenCalled();
+  });
+
   it("degrades persistence output and appends audit issues when retry still fails", async () => {
     const validator = {
       validate: vi.fn()

+ 153 - 1
packages/core/src/__tests__/config-loader.test.ts

@@ -1,4 +1,4 @@
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { afterEach, describe, expect, it } from "vitest";
@@ -77,4 +77,156 @@ describe("loadProjectConfig local provider auth", () => {
     await writeFile(join(root, ".env"), "", "utf-8");
     await expect(loadProjectConfig(root)).rejects.toThrow(/INKOS_LLM_API_KEY not set/i);
   });
+
+  it("loads service-based config using defaultModel and project secrets", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-config-loader-services-"));
+    for (const key of ENV_KEYS) {
+      previousEnv.set(key, process.env[key]);
+      process.env[key] = "";
+    }
+
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      name: "service-project",
+      version: "0.1.0",
+      language: "zh",
+      llm: {
+        services: [
+          { service: "moonshot", temperature: 1, maxTokens: 4096 },
+        ],
+        defaultModel: "kimi-k2.5",
+      },
+      notify: [],
+    }, null, 2), "utf-8");
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { moonshot: { apiKey: "sk-moon" } } }, null, 2),
+      "utf-8",
+    );
+
+    const config = await loadProjectConfig(root);
+
+    expect(config.llm.service).toBe("moonshot");
+    expect(config.llm.provider).toBe("openai");
+    expect(config.llm.baseUrl).toBe("https://api.moonshot.cn/v1");
+    expect(config.llm.model).toBe("kimi-k2.5");
+    expect(config.llm.apiKey).toBe("sk-moon");
+    expect(config.llm.temperature).toBe(1);
+    expect(config.llm.maxTokens).toBe(4096);
+  });
+
+  it("derives provider/baseUrl from the MiniMax preset single source of truth", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-config-loader-minimax-"));
+    for (const key of ENV_KEYS) {
+      previousEnv.set(key, process.env[key]);
+      process.env[key] = "";
+    }
+
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      name: "minimax-project",
+      version: "0.1.0",
+      language: "zh",
+      llm: {
+        services: [
+          { service: "minimax", temperature: 0.9, maxTokens: 4096 },
+        ],
+        defaultModel: "MiniMax-M2.7",
+      },
+      notify: [],
+    }, null, 2), "utf-8");
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { minimax: { apiKey: "sk-minimax" } } }, null, 2),
+      "utf-8",
+    );
+
+    const config = await loadProjectConfig(root);
+
+    expect(config.llm.service).toBe("minimax");
+    expect(config.llm.provider).toBe("anthropic");
+    expect(config.llm.baseUrl).toBe("https://api.minimaxi.com/anthropic");
+    expect(config.llm.model).toBe("MiniMax-M2.7");
+    expect(config.llm.apiKey).toBe("sk-minimax");
+  });
+
+  it("loads custom service config using custom secret key and entry baseUrl", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-config-loader-custom-"));
+    for (const key of ENV_KEYS) {
+      previousEnv.set(key, process.env[key]);
+      process.env[key] = "";
+    }
+
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      name: "custom-project",
+      version: "0.1.0",
+      language: "zh",
+      llm: {
+        services: [
+          { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
+        ],
+        defaultModel: "corp-chat",
+      },
+      notify: [],
+    }, null, 2), "utf-8");
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { "custom:内网GPT": { apiKey: "sk-corp" } } }, null, 2),
+      "utf-8",
+    );
+
+    const config = await loadProjectConfig(root);
+
+    expect(config.llm.service).toBe("custom");
+    expect(config.llm.provider).toBe("custom");
+    expect(config.llm.baseUrl).toBe("https://llm.internal.corp/v1");
+    expect(config.llm.model).toBe("corp-chat");
+    expect(config.llm.apiKey).toBe("sk-corp");
+    expect(config.llm.temperature).toBe(0.9);
+    expect(config.llm.apiFormat).toBe("responses");
+    expect(config.llm.stream).toBe(false);
+  });
+
+  it("keeps Studio config active when llm.configSource is studio", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-config-loader-studio-source-"));
+    for (const key of ENV_KEYS) {
+      previousEnv.set(key, process.env[key]);
+      process.env[key] = "";
+    }
+
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      name: "studio-source-project",
+      version: "0.1.0",
+      language: "zh",
+      llm: {
+        configSource: "studio",
+        services: [
+          { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9 },
+        ],
+        defaultModel: "corp-chat",
+      },
+      notify: [],
+    }, null, 2), "utf-8");
+    await writeFile(join(root, ".env"), [
+      "INKOS_LLM_PROVIDER=openai",
+      "INKOS_LLM_BASE_URL=https://api-vip.codex-for.me/v1",
+      "INKOS_LLM_MODEL=gpt-5.4",
+      "INKOS_LLM_API_KEY=sk-env",
+    ].join("\n"), "utf-8");
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { "custom:内网GPT": { apiKey: "sk-corp" } } }, null, 2),
+      "utf-8",
+    );
+
+    const config = await loadProjectConfig(root);
+
+    expect(config.llm.configSource).toBe("studio");
+    expect(config.llm.provider).toBe("custom");
+    expect(config.llm.baseUrl).toBe("https://llm.internal.corp/v1");
+    expect(config.llm.model).toBe("corp-chat");
+    expect(config.llm.apiKey).toBe("sk-corp");
+  });
 });

+ 102 - 0
packages/core/src/__tests__/config-migration.test.ts

@@ -0,0 +1,102 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { migrateConfig } from "../llm/config-migration.js";
+import { loadSecrets } from "../llm/secrets.js";
+import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+describe("config migration", () => {
+  let root: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-migrate-"));
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("migrates old llm.provider+model+apiKey to services[] + secrets", async () => {
+    const oldConfig = {
+      name: "mybook",
+      llm: {
+        provider: "openai",
+        model: "kimi-k2.5",
+        baseUrl: "https://api.moonshot.cn/v1",
+        apiKey: "sk-old-key",
+      },
+      language: "zh",
+    };
+    await writeFile(join(root, "inkos.json"), JSON.stringify(oldConfig));
+
+    const result = await migrateConfig(root);
+
+    expect(result.migrated).toBe(true);
+
+    const raw = await readFile(join(root, "inkos.json"), "utf-8");
+    const config = JSON.parse(raw);
+    expect(config.llm.services).toHaveLength(1);
+    expect(config.llm.services[0].service).toBe("moonshot");
+    expect(config.llm.services[0].apiKey).toBeUndefined();
+    expect(config.llm.defaultModel).toBe("kimi-k2.5");
+    expect(config.llm.provider).toBeUndefined();
+    expect(config.llm.model).toBeUndefined();
+    expect(config.llm.apiKey).toBeUndefined();
+
+    const secrets = await loadSecrets(root);
+    expect(secrets.services.moonshot.apiKey).toBe("sk-old-key");
+  });
+
+  it("does nothing if already in new format", async () => {
+    const newConfig = {
+      name: "mybook",
+      llm: {
+        services: [{ service: "moonshot" }],
+        defaultModel: "kimi-k2.5",
+      },
+      language: "zh",
+    };
+    await writeFile(join(root, "inkos.json"), JSON.stringify(newConfig));
+
+    const result = await migrateConfig(root);
+    expect(result.migrated).toBe(false);
+  });
+
+  it("guesses service from baseUrl", async () => {
+    const oldConfig = {
+      llm: {
+        provider: "openai",
+        model: "deepseek-chat",
+        baseUrl: "https://api.deepseek.com/v1",
+        apiKey: "sk-deep",
+      },
+    };
+    await writeFile(join(root, "inkos.json"), JSON.stringify(oldConfig));
+
+    await migrateConfig(root);
+
+    const raw = await readFile(join(root, "inkos.json"), "utf-8");
+    const config = JSON.parse(raw);
+    expect(config.llm.services[0].service).toBe("deepseek");
+  });
+
+  it("creates custom service when baseUrl is unrecognized", async () => {
+    const oldConfig = {
+      llm: {
+        provider: "openai",
+        model: "my-model",
+        baseUrl: "https://llm.internal.corp/v1",
+        apiKey: "sk-corp",
+      },
+    };
+    await writeFile(join(root, "inkos.json"), JSON.stringify(oldConfig));
+
+    await migrateConfig(root);
+
+    const raw = await readFile(join(root, "inkos.json"), "utf-8");
+    const config = JSON.parse(raw);
+    expect(config.llm.services[0].service).toBe("custom");
+    expect(config.llm.services[0].baseUrl).toBe("https://llm.internal.corp/v1");
+    expect(config.llm.services[0].name).toBe("Custom");
+  });
+});

+ 386 - 0
packages/core/src/__tests__/draft-directive-parser.test.ts

@@ -0,0 +1,386 @@
+import { describe, it, expect } from "vitest";
+import {
+  parseDraftDirectives,
+  createDirectiveStreamFilter,
+} from "../interaction/draft-directive-parser.js";
+
+// ---------------------------------------------------------------------------
+// 1. Pure markdown — no directives
+// ---------------------------------------------------------------------------
+
+describe("parseDraftDirectives", () => {
+  it("returns empty fields and full text when input has no directives", () => {
+    const raw = "# 欢迎\n\n这是一段普通的 markdown,没有任何表单标记。";
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields).toEqual({});
+    expect(result.textContent).toBe(raw);
+    expect(result.summary).toBe("");
+    expect(result.raw).toBe(raw);
+  });
+
+  // ---------------------------------------------------------------------------
+  // 2. Single :::field extraction
+  // ---------------------------------------------------------------------------
+
+  it("extracts a single :::field block", () => {
+    const raw = [
+      "请为你的小说起一个名字:",
+      "",
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      "",
+      "很好的名字!",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields["title"]).toBe("星河彼岸");
+    expect(result.textContent).toBe(
+      ["请为你的小说起一个名字:", "", "", "很好的名字!"].join("\n"),
+    );
+    expect(result.raw).toBe(raw);
+  });
+
+  // ---------------------------------------------------------------------------
+  // 3. Multiple fields of different types
+  // ---------------------------------------------------------------------------
+
+  it("extracts multiple fields of different types", () => {
+    const raw = [
+      "以下是你的创作信息:",
+      "",
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      "",
+      ':::field{key="worldPremise" label="世界观" type="textarea"}',
+      "一个被星际战争撕裂的宇宙",
+      ":::",
+      "",
+      ':::pick{key="platform" label="目标平台"}',
+      "- 起点中文网",
+      "- 番茄小说",
+      "- 七猫",
+      ":::",
+      "",
+      ':::number{key="targetChapters" label="目标章数"}',
+      "300",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields["title"]).toBe("星河彼岸");
+    expect(result.fields["worldPremise"]).toBe("一个被星际战争撕裂的宇宙");
+    expect(result.fields["platform"]).toBe("起点中文网");
+    expect(result.fields["targetChapters"]).toBe("300");
+  });
+
+  // ---------------------------------------------------------------------------
+  // 4. Nested :::group containing multiple fields
+  // ---------------------------------------------------------------------------
+
+  it("extracts fields nested inside a :::group", () => {
+    const raw = [
+      "请确认篇幅设置:",
+      "",
+      ':::group{label="篇幅"}',
+      ':::number{key="targetChapters" label="目标章数"}',
+      "300",
+      ":::",
+      ':::number{key="chapterLength" label="每章字数"}',
+      "3000",
+      ":::",
+      ":::",
+      "",
+      "确认无误!",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields["targetChapters"]).toBe("300");
+    expect(result.fields["chapterLength"]).toBe("3000");
+    // group itself should not appear in textContent
+    expect(result.textContent).toBe(
+      ["请确认篇幅设置:", "", "", "确认无误!"].join("\n"),
+    );
+  });
+
+  // ---------------------------------------------------------------------------
+  // 5. Mixed content: markdown paragraphs interspersed with directives
+  // ---------------------------------------------------------------------------
+
+  it("handles mixed markdown and directives", () => {
+    const raw = [
+      "# 创建新书",
+      "",
+      "让我们开始吧。首先需要一个书名:",
+      "",
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      "",
+      "好的!接下来设定你的世界观:",
+      "",
+      ':::field{key="worldPremise" label="世界观" type="textarea"}',
+      "宇宙分裂为光暗两域",
+      ":::",
+      "",
+      "让我们继续完善细节。",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields["title"]).toBe("星河彼岸");
+    expect(result.fields["worldPremise"]).toBe("宇宙分裂为光暗两域");
+    expect(result.textContent).toContain("# 创建新书");
+    expect(result.textContent).toContain("让我们开始吧。首先需要一个书名:");
+    expect(result.textContent).toContain("好的!接下来设定你的世界观:");
+    expect(result.textContent).toContain("让我们继续完善细节。");
+    expect(result.textContent).not.toContain(":::field");
+    expect(result.textContent).not.toContain("星河彼岸");
+  });
+
+  // ---------------------------------------------------------------------------
+  // 6. :::pick extracts first option as default value
+  // ---------------------------------------------------------------------------
+
+  it("extracts first option from :::pick as default value", () => {
+    const raw = [
+      ':::pick{key="genre" label="题材"}',
+      "- 玄幻",
+      "- 仙侠",
+      "- 都市",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["genre"]).toBe("玄幻");
+  });
+
+  it("handles :::pick with no options gracefully", () => {
+    const raw = [
+      ':::pick{key="genre" label="题材"}',
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["genre"]).toBe("");
+  });
+
+  // ---------------------------------------------------------------------------
+  // 7. Summary generation from field labels
+  // ---------------------------------------------------------------------------
+
+  it("generates summary from field labels", () => {
+    const raw = [
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      ':::field{key="worldPremise" label="世界观"}',
+      "一个宇宙",
+      ":::",
+      ':::field{key="protagonist" label="主角"}',
+      "陈风",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.summary).toBe("确立了书名、世界观和主角");
+  });
+
+  it("generates summary with single field", () => {
+    const raw = [
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.summary).toBe("确立了书名");
+  });
+
+  it("generates summary with two fields", () => {
+    const raw = [
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      ':::field{key="worldPremise" label="世界观"}',
+      "一个宇宙",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.summary).toBe("确立了书名和世界观");
+  });
+
+  // ---------------------------------------------------------------------------
+  // 8. Edge case: ::: in code blocks should NOT be parsed as directives
+  // ---------------------------------------------------------------------------
+
+  it("does not parse ::: inside fenced code blocks", () => {
+    const raw = [
+      "下面是一个示例:",
+      "",
+      "```markdown",
+      ':::field{key="demo" label="示例"}',
+      "这不是真正的字段",
+      ":::",
+      "```",
+      "",
+      "以上只是演示。",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields).toEqual({});
+    expect(result.textContent).toBe(raw);
+  });
+
+  it("does not parse ::: inside indented code blocks with backtick fences", () => {
+    const raw = [
+      "示例代码:",
+      "",
+      "````",
+      ':::field{key="demo" label="示例"}',
+      "不是字段",
+      ":::",
+      "````",
+      "",
+      "结束。",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields).toEqual({});
+    expect(result.textContent).toBe(raw);
+  });
+
+  // ---------------------------------------------------------------------------
+  // Multi-line field value
+  // ---------------------------------------------------------------------------
+
+  it("extracts multi-line field value from textarea type", () => {
+    const raw = [
+      ':::field{key="outline" label="大纲" type="textarea"}',
+      "第一卷:起源",
+      "第二卷:征途",
+      "第三卷:终局",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["outline"]).toBe(
+      "第一卷:起源\n第二卷:征途\n第三卷:终局",
+    );
+  });
+
+  // ---------------------------------------------------------------------------
+  // group label appears in summary
+  // ---------------------------------------------------------------------------
+
+  it("does not include group labels in summary (only leaf fields)", () => {
+    const raw = [
+      ':::group{label="篇幅设置"}',
+      ':::number{key="chapterCount" label="总章数"}',
+      "200",
+      ":::",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    // summary should mention "总章数", not "篇幅设置"
+    expect(result.summary).toBe("确立了总章数");
+  });
+
+  // ---------------------------------------------------------------------------
+  // Attribute parsing edge cases
+  // ---------------------------------------------------------------------------
+
+  it("handles single-quoted attributes", () => {
+    const raw = [
+      ":::field{key='title' label='书名'}",
+      "星河彼岸",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["title"]).toBe("星河彼岸");
+  });
+
+  it("handles attributes with extra spaces", () => {
+    const raw = [
+      ':::field{ key="title"  label="书名" }',
+      "星河彼岸",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["title"]).toBe("星河彼岸");
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Streaming filter
+// ---------------------------------------------------------------------------
+
+describe("createDirectiveStreamFilter", () => {
+  it("passes through pure text unchanged", () => {
+    const filter = createDirectiveStreamFilter();
+    expect(filter("你好世界")).toBe("你好世界");
+    expect(filter("第二段文字")).toBe("第二段文字");
+  });
+
+  it("filters out a complete directive block arriving in one chunk", () => {
+    const filter = createDirectiveStreamFilter();
+    const chunk = ':::field{key="title" label="书名"}\n星河彼岸\n:::\n';
+    expect(filter(chunk)).toBe("");
+  });
+
+  it("filters directive blocks arriving across multiple chunks", () => {
+    const filter = createDirectiveStreamFilter();
+
+    const out1 = filter("欢迎!\n");
+    expect(out1).toBe("欢迎!\n");
+
+    // directive opening arrives
+    const out2 = filter(':::field{key="title" label="书名"}\n');
+    expect(out2).toBe("");
+
+    // content inside directive
+    const out3 = filter("星河彼岸\n");
+    expect(out3).toBe("");
+
+    // directive close
+    const out4 = filter(":::\n");
+    expect(out4).toBe("");
+
+    // back to normal text
+    const out5 = filter("继续对话。\n");
+    expect(out5).toBe("继续对话。\n");
+  });
+
+  it("handles nested group directives in stream", () => {
+    const filter = createDirectiveStreamFilter();
+
+    expect(filter("开始\n")).toBe("开始\n");
+    expect(filter(':::group{label="篇幅"}\n')).toBe("");
+    expect(filter(':::number{key="ch" label="章数"}\n')).toBe("");
+    expect(filter("300\n")).toBe("");
+    expect(filter(":::\n")).toBe(""); // closes number
+    expect(filter(":::\n")).toBe(""); // closes group
+    expect(filter("结束\n")).toBe("结束\n");
+  });
+
+  it("does not filter ::: inside code blocks during streaming", () => {
+    const filter = createDirectiveStreamFilter();
+
+    expect(filter("```\n")).toBe("```\n");
+    expect(filter(':::field{key="x" label="y"}\n')).toBe(
+      ':::field{key="x" label="y"}\n',
+    );
+    expect(filter(":::\n")).toBe(":::\n");
+    expect(filter("```\n")).toBe("```\n");
+  });
+});

+ 190 - 0
packages/core/src/__tests__/edit-controller.test.ts

@@ -0,0 +1,190 @@
+import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { beforeAll, describe, expect, it, vi } from "vitest";
+import type { ChapterMeta } from "../models/chapter.js";
+import {
+  classifyTruthAuthority,
+  normalizeTruthFileName,
+} from "../interaction/truth-authority.js";
+import {
+  executeEditTransaction,
+  planEditTransaction,
+  type EditRequest,
+} from "../interaction/edit-controller.js";
+
+let projectRoot: string;
+
+beforeAll(async () => {
+  projectRoot = await mkdtemp(join(tmpdir(), "inkos-edit-controller-"));
+  await mkdir(join(projectRoot, "books", "harbor", "story", "runtime"), { recursive: true });
+  await mkdir(join(projectRoot, "books", "harbor", "chapters"), { recursive: true });
+});
+
+describe("truth authority", () => {
+  it("normalizes supported truth files", () => {
+    expect(normalizeTruthFileName("story_bible")).toBe("story_bible.md");
+    expect(normalizeTruthFileName("current_state.md")).toBe("current_state.md");
+  });
+
+  it("classifies control and truth authority tiers", () => {
+    expect(classifyTruthAuthority("author_intent.md")).toBe("direction");
+    expect(classifyTruthAuthority("current_focus.md")).toBe("direction");
+    expect(classifyTruthAuthority("story_bible.md")).toBe("foundation");
+    expect(classifyTruthAuthority("book_rules.md")).toBe("rules");
+    expect(classifyTruthAuthority("current_state.md")).toBe("runtime-truth");
+  });
+});
+
+describe("edit controller", () => {
+  it("plans entity rename transactions", () => {
+    const result = planEditTransaction({
+      kind: "entity-rename",
+      bookId: "harbor",
+      entityType: "protagonist",
+      oldValue: "陆尘",
+      newValue: "林砚",
+    });
+
+    expect(result.transactionType).toBe("entity-rename");
+    expect(result.affectedScope).toBe("book");
+    expect(result.requiresTruthRebuild).toBe(true);
+  });
+
+  it("plans chapter rewrite transactions", () => {
+    const result = planEditTransaction({
+      kind: "chapter-rewrite",
+      bookId: "harbor",
+      chapterNumber: 3,
+      instruction: "Keep the ending reveal.",
+    });
+
+    expect(result.transactionType).toBe("chapter-rewrite");
+    expect(result.affectedScope).toBe("downstream");
+    expect(result.requiresTruthRebuild).toBe(true);
+  });
+
+  it("plans local text edits without forcing full-book rebuild", () => {
+    const result = planEditTransaction({
+      kind: "chapter-local-edit",
+      bookId: "harbor",
+      chapterNumber: 5,
+      instruction: "Only rewrite the final paragraph.",
+    });
+
+    expect(result.transactionType).toBe("chapter-local-edit");
+    expect(result.affectedScope).toBe("chapter");
+    expect(result.requiresTruthRebuild).toBe(true);
+  });
+
+  it("plans truth-file edits with authority metadata", () => {
+    const result = planEditTransaction({
+      kind: "truth-file-edit",
+      bookId: "harbor",
+      fileName: "book_rules",
+      instruction: "Lock the protagonist name to Lin Yan.",
+    });
+
+    expect(result.transactionType).toBe("truth-file-edit");
+    expect(result.truthAuthority).toBe("rules");
+    expect(result.affectedScope).toBe("book");
+  });
+
+  it("plans focus edits as direction-level transactions", () => {
+    const result = planEditTransaction({
+      kind: "focus-edit",
+      bookId: "harbor",
+      instruction: "Bring the story back to the old case.",
+    });
+
+    expect(result.transactionType).toBe("focus-edit");
+    expect(result.truthAuthority).toBe("direction");
+    expect(result.affectedScope).toBe("future");
+    expect(result.requiresTruthRebuild).toBe(false);
+  });
+
+  it("executes entity rename across truth files and chapters", async () => {
+    const bookDir = join(projectRoot, "books", "harbor");
+    await writeFile(join(bookDir, "story", "story_bible.md"), "主角陆尘住在港口。", "utf-8");
+    await writeFile(join(bookDir, "chapters", "0001_旧名字.md"), "# 第1章 旧名字\n\n陆尘走进港口。", "utf-8");
+
+    const result = await executeEditTransaction(
+      {
+        bookDir: (bookId) => join(projectRoot, "books", bookId),
+        loadChapterIndex: async () => [],
+        saveChapterIndex: async () => undefined,
+      },
+      {
+        kind: "entity-rename",
+        bookId: "harbor",
+        entityType: "protagonist",
+        oldValue: "陆尘",
+        newValue: "林砚",
+      },
+    );
+
+    await expect(readFile(join(bookDir, "story", "story_bible.md"), "utf-8")).resolves.toContain("林砚");
+    await expect(readFile(join(bookDir, "chapters", "0001_旧名字.md"), "utf-8")).resolves.toContain("林砚");
+    expect(result.touchedFiles.length).toBeGreaterThan(0);
+  });
+
+  it("executes chapter text patches and marks the chapter for review", async () => {
+    const bookDir = join(projectRoot, "books", "harbor");
+    await writeFile(join(bookDir, "chapters", "0003_灰墙榜下.md"), "# 第3章 灰墙榜下\n\n旧名字在这里。", "utf-8");
+    await writeFile(join(bookDir, "story", "runtime", "chapter-0003.intent.md"), "stale", "utf-8");
+    const chapterIndex = [{
+      number: 3,
+      title: "灰墙榜下",
+      status: "ready-for-review" as const,
+      wordCount: 12,
+      createdAt: new Date().toISOString(),
+      updatedAt: new Date().toISOString(),
+      auditIssues: [],
+      lengthWarnings: [],
+    }];
+
+    let savedIndex: ChapterMeta[] = [...chapterIndex];
+    const result = await executeEditTransaction(
+      {
+        bookDir: (bookId) => join(projectRoot, "books", bookId),
+        loadChapterIndex: async () => chapterIndex,
+        saveChapterIndex: async (_bookId, index) => {
+          savedIndex = [...index];
+        },
+      },
+      {
+        kind: "chapter-local-edit",
+        bookId: "harbor",
+        chapterNumber: 3,
+        instruction: "Replace old text",
+        targetText: "旧名字",
+        replacementText: "新名字",
+      },
+    );
+
+    await expect(readFile(join(bookDir, "chapters", "0003_灰墙榜下.md"), "utf-8")).resolves.toContain("新名字");
+    expect(savedIndex[0]?.status).toBe("audit-failed");
+    expect(savedIndex[0]?.auditIssues.at(-1)).toContain("Manual text edit requires review");
+    expect(result.reviewRequired).toBe(true);
+  });
+
+  it("does not swallow unexpected filesystem errors while collecting editable files", async () => {
+    const invalidRoot = join(projectRoot, "invalid-root.txt");
+    await writeFile(invalidRoot, "not a directory", "utf-8");
+
+    await expect(executeEditTransaction(
+      {
+        bookDir: () => invalidRoot,
+        loadChapterIndex: async () => [],
+        saveChapterIndex: async () => undefined,
+      },
+      {
+        kind: "entity-rename",
+        bookId: "harbor",
+        entityType: "protagonist",
+        oldValue: "陆尘",
+        newValue: "林砚",
+      },
+    )).rejects.toThrow(/not a directory|ENOTDIR/i);
+  });
+});

+ 170 - 0
packages/core/src/__tests__/interaction-chat-tokens.test.ts

@@ -0,0 +1,170 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
+
+const mockChatCompletion = vi.hoisted(() => vi.fn());
+const mockChatWithTools = vi.hoisted(() => vi.fn());
+
+vi.mock("../index.js", async (importOriginal) => {
+  const actual = await importOriginal<Record<string, unknown>>();
+  return { ...actual, chatCompletion: mockChatCompletion, chatWithTools: mockChatWithTools };
+});
+
+const fakePipeline = {
+  config: {
+    client: {} as object,
+    model: "gpt-4o",
+  },
+  writeNextChapter: vi.fn(),
+  reviseDraft: vi.fn(),
+};
+
+const fakeState = {
+  ensureControlDocuments: vi.fn(async () => {}),
+  bookDir: vi.fn(() => "/tmp/books/test"),
+  loadBookConfig: vi.fn(async () => undefined),
+  loadChapterIndex: vi.fn(async () => []),
+  saveChapterIndex: vi.fn(async () => undefined),
+  listBooks: vi.fn(async () => []),
+};
+
+const MOCK_CHAT_RESPONSE = {
+  content: [
+    "好的,你想写都市异能,请问主角是什么类型的能力?",
+    "",
+    ':::field{key="title" label="书名"}',
+    "都市异能",
+    ":::",
+  ].join("\n"),
+  tokensUsed: { prompt: 5, completion: 80, total: 85 },
+};
+
+const MOCK_TOOL_RESPONSE = {
+  content: "好的,已根据你的描述生成建书参数。",
+  toolCalls: [
+    {
+      id: "call_1",
+      name: "create_book",
+      arguments: JSON.stringify({ title: "都市异能", genre: "urban", platform: "tomato", brief: "都市异能题材" }),
+    },
+  ],
+};
+
+describe("chat tool – maxTokens forwarding", () => {
+  beforeEach(() => {
+    mockChatCompletion.mockResolvedValue({
+      content: "Hello",
+      tokensUsed: { prompt: 5, completion: 10, total: 15 },
+    });
+    mockChatCompletion.mockClear();
+  });
+
+  it("does not pass maxTokens to chatCompletion when depth has no maxTokens set", async () => {
+    const tools = createInteractionToolsFromDeps(
+      fakePipeline as never,
+      fakeState as never,
+      {
+        getChatRequestOptions: () => ({ temperature: 0.7 }),
+      },
+    );
+
+    await tools.chat?.("你好", { bookId: "test-book", automationMode: "manual" });
+
+    expect(mockChatCompletion).toHaveBeenCalledOnce();
+    const options = mockChatCompletion.mock.calls[0]?.[3] as Record<string, unknown> | undefined;
+    expect(options).not.toHaveProperty("maxTokens");
+  });
+
+  it("passes maxTokens to chatCompletion when depth explicitly sets it", async () => {
+    const tools = createInteractionToolsFromDeps(
+      fakePipeline as never,
+      fakeState as never,
+      {
+        getChatRequestOptions: () => ({ temperature: 0.7, maxTokens: 512 }),
+      },
+    );
+
+    await tools.chat?.("你好", { bookId: "test-book", automationMode: "manual" });
+
+    expect(mockChatCompletion).toHaveBeenCalledOnce();
+    const options = mockChatCompletion.mock.calls[0]?.[3] as Record<string, unknown> | undefined;
+    expect(options).toHaveProperty("maxTokens", 512);
+  });
+
+  it("rethrows real chatCompletion errors instead of silently falling back", async () => {
+    mockChatCompletion.mockRejectedValueOnce(new Error("provider down"));
+
+    const tools = createInteractionToolsFromDeps(
+      fakePipeline as never,
+      fakeState as never,
+      {
+        getChatRequestOptions: () => ({ temperature: 0.7 }),
+      },
+    );
+
+    await expect(
+      tools.chat?.("你好", { bookId: "test-book", automationMode: "manual" }),
+    ).rejects.toThrow("provider down");
+  });
+});
+
+describe("developBookDraft – uses chatWithTools", () => {
+  beforeEach(() => {
+    mockChatWithTools.mockResolvedValue(MOCK_TOOL_RESPONSE);
+    mockChatWithTools.mockClear();
+  });
+
+  it("calls chatWithTools with create_book tool and does not pass maxTokens", async () => {
+    const tools = createInteractionToolsFromDeps(
+      fakePipeline as never,
+      fakeState as never,
+    );
+
+    await tools.developBookDraft?.("我想写都市异能", undefined);
+
+    expect(mockChatWithTools).toHaveBeenCalledOnce();
+    const options = mockChatWithTools.mock.calls[0]?.[4] as Record<string, unknown> | undefined;
+    expect(options).not.toHaveProperty("maxTokens");
+  });
+
+  it("extracts tool call arguments into the creation draft", async () => {
+    const tools = createInteractionToolsFromDeps(
+      fakePipeline as never,
+      fakeState as never,
+    );
+
+    const result = await tools.developBookDraft?.("我想写都市异能", undefined) as Record<string, unknown>;
+    const interaction = (result as { __interaction: Record<string, unknown> }).__interaction;
+    const details = interaction.details as Record<string, unknown>;
+
+    expect(details.creationDraft).toEqual(expect.objectContaining({
+      title: "都市异能",
+      genre: "urban",
+      platform: "tomato",
+      blurb: "都市异能题材",
+      readyToCreate: true,
+    }));
+    expect(details.toolCall).toEqual({
+      name: "create_book",
+      arguments: { title: "都市异能", genre: "urban", platform: "tomato", brief: "都市异能题材" },
+    });
+  });
+
+  it("returns fallback when no LLM is configured", async () => {
+    const noLlmPipeline = {
+      config: {},
+      writeNextChapter: vi.fn(),
+      reviseDraft: vi.fn(),
+    };
+
+    const tools = createInteractionToolsFromDeps(
+      noLlmPipeline as never,
+      fakeState as never,
+    );
+
+    const result = await tools.developBookDraft?.("我想写都市异能", undefined) as Record<string, unknown>;
+    const interaction = (result as { __interaction: Record<string, unknown> }).__interaction;
+
+    expect(mockChatWithTools).not.toHaveBeenCalled();
+    expect(interaction.responseText).toContain("请先配置 LLM 模型");
+  });
+});

+ 155 - 0
packages/core/src/__tests__/interaction-models.test.ts

@@ -0,0 +1,155 @@
+import { describe, expect, it } from "vitest";
+import {
+  AutomationModeSchema,
+  BookCreationDraftSchema,
+  InteractionIntentTypeSchema,
+  ExecutionStatusSchema,
+  InteractionSessionSchema,
+  bindActiveBook,
+  clearPendingDecision,
+  isTerminalExecutionStatus,
+  appendInteractionMessage,
+  appendInteractionEvent,
+  updateCreationDraft,
+  clearCreationDraft,
+} from "../index.js";
+
+describe("interaction models", () => {
+  it("parses supported automation modes", () => {
+    expect(AutomationModeSchema.parse("auto")).toBe("auto");
+    expect(AutomationModeSchema.parse("semi")).toBe("semi");
+    expect(AutomationModeSchema.parse("manual")).toBe("manual");
+  });
+
+  it("parses supported interaction intents", () => {
+    expect(InteractionIntentTypeSchema.parse("develop_book")).toBe("develop_book");
+    expect(InteractionIntentTypeSchema.parse("create_book")).toBe("create_book");
+    expect(InteractionIntentTypeSchema.parse("discard_book_draft")).toBe("discard_book_draft");
+    expect(InteractionIntentTypeSchema.parse("chat")).toBe("chat");
+    expect(InteractionIntentTypeSchema.parse("write_next")).toBe("write_next");
+    expect(InteractionIntentTypeSchema.parse("rewrite_chapter")).toBe("rewrite_chapter");
+    expect(InteractionIntentTypeSchema.parse("explain_failure")).toBe("explain_failure");
+  });
+
+  it("recognizes terminal execution statuses", () => {
+    expect(isTerminalExecutionStatus(ExecutionStatusSchema.parse("completed"))).toBe(true);
+    expect(isTerminalExecutionStatus(ExecutionStatusSchema.parse("failed"))).toBe(true);
+    expect(isTerminalExecutionStatus(ExecutionStatusSchema.parse("idle"))).toBe(false);
+    expect(isTerminalExecutionStatus(ExecutionStatusSchema.parse("writing"))).toBe(false);
+  });
+
+  it("binds the active book without disturbing unrelated session fields", () => {
+    const session = InteractionSessionSchema.parse({
+      sessionId: "session-1",
+      projectRoot: "/tmp/project",
+      automationMode: "semi",
+      messages: [],
+      pendingDecision: {
+        kind: "approve-chapter",
+        bookId: "book-a",
+        chapterNumber: 3,
+        summary: "Chapter 3 is waiting for review.",
+      },
+      currentExecution: {
+        status: "waiting_human",
+        bookId: "book-a",
+        chapterNumber: 3,
+        stageLabel: "waiting for approval",
+      },
+    });
+
+    expect(bindActiveBook(session, "book-b")).toEqual({
+      ...session,
+      activeBookId: "book-b",
+    });
+  });
+
+  it("clears pending decisions while keeping the rest of the session intact", () => {
+    const session = InteractionSessionSchema.parse({
+      sessionId: "session-2",
+      projectRoot: "/tmp/project",
+      activeBookId: "book-a",
+      automationMode: "auto",
+      messages: [],
+      pendingDecision: {
+        kind: "choose-repair-mode",
+        bookId: "book-a",
+        chapterNumber: 8,
+        summary: "Choose whether to local-fix or rewrite chapter 8.",
+      },
+    });
+
+    expect(clearPendingDecision(session)).toEqual({
+      ...session,
+      pendingDecision: undefined,
+    });
+  });
+
+  it("appends interaction messages in timestamp order", () => {
+    const session = InteractionSessionSchema.parse({
+      sessionId: "session-3",
+      projectRoot: "/tmp/project",
+      automationMode: "semi",
+      messages: [],
+    });
+
+    const next = appendInteractionMessage(session, {
+      role: "user",
+      content: "continue",
+      timestamp: 1,
+    });
+
+    expect(next.messages).toEqual([{
+      role: "user",
+      content: "continue",
+      timestamp: 1,
+    }]);
+  });
+
+  it("appends interaction events in timestamp order", () => {
+    const session = InteractionSessionSchema.parse({
+      sessionId: "session-4",
+      projectRoot: "/tmp/project",
+      automationMode: "semi",
+      messages: [],
+      events: [],
+    });
+
+    const next = appendInteractionEvent(session, {
+      kind: "task.completed",
+      timestamp: 2,
+      status: "completed",
+      bookId: "harbor",
+      detail: "Completed write_next for harbor.",
+    });
+
+    expect(next.events).toEqual([{
+      kind: "task.completed",
+      timestamp: 2,
+      status: "completed",
+      bookId: "harbor",
+      detail: "Completed write_next for harbor.",
+    }]);
+  });
+
+  it("stores and clears a creation draft inside the shared session", () => {
+    const draft = BookCreationDraftSchema.parse({
+      concept: "港风商战悬疑,主角从灰产洗白。",
+      title: "夜港账本",
+      genre: "urban",
+      readyToCreate: false,
+    });
+
+    const session = InteractionSessionSchema.parse({
+      sessionId: "session-5",
+      projectRoot: "/tmp/project",
+      automationMode: "semi",
+      messages: [],
+      events: [],
+    });
+
+    const withDraft = updateCreationDraft(session, draft);
+    expect(withDraft.creationDraft?.title).toBe("夜港账本");
+    expect(clearCreationDraft(withDraft).creationDraft).toBeUndefined();
+  });
+});

+ 223 - 0
packages/core/src/__tests__/interaction-nl-router.test.ts

@@ -0,0 +1,223 @@
+import { describe, expect, it } from "vitest";
+import { routeNaturalLanguageIntent } from "../interaction/nl-router.js";
+
+describe("interaction natural-language router", () => {
+  it("maps continue-style requests to write_next", () => {
+    expect(routeNaturalLanguageIntent("continue", { activeBookId: "harbor" })).toEqual({
+      intent: "write_next",
+      bookId: "harbor",
+    });
+    expect(routeNaturalLanguageIntent("继续写", { activeBookId: "harbor" })).toEqual({
+      intent: "write_next",
+      bookId: "harbor",
+    });
+  });
+
+  it("maps pause requests to pause_book", () => {
+    expect(routeNaturalLanguageIntent("pause this book", { activeBookId: "harbor" })).toEqual({
+      intent: "pause_book",
+      bookId: "harbor",
+    });
+  });
+
+  it("maps rewrite chapter requests with chapter numbers", () => {
+    expect(routeNaturalLanguageIntent("rewrite chapter 3", { activeBookId: "harbor" })).toEqual({
+      intent: "rewrite_chapter",
+      bookId: "harbor",
+      chapterNumber: 3,
+    });
+    expect(routeNaturalLanguageIntent("重写第3章", { activeBookId: "harbor" })).toEqual({
+      intent: "rewrite_chapter",
+      bookId: "harbor",
+      chapterNumber: 3,
+    });
+  });
+
+  it("maps revise chapter requests with freeform instructions", () => {
+    expect(routeNaturalLanguageIntent("revise chapter 5 ending only", { activeBookId: "harbor" })).toEqual({
+      intent: "revise_chapter",
+      bookId: "harbor",
+      chapterNumber: 5,
+      instruction: "ending only",
+    });
+  });
+
+  it("maps focus updates to update_focus", () => {
+    expect(routeNaturalLanguageIntent("把 focus 拉回旧案线", { activeBookId: "harbor" })).toEqual({
+      intent: "update_focus",
+      bookId: "harbor",
+      instruction: "把 focus 拉回旧案线",
+    });
+  });
+
+  it("maps explanation requests to explain_failure", () => {
+    expect(routeNaturalLanguageIntent("为什么主角名字没按设定走", { activeBookId: "harbor", hasFailed: true })).toEqual({
+      intent: "explain_failure",
+      bookId: "harbor",
+      instruction: "为什么主角名字没按设定走",
+    });
+  });
+
+  it("maps export-style natural language requests to export_book", () => {
+    expect(routeNaturalLanguageIntent("导出全书", { activeBookId: "harbor" })).toEqual({
+      intent: "export_book",
+      bookId: "harbor",
+      format: "txt",
+    });
+    expect(routeNaturalLanguageIntent("导出全书为 epub", { activeBookId: "harbor" })).toEqual({
+      intent: "export_book",
+      bookId: "harbor",
+      format: "epub",
+    });
+    expect(routeNaturalLanguageIntent("export book as epub", { activeBookId: "harbor" })).toEqual({
+      intent: "export_book",
+      bookId: "harbor",
+      format: "epub",
+    });
+  });
+
+  it("maps greetings to chat instead of status explanation", () => {
+    expect(routeNaturalLanguageIntent("hi", { activeBookId: "harbor" })).toEqual({
+      intent: "chat",
+      bookId: "harbor",
+      instruction: "hi",
+    });
+    expect(routeNaturalLanguageIntent("你好", { activeBookId: "harbor" })).toEqual({
+      intent: "chat",
+      bookId: "harbor",
+      instruction: "你好",
+    });
+  });
+
+  it("routes freeform input into book-development when no active book is bound", () => {
+    expect(routeNaturalLanguageIntent("我想写个港风商战悬疑")).toEqual({
+      intent: "develop_book",
+      instruction: "我想写个港风商战悬疑",
+    });
+    expect(routeNaturalLanguageIntent("先不要开书,我想想名字", {
+      hasCreationDraft: true,
+    })).toEqual({
+      intent: "develop_book",
+      instruction: "先不要开书,我想想名字",
+    });
+    expect(routeNaturalLanguageIntent("名字再狠一点", {
+      activeBookId: "harbor",
+      hasCreationDraft: true,
+    })).toEqual({
+      intent: "develop_book",
+      instruction: "名字再狠一点",
+    });
+  });
+
+  it("maps mode switch requests to switch_mode", () => {
+    expect(routeNaturalLanguageIntent("切换到全自动", { activeBookId: "harbor" })).toEqual({
+      intent: "switch_mode",
+      mode: "auto",
+    });
+    expect(routeNaturalLanguageIntent("半自动", { activeBookId: "harbor" })).toEqual({
+      intent: "switch_mode",
+      mode: "semi",
+    });
+    expect(routeNaturalLanguageIntent("切到全自主", { activeBookId: "harbor" })).toEqual({
+      intent: "switch_mode",
+      mode: "manual",
+    });
+  });
+
+  it("maps slash commands for direct control", () => {
+    expect(routeNaturalLanguageIntent("/books", { activeBookId: "harbor" })).toEqual({
+      intent: "list_books",
+    });
+    expect(routeNaturalLanguageIntent("/new Night Harbor", { activeBookId: "harbor" })).toEqual({
+      intent: "develop_book",
+      instruction: "Night Harbor",
+    });
+    expect(routeNaturalLanguageIntent("/create", { hasCreationDraft: true })).toEqual({
+      intent: "create_book",
+    });
+    expect(routeNaturalLanguageIntent("/draft", { hasCreationDraft: true })).toEqual({
+      intent: "show_book_draft",
+    });
+    expect(routeNaturalLanguageIntent("/discard", { hasCreationDraft: true })).toEqual({
+      intent: "discard_book_draft",
+    });
+    expect(routeNaturalLanguageIntent("/open beta", { activeBookId: "harbor" })).toEqual({
+      intent: "select_book",
+      bookId: "beta",
+    });
+    expect(routeNaturalLanguageIntent("/write", { activeBookId: "harbor" })).toEqual({
+      intent: "write_next",
+      bookId: "harbor",
+    });
+    expect(routeNaturalLanguageIntent("/mode auto", { activeBookId: "harbor" })).toEqual({
+      intent: "switch_mode",
+      mode: "auto",
+    });
+    expect(routeNaturalLanguageIntent("/rewrite 3", { activeBookId: "harbor" })).toEqual({
+      intent: "rewrite_chapter",
+      bookId: "harbor",
+      chapterNumber: 3,
+    });
+    expect(routeNaturalLanguageIntent("/focus bring it back to the old case", { activeBookId: "harbor" })).toEqual({
+      intent: "update_focus",
+      bookId: "harbor",
+      instruction: "bring it back to the old case",
+    });
+    expect(routeNaturalLanguageIntent("/truth current_focus.md Bring it back", { activeBookId: "harbor" })).toEqual({
+      intent: "edit_truth",
+      bookId: "harbor",
+      fileName: "current_focus.md",
+      instruction: "Bring it back",
+    });
+    expect(routeNaturalLanguageIntent("/rename 陆尘 => 林砚", { activeBookId: "harbor" })).toEqual({
+      intent: "rename_entity",
+      bookId: "harbor",
+      oldValue: "陆尘",
+      newValue: "林砚",
+    });
+    expect(routeNaturalLanguageIntent("/replace 3 旧名字 => 新名字", { activeBookId: "harbor" })).toEqual({
+      intent: "patch_chapter_text",
+      bookId: "harbor",
+      chapterNumber: 3,
+      targetText: "旧名字",
+      replacementText: "新名字",
+    });
+    expect(routeNaturalLanguageIntent("/export", { activeBookId: "harbor" })).toEqual({
+      intent: "export_book",
+      bookId: "harbor",
+      format: "txt",
+    });
+    expect(routeNaturalLanguageIntent("/export md", { activeBookId: "harbor" })).toEqual({
+      intent: "export_book",
+      bookId: "harbor",
+      format: "md",
+    });
+  });
+
+  it("maps rename and chapter patch requests from natural language", () => {
+    expect(routeNaturalLanguageIntent("open beta", { activeBookId: "harbor" })).toEqual({
+      intent: "select_book",
+      bookId: "beta",
+    });
+    expect(routeNaturalLanguageIntent("把陆尘改成林砚", { activeBookId: "harbor" })).toEqual({
+      intent: "rename_entity",
+      bookId: "harbor",
+      oldValue: "陆尘",
+      newValue: "林砚",
+    });
+    expect(routeNaturalLanguageIntent("rename Lu Chen to Lin Yan", { activeBookId: "harbor" })).toEqual({
+      intent: "rename_entity",
+      bookId: "harbor",
+      oldValue: "Lu Chen",
+      newValue: "Lin Yan",
+    });
+  });
+
+  it("falls back to chat for unmatched freeform input", () => {
+    expect(routeNaturalLanguageIntent("没有动效没有回答", { activeBookId: "harbor" })).toEqual({
+      intent: "chat",
+      bookId: "harbor",
+      instruction: "没有动效没有回答",
+    });
+  });
+});

+ 633 - 0
packages/core/src/__tests__/interaction-runtime.test.ts

@@ -0,0 +1,633 @@
+import { describe, expect, it, vi } from "vitest";
+import { InteractionSessionSchema } from "../interaction/session.js";
+import { runInteractionRequest } from "../interaction/runtime.js";
+
+function makeTools(overrides: Partial<Parameters<typeof runInteractionRequest>[0]["tools"]> = {}) {
+  return {
+    listBooks: vi.fn(async () => ["harbor"]),
+    developBookDraft: vi.fn(),
+    createBook: vi.fn(),
+    exportBook: vi.fn(),
+    writeNextChapter: vi.fn(),
+    reviseDraft: vi.fn(),
+    patchChapterText: vi.fn(),
+    renameEntity: vi.fn(),
+    updateCurrentFocus: vi.fn(),
+    updateAuthorIntent: vi.fn(),
+    writeTruthFile: vi.fn(),
+    ...overrides,
+  };
+}
+
+describe("interaction runtime", () => {
+  it("routes develop_book through the shared draft tool and updates the creation draft", async () => {
+    const developBookDraft = vi.fn(async () => ({
+      __interaction: {
+        responseText: "我先按港风商战悬疑收着。你更想写长篇连载,还是十来章能收住?",
+        details: {
+          creationDraft: {
+            concept: "港风商战悬疑,主角从灰产洗白。",
+            title: "夜港账本",
+            genre: "urban",
+            nextQuestion: "更想写长篇连载,还是十来章能收住?",
+            missingFields: ["targetChapters"],
+            readyToCreate: false,
+          },
+        },
+      },
+    }));
+
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-draft",
+        projectRoot: "/tmp/project",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: {
+        intent: "develop_book",
+        instruction: "我想写个港风商战悬疑,主角从灰产洗白。",
+      },
+      tools: makeTools({
+        developBookDraft,
+      }),
+    });
+
+    expect(developBookDraft).toHaveBeenCalledWith("我想写个港风商战悬疑,主角从灰产洗白。", undefined);
+    expect(result.session.creationDraft).toEqual(expect.objectContaining({
+      title: "夜港账本",
+      genre: "urban",
+    }));
+    expect(result.responseText).toContain("港风商战悬疑");
+  });
+
+  it("routes create_book through the shared create tool and binds the created book", async () => {
+    const createBook = vi.fn(async () => ({
+      bookId: "night-harbor",
+      title: "Night Harbor",
+      __interaction: {
+        responseText: "Created Night Harbor.",
+      },
+    }));
+
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-create",
+        projectRoot: "/tmp/project",
+        automationMode: "semi",
+        creationDraft: {
+          concept: "港风商战悬疑,主角从灰产洗白。",
+          title: "Night Harbor",
+          genre: "urban",
+          platform: "tomato",
+          targetChapters: 120,
+          chapterWordCount: 2800,
+          readyToCreate: true,
+          missingFields: [],
+        },
+        messages: [],
+        events: [],
+      }),
+      request: {
+        intent: "create_book",
+      },
+      tools: makeTools({
+        createBook,
+      }),
+    });
+
+    expect(createBook).toHaveBeenCalledWith({
+      title: "Night Harbor",
+      genre: "urban",
+      platform: "tomato",
+      targetChapters: 120,
+      chapterWordCount: 2800,
+    });
+    expect(result.session.activeBookId).toBe("night-harbor");
+    expect(result.session.creationDraft).toBeUndefined();
+    expect(result.responseText).toContain("Created Night Harbor.");
+  });
+
+  it("clears the creation draft when discard_book_draft is requested", async () => {
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-discard",
+        projectRoot: "/tmp/project",
+        automationMode: "semi",
+        creationDraft: {
+          concept: "港风商战悬疑,主角从灰产洗白。",
+          title: "夜港账本",
+          readyToCreate: false,
+          missingFields: ["genre"],
+        },
+        messages: [],
+        events: [],
+      }),
+      request: {
+        intent: "discard_book_draft",
+      },
+      tools: makeTools(),
+    });
+
+    expect(result.session.creationDraft).toBeUndefined();
+    expect(result.responseText).toContain("已丢弃");
+  });
+
+  it("renders the current creation draft when show_book_draft is requested", async () => {
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-show-draft",
+        projectRoot: "/tmp/project",
+        automationMode: "semi",
+        creationDraft: {
+          concept: "港风商战悬疑,主角从灰产洗白。",
+          title: "夜港账本",
+          genre: "urban",
+          worldPremise: "近未来港口城,账本牵出多方势力。",
+          protagonist: "林砚,水货账房出身,擅长记账和看人。",
+          conflictCore: "洗白与旧债回潮的对撞。",
+          nextQuestion: "卷一先查账还是先砸场?",
+          missingFields: ["targetChapters"],
+          readyToCreate: false,
+        },
+        messages: [],
+        events: [],
+      }),
+      request: {
+        intent: "show_book_draft",
+      },
+      tools: makeTools(),
+    });
+
+    expect(result.responseText).toContain("夜港账本");
+    expect(result.responseText).toContain("近未来港口城");
+    expect(result.responseText).toContain("卷一先查账还是先砸场");
+  });
+
+  it("routes export_book through the shared export tool", async () => {
+    const exportBook = vi.fn(async () => ({
+      outputPath: "/tmp/project/exports/harbor.md",
+      chaptersExported: 9,
+      __interaction: {
+        responseText: "Exported harbor to /tmp/project/exports/harbor.md",
+      },
+    }));
+
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-export",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: {
+        intent: "export_book",
+        bookId: "harbor",
+        format: "md",
+        approvedOnly: true,
+        outputPath: "/tmp/project/exports/harbor.md",
+      },
+      tools: makeTools({
+        exportBook,
+      }),
+    });
+
+    expect(exportBook).toHaveBeenCalledWith("harbor", {
+      format: "md",
+      approvedOnly: true,
+      outputPath: "/tmp/project/exports/harbor.md",
+    });
+    expect(result.responseText).toContain("Exported harbor");
+  });
+
+  it("keeps write_next completed in auto mode", async () => {
+    const writeNextChapter = vi.fn(async () => ({
+      chapterNumber: 7,
+      title: "Harbor Ledger",
+      wordCount: 3210,
+      revised: false,
+      status: "ready-for-review" as const,
+    }));
+    const reviseDraft = vi.fn();
+    const updateCurrentFocus = vi.fn();
+    const updateAuthorIntent = vi.fn();
+    const writeTruthFile = vi.fn();
+
+    const session = InteractionSessionSchema.parse({
+      sessionId: "session-1",
+      projectRoot: "/tmp/project",
+      activeBookId: "harbor",
+      automationMode: "auto",
+      messages: [],
+      events: [],
+    });
+
+    const result = await runInteractionRequest({
+      session,
+      request: { intent: "write_next", bookId: "harbor" },
+      tools: makeTools({
+        writeNextChapter,
+        reviseDraft,
+        updateCurrentFocus,
+        updateAuthorIntent,
+        writeTruthFile,
+      }),
+    });
+
+    expect(writeNextChapter).toHaveBeenCalledWith("harbor");
+    expect(reviseDraft).not.toHaveBeenCalled();
+    expect(result.session.activeBookId).toBe("harbor");
+    expect(result.session.activeChapterNumber).toBe(7);
+    expect(result.session.currentExecution?.status).toBe("completed");
+    expect(result.session.pendingDecision).toBeUndefined();
+    expect(result.session.events.map((event) => event.kind)).toEqual([
+      "task.started",
+      "task.completed",
+    ]);
+  });
+
+  it("moves content-producing work into waiting_human in semi mode", async () => {
+    const writeNextChapter = vi.fn(async () => ({
+      chapterNumber: 7,
+      title: "Harbor Ledger",
+      wordCount: 3210,
+      revised: false,
+      status: "ready-for-review" as const,
+      auditResult: { passed: true, issues: [], summary: "ok" },
+      __interaction: {
+        events: [{
+          kind: "stage.changed",
+          timestamp: 1710000000000,
+          status: "writing" as const,
+          bookId: "harbor",
+          chapterNumber: 7,
+          detail: "writing chapter draft",
+        }],
+      },
+    }));
+
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-1b",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: { intent: "write_next", bookId: "harbor" },
+      tools: makeTools({
+        writeNextChapter,
+      }),
+    });
+
+    expect(result.session.currentExecution?.status).toBe("waiting_human");
+    expect(result.session.pendingDecision?.kind).toBe("review-next-step");
+    expect(result.session.pendingDecision?.chapterNumber).toBe(7);
+    expect(result.session.events.map((event) => event.kind)).toEqual([
+      "task.started",
+      "stage.changed",
+      "task.completed",
+    ]);
+    expect(result.responseText).toContain("等待你的下一步决定");
+  });
+
+  it("routes revise_chapter to reviseDraft with local-fix", async () => {
+    const writeNextChapter = vi.fn();
+    const reviseDraft = vi.fn(async () => ({
+      chapterNumber: 3,
+      wordCount: 2800,
+      fixedIssues: ["tightened ending"],
+      applied: true,
+      status: "ready-for-review" as const,
+    }));
+
+    const session = InteractionSessionSchema.parse({
+      sessionId: "session-2",
+      projectRoot: "/tmp/project",
+      activeBookId: "harbor",
+      automationMode: "manual",
+      messages: [],
+    });
+
+    await runInteractionRequest({
+      session,
+      request: { intent: "revise_chapter", bookId: "harbor", chapterNumber: 3 },
+      tools: makeTools({
+        writeNextChapter,
+        reviseDraft,
+      }),
+    });
+
+    expect(reviseDraft).toHaveBeenCalledWith("harbor", 3, "local-fix");
+  });
+
+  it("routes rewrite_chapter to reviseDraft with rewrite mode", async () => {
+    const reviseDraft = vi.fn(async () => ({
+      chapterNumber: 5,
+      wordCount: 3100,
+      fixedIssues: ["rewrote chapter"],
+      applied: true,
+      status: "ready-for-review" as const,
+    }));
+
+    await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-3",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "manual",
+        messages: [],
+      }),
+      request: { intent: "rewrite_chapter", bookId: "harbor", chapterNumber: 5 },
+      tools: makeTools({
+        reviseDraft,
+      }),
+    });
+
+    expect(reviseDraft).toHaveBeenCalledWith("harbor", 5, "rewrite");
+  });
+
+  it("routes update_focus to the focus updater", async () => {
+    const updateCurrentFocus = vi.fn(async () => ({ ok: true }));
+
+    await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-4",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+      }),
+      request: {
+        intent: "update_focus",
+        bookId: "harbor",
+        instruction: "Bring the story back to the old harbor debt line.",
+      },
+      tools: makeTools({
+        updateCurrentFocus,
+      }),
+    });
+
+    expect(updateCurrentFocus).toHaveBeenCalledWith(
+      "harbor",
+      "Bring the story back to the old harbor debt line.",
+    );
+  });
+
+  it("answers chat-style requests without forcing a status summary", async () => {
+    const chat = vi.fn(async () => ({
+      __interaction: {
+        responseText: "你好,我在。当前没有活动书,要不要先说说你想写什么?",
+      },
+    }));
+
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-chat",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: {
+        intent: "chat",
+        bookId: "harbor",
+        instruction: "hi",
+      },
+      tools: makeTools({
+        chat,
+      }),
+    });
+
+    expect(chat).toHaveBeenCalledWith("hi", {
+      bookId: "harbor",
+      automationMode: "semi",
+    });
+    expect(result.responseText).toContain("你好");
+    expect(result.responseText).not.toContain("Current status");
+    expect(result.session.events.map((event) => event.kind)).toEqual([
+      "task.started",
+      "task.completed",
+    ]);
+  });
+
+  it("routes edit_truth to the truth-file updater", async () => {
+    const writeTruthFile = vi.fn(async () => ({ ok: true }));
+
+    await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-4b",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: {
+        intent: "edit_truth",
+        bookId: "harbor",
+        fileName: "current_focus.md",
+        instruction: "# Current Focus\n\nBring the story back to the old harbor debt line.\n",
+      },
+      tools: makeTools({
+        writeTruthFile,
+      }),
+    });
+
+    expect(writeTruthFile).toHaveBeenCalledWith(
+      "harbor",
+      "current_focus.md",
+      "# Current Focus\n\nBring the story back to the old harbor debt line.\n",
+    );
+  });
+
+  it("routes rename_entity to the rename tool", async () => {
+    const renameEntity = vi.fn(async () => ({ ok: true }));
+
+    await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-4c",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "manual",
+        messages: [],
+        events: [],
+      }),
+      request: {
+        intent: "rename_entity",
+        bookId: "harbor",
+        oldValue: "陆尘",
+        newValue: "林砚",
+      },
+      tools: makeTools({
+        renameEntity,
+      }),
+    });
+
+    expect(renameEntity).toHaveBeenCalledWith("harbor", "陆尘", "林砚");
+  });
+
+  it("routes patch_chapter_text to the chapter patch tool and waits for review", async () => {
+    const patchChapterText = vi.fn(async () => ({
+      chapterNumber: 3,
+      __interaction: {
+        responseText: "Patched chapter 3 and marked it for review.",
+      },
+    }));
+
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-4d",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: {
+        intent: "patch_chapter_text",
+        bookId: "harbor",
+        chapterNumber: 3,
+        targetText: "旧名字",
+        replacementText: "新名字",
+      },
+      tools: makeTools({
+        patchChapterText,
+      }),
+    });
+
+    expect(patchChapterText).toHaveBeenCalledWith("harbor", 3, "旧名字", "新名字");
+    expect(result.session.currentExecution?.status).toBe("waiting_human");
+    expect(result.session.pendingDecision?.chapterNumber).toBe(3);
+    expect(result.responseText).toContain("marked it for review");
+  });
+
+  it("updates automation mode without invoking pipeline tools", async () => {
+    const writeNextChapter = vi.fn();
+    const reviseDraft = vi.fn();
+    const updateCurrentFocus = vi.fn();
+    const updateAuthorIntent = vi.fn();
+    const writeTruthFile = vi.fn();
+
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-5",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: { intent: "switch_mode", mode: "auto" },
+      tools: makeTools({
+        writeNextChapter,
+        reviseDraft,
+        updateCurrentFocus,
+        updateAuthorIntent,
+        writeTruthFile,
+      }),
+    });
+
+    expect(result.session.automationMode).toBe("auto");
+    expect(writeNextChapter).not.toHaveBeenCalled();
+    expect(reviseDraft).not.toHaveBeenCalled();
+    expect(updateCurrentFocus).not.toHaveBeenCalled();
+    expect(updateAuthorIntent).not.toHaveBeenCalled();
+    expect(result.session.events.map((event) => event.kind)).toEqual([
+      "task.started",
+      "task.completed",
+    ]);
+  });
+
+  it("binds the selected book without invoking pipeline tools", async () => {
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-5b",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: { intent: "select_book", bookId: "beta" },
+      tools: makeTools({
+        listBooks: vi.fn(async () => ["harbor", "beta"]),
+      }),
+    });
+
+    expect(result.session.activeBookId).toBe("beta");
+    expect(result.responseText).toContain("beta");
+  });
+
+  it("lists project books through the shared runtime", async () => {
+    const listBooks = vi.fn(async () => ["harbor", "beta"]);
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-5c",
+        projectRoot: "/tmp/project",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: { intent: "list_books" },
+      tools: makeTools({
+        listBooks,
+      }),
+    });
+
+    expect(listBooks).toHaveBeenCalledTimes(1);
+    expect(result.responseText).toContain("harbor");
+    expect(result.responseText).toContain("beta");
+  });
+
+  it("pauses the active book without invoking pipeline tools", async () => {
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-6",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "auto",
+        messages: [],
+        events: [],
+      }),
+      request: { intent: "pause_book", bookId: "harbor" },
+      tools: makeTools(),
+    });
+
+    expect(result.session.currentExecution?.status).toBe("blocked");
+    expect(result.responseText).toContain("已暂停");
+    expect(result.session.events.map((event) => event.kind)).toEqual([
+      "task.started",
+      "task.completed",
+    ]);
+  });
+
+  it("returns a human-readable explanation for explain_status", async () => {
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-7",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+        currentExecution: {
+          status: "repairing",
+          bookId: "harbor",
+          chapterNumber: 3,
+          stageLabel: "repairing chapter 3",
+        },
+      }),
+      request: { intent: "explain_status", bookId: "harbor", instruction: "what are you doing?" },
+      tools: makeTools(),
+    });
+
+    expect(result.responseText).toContain("repairing");
+    expect(result.responseText).toContain("harbor");
+  });
+});

+ 269 - 0
packages/core/src/__tests__/interaction-tools.test.ts

@@ -0,0 +1,269 @@
+import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { beforeAll, describe, expect, it, vi } from "vitest";
+import { createLogger, type LogSink } from "../index.js";
+import {
+  buildChapterFileLookup,
+  createInteractionToolsFromDeps,
+} from "../interaction/project-tools.js";
+
+let projectRoot: string;
+
+describe("interaction tools", () => {
+  beforeAll(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-core-interaction-tools-"));
+    await mkdir(join(projectRoot, "books", "harbor", "story"), { recursive: true });
+  });
+
+  it("delegates writeNextChapter and reviseDraft to the pipeline", async () => {
+    const events: string[] = [];
+    const sink: LogSink = {
+      write(entry) {
+        events.push(entry.message);
+      },
+    };
+    const pipeline = {
+      config: {
+        logger: createLogger({ tag: "test", sinks: [sink] }),
+      },
+      writeNextChapter: vi.fn(async () => ({
+        config: undefined,
+        chapterNumber: 1,
+        title: "Draft",
+        wordCount: 1000,
+        revised: false,
+        status: "ready-for-review" as const,
+        auditResult: { passed: true, issues: [], summary: "ok" },
+      })),
+      reviseDraft: vi.fn(async () => ({
+        chapterNumber: 3,
+        wordCount: 1200,
+        fixedIssues: [],
+        applied: true,
+        status: "ready-for-review" as const,
+      })),
+    };
+    const state = {
+      ensureControlDocuments: vi.fn(async () => {}),
+      bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadBookConfig: vi.fn(async () => ({
+        id: "harbor",
+        title: "Harbor",
+        platform: "other" as const,
+        genre: "other",
+        status: "outlining" as const,
+        targetChapters: 200,
+        chapterWordCount: 3000,
+        createdAt: "2026-04-10T00:00:00.000Z",
+        updatedAt: "2026-04-10T00:00:00.000Z",
+      })),
+      loadChapterIndex: vi.fn(async () => []),
+      saveChapterIndex: vi.fn(async () => undefined),
+      listBooks: vi.fn(async () => ["harbor"]),
+    };
+
+    const tools = createInteractionToolsFromDeps(pipeline, state);
+
+    const writeResult = await tools.writeNextChapter("harbor");
+    await tools.reviseDraft("harbor", 3, "rewrite");
+
+    expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor");
+    expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "rewrite");
+    expect((writeResult as { __interaction?: { activeChapterNumber?: number } }).__interaction?.activeChapterNumber).toBe(1);
+    expect(events).toEqual([]);
+  });
+
+  it("captures pipeline stage logs into interaction events", async () => {
+    const pipeline = {
+      config: {
+        logger: createLogger({
+          tag: "test",
+          sinks: [{
+            write() {},
+          }],
+        }),
+      },
+      writeNextChapter: vi.fn(async function (this: { config: { logger?: { info: (msg: string) => void } } }) {
+        this.config.logger?.info("Stage: preparing chapter inputs");
+        this.config.logger?.info("Stage: writing chapter draft");
+        return {
+          chapterNumber: 4,
+          title: "Draft",
+          wordCount: 1000,
+          revised: false,
+          status: "ready-for-review" as const,
+          auditResult: { passed: true, issues: [], summary: "ok" },
+        };
+      }),
+      reviseDraft: vi.fn(async () => ({
+        chapterNumber: 3,
+        wordCount: 1200,
+        fixedIssues: [],
+        applied: true,
+        status: "ready-for-review" as const,
+      })),
+    };
+    const state = {
+      ensureControlDocuments: vi.fn(async () => {}),
+      bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadBookConfig: vi.fn(async () => ({
+        id: "harbor",
+        title: "Harbor",
+        platform: "other" as const,
+        genre: "other",
+        status: "outlining" as const,
+        targetChapters: 200,
+        chapterWordCount: 3000,
+        createdAt: "2026-04-10T00:00:00.000Z",
+        updatedAt: "2026-04-10T00:00:00.000Z",
+      })),
+      loadChapterIndex: vi.fn(async () => []),
+      saveChapterIndex: vi.fn(async () => undefined),
+      listBooks: vi.fn(async () => ["harbor"]),
+    };
+
+    const tools = createInteractionToolsFromDeps(pipeline, state);
+    const result = await tools.writeNextChapter("harbor");
+
+    expect((result as {
+      __interaction?: {
+        events?: ReadonlyArray<{ kind: string; status: string; detail?: string }>;
+      };
+    }).__interaction?.events).toEqual([
+      expect.objectContaining({ kind: "stage.changed", status: "planning", detail: "preparing chapter inputs" }),
+      expect.objectContaining({ kind: "stage.changed", status: "writing", detail: "writing chapter draft" }),
+    ]);
+  });
+
+  it("writes current_focus and author_intent into canonical story paths", async () => {
+    const tools = createInteractionToolsFromDeps(
+      {
+        writeNextChapter: vi.fn(async () => ({
+          chapterNumber: 1,
+          title: "Draft",
+          wordCount: 1000,
+          revised: false,
+          status: "ready-for-review" as const,
+          auditResult: { passed: true, issues: [], summary: "ok" },
+        })),
+        reviseDraft: vi.fn(async () => ({
+          chapterNumber: 3,
+          wordCount: 1200,
+          fixedIssues: [],
+          applied: true,
+          status: "ready-for-review" as const,
+        })),
+      },
+      {
+        ensureControlDocuments: vi.fn(async () => {}),
+        bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+        loadBookConfig: vi.fn(async () => ({
+          id: "harbor",
+          title: "Harbor",
+          platform: "other" as const,
+          genre: "other",
+          status: "outlining" as const,
+          targetChapters: 200,
+          chapterWordCount: 3000,
+          createdAt: "2026-04-10T00:00:00.000Z",
+          updatedAt: "2026-04-10T00:00:00.000Z",
+        })),
+        loadChapterIndex: vi.fn(async () => []),
+        saveChapterIndex: vi.fn(async () => undefined),
+        listBooks: vi.fn(async () => ["harbor"]),
+      },
+    );
+
+    await tools.updateCurrentFocus("harbor", "# Current Focus\n\nBring focus back.\n");
+    await tools.updateAuthorIntent("harbor", "# Author Intent\n\nWrite a harbor mystery.\n");
+
+    await expect(readFile(join(projectRoot, "books", "harbor", "story", "current_focus.md"), "utf-8"))
+      .resolves.toContain("Bring focus back");
+    await expect(readFile(join(projectRoot, "books", "harbor", "story", "author_intent.md"), "utf-8"))
+      .resolves.toContain("harbor mystery");
+  });
+
+  it("forwards foundation draft fields into shared book creation", async () => {
+    const pipeline = {
+      initBook: vi.fn(async () => undefined),
+      writeNextChapter: vi.fn(async () => ({
+        chapterNumber: 1,
+        title: "Draft",
+        wordCount: 1000,
+        revised: false,
+        status: "ready-for-review" as const,
+        auditResult: { passed: true, issues: [], summary: "ok" },
+      })),
+      reviseDraft: vi.fn(async () => ({
+        chapterNumber: 3,
+        wordCount: 1200,
+        fixedIssues: [],
+        applied: true,
+        status: "ready-for-review" as const,
+      })),
+    };
+    const state = {
+      ensureControlDocuments: vi.fn(async () => {}),
+      bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadBookConfig: vi.fn(async () => ({
+        id: "night-harbor",
+        title: "Night Harbor",
+        platform: "other" as const,
+        genre: "urban",
+        status: "outlining" as const,
+        targetChapters: 120,
+        chapterWordCount: 2800,
+        createdAt: "2026-04-10T00:00:00.000Z",
+        updatedAt: "2026-04-10T00:00:00.000Z",
+      })),
+      loadChapterIndex: vi.fn(async () => []),
+      saveChapterIndex: vi.fn(async () => undefined),
+      listBooks: vi.fn(async () => []),
+    };
+
+    const tools = createInteractionToolsFromDeps(pipeline, state);
+    await tools.createBook?.({
+      title: "Night Harbor",
+      genre: "urban",
+      platform: "tomato",
+      chapterWordCount: 2800,
+      targetChapters: 120,
+      blurb: "一个做灰产生意的人,准备在夜港洗白,却先被旧账拖回去。",
+      worldPremise: "近未来架空香港,港口账本牵出多方势力。",
+      protagonist: "林砚,水货账房出身,聪明克制,不轻易信人。",
+      conflictCore: "洗白与旧债回潮的对撞。",
+      volumeOutline: "卷一先查账,再暴露港口旧案。",
+      authorIntent: "# 作者意图\n\n写成冷硬、克制、利益驱动的商战悬疑。\n",
+      currentFocus: "# 当前聚焦\n\n先把旧账线和港口势力网立住。\n",
+    });
+
+    expect(pipeline.initBook).toHaveBeenCalledWith(
+      expect.objectContaining({
+        title: "Night Harbor",
+        genre: "urban",
+        platform: "tomato",
+        targetChapters: 120,
+        chapterWordCount: 2800,
+      }),
+      expect.objectContaining({
+        externalContext: expect.stringContaining("近未来架空香港"),
+        authorIntent: expect.stringContaining("冷硬、克制"),
+        currentFocus: expect.stringContaining("旧账线"),
+      }),
+    );
+  });
+
+  it("builds a reusable chapter lookup from a single directory listing", () => {
+    const lookup = buildChapterFileLookup([
+      "0001_First.md",
+      "0002_Second.md",
+      "notes.txt",
+      "0002_Second.backup",
+    ]);
+
+    expect(lookup.get(1)).toBe("0001_First.md");
+    expect(lookup.get(2)).toBe("0002_Second.md");
+    expect(lookup.size).toBe(2);
+  });
+});

+ 240 - 6
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -306,6 +306,8 @@ describe("PipelineRunner", () => {
         projectRoot: process.cwd(),
         defaultLLMConfig: {
           provider: "custom",
+          service: "custom",
+          configSource: "env",
           baseUrl: "https://base.example/v1",
           apiKey: "base-key",
           model: "base-model",
@@ -407,6 +409,67 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("applies creation-draft overrides while initializing a book", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-init-book-overrides-"));
+    const bookId = "override-book";
+    const book: BookConfig = {
+      id: bookId,
+      title: "Override Book",
+      platform: "tomato",
+      genre: "xuanhuan",
+      status: "outlining",
+      targetChapters: 20,
+      chapterWordCount: 2800,
+      createdAt: "2026-04-13T00:00:00.000Z",
+      updatedAt: "2026-04-13T00:00:00.000Z",
+    };
+
+    const runner = new PipelineRunner({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+        },
+      } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const generateFoundationSpy = vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({
+      storyBible: "# Story Bible\n",
+      volumeOutline: "# Volume Outline\n",
+      bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
+      currentState: "# Current State\n",
+      pendingHooks: "# Pending Hooks\n",
+    });
+
+    try {
+      await runner.initBook(book, {
+        externalContext: "世界观重点:近未来港口城,账本与旧案牵出多方势力。",
+        authorIntent: "# 作者意图\n\n写成冷硬、克制、利益驱动的商战悬疑。\n",
+        currentFocus: "# 当前聚焦\n\n先把旧账线和港口势力网立住。\n",
+      });
+
+      expect(generateFoundationSpy).toHaveBeenCalledWith(
+        book,
+        expect.stringContaining("近未来港口城"),
+        undefined,
+      );
+
+      const storyDir = join(root, "books", bookId, "story");
+      await expect(readFile(join(storyDir, "author_intent.md"), "utf-8"))
+        .resolves.toContain("冷硬、克制、利益驱动");
+      await expect(readFile(join(storyDir, "current_focus.md"), "utf-8"))
+        .resolves.toContain("旧账线和港口势力网");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("feeds foundation review feedback into the regeneration call after a rejection", async () => {
     const { root, runner, bookId } = await createRunnerFixture();
     const reviewer = new FoundationReviewerAgent({
@@ -970,6 +1033,7 @@ describe("PipelineRunner", () => {
       const result = await runner.writeDraft(bookId);
 
       expect(result.chapterNumber).toBe(1);
+      console.log("DEBUG warnings:", JSON.stringify(warnings, null, 2));
       expect(warnings).toContain(
         "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。",
       );
@@ -2419,11 +2483,10 @@ describe("PipelineRunner", () => {
     await rm(root, { recursive: true, force: true });
   });
 
-  it("does not persist chapter files or index entries when state validation errors before save", async () => {
+  it("degrades to state-degraded when state validation errors instead of aborting", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture({
       inputGovernanceMode: "legacy",
     });
-    const chaptersDir = join(state.bookDir(bookId), "chapters");
 
     vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
       createWriterOutput({
@@ -2442,9 +2505,13 @@ describe("PipelineRunner", () => {
       new Error("LLM returned empty response"),
     );
 
-    await expect(runner.writeNextChapter(bookId)).rejects.toThrow("LLM returned empty response");
-    await expect(readdir(chaptersDir)).resolves.toEqual([]);
-    await expect(state.loadChapterIndex(bookId)).resolves.toEqual([]);
+    const result = await runner.writeNextChapter(bookId);
+    expect(result.status).toBe("state-degraded");
+
+    // Chapter should be saved (content is fine, only truth files are degraded)
+    const index = await state.loadChapterIndex(bookId);
+    expect(index).toHaveLength(1);
+    expect(index[0]!.status).toBe("state-degraded");
 
     await rm(root, { recursive: true, force: true });
   });
@@ -2658,7 +2725,7 @@ describe("PipelineRunner", () => {
       }]),
     ]);
 
-    vi.spyOn(
+    const settleSpy = vi.spyOn(
       WriterAgent.prototype as unknown as {
         settleChapterState: (input: Record<string, unknown>) => Promise<WriteChapterOutput>;
       },
@@ -2691,6 +2758,9 @@ describe("PipelineRunner", () => {
 
     expect(result.status).toBe("ready-for-review");
     expect(result.chapterNumber).toBe(1);
+    expect(settleSpy).toHaveBeenCalledWith(expect.objectContaining({
+      allowReapply: true,
+    }));
     await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("fixed state");
     await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe("fixed hooks");
     expect(savedIndex[0]?.status).toBe("ready-for-review");
@@ -2700,6 +2770,91 @@ describe("PipelineRunner", () => {
     await rm(root, { recursive: true, force: true });
   });
 
+  it("syncs the latest edited chapter body back into truth files without requiring state-degraded status", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "v2",
+      externalContext: "把注意力收回师债主线。",
+    });
+    const now = "2026-03-19T00:00:00.000Z";
+    const bookDir = state.bookDir(bookId);
+    const storyDir = join(bookDir, "story");
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_focus.md"), "# 当前聚焦\n\n## 当前重点\n\n商会路线优先。\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# 卷纲\n\n## 第1章\n先处理商会路线噪音。\n", "utf-8"),
+      writeFile(join(storyDir, "story_bible.md"), "# 世界观设定\n\n- 誓令碎片不可伪造。\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"),
+      writeFile(join(storyDir, "particle_ledger.md"), "stable ledger", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), [
+        "# 章节摘要",
+        "",
+        "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+        "| --- | --- | --- | --- | --- | --- | --- | --- |",
+        "| 1 | 夜灯 | 林越 | 林越继续追查师债 | 追查意图更强 | 师债推进 | 压抑 | 主线推进 |",
+        "",
+      ].join("\n"), "utf-8"),
+      writeFile(
+        join(bookDir, "chapters", "0001_夜灯.md"),
+        "# 第1章 夜灯\n\n林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。",
+        "utf-8",
+      ),
+      state.saveChapterIndex(bookId, [{
+        number: 1,
+        title: "夜灯",
+        status: "approved" as ChapterMeta["status"],
+        wordCount: 55,
+        createdAt: now,
+        updatedAt: now,
+        auditIssues: [],
+        lengthWarnings: [],
+      }]),
+    ]);
+
+    const settleSpy = vi.spyOn(
+      WriterAgent.prototype as unknown as {
+        settleChapterState: (input: Record<string, unknown>) => Promise<WriteChapterOutput>;
+      },
+      "settleChapterState",
+    ).mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        title: "夜灯",
+        content: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。",
+        wordCount: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。".length,
+        updatedState: "synced state",
+        updatedHooks: "synced hooks",
+        updatedLedger: "synced ledger",
+      }),
+    );
+    vi.spyOn(StateValidatorAgent.prototype, "validate").mockResolvedValue({
+      passed: true,
+      warnings: [],
+    });
+
+    const result = await (
+      runner as unknown as {
+        resyncChapterArtifacts: (bookId: string, chapterNumber?: number) => Promise<{
+          status: string;
+          chapterNumber: number;
+        }>;
+      }
+    ).resyncChapterArtifacts(bookId, 1);
+    const savedIndex = await state.loadChapterIndex(bookId);
+
+    expect(result.status).toBe("ready-for-review");
+    expect(result.chapterNumber).toBe(1);
+    expect(settleSpy).toHaveBeenCalledWith(expect.objectContaining({
+      allowReapply: true,
+      chapterIntent: expect.stringContaining("把注意力收回师债主线"),
+    }));
+    await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("synced state");
+    await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe("synced hooks");
+    expect(savedIndex[0]?.status).toBe("ready-for-review");
+
+    await rm(root, { recursive: true, force: true });
+  });
+
   it("still persists the chapter when the state validator appends markdown after a valid JSON verdict", async () => {
     vi.restoreAllMocks();
     vi.spyOn(LengthNormalizerAgent.prototype, "normalizeChapter").mockImplementation(
@@ -4197,6 +4352,85 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("passes one-off external brief into manual revise in v2 mode", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "v2",
+      externalContext: "把注意力收回师债主线,并强调柜台后的异常灯光。",
+    });
+    const storyDir = join(state.bookDir(bookId), "story");
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+    const originalBody = "林越推门进去,先看见柜台后那盏没关的灯。";
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_focus.md"), "# 当前聚焦\n\n## 当前重点\n\n商会路线优先。\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# 卷纲\n\n## 第1章\n先处理商会路线噪音。\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 1,
+        location: "旧港便利店",
+        protagonistState: "林越仍在追查师债。",
+        goal: "把注意力拉回师债线索。",
+        conflict: "商会路线仍在分散注意力。",
+      }), "utf-8"),
+      writeFile(join(storyDir, "story_bible.md"), "# 世界观设定\n\n- 誓令碎片不可伪造。\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# 伏笔池\n\n- 师债线索仍未回收。\n", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), [
+        "# 章节摘要",
+        "",
+        "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+        "| --- | --- | --- | --- | --- | --- | --- | --- |",
+        "| 1 | 夜灯 | 林越 | 林越继续追查师债 | 追查意图更强 | 师债推进 | 压抑 | 主线推进 |",
+        "",
+      ].join("\n"), "utf-8"),
+      writeFile(join(chaptersDir, "0001_夜灯.md"), `# 第1章 夜灯\n\n${originalBody}`, "utf-8"),
+    ]);
+    await state.saveChapterIndex(bookId, [{
+      number: 1,
+      title: "夜灯",
+      status: "audit-failed",
+      wordCount: originalBody.length,
+      createdAt: "2026-03-19T00:00:00.000Z",
+      updatedAt: "2026-03-19T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+    }]);
+
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: false,
+          issues: [CRITICAL_ISSUE],
+          summary: "needs revision",
+        }),
+      )
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: true,
+          issues: [],
+          summary: "clean",
+        }),
+      );
+    const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。",
+        wordCount: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。".length,
+        fixedIssues: ["- 收紧了主线焦点。"],
+      }),
+    );
+
+    try {
+      await runner.reviseDraft(bookId, 1);
+
+      expect(reviseChapter.mock.calls[0]?.[6]).toMatchObject({
+        chapterIntent: expect.stringContaining("把注意力收回师债主线"),
+      });
+      expect(reviseChapter.mock.calls[0]?.[6]).not.toMatchObject({
+        chapterIntent: expect.stringContaining("商会路线优先"),
+      });
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("passes merged AI-tell issues into manual revise and rejects no-improvement revisions", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const storyDir = join(state.bookDir(bookId), "story");

+ 241 - 0
packages/core/src/__tests__/project-interaction.test.ts

@@ -0,0 +1,241 @@
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import {
+  createProjectSession,
+  loadProjectSession,
+  persistProjectSession,
+} from "../interaction/project-session-store.js";
+import {
+  processProjectInteractionInput,
+  processProjectInteractionRequest,
+} from "../interaction/project-control.js";
+
+let projectRoot: string;
+
+describe("project interaction control", () => {
+  beforeAll(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-project-control-"));
+    await mkdir(join(projectRoot, "books", "harbor"), { recursive: true });
+    await writeFile(join(projectRoot, "books", "harbor", "book.json"), "{}", "utf-8");
+  });
+
+  afterAll(async () => {
+    // tmpdir cleanup omitted
+  });
+
+  it("routes continue through the persisted active book", async () => {
+    await persistProjectSession(projectRoot, {
+      ...createProjectSession(projectRoot),
+      activeBookId: "harbor",
+    });
+
+    const tools = {
+      listBooks: vi.fn(async () => ["harbor"]),
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+      writeTruthFile: vi.fn(async () => ({ ok: true })),
+    };
+
+    const result = await processProjectInteractionInput({
+      projectRoot,
+      input: "continue",
+      tools,
+    });
+
+    expect(tools.writeNextChapter).toHaveBeenCalledWith("harbor");
+    expect(result.session.activeBookId).toBe("harbor");
+    expect(result.request.intent).toBe("write_next");
+    expect(result.session.events.map((event) => event.kind)).toEqual([
+      "task.started",
+      "task.completed",
+    ]);
+  });
+
+  it("persists mode switches in the project session", async () => {
+    await persistProjectSession(projectRoot, {
+      ...createProjectSession(projectRoot),
+      activeBookId: "harbor",
+    });
+
+    const result = await processProjectInteractionInput({
+      projectRoot,
+      input: "切换到全自动",
+      tools: {
+        listBooks: vi.fn(async () => ["harbor"]),
+        writeNextChapter: vi.fn(async () => ({ ok: true })),
+        reviseDraft: vi.fn(async () => ({ ok: true })),
+        patchChapterText: vi.fn(async () => ({ ok: true })),
+        renameEntity: vi.fn(async () => ({ ok: true })),
+        updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+        updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+        writeTruthFile: vi.fn(async () => ({ ok: true })),
+      },
+    });
+
+    expect(result.session.automationMode).toBe("auto");
+    expect(result.session.events.map((event) => event.kind)).toEqual([
+      "task.started",
+      "task.completed",
+    ]);
+  });
+
+  it("persists failed execution state when a routed action throws", async () => {
+    await persistProjectSession(projectRoot, {
+      ...createProjectSession(projectRoot),
+      activeBookId: "harbor",
+    });
+
+    await expect(processProjectInteractionInput({
+      projectRoot,
+      input: "continue",
+      tools: {
+        listBooks: vi.fn(async () => ["harbor"]),
+        writeNextChapter: vi.fn(async () => {
+          throw new Error("boom");
+        }),
+        reviseDraft: vi.fn(async () => ({ ok: true })),
+        patchChapterText: vi.fn(async () => ({ ok: true })),
+        renameEntity: vi.fn(async () => ({ ok: true })),
+        updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+        updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+        writeTruthFile: vi.fn(async () => ({ ok: true })),
+      },
+    })).rejects.toThrow("boom");
+
+    const failedSession = await loadProjectSession(projectRoot);
+    expect(failedSession.currentExecution?.status).toBe("failed");
+    expect(failedSession.events.at(-1)?.kind).toBe("task.failed");
+    expect(failedSession.events.at(-1)?.detail).toContain("boom");
+  });
+
+  it("persists book selection into the shared project session", async () => {
+    await persistProjectSession(projectRoot, createProjectSession(projectRoot));
+
+    const result = await processProjectInteractionInput({
+      projectRoot,
+      input: "/open harbor",
+      tools: {
+        writeNextChapter: vi.fn(async () => ({ ok: true })),
+        reviseDraft: vi.fn(async () => ({ ok: true })),
+        patchChapterText: vi.fn(async () => ({ ok: true })),
+        renameEntity: vi.fn(async () => ({ ok: true })),
+        updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+        updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+        writeTruthFile: vi.fn(async () => ({ ok: true })),
+        listBooks: vi.fn(async () => ["harbor"]),
+      },
+    });
+
+    expect(result.session.activeBookId).toBe("harbor");
+    expect(result.request.intent).toBe("select_book");
+  });
+
+  it("persists structured create_book requests into the shared project session", async () => {
+    await persistProjectSession(projectRoot, createProjectSession(projectRoot));
+
+    const tools = {
+      listBooks: vi.fn(async () => ["harbor"]),
+      createBook: vi.fn(async () => ({
+        bookId: "night-harbor",
+        title: "Night Harbor",
+        __interaction: {
+          responseText: "Created Night Harbor.",
+        },
+      })),
+      exportBook: vi.fn(async () => ({ ok: true })),
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+      writeTruthFile: vi.fn(async () => ({ ok: true })),
+    };
+
+    const result = await processProjectInteractionRequest({
+      projectRoot,
+      request: {
+        intent: "create_book",
+        title: "Night Harbor",
+        genre: "urban",
+        platform: "tomato",
+        chapterWordCount: 2800,
+        targetChapters: 120,
+      },
+      tools,
+    });
+
+    expect(tools.createBook).toHaveBeenCalledWith({
+      title: "Night Harbor",
+      genre: "urban",
+      platform: "tomato",
+      chapterWordCount: 2800,
+      targetChapters: 120,
+    });
+    expect(result.session.activeBookId).toBe("night-harbor");
+
+    const persisted = await loadProjectSession(projectRoot);
+    expect(persisted.activeBookId).toBe("night-harbor");
+  });
+
+  it("persists a creation draft across freeform ideation turns", async () => {
+    const ideationRoot = await mkdtemp(join(tmpdir(), "inkos-project-ideation-"));
+    await writeFile(join(ideationRoot, "inkos.json"), JSON.stringify({ language: "zh" }), "utf-8");
+    await persistProjectSession(ideationRoot, createProjectSession(ideationRoot));
+
+    const tools = {
+      listBooks: vi.fn(async () => ["harbor"]),
+      developBookDraft: vi.fn(async () => ({
+        __interaction: {
+          responseText: "我先按港风商战悬疑收着。你更想写长篇连载,还是十来章能收住?",
+          details: {
+            creationDraft: {
+              concept: "港风商战悬疑,主角从灰产洗白。",
+              title: "夜港账本",
+              genre: "urban",
+              nextQuestion: "更想写长篇连载,还是十来章能收住?",
+              missingFields: ["targetChapters"],
+              readyToCreate: false,
+            },
+          },
+        },
+      })),
+      createBook: vi.fn(async () => ({ ok: true })),
+      exportBook: vi.fn(async () => ({ ok: true })),
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+      writeTruthFile: vi.fn(async () => ({ ok: true })),
+    };
+
+    try {
+      const result = await processProjectInteractionInput({
+        projectRoot: ideationRoot,
+        input: "我想写个港风商战悬疑,主角从灰产洗白。",
+        tools,
+      });
+
+      expect(result.request.intent).toBe("develop_book");
+      expect(result.session.creationDraft).toEqual(expect.objectContaining({
+        title: "夜港账本",
+        genre: "urban",
+      }));
+
+      const persisted = await loadProjectSession(ideationRoot);
+      expect(persisted.creationDraft).toEqual(expect.objectContaining({
+        title: "夜港账本",
+      }));
+    } finally {
+      await rm(ideationRoot, { recursive: true, force: true });
+    }
+  });
+});

+ 452 - 102
packages/core/src/__tests__/provider.test.ts

@@ -1,12 +1,131 @@
-import { describe, expect, it, vi } from "vitest";
-import type OpenAI from "openai";
-import { chatCompletion, type LLMClient } from "../llm/provider.js";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { AssistantMessage, Model, Api } from "@mariozechner/pi-ai";
+import {
+  __resetFixedTemperatureWarnings,
+  chatCompletion,
+  type LLMClient,
+} from "../llm/provider.js";
 
-const ZERO_USAGE = {
-  prompt_tokens: 11,
-  completion_tokens: 7,
-  total_tokens: 18,
-} as const;
+// ── Mock @mariozechner/pi-ai ──────────────────────────────────────────────────
+// We intercept streamSimple so tests don't hit the network.
+
+const mockStreamSimple = vi.fn();
+const mockCompleteSimple = vi.fn();
+const mockComplete = vi.fn();
+
+vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
+  const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
+  return {
+    ...original,
+    streamSimple: (...args: unknown[]) => mockStreamSimple(...args),
+    completeSimple: (...args: unknown[]) => mockCompleteSimple(...args),
+    complete: (...args: unknown[]) => mockComplete(...args),
+  };
+});
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+const MOCK_USAGE = {
+  input: 11,
+  output: 7,
+  cacheRead: 0,
+  cacheWrite: 0,
+  totalTokens: 18,
+  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+};
+
+function makeAssistantMessage(text: string): AssistantMessage {
+  return {
+    role: "assistant",
+    content: [{ type: "text", text }],
+    api: "openai-completions" as Api,
+    provider: "openai",
+    model: "test-model",
+    usage: MOCK_USAGE,
+    stopReason: "stop",
+    timestamp: Date.now(),
+  };
+}
+
+/** Builds an async iterable that emits the given events. */
+function makeEventStream(
+  events: Array<Record<string, unknown>>,
+): AsyncIterable<Record<string, unknown>> {
+  return {
+    [Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
+      let i = 0;
+      return {
+        async next() {
+          if (i < events.length) return { value: events[i++]!, done: false };
+          return { value: undefined as unknown as Record<string, unknown>, done: true };
+        },
+      };
+    },
+  };
+}
+
+/** Stream that emits one text_delta and then done. */
+function makeTextStream(text: string): AsyncIterable<Record<string, unknown>> {
+  const msg = makeAssistantMessage(text);
+  return makeEventStream([
+    { type: "text_delta", contentIndex: 0, delta: text, partial: msg },
+    { type: "done", reason: "stop", message: msg },
+  ]);
+}
+
+/** Stream that emits only done with empty content. */
+function makeEmptyStream(): AsyncIterable<Record<string, unknown>> {
+  const msg = makeAssistantMessage("");
+  return makeEventStream([
+    { type: "done", reason: "stop", message: msg },
+  ]);
+}
+
+/** Stream that throws immediately. */
+function makeErrorStream(message: string): AsyncIterable<Record<string, unknown>> {
+  return {
+    [Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
+      return {
+        async next() {
+          throw new Error(message);
+        },
+      };
+    },
+  };
+}
+
+const MOCK_PI_MODEL: Model<Api> = {
+  id: "test-model",
+  name: "test-model",
+  api: "openai-completions",
+  provider: "openai",
+  baseUrl: "https://api.openai.com/v1",
+  reasoning: false,
+  input: ["text"],
+  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+  contextWindow: 128000,
+  maxTokens: 8192,
+};
+
+function makeClient(temperature = 0.7, extra: Partial<LLMClient> = {}): LLMClient {
+  return {
+    provider: "openai",
+    service: "openai",
+    configSource: "studio",
+    apiFormat: "chat",
+    stream: true,
+    _piModel: MOCK_PI_MODEL,
+    _apiKey: "test-key",
+    defaults: {
+      temperature,
+      maxTokens: 512,
+      thinkingBudget: 0,
+      maxTokensCap: null,
+      extra: {},
+    },
+    ...extra,
+  };
+}
 
 async function captureError(task: Promise<unknown>): Promise<Error> {
   try {
@@ -17,120 +136,351 @@ async function captureError(task: Promise<unknown>): Promise<Error> {
   throw new Error("Expected promise to reject");
 }
 
-describe("chatCompletion stream fallback", () => {
-  it("falls back to sync chat completion when streamed chat returns no chunks", async () => {
-    const create = vi.fn()
-      .mockResolvedValueOnce({
-        async *[Symbol.asyncIterator](): AsyncIterableIterator<unknown> {
-          return;
-        },
-      })
-      .mockResolvedValueOnce({
-        choices: [{ message: { content: "fallback content" } }],
-        usage: ZERO_USAGE,
-      });
-
-    const client: LLMClient = {
-      provider: "openai",
-      apiFormat: "chat",
-      stream: true,
-      _openai: {
-        chat: {
-          completions: {
-            create,
-          },
-        },
-      } as unknown as OpenAI,
-      defaults: {
-        temperature: 0.7,
-        maxTokens: 512,
-        thinkingBudget: 0, maxTokensCap: null,
-        extra: {},
-      },
-    };
+// ── Tests ─────────────────────────────────────────────────────────────────────
 
+describe("chatCompletion via pi-ai", () => {
+  beforeEach(() => {
+    mockStreamSimple.mockReset();
+    mockCompleteSimple.mockReset();
+    mockComplete.mockReset();
+  });
+
+  it("returns text content from a successful stream", async () => {
+    mockStreamSimple.mockReturnValue(makeTextStream("hello world"));
+
+    const client = makeClient();
     const result = await chatCompletion(client, "test-model", [
       { role: "user", content: "ping" },
     ]);
 
-    expect(result.content).toBe("fallback content");
-    expect(result.usage).toEqual({
-      promptTokens: 11,
-      completionTokens: 7,
-      totalTokens: 18,
+    expect(result.content).toBe("hello world");
+    expect(result.usage.promptTokens).toBe(11);
+    expect(result.usage.completionTokens).toBe(7);
+    expect(result.usage.totalTokens).toBe(18);
+    expect(mockStreamSimple).toHaveBeenCalledOnce();
+  });
+
+  it("throws when stream produces no text content", async () => {
+    mockStreamSimple.mockReturnValue(makeEmptyStream());
+
+    const client = makeClient();
+    const error = await captureError(
+      chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
+    );
+
+    expect(error.message).toContain("empty response");
+  });
+
+  it("wraps 400 API errors with a user-friendly message", async () => {
+    mockStreamSimple.mockReturnValue(makeErrorStream("400 Bad Request"));
+
+    const client = makeClient();
+    const error = await captureError(
+      chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
+    );
+
+    expect(error.message).toContain("API 返回 400");
+    expect(error.message).toContain("检查提供方文档");
+  });
+
+  it("wraps 401 errors with an unauthorized message", async () => {
+    mockStreamSimple.mockReturnValue(makeErrorStream("401 Unauthorized"));
+
+    const client = makeClient();
+    const error = await captureError(
+      chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
+    );
+
+    expect(error.message).toContain("API 返回 401");
+  });
+
+  it("wraps connection errors with a friendly message", async () => {
+    mockStreamSimple.mockReturnValue(makeErrorStream("fetch failed: ECONNREFUSED"));
+
+    const client = makeClient();
+    const error = await captureError(
+      chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
+    );
+
+    expect(error.message).toContain("无法连接到 API 服务");
+  });
+
+  it("passes temperature and maxTokens to streamSimple", async () => {
+    mockStreamSimple.mockReturnValue(makeTextStream("ok"));
+
+    const client = makeClient(0.5);
+    await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }], {
+      temperature: 0.3,
+      maxTokens: 256,
     });
-    expect(create).toHaveBeenCalledTimes(2);
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ stream: true });
-    expect(create.mock.calls[1]?.[0]).toMatchObject({ stream: false });
+
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(0.3);
+    expect(opts.maxTokens).toBe(256);
+  });
+
+  it("uses client defaults when no per-call overrides are provided", async () => {
+    mockStreamSimple.mockReturnValue(makeTextStream("ok"));
+
+    const client = makeClient(0.8);
+    await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }]);
+
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(0.8);
+    expect(opts.maxTokens).toBe(512);
   });
 
-  it("does not blindly suggest stream false for generic 400 errors", async () => {
-    const create = vi.fn().mockRejectedValue(new Error("400 Bad Request"));
+  it("calls onTextDelta for each text chunk", async () => {
+    const msg = makeAssistantMessage("abc");
+    mockStreamSimple.mockReturnValue(makeEventStream([
+      { type: "text_delta", contentIndex: 0, delta: "a", partial: msg },
+      { type: "text_delta", contentIndex: 0, delta: "b", partial: msg },
+      { type: "text_delta", contentIndex: 0, delta: "c", partial: msg },
+      { type: "done", reason: "stop", message: msg },
+    ]));
 
-    const client: LLMClient = {
-      provider: "openai",
-      apiFormat: "chat",
+    const deltas: string[] = [];
+    const client = makeClient();
+    await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }], {
+      onTextDelta: (d) => deltas.push(d),
+    });
+
+    expect(deltas).toEqual(["a", "b", "c"]);
+  });
+
+  it("uses completeSimple when client.stream is false", async () => {
+    mockCompleteSimple.mockResolvedValue(makeAssistantMessage("offline hello"));
+
+    const client = makeClient(0.7, { stream: false });
+    const result = await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }]);
+
+    expect(result.content).toBe("offline hello");
+    expect(mockCompleteSimple).toHaveBeenCalledOnce();
+    expect(mockStreamSimple).not.toHaveBeenCalled();
+  });
+
+  it("uses native fetch transport for custom openai-compatible chat", async () => {
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        choices: [{ message: { content: "你好!" } }],
+        usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
+      }),
+    });
+    vi.stubGlobal("fetch", fetchMock);
+
+    const client = makeClient(0.7, {
+      service: "custom",
       stream: false,
-      _openai: {
-        chat: {
-          completions: {
-            create,
-          },
-        },
-      } as unknown as OpenAI,
-      defaults: {
-        temperature: 0.7,
-        maxTokens: 512,
-        thinkingBudget: 0, maxTokensCap: null,
-        extra: {},
+      _piModel: {
+        ...MOCK_PI_MODEL,
+        provider: "openai",
+        baseUrl: "https://gateway.example/v1",
       },
-    };
+    });
+    const result = await chatCompletion(client, "gpt-5.4", [{ role: "user", content: "nihao" }]);
 
-    const error = await captureError(chatCompletion(client, "test-model", [
-      { role: "user", content: "ping" },
-    ]));
+    expect(result.content).toBe("你好!");
+    expect(fetchMock).toHaveBeenCalledOnce();
+    expect(mockCompleteSimple).not.toHaveBeenCalled();
+    expect(mockStreamSimple).not.toHaveBeenCalled();
 
-    expect(error.message).toContain("API 返回 400");
-    expect(error.message).not.toContain("\"stream\": false");
-    expect(error.message).toContain("检查提供方文档");
+    vi.unstubAllGlobals();
+  });
+
+  it("keeps legacy env custom openai-compatible chat on pi-ai path", async () => {
+    const fetchMock = vi.fn();
+    vi.stubGlobal("fetch", fetchMock);
+    mockCompleteSimple.mockResolvedValue(makeAssistantMessage("legacy ok"));
+
+    const client = makeClient(0.7, {
+      service: "custom",
+      configSource: "env",
+      stream: false,
+      _piModel: {
+        ...MOCK_PI_MODEL,
+        provider: "openai",
+        baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
+      },
+    });
+
+    const result = await chatCompletion(client, "gemma-4", [{ role: "user", content: "ping" }]);
+
+    expect(result.content).toBe("legacy ok");
+    expect(mockCompleteSimple).toHaveBeenCalledOnce();
+    expect(fetchMock).not.toHaveBeenCalled();
+
+    vi.unstubAllGlobals();
   });
 
-  it("reports when sync fallback is rejected because provider requires streaming", async () => {
-    const create = vi.fn()
-      .mockResolvedValueOnce({
-        async *[Symbol.asyncIterator](): AsyncIterableIterator<unknown> {
-          return;
+  it("uses native fetch transport for custom anthropic-compatible non-stream chat", async () => {
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        content: [{ type: "text", text: "你好,Anthropic!" }],
+        usage: { input_tokens: 5, output_tokens: 3 },
+      }),
+    });
+    vi.stubGlobal("fetch", fetchMock);
+
+    const client = makeClient(0.7, {
+      provider: "anthropic",
+      service: "custom",
+      stream: false,
+      _piModel: {
+        ...MOCK_PI_MODEL,
+        provider: "anthropic",
+        api: "anthropic-messages" as Api,
+        baseUrl: "https://gateway.example",
+      },
+    });
+    const result = await chatCompletion(client, "claude-sonnet-4-6", [{ role: "user", content: "nihao" }]);
+
+    expect(result.content).toBe("你好,Anthropic!");
+    expect(result.usage.promptTokens).toBe(5);
+    expect(result.usage.completionTokens).toBe(3);
+    expect(fetchMock).toHaveBeenCalledOnce();
+    expect(mockCompleteSimple).not.toHaveBeenCalled();
+    expect(mockStreamSimple).not.toHaveBeenCalled();
+
+    vi.unstubAllGlobals();
+  });
+
+  it("uses native fetch transport for custom anthropic-compatible stream chat", async () => {
+    const encoder = new TextEncoder();
+    const sse = [
+      "event: message_start\n",
+      "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":4}}}\n\n",
+      "event: content_block_delta\n",
+      "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你\"}}\n\n",
+      "event: content_block_delta\n",
+      "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"好\"}}\n\n",
+      "event: message_delta\n",
+      "data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":2}}\n\n",
+      "event: message_stop\n",
+      "data: {\"type\":\"message_stop\"}\n\n",
+    ].join("");
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: true,
+      body: new ReadableStream({
+        start(controller) {
+          controller.enqueue(encoder.encode(sse));
+          controller.close();
         },
-      })
-      .mockRejectedValueOnce(new Error("400 {\"detail\":\"Stream must be set to true\"}"));
+      }),
+    });
+    vi.stubGlobal("fetch", fetchMock);
 
-    const client: LLMClient = {
-      provider: "openai",
-      apiFormat: "chat",
+    const client = makeClient(0.7, {
+      provider: "anthropic",
+      service: "custom",
       stream: true,
-      _openai: {
-        chat: {
-          completions: {
-            create,
-          },
-        },
-      } as unknown as OpenAI,
-      defaults: {
-        temperature: 0.7,
-        maxTokens: 512,
-        thinkingBudget: 0, maxTokensCap: null,
-        extra: {},
+      _piModel: {
+        ...MOCK_PI_MODEL,
+        provider: "anthropic",
+        api: "anthropic-messages" as Api,
+        baseUrl: "https://gateway.example",
       },
-    };
+    });
+    const result = await chatCompletion(client, "claude-sonnet-4-6", [{ role: "user", content: "nihao" }]);
 
-    const error = await captureError(chatCompletion(client, "test-model", [
-      { role: "user", content: "ping" },
-    ]));
+    expect(result.content).toBe("你好");
+    expect(result.usage.promptTokens).toBe(4);
+    expect(result.usage.completionTokens).toBe(2);
+    expect(result.usage.totalTokens).toBe(6);
+    expect(fetchMock).toHaveBeenCalledOnce();
+    expect(mockCompleteSimple).not.toHaveBeenCalled();
+    expect(mockStreamSimple).not.toHaveBeenCalled();
+
+    vi.unstubAllGlobals();
+  });
+});
+
+describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
+  beforeEach(() => {
+    __resetFixedTemperatureWarnings();
+    mockStreamSimple.mockReset();
+    mockStreamSimple.mockReturnValue(makeTextStream("ok"));
+  });
+
+  it("forces temperature=1 for kimi-k2.5 even when client default is 0.7", async () => {
+    const client = makeClient(0.7);
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+    await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "hi" }]);
+
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(1);
+    expect(warn).toHaveBeenCalledOnce();
+    expect(warn.mock.calls[0]?.[0]).toContain("kimi-k2.5");
+    warn.mockRestore();
+  });
+
+  it("clamps per-call temperature override (0.3) to 1 for kimi-k2.5", async () => {
+    const client = makeClient(0.7);
+    vi.spyOn(console, "warn").mockImplementation(() => {});
+
+    await chatCompletion(
+      client,
+      "kimi-k2.5",
+      [{ role: "user", content: "hi" }],
+      { temperature: 0.3 },
+    );
+
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(1);
+  });
+
+  it("only warns once per model name across multiple calls", async () => {
+    const client = makeClient(0.7);
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+    await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "a" }]);
+    await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "b" }]);
+    await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "c" }]);
+
+    expect(warn).toHaveBeenCalledOnce();
+    warn.mockRestore();
+  });
+
+  it("also clamps any model name containing 'thinking'", async () => {
+    const client = makeClient(0.5);
+    vi.spyOn(console, "warn").mockImplementation(() => {});
+
+    await chatCompletion(client, "kimi-thinking-preview", [
+      { role: "user", content: "hi" },
+    ]);
+
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(1);
+  });
+
+  it("leaves regular models untouched (no clamp, no warning)", async () => {
+    const client = makeClient(0.7);
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+    await chatCompletion(
+      client,
+      "moonshot-v1-32k",
+      [{ role: "user", content: "hi" }],
+      { temperature: 0.3 },
+    );
+
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(0.3);
+    expect(warn).not.toHaveBeenCalled();
+    warn.mockRestore();
+  });
+
+  it("does not warn when requested temperature is already 1", async () => {
+    const client = makeClient(1);
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+    await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "hi" }]);
 
-    expect(create).toHaveBeenCalledTimes(2);
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ stream: true });
-    expect(create.mock.calls[1]?.[0]).toMatchObject({ stream: false });
-    expect(error.message).toContain("stream:true");
-    expect(error.message).not.toContain("\"stream\": false");
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(1);
+    expect(warn).not.toHaveBeenCalled();
+    warn.mockRestore();
   });
 });

+ 95 - 0
packages/core/src/__tests__/secrets.test.ts

@@ -0,0 +1,95 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { loadSecrets, saveSecrets, getServiceApiKey } from "../llm/secrets.js";
+import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+describe("secrets", () => {
+  let root: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-secrets-"));
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  describe("loadSecrets", () => {
+    it("returns empty when .inkos/secrets.json does not exist", async () => {
+      const secrets = await loadSecrets(root);
+      expect(secrets).toEqual({ services: {} });
+    });
+
+    it("reads existing secrets file", async () => {
+      await mkdir(join(root, ".inkos"), { recursive: true });
+      await writeFile(
+        join(root, ".inkos", "secrets.json"),
+        JSON.stringify({ services: { moonshot: { apiKey: "sk-test" } } }),
+      );
+      const secrets = await loadSecrets(root);
+      expect(secrets.services.moonshot.apiKey).toBe("sk-test");
+    });
+  });
+
+  describe("saveSecrets", () => {
+    it("creates .inkos dir and writes secrets file", async () => {
+      await saveSecrets(root, {
+        services: { deepseek: { apiKey: "sk-deep" } },
+      });
+      const raw = await readFile(join(root, ".inkos", "secrets.json"), "utf-8");
+      const parsed = JSON.parse(raw);
+      expect(parsed.services.deepseek.apiKey).toBe("sk-deep");
+    });
+
+    it("overwrites existing secrets file", async () => {
+      await mkdir(join(root, ".inkos"), { recursive: true });
+      await writeFile(
+        join(root, ".inkos", "secrets.json"),
+        JSON.stringify({ services: { old: { apiKey: "old-key" } } }),
+      );
+      await saveSecrets(root, {
+        services: { new: { apiKey: "new-key" } },
+      });
+      const secrets = await loadSecrets(root);
+      expect(secrets.services.new.apiKey).toBe("new-key");
+      expect(secrets.services.old).toBeUndefined();
+    });
+  });
+
+  describe("getServiceApiKey", () => {
+    it("returns key from secrets.json first", async () => {
+      await mkdir(join(root, ".inkos"), { recursive: true });
+      await writeFile(
+        join(root, ".inkos", "secrets.json"),
+        JSON.stringify({ services: { moonshot: { apiKey: "sk-from-file" } } }),
+      );
+      const key = await getServiceApiKey(root, "moonshot");
+      expect(key).toBe("sk-from-file");
+    });
+
+    it("falls back to environment variable", async () => {
+      vi.stubEnv("MOONSHOT_API_KEY", "sk-from-env");
+      const key = await getServiceApiKey(root, "moonshot");
+      expect(key).toBe("sk-from-env");
+      vi.unstubAllEnvs();
+    });
+
+    it("returns null when neither secrets nor env exists", async () => {
+      const key = await getServiceApiKey(root, "moonshot");
+      expect(key).toBeNull();
+    });
+
+    it("handles custom service with colon key format", async () => {
+      await mkdir(join(root, ".inkos"), { recursive: true });
+      await writeFile(
+        join(root, ".inkos", "secrets.json"),
+        JSON.stringify({
+          services: { "custom:内网GPT": { apiKey: "sk-custom" } },
+        }),
+      );
+      const key = await getServiceApiKey(root, "custom:内网GPT");
+      expect(key).toBe("sk-custom");
+    });
+  });
+});

+ 174 - 0
packages/core/src/__tests__/service-resolver.test.ts

@@ -0,0 +1,174 @@
+// packages/core/src/__tests__/service-resolver.test.ts
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+// Models that exist in pi-ai's built-in registry (simulated)
+const KNOWN_MODELS = new Set(["gpt-4o", "kimi-k2.5", "MiniMax-M2.7"]);
+
+// Mock pi-ai's getModel — returns undefined for models not in registry (like the real implementation)
+vi.mock("@mariozechner/pi-ai", () => ({
+  getModel: vi.fn((provider: string, modelId: string) => {
+    if (!KNOWN_MODELS.has(modelId)) return undefined;
+    if (modelId === "MiniMax-M2.7" && provider === "anthropic") {
+      return {
+        id: modelId,
+        name: modelId,
+        api: "anthropic-messages",
+        provider: "anthropic",
+        baseUrl: "https://api.minimaxi.com/anthropic",
+        reasoning: true,
+        input: ["text"],
+        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+        contextWindow: 204800,
+        maxTokens: 131072,
+      };
+    }
+    return {
+      id: modelId,
+      name: modelId,
+      api: "openai-completions",
+      provider,
+      baseUrl: "https://api.openai.com/v1",
+      reasoning: false,
+      input: ["text"],
+      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+      contextWindow: 128000,
+      maxTokens: 16384,
+    };
+  }),
+  getEnvApiKey: vi.fn(() => undefined),
+}));
+
+import { resolveServiceModel } from "../llm/service-resolver.js";
+
+describe("resolveServiceModel", () => {
+  let root: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-resolver-"));
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+    vi.unstubAllEnvs();
+  });
+
+  it("resolves built-in service with key from secrets", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { moonshot: { apiKey: "sk-moon" } } }),
+    );
+
+    const result = await resolveServiceModel("moonshot", "kimi-k2.5", root);
+
+    expect(result.model.id).toBe("kimi-k2.5");
+    expect(result.apiKey).toBe("sk-moon");
+    expect(result.writingTemperature).toBe(1.0);
+    expect(result.temperatureRange).toEqual([0, 1]);
+  });
+
+  it("resolves deepseek with correct temperature", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { deepseek: { apiKey: "sk-deep" } } }),
+    );
+
+    const result = await resolveServiceModel("deepseek", "deepseek-chat", root);
+
+    expect(result.apiKey).toBe("sk-deep");
+    expect(result.writingTemperature).toBe(1.5);
+    expect(result.temperatureRange).toEqual([0, 2]);
+  });
+
+  it("constructs model from preset when getModel returns undefined", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { deepseek: { apiKey: "sk-deep" } } }),
+    );
+
+    // "deepseek-chat" is NOT in KNOWN_MODELS, so getModel returns undefined
+    const result = await resolveServiceModel("deepseek", "deepseek-chat", root);
+
+    expect(result.model).toBeDefined();
+    expect(result.model.id).toBe("deepseek-chat");
+    expect(result.model.api).toBe("openai-completions");
+    expect(result.model.baseUrl).toBe("https://api.deepseek.com");
+    expect(result.model.provider).toBe("openai");
+    expect(result.apiKey).toBe("sk-deep");
+  });
+
+  it("falls back to env var when no secrets file", async () => {
+    vi.stubEnv("DEEPSEEK_API_KEY", "sk-env");
+
+    const result = await resolveServiceModel("deepseek", "deepseek-chat", root);
+
+    expect(result.apiKey).toBe("sk-env");
+  });
+
+  it("throws when no key found", async () => {
+    await expect(
+      resolveServiceModel("moonshot", "kimi-k2.5", root),
+    ).rejects.toThrow(/API key/i);
+  });
+
+  it("resolves custom service with baseUrl", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { "custom:内网GPT": { apiKey: "sk-corp" } } }),
+    );
+
+    const result = await resolveServiceModel(
+      "custom:内网GPT",
+      "gpt-4o",
+      root,
+      "https://llm.internal.corp/v1",
+    );
+
+    expect(result.apiKey).toBe("sk-corp");
+    expect(result.model.id).toBe("gpt-4o");
+  });
+
+  it("resolves custom service with responses api format", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { "custom:内网GPT": { apiKey: "sk-corp" } } }),
+    );
+
+    const result = await resolveServiceModel(
+      "custom:内网GPT",
+      "gpt-5.4",
+      root,
+      "https://llm.internal.corp/v1",
+      "responses",
+    );
+
+    expect(result.apiKey).toBe("sk-corp");
+    expect(result.model.id).toBe("gpt-5.4");
+    expect(result.model.api).toBe("openai-responses");
+  });
+
+  it("resolves MiniMax using the preset provider family and anthropic endpoint", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { minimax: { apiKey: "sk-minimax" } } }),
+    );
+
+    const result = await resolveServiceModel("minimax", "MiniMax-M2.7", root);
+
+    expect(result.apiKey).toBe("sk-minimax");
+    expect(result.model.baseUrl).toBe("https://api.minimaxi.com/anthropic");
+    expect(result.model.api).toBe("anthropic-messages");
+    expect(result.model.provider).toBe("anthropic");
+    expect(result.model.reasoning).toBe(true);
+    expect(result.model.contextWindow).toBe(204800);
+    expect(result.model.maxTokens).toBe(131072);
+  });
+});

+ 32 - 0
packages/core/src/__tests__/state-manager.test.ts

@@ -472,6 +472,22 @@ describe("StateManager", () => {
       expect(ledger).toBe("# Ledger at ch1");
     });
 
+    it("removes live optional truth files that are absent from the snapshot", async () => {
+      const storyDir = join(manager.bookDir(bookId), "story");
+      await rm(join(storyDir, "particle_ledger.md"));
+      await manager.snapshotState(bookId, 1);
+
+      await writeFile(
+        join(storyDir, "particle_ledger.md"),
+        "# Ledger added after snapshot",
+        "utf-8",
+      );
+
+      const restored = await manager.restoreState(bookId, 1);
+      expect(restored).toBe(true);
+      await expect(stat(join(storyDir, "particle_ledger.md"))).rejects.toThrow();
+    });
+
     it("restores structured runtime state files from snapshot/state", async () => {
       const stateDir = manager.stateDir(bookId);
       await mkdir(stateDir, { recursive: true });
@@ -662,6 +678,22 @@ describe("StateManager", () => {
       expect(String(rejected[0]?.reason)).toMatch(/is locked/);
     });
 
+    it("reclaims same-process stale lock when no active write is in progress", async () => {
+      await mkdir(manager.bookDir("lock-book-self"), { recursive: true });
+      const lockPath = join(manager.bookDir("lock-book-self"), ".write.lock");
+      // Simulate a stale lock left by our own process (e.g. after a failed pipeline)
+      await writeFile(lockPath, `pid:${process.pid} ts:${Date.now() - 60000}`, "utf-8");
+
+      // Should auto-reclaim since our process knows it's not actively writing this book
+      const release = await manager.acquireBookLock("lock-book-self");
+      expect(typeof release).toBe("function");
+
+      const lockData = await readFile(lockPath, "utf-8");
+      expect(lockData).toContain(`pid:${process.pid}`);
+
+      await release();
+    });
+
     it("reclaims a stale lock when the recorded pid is no longer alive", async () => {
       await mkdir(manager.bookDir("lock-book-5"), { recursive: true });
       const lockPath = join(manager.bookDir("lock-book-5"), ".write.lock");

+ 64 - 0
packages/core/src/__tests__/state-reducer.test.ts

@@ -158,6 +158,70 @@ describe("applyRuntimeStateDelta", () => {
     ).toThrow(/duplicate summary/i);
   });
 
+  it("allows reapplying the same chapter when explicitly enabled", () => {
+    const result = applyRuntimeStateDelta({
+      snapshot: {
+        manifest: {
+          schemaVersion: 2,
+          language: "zh",
+          lastAppliedChapter: 12,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        },
+        currentState: {
+          chapter: 12,
+          facts: [],
+        },
+        hooks: {
+          hooks: [],
+        },
+        chapterSummaries: {
+          rows: [
+            {
+              chapter: 12,
+              title: "旧版河埠对账",
+              characters: "林月",
+              events: "旧摘要。",
+              stateChanges: "旧变化。",
+              hookActivity: "旧钩子",
+              mood: "紧绷",
+              chapterType: "主线推进",
+            },
+          ],
+        },
+      },
+      delta: RuntimeStateDeltaSchema.parse({
+        chapter: 12,
+        hookOps: {
+          upsert: [],
+          resolve: [],
+          defer: [],
+        },
+        chapterSummary: {
+          chapter: 12,
+          title: "新版河埠对账",
+          characters: "林月",
+          events: "新摘要。",
+          stateChanges: "新变化。",
+          hookActivity: "新钩子",
+          mood: "压抑",
+          chapterType: "修订",
+        },
+        notes: [],
+      }),
+      allowReapply: true,
+    });
+
+    expect(result.manifest.lastAppliedChapter).toBe(12);
+    expect(result.chapterSummaries.rows).toEqual([
+      expect.objectContaining({
+        chapter: 12,
+        title: "新版河埠对账",
+        events: "新摘要。",
+      }),
+    ]);
+  });
+
   it("ignores resolve and defer operations for unknown hooks", () => {
     const result = applyRuntimeStateDelta({
       snapshot: {

Некоторые файлы не были показаны из-за большого количества измененных файлов