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

feat: merge interaction control layer, TUI dashboard, and studio enhancements

54 commits from dev branch introducing:
- Shared interaction runtime (NL router, session management, edit controller)
- Ink-based TUI dashboard with themed animations and i18n
- Studio ChatBar shared session integration and book draft conversation flow
- CLI interact command for OpenClaw/agent interop
- Studio server endpoints for interaction session and agent routing

Core 585/585, Studio 70/70 tests pass.
Ma 7 месяцев назад
Родитель
Сommit
8f12ee2ff1
95 измененных файлов с 11976 добавлено и 341 удалено
  1. 3 1
      .gitignore
  2. 15 1
      README.en.md
  3. 15 1
      README.md
  4. 559 0
      docs/plans/2026-04-06-interaction-control-layer-design.md
  5. 523 0
      docs/plans/2026-04-06-interaction-control-layer-implementation.md
  6. 89 0
      docs/plans/2026-04-09-tui-floating-input-implementation.md
  7. 179 0
      docs/plans/2026-04-10-interaction-create-export-runtime.md
  8. 76 0
      docs/plans/2026-04-11-ink-tui-dashboard-design.md
  9. 153 0
      docs/plans/2026-04-11-ink-tui-dashboard-implementation.md
  10. 118 0
      docs/plans/2026-04-13-book-draft-conversation-design.md
  11. 58 0
      docs/plans/2026-04-13-book-draft-conversation-implementation.md
  12. 10 5
      packages/cli/package.json
  13. 45 0
      packages/cli/src/__tests__/cli-integration.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. 20 0
      packages/cli/src/__tests__/tui-activity-state.test.ts
  17. 25 0
      packages/cli/src/__tests__/tui-chat-depth.test.ts
  18. 43 0
      packages/cli/src/__tests__/tui-chat-draft.test.ts
  19. 29 0
      packages/cli/src/__tests__/tui-command.test.ts
  20. 46 0
      packages/cli/src/__tests__/tui-composer-caret.test.ts
  21. 40 0
      packages/cli/src/__tests__/tui-composer-display.test.ts
  22. 163 0
      packages/cli/src/__tests__/tui-control-flow.test.ts
  23. 222 0
      packages/cli/src/__tests__/tui-dashboard.test.tsx
  24. 29 0
      packages/cli/src/__tests__/tui-effects-i18n.test.ts
  25. 22 0
      packages/cli/src/__tests__/tui-i18n.test.ts
  26. 10 0
      packages/cli/src/__tests__/tui-input-chrome.test.ts
  27. 40 0
      packages/cli/src/__tests__/tui-input-history.test.ts
  28. 55 0
      packages/cli/src/__tests__/tui-layout.test.ts
  29. 47 0
      packages/cli/src/__tests__/tui-local-commands.test.ts
  30. 38 0
      packages/cli/src/__tests__/tui-output.test.ts
  31. 81 0
      packages/cli/src/__tests__/tui-session-store.test.ts
  32. 18 0
      packages/cli/src/__tests__/tui-setup-i18n.test.ts
  33. 36 0
      packages/cli/src/__tests__/tui-slash-autocomplete.test.ts
  34. 109 0
      packages/cli/src/commands/interact.ts
  35. 18 0
      packages/cli/src/commands/tui.ts
  36. 2 67
      packages/cli/src/index.ts
  37. 99 0
      packages/cli/src/program.ts
  38. 41 0
      packages/cli/src/tui/activity-state.ts
  39. 72 0
      packages/cli/src/tui/ansi.ts
  40. 165 0
      packages/cli/src/tui/app.ts
  41. 20 0
      packages/cli/src/tui/chat-depth.ts
  42. 42 0
      packages/cli/src/tui/chat-draft.ts
  43. 22 0
      packages/cli/src/tui/composer-caret.ts
  44. 26 0
      packages/cli/src/tui/composer-display.ts
  45. 159 0
      packages/cli/src/tui/dashboard-model.ts
  46. 527 0
      packages/cli/src/tui/dashboard.tsx
  47. 546 0
      packages/cli/src/tui/effects.ts
  48. 324 0
      packages/cli/src/tui/i18n.ts
  49. 69 0
      packages/cli/src/tui/input-history.ts
  50. 55 0
      packages/cli/src/tui/local-commands.ts
  51. 39 0
      packages/cli/src/tui/output.ts
  52. 6 0
      packages/cli/src/tui/session-store.ts
  53. 351 0
      packages/cli/src/tui/setup.ts
  54. 67 0
      packages/cli/src/tui/slash-autocomplete.ts
  55. 14 0
      packages/cli/src/tui/theme.ts
  56. 47 0
      packages/cli/src/tui/tools.ts
  57. 1 0
      packages/cli/tsconfig.json
  58. 190 0
      packages/core/src/__tests__/edit-controller.test.ts
  59. 155 0
      packages/core/src/__tests__/interaction-models.test.ts
  60. 223 0
      packages/core/src/__tests__/interaction-nl-router.test.ts
  61. 633 0
      packages/core/src/__tests__/interaction-runtime.test.ts
  62. 269 0
      packages/core/src/__tests__/interaction-tools.test.ts
  63. 61 0
      packages/core/src/__tests__/pipeline-runner.test.ts
  64. 241 0
      packages/core/src/__tests__/project-interaction.test.ts
  65. 72 0
      packages/core/src/index.ts
  66. 270 0
      packages/core/src/interaction/edit-controller.ts
  67. 41 0
      packages/core/src/interaction/events.ts
  68. 63 0
      packages/core/src/interaction/intents.ts
  69. 13 0
      packages/core/src/interaction/modes.ts
  70. 257 0
      packages/core/src/interaction/nl-router.ts
  71. 150 0
      packages/core/src/interaction/project-control.ts
  72. 59 0
      packages/core/src/interaction/project-session-store.ts
  73. 667 0
      packages/core/src/interaction/project-tools.ts
  74. 5 0
      packages/core/src/interaction/request-router.ts
  75. 1154 0
      packages/core/src/interaction/runtime.ts
  76. 134 0
      packages/core/src/interaction/session.ts
  77. 45 0
      packages/core/src/interaction/truth-authority.ts
  78. 20 6
      packages/core/src/llm/provider.ts
  79. 16 3
      packages/core/src/pipeline/runner.ts
  80. 1 1
      packages/studio/package.json
  81. 3 7
      packages/studio/src/App.tsx
  82. 241 3
      packages/studio/src/api/server.test.ts
  83. 92 61
      packages/studio/src/api/server.ts
  84. 8 0
      packages/studio/src/app-state.test.ts
  85. 1 0
      packages/studio/src/app-state.ts
  86. 151 29
      packages/studio/src/components/ChatBar.tsx
  87. 32 1
      packages/studio/src/components/chatbar-state.test.ts
  88. 336 122
      packages/studio/src/pages/BookCreate.tsx
  89. 27 8
      packages/studio/src/pages/BookDetail.tsx
  90. 12 4
      packages/studio/src/pages/ChapterReader.tsx
  91. 4 1
      packages/studio/src/pages/Dashboard.tsx
  92. 16 7
      packages/studio/src/pages/GenreManager.tsx
  93. 66 0
      packages/studio/src/pages/page-state.test.ts
  94. 350 12
      pnpm-lock.yaml
  95. 21 1
      skills/SKILL.md

+ 3 - 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/

+ 15 - 1
README.en.md

@@ -42,7 +42,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
 

+ 15 - 1
README.md

@@ -51,7 +51,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` 在线查看。
 
 ### 配置
 

+ 559 - 0
docs/plans/2026-04-06-interaction-control-layer-design.md

@@ -0,0 +1,559 @@
+# Interaction Control Layer Design
+
+## Goal
+
+Build a shared interaction control layer for InkOS so that `TUI` and `Studio` stop acting like separate products and instead become two shells over the same writing control brain.
+
+This layer must let users:
+- create a book through natural conversation
+- continue an active book inside a project workspace
+- see exactly what the system is doing right now
+- intervene safely on chapters, truth files, and direction
+- choose how much autonomy the system has
+
+## Why This Exists
+
+Current InkOS has strong execution primitives but weak control ergonomics.
+
+Today the user experience is split across:
+- CLI commands
+- Studio buttons and pages
+- a shallow `/api/agent` chat box
+- daemon logs
+- direct truth-file editing
+
+That produces the exact failures users complain about:
+- the system keeps writing when the user expected it to stop
+- the user cannot tell what stage the system is in
+- edits to truth files do not feel authoritative
+- rejecting or revising a chapter does not feel like part of one coherent flow
+- Studio chat does not hold enough session context to feel like a real assistant
+
+The solution is not “more buttons” and not “just add a TUI”. The solution is a single interaction control layer that owns:
+- session context
+- book binding
+- autonomy policy
+- natural-language intent routing
+- execution status
+- human edit transactions
+
+## Product Principles
+
+### 1. Project-first
+
+InkOS should behave like a writing version of Coding CLI.
+
+The user opens a project directory and runs:
+
+```bash
+inkos
+```
+
+The current working directory is the project context. The control layer restores:
+- project config
+- books in the project
+- active book, if one exists
+- current running task, if one exists
+
+The user should not be thrown into a global dashboard first.
+
+### 2. Conversation-first, not form-first
+
+When there is no active task, the interface should be quiet.
+
+It should not dump a questionnaire. It should wait for the user to speak first. The system then gradually narrows the task by asking only the most important next question.
+
+Bad:
+- “Choose language / genre / length / mode / trend scanning / source mode”
+
+Good:
+- user: “I want to write a cold urban mystery”
+- system: “I’ll hold it as a cold urban mystery. Do you want something long-running, or something that can close within about 10-20 chapters?”
+
+### 3. Status must stay visible at all times
+
+The user must always be able to answer:
+- which book is active?
+- what chapter is being worked on?
+- which stage is running right now?
+- why is the system waiting or blocked?
+
+### 4. Human edits are transactions, not raw file writes
+
+Users must be able to change:
+- a person name
+- a chapter
+- a paragraph
+- `current_focus`
+- `author_intent`
+- truth files
+
+But these changes cannot be “write to one file and hope the rest follows”. The control layer must route human edits through a consistency-preserving edit controller.
+
+### 5. TUI and Studio share the same brain
+
+`TUI` and `Studio` may have different layouts, but they must share:
+- session model
+- intent routing
+- autonomy policy
+- control actions
+- execution status
+- pending decisions
+
+No duplicate business logic in both shells.
+
+## User Journeys
+
+### Journey A: New book from a vague idea
+
+1. User runs `inkos` inside a project.
+2. No active book exists.
+3. User types: “I want to write a quiet harbor mystery.”
+4. The control layer opens a book-creation conversation.
+5. Through a few turns, the system gathers enough intent to draft:
+   - title direction
+   - genre
+   - target scale
+   - language
+   - source mode: original / continuation / fanfic
+   - autonomy preference
+6. System proposes a structured book draft.
+7. User confirms or adjusts.
+8. System runs foundation generation.
+9. If the autonomy policy allows, it proceeds into writing; otherwise it waits for outline review.
+
+### Journey B: Continue an active book
+
+1. User runs `inkos` in a project that already has books.
+2. The control layer restores the active book.
+3. The interface opens on that book’s current session.
+4. User types “continue”.
+5. Control layer maps that to `write_next`.
+6. Status stream shows:
+   - planning
+   - composing
+   - writing
+   - assessing
+   - repairing
+   - persisting
+7. If a stop condition is met, the task switches to `waiting_human`.
+
+### Journey C: Intervene on a chapter
+
+1. User types: “Rewrite chapter 3, but keep the ending reveal.”
+2. Control layer identifies:
+   - action: `rewrite_chapter`
+   - chapter: `3`
+   - constraint: keep ending reveal
+3. It builds an edit transaction.
+4. Runtime executes rewrite + reanalysis + truth/state rebuild for affected scope.
+5. User sees diff, updated status, and any downstream invalidations.
+
+### Journey D: Fix authority confusion
+
+1. User types: “Why is the protagonist still called Lu Chen?”
+2. Control layer resolves authoritative sources:
+   - story bible
+   - current state
+   - book rules
+   - recent chapters
+   - active entity state
+3. It explains which source currently wins.
+4. User can then say:
+   - “Rename the protagonist to Lin Yan everywhere”
+5. Control layer opens an entity-level rename transaction instead of editing random files manually.
+
+## Architecture
+
+There should be one new subsystem spanning `core`, `cli`, and `studio`.
+
+### Layer 1: Interaction Core
+
+New `core` module family that owns conversation state and intent routing.
+
+Responsibilities:
+- maintain `InteractionSession`
+- bind current project and active book
+- classify user messages into intent types
+- ask follow-up questions when required
+- emit structured requests for execution
+
+This layer does not directly run writing logic.
+
+### Layer 2: Control Runtime
+
+Execution orchestrator for interaction requests.
+
+Responsibilities:
+- turn a request into an executable action plan
+- call existing pipeline capabilities
+- track stage transitions
+- publish execution events
+- manage waiting-human transitions
+
+This layer is the bridge between conversation and pipeline.
+
+### Layer 3: Execution Substrate
+
+Existing InkOS capabilities remain underneath:
+- `PipelineRunner`
+- `writer`
+- `review`
+- `repair-state`
+- truth file read/write
+- `scheduler`
+
+This design avoids rewriting the execution engine from scratch.
+
+### Layer 4: Shells
+
+Two shells sit above the same control layer:
+- `TUI`
+- `Studio`
+
+Both subscribe to the same session and status model.
+
+## Core Objects
+
+### `InteractionSession`
+
+Represents one project-scoped working conversation.
+
+Fields:
+- `sessionId`
+- `projectRoot`
+- `activeBookId?`
+- `activeChapterNumber?`
+- `messages`
+- `automationMode`
+- `pendingDecision?`
+- `currentExecution?`
+- `lastResolvedIntent?`
+
+### `AutomationMode`
+
+Three explicit modes:
+- `auto`
+- `semi`
+- `manual`
+
+Meaning:
+- `auto`: continue chapters automatically unless a true hard stop occurs
+- `semi`: user starts each chapter; chapter-internal loop stays automatic
+- `manual`: no autonomous progression; only explicit user commands run
+
+### `InteractionIntent`
+
+Normalized intent categories:
+- `create_book`
+- `select_book`
+- `continue_book`
+- `write_next`
+- `pause_book`
+- `resume_book`
+- `revise_chapter`
+- `rewrite_chapter`
+- `edit_truth`
+- `update_focus`
+- `update_author_intent`
+- `explain_status`
+- `explain_failure`
+- `export_book`
+- `switch_mode`
+
+### `InterventionRequest`
+
+The structured handoff from interaction core to runtime.
+
+Fields:
+- `intent`
+- `bookId`
+- `chapterNumber?`
+- `instruction?`
+- `truthFile?`
+- `modeOverride?`
+- `constraints`
+
+### `ExecutionStatus`
+
+Normalized, shell-friendly runtime state:
+- `idle`
+- `planning`
+- `composing`
+- `writing`
+- `assessing`
+- `repairing`
+- `persisting`
+- `waiting_human`
+- `blocked`
+- `completed`
+- `failed`
+
+### `PendingDecision`
+
+Represents a human decision that must be surfaced clearly.
+
+Examples:
+- approve current chapter before continuing
+- choose whether to rewrite or keep a chapter
+- resolve a truth authority conflict
+- confirm a project-level change
+
+## Edit Controller
+
+This is required. Without it, human intervention will keep poisoning consistency.
+
+### Why It Exists
+
+Users need to change:
+- names
+- chapters
+- paragraphs
+- direction
+- truth files
+
+Those changes can affect:
+- chapter text
+- current state
+- hook state
+- chapter summaries
+- future chapter assumptions
+
+So edits must be transactional.
+
+### Edit Types
+
+#### 1. Entity edits
+
+Examples:
+- rename protagonist
+- change a character attribute
+
+These are not text replacements. They must update the authoritative entity definition and then determine which downstream material must be rebuilt or flagged.
+
+#### 2. Chapter edits
+
+Examples:
+- rewrite chapter 3
+- revise chapter 5
+
+These rebuild chapter-derived truth outputs.
+
+#### 3. Local text edits
+
+Examples:
+- rewrite one paragraph
+- change the ending sentence
+
+These still require reanalysis of the affected chapter.
+
+#### 4. Direction/rule edits
+
+Examples:
+- update `current_focus`
+- update `author_intent`
+- edit `book_rules`
+- edit `volume_outline`
+
+These do not always require rewriting old chapters, but they must affect future planning deterministically.
+
+### Consistency Rules
+
+The edit controller should enforce:
+- chapter body is authoritative for “what happened in this chapter”
+- explicit user override is authoritative for user-owned fields
+- truth projections must be regenerated from edited content where applicable
+- downstream chapters may be marked stale if an upstream edit invalidates assumptions
+
+## Status, Events, and Interaction Feedback
+
+This system needs explicit event flow, not ad-hoc log strings.
+
+### Event Stream
+
+New shared execution events should include:
+- `session.bound`
+- `book.selected`
+- `task.started`
+- `task.progress`
+- `task.waiting_human`
+- `task.completed`
+- `task.failed`
+- `edit.applied`
+- `truth.updated`
+- `mode.changed`
+
+### Stage Visibility
+
+The user should always see:
+- active book
+- active chapter
+- stage
+- last successful action
+- current warning/block reason
+
+### Interaction Animation
+
+Animations should communicate process, not decoration.
+
+Required feedback:
+- active stage indicator
+- progress/event stream
+- visible `waiting_human` state
+- clear acceptance / rollback / rewrite transitions
+
+No decorative motion that does not improve process readability.
+
+## TUI Shape
+
+The `TUI` is the first shell, but it must be designed as a reusable client for the shared control layer.
+
+### Main layout
+
+- **Top status bar**
+  - project
+  - active book
+  - automation mode
+  - current stage
+- **Center conversation pane**
+  - user messages
+  - assistant responses
+  - system event summaries
+- **Right or bottom process pane**
+  - current task
+  - last 10 events
+  - pending decisions
+  - current warnings
+- **Input row**
+  - natural-language input
+  - optional slash commands
+
+### Slash commands
+
+Support a small, stable command layer:
+- `/books`
+- `/open <book>`
+- `/new`
+- `/write`
+- `/pause`
+- `/resume`
+- `/mode`
+- `/revise`
+- `/rewrite`
+- `/focus`
+- `/intent`
+- `/truth`
+- `/export`
+- `/status`
+
+Natural language remains primary.
+
+## Studio Shape
+
+Studio should stop treating chat as a side widget over a page collection.
+
+Instead:
+- chat becomes a first-class surface over the same interaction core
+- book pages become supporting views, not the only control mechanism
+- daemon/log/truth views subscribe to the same execution event model
+
+Studio should preserve its richer visual editing affordances, but it should not own separate orchestration logic.
+
+## File and Module Boundaries
+
+This task is large, so file size control must be a design constraint from day one.
+
+### New core modules
+
+Create a focused module family, for example:
+
+- `packages/core/src/interaction/session.ts`
+- `packages/core/src/interaction/intents.ts`
+- `packages/core/src/interaction/router.ts`
+- `packages/core/src/interaction/runtime.ts`
+- `packages/core/src/interaction/events.ts`
+- `packages/core/src/interaction/modes.ts`
+- `packages/core/src/interaction/edit-controller.ts`
+- `packages/core/src/interaction/truth-authority.ts`
+
+### New CLI/TUI modules
+
+- `packages/cli/src/tui/app.tsx`
+- `packages/cli/src/tui/layout.tsx`
+- `packages/cli/src/tui/session-view.tsx`
+- `packages/cli/src/tui/status-pane.tsx`
+- `packages/cli/src/tui/input-bar.tsx`
+- `packages/cli/src/tui/commands.ts`
+
+### Studio changes
+
+Keep Studio changes shallow:
+- replace `/api/agent` one-shot orchestration with shared control-layer API
+- adapt existing pages to subscribe to shared events
+- move current `ChatBar` toward a shared interaction client
+
+### File size guardrails
+
+Hard constraints:
+- New files should target `< 250` lines.
+- Orchestrator files may reach `<= 350` lines if unavoidable.
+- No new monolith comparable to `runner.ts`.
+- Shared types go in dedicated model files, not in shells.
+- UI shells should remain thin and call shared control-layer modules.
+
+## Rollout Plan
+
+### Phase 1: Shared models and runtime bridge
+
+Build:
+- session model
+- intent model
+- execution status/events
+- automation modes
+- runtime bridge to existing pipeline actions
+
+No UI replacement yet.
+
+### Phase 2: TUI shell
+
+Build the project-scoped TUI:
+- project startup
+- active book binding
+- chat input
+- process/status pane
+- core commands
+
+### Phase 3: Human edit transactions
+
+Add:
+- chapter-level edit controller
+- truth-file-aware edit flow
+- entity rename flow
+
+### Phase 4: Studio adoption
+
+Refit Studio:
+- shared conversation brain
+- shared event/status model
+- replace one-shot chat behavior
+
+## What This Design Explicitly Avoids
+
+- no separate Studio and TUI orchestration stacks
+- no top-heavy onboarding questionnaire
+- no raw file editing as the primary mutation model
+- no giant new “god file”
+- no rewriting the entire pipeline before the control layer exists
+
+## Recommendation
+
+Implement this as a new long-running branch rooted in an isolated worktree.
+
+The order should be:
+1. shared interaction/control models
+2. runtime bridge
+3. TUI shell
+4. edit controller
+5. Studio migration
+
+That keeps the architecture correct while still delivering the writing-workbench experience early through TUI.

+ 523 - 0
docs/plans/2026-04-06-interaction-control-layer-implementation.md

@@ -0,0 +1,523 @@
+# Interaction Control Layer Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Build a shared interaction control layer that powers a project-scoped writing TUI first and becomes the common control brain for both TUI and Studio.
+
+**Architecture:** Introduce a new `core/interaction` subsystem for session state, intent routing, execution status, automation policy, and edit transactions. Keep `PipelineRunner` as the execution substrate, build a thin TUI shell in `packages/cli`, and refit Studio to call the same interaction APIs instead of owning separate orchestration logic.
+
+**Tech Stack:** TypeScript, InkOS core pipeline, Commander CLI, Ink-based TUI shell, existing Studio React client, Vitest.
+
+---
+
+### Task 1: Add interaction domain models in core
+
+**Files:**
+- Create: `packages/core/src/interaction/session.ts`
+- Create: `packages/core/src/interaction/intents.ts`
+- Create: `packages/core/src/interaction/modes.ts`
+- Create: `packages/core/src/interaction/events.ts`
+- Modify: `packages/core/src/index.ts`
+- Test: `packages/core/src/__tests__/interaction-models.test.ts`
+
+**Step 1: Write the failing test**
+
+Create coverage for:
+- `InteractionSession`
+- `AutomationMode`
+- `InteractionIntent`
+- `ExecutionStatus`
+- `PendingDecision`
+
+Include assertions for:
+- valid mode parsing
+- valid intent discrimination
+- session book binding
+- event payload shape
+
+**Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/interaction-models.test.ts
+```
+
+Expected:
+- fail because the new modules do not exist
+
+**Step 3: Write minimal implementation**
+
+Implement:
+- `AutomationMode = "auto" | "semi" | "manual"`
+- `InteractionIntentType` union
+- `ExecutionStatus` union
+- `InteractionSession` interface
+- `PendingDecision` interface
+- small helpers:
+  - `bindActiveBook`
+  - `clearPendingDecision`
+  - `isTerminalExecutionStatus`
+
+Keep each file under 200 lines.
+
+**Step 4: Run test to verify it passes**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/interaction-models.test.ts
+```
+
+Expected:
+- PASS
+
+**Step 5: Commit**
+
+```bash
+git add packages/core/src/interaction/session.ts packages/core/src/interaction/intents.ts packages/core/src/interaction/modes.ts packages/core/src/interaction/events.ts packages/core/src/index.ts packages/core/src/__tests__/interaction-models.test.ts
+git commit -m "feat(interaction): add shared control models"
+```
+
+### Task 2: Build the interaction runtime bridge
+
+**Files:**
+- Create: `packages/core/src/interaction/runtime.ts`
+- Create: `packages/core/src/interaction/request-router.ts`
+- Modify: `packages/core/src/index.ts`
+- Test: `packages/core/src/__tests__/interaction-runtime.test.ts`
+
+**Step 1: Write the failing test**
+
+Cover:
+- `continue_book` routes to `writeNextChapter`
+- `revise_chapter` routes to `reviseDraft`
+- `rewrite_chapter` routes to `reviseDraft(mode="rewrite")`
+- `update_current_focus` routes to the existing write surface
+- `switch_mode` updates session policy only
+
+Use a mocked `PipelineRunner`-shaped object.
+
+**Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/interaction-runtime.test.ts
+```
+
+Expected:
+- fail because runtime bridge is missing
+
+**Step 3: Write minimal implementation**
+
+Implement:
+- `InteractionRuntime`
+- `runInteractionRequest(...)`
+- `routeIntentToRequest(...)`
+
+Rules:
+- no direct filesystem edits in this layer
+- only call existing pipeline capabilities
+- emit explicit `ExecutionStatus` transitions
+
+**Step 4: Run test to verify it passes**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/interaction-runtime.test.ts
+```
+
+Expected:
+- PASS
+
+**Step 5: Commit**
+
+```bash
+git add packages/core/src/interaction/runtime.ts packages/core/src/interaction/request-router.ts packages/core/src/index.ts packages/core/src/__tests__/interaction-runtime.test.ts
+git commit -m "feat(interaction): add runtime bridge"
+```
+
+### Task 3: Introduce a project-scoped TUI entry
+
+**Files:**
+- Create: `packages/cli/src/commands/tui.ts`
+- Modify: `packages/cli/src/index.ts`
+- Test: `packages/cli/src/__tests__/tui-command.test.ts`
+
+**Step 1: Write the failing test**
+
+Cover:
+- `inkos` without a subcommand launches the TUI entry
+- current working directory is treated as project root
+- when no project config exists, the TUI still opens in a neutral state
+
+**Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+pnpm --dir packages/cli exec vitest run src/__tests__/tui-command.test.ts
+```
+
+Expected:
+- fail because command does not exist
+
+**Step 3: Write minimal implementation**
+
+Implement:
+- `tuiCommand`
+- fallback behavior so `inkos` opens TUI when no subcommand is provided
+
+Do not add visual complexity yet.
+
+**Step 4: Run test to verify it passes**
+
+Run:
+
+```bash
+pnpm --dir packages/cli exec vitest run src/__tests__/tui-command.test.ts
+```
+
+Expected:
+- PASS
+
+**Step 5: Commit**
+
+```bash
+git add packages/cli/src/commands/tui.ts packages/cli/src/index.ts packages/cli/src/__tests__/tui-command.test.ts
+git commit -m "feat(cli): add project-scoped TUI entry"
+```
+
+### Task 4: Scaffold the TUI shell layout
+
+**Files:**
+- Create: `packages/cli/src/tui/app.tsx`
+- Create: `packages/cli/src/tui/status-bar.tsx`
+- Create: `packages/cli/src/tui/session-pane.tsx`
+- Create: `packages/cli/src/tui/process-pane.tsx`
+- Create: `packages/cli/src/tui/input-bar.tsx`
+- Test: `packages/cli/src/__tests__/tui-layout.test.tsx`
+
+**Step 1: Write the failing test**
+
+Cover rendering for:
+- status bar
+- empty session pane
+- process pane with current stage
+- input bar placeholder
+
+**Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+pnpm --dir packages/cli exec vitest run src/__tests__/tui-layout.test.tsx
+```
+
+Expected:
+- fail because TUI components are missing
+
+**Step 3: Write minimal implementation**
+
+Render:
+- project name
+- active book
+- automation mode
+- current stage
+- empty conversation area
+- process/status area
+
+Keep all components small:
+- target `< 180` lines each
+
+**Step 4: Run test to verify it passes**
+
+Run:
+
+```bash
+pnpm --dir packages/cli exec vitest run src/__tests__/tui-layout.test.tsx
+```
+
+Expected:
+- PASS
+
+**Step 5: Commit**
+
+```bash
+git add packages/cli/src/tui/app.tsx packages/cli/src/tui/status-bar.tsx packages/cli/src/tui/session-pane.tsx packages/cli/src/tui/process-pane.tsx packages/cli/src/tui/input-bar.tsx packages/cli/src/__tests__/tui-layout.test.tsx
+git commit -m "feat(tui): scaffold interaction shell"
+```
+
+### Task 5: Add natural-language intent parsing for first-wave commands
+
+**Files:**
+- Create: `packages/core/src/interaction/nl-router.ts`
+- Modify: `packages/core/src/interaction/request-router.ts`
+- Test: `packages/core/src/__tests__/interaction-nl-router.test.ts`
+
+**Step 1: Write the failing test**
+
+Cover parsing for:
+- “continue”
+- “write next”
+- “pause this book”
+- “rewrite chapter 3”
+- “revise chapter 5 ending only”
+- “set focus back to the old case”
+- “why did the protagonist name not change”
+
+**Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/interaction-nl-router.test.ts
+```
+
+Expected:
+- fail because parser is missing
+
+**Step 3: Write minimal implementation**
+
+Implement a deterministic first-wave parser:
+- command-first and phrase-first
+- no LLM dependency in phase 1
+- extract:
+  - intent
+  - chapter number
+  - instruction payload
+  - optional scope hints
+
+**Step 4: Run test to verify it passes**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/interaction-nl-router.test.ts
+```
+
+Expected:
+- PASS
+
+**Step 5: Commit**
+
+```bash
+git add packages/core/src/interaction/nl-router.ts packages/core/src/interaction/request-router.ts packages/core/src/__tests__/interaction-nl-router.test.ts
+git commit -m "feat(interaction): add deterministic natural-language routing"
+```
+
+### Task 6: Implement automation modes and stop behavior
+
+**Files:**
+- Modify: `packages/core/src/interaction/modes.ts`
+- Modify: `packages/core/src/interaction/runtime.ts`
+- Modify: `packages/core/src/pipeline/runner.ts`
+- Test: `packages/core/src/__tests__/interaction-modes.test.ts`
+
+**Step 1: Write the failing test**
+
+Cover:
+- `auto` continues chapter progression unless a hard stop occurs
+- `semi` requires an explicit next-chapter start
+- `manual` never self-continues
+- `state-degraded` still hard-blocks in all modes
+
+**Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/interaction-modes.test.ts
+```
+
+Expected:
+- fail because runtime does not honor these policies
+
+**Step 3: Write minimal implementation**
+
+Add:
+- mode-aware continuation checks
+- mode exposure in session/runtime status
+- no duplicate policy logic in CLI and Studio
+
+**Step 4: Run test to verify it passes**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/interaction-modes.test.ts
+```
+
+Expected:
+- PASS
+
+**Step 5: Commit**
+
+```bash
+git add packages/core/src/interaction/modes.ts packages/core/src/interaction/runtime.ts packages/core/src/pipeline/runner.ts packages/core/src/__tests__/interaction-modes.test.ts
+git commit -m "feat(interaction): add automation modes"
+```
+
+### Task 7: Add edit controller for chapter and truth-file transactions
+
+**Files:**
+- Create: `packages/core/src/interaction/edit-controller.ts`
+- Create: `packages/core/src/interaction/truth-authority.ts`
+- Test: `packages/core/src/__tests__/edit-controller.test.ts`
+
+**Step 1: Write the failing test**
+
+Cover:
+- chapter rewrite produces a rebuild-required action
+- local paragraph edit marks only the affected chapter dirty
+- truth-file edit is classified as:
+  - direction edit
+  - truth authority edit
+  - entity edit
+- entity rename returns affected-scope metadata
+
+**Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/edit-controller.test.ts
+```
+
+Expected:
+- fail because edit controller is missing
+
+**Step 3: Write minimal implementation**
+
+Implement:
+- edit transaction types
+- affected-scope classification
+- truth authority resolution helpers
+
+Do not yet perform all downstream rewrites automatically; phase 1 only needs correct planning and scope marking.
+
+**Step 4: Run test to verify it passes**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/edit-controller.test.ts
+```
+
+Expected:
+- PASS
+
+**Step 5: Commit**
+
+```bash
+git add packages/core/src/interaction/edit-controller.ts packages/core/src/interaction/truth-authority.ts packages/core/src/__tests__/edit-controller.test.ts
+git commit -m "feat(interaction): add edit controller"
+```
+
+### Task 8: Connect Studio to the shared control layer
+
+**Files:**
+- Modify: `packages/studio/src/api/server.ts`
+- Modify: `packages/studio/src/components/ChatBar.tsx`
+- Modify: `packages/studio/src/App.tsx`
+- Test: `packages/studio/src/api/server.test.ts`
+- Test: `packages/studio/src/components/chatbar-state.test.ts`
+
+**Step 1: Write the failing test**
+
+Cover:
+- Studio chat routes through shared interaction runtime
+- active book binding is preserved
+- stage/status events are surfaced through the same event model
+
+**Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+pnpm --dir packages/studio exec vitest run src/api/server.test.ts src/components/chatbar-state.test.ts
+```
+
+Expected:
+- fail because Studio still uses one-shot `/api/agent`
+
+**Step 3: Write minimal implementation**
+
+Replace one-shot behavior with:
+- session-aware interaction endpoint(s)
+- shared status/event contract
+- ChatBar updates from the shared control state
+
+Keep Studio page structure mostly intact for phase 1.
+
+**Step 4: Run test to verify it passes**
+
+Run:
+
+```bash
+pnpm --dir packages/studio exec vitest run src/api/server.test.ts src/components/chatbar-state.test.ts
+```
+
+Expected:
+- PASS
+
+**Step 5: Commit**
+
+```bash
+git add packages/studio/src/api/server.ts packages/studio/src/components/ChatBar.tsx packages/studio/src/App.tsx packages/studio/src/api/server.test.ts packages/studio/src/components/chatbar-state.test.ts
+git commit -m "feat(studio): route chat through interaction control layer"
+```
+
+### Task 9: Run end-to-end verification for TUI + core
+
+**Files:**
+- Modify: `packages/cli/src/__tests__/cli-integration.test.ts`
+- Modify: `packages/core/src/__tests__/pipeline-agent.test.ts`
+
+**Step 1: Write the failing test**
+
+Add one flow that covers:
+- launch TUI in project context
+- bind active book
+- send natural-language “write next”
+- observe stage/status progression
+- switch to `semi`
+
+**Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+pnpm --dir packages/cli exec vitest run src/__tests__/cli-integration.test.ts
+```
+
+Expected:
+- fail because the new flow is not wired
+
+**Step 3: Write minimal implementation**
+
+Fill any missing glue only.
+
+Avoid broad refactors at this stage.
+
+**Step 4: Run test to verify it passes**
+
+Run:
+
+```bash
+pnpm --dir packages/cli exec vitest run src/__tests__/cli-integration.test.ts
+pnpm --dir packages/core exec tsc --noEmit --pretty false
+pnpm --dir packages/core run build
+```
+
+Expected:
+- all pass
+
+**Step 5: Commit**
+
+```bash
+git add packages/cli/src/__tests__/cli-integration.test.ts packages/core/src/__tests__/pipeline-agent.test.ts
+git commit -m "test(interaction): verify end-to-end control flow"
+```

+ 89 - 0
docs/plans/2026-04-09-tui-floating-input-implementation.md

@@ -0,0 +1,89 @@
+# TUI Floating Input Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Make the TUI input area feel closer to Codex by giving it a floating input-shell look with clearer bottom spacing.
+
+**Architecture:** Keep the existing REPL loop and natural-language flow intact. Only reshape the prompt chrome: extract a small render helper in the TUI effects layer, use it from the app loop, and lock the visual contract with a focused CLI test.
+
+**Tech Stack:** TypeScript, Node readline, Vitest
+
+---
+
+### Task 1: Add a failing test for floating input chrome
+
+**Files:**
+- Create: `packages/cli/src/__tests__/tui-input-chrome.test.ts`
+- Reference: `packages/cli/src/tui/effects.ts`
+
+**Step 1: Write the failing test**
+
+Add tests that expect:
+- a bounded/floating top border line
+- a prompt prefix that includes inner padding
+- bottom spacing metadata greater than zero
+
+**Step 2: Run test to verify it fails**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-input-chrome.test.ts`
+
+Expected: FAIL because the helper does not exist yet.
+
+**Step 3: Write minimal implementation**
+
+Add a small helper in `effects.ts` that builds the input chrome description from terminal width.
+
+**Step 4: Run test to verify it passes**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-input-chrome.test.ts`
+
+Expected: PASS
+
+### Task 2: Wire the floating chrome into the TUI prompt loop
+
+**Files:**
+- Modify: `packages/cli/src/tui/app.ts`
+- Modify: `packages/cli/src/tui/effects.ts`
+
+**Step 1: Write the failing test**
+
+Extend the input-chrome test (or add a small app-facing assertion) so the exported prompt prefix and spacing are stable enough to drive the REPL loop.
+
+**Step 2: Run test to verify it fails**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-input-chrome.test.ts src/__tests__/tui-layout.test.ts`
+
+Expected: FAIL until the app loop uses the new chrome contract.
+
+**Step 3: Write minimal implementation**
+
+Update `app.ts` so:
+- the prompt string comes from the chrome helper
+- the input area prints a floating top border and helper line
+- the prompt is not visually glued to the terminal bottom
+
+**Step 4: Run test to verify it passes**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-input-chrome.test.ts src/__tests__/tui-layout.test.ts`
+
+Expected: PASS
+
+### Task 3: Verify CLI package build
+
+**Files:**
+- Modify: `packages/cli/src/tui/app.ts`
+- Modify: `packages/cli/src/tui/effects.ts`
+- Test: `packages/cli/src/__tests__/tui-input-chrome.test.ts`
+
+**Step 1: Run package build**
+
+Run: `pnpm --dir packages/cli run build`
+
+Expected: PASS
+
+**Step 2: Commit**
+
+```bash
+git add docs/plans/2026-04-09-tui-floating-input-implementation.md packages/cli/src/tui/app.ts packages/cli/src/tui/effects.ts packages/cli/src/__tests__/tui-input-chrome.test.ts
+git commit -m "feat(tui): float input chrome above terminal edge"
+```

+ 179 - 0
docs/plans/2026-04-10-interaction-create-export-runtime.md

@@ -0,0 +1,179 @@
+# Interaction Create/Export Runtime Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Finish the remaining shared interaction-layer work by wiring `create_book` and `export_book` into the shared runtime and moving Studio's create/export flows onto the same execution path.
+
+**Architecture:** Keep the existing natural-language interaction path for chat/TUI/OpenClaw, but add a structured interaction entrypoint for UI-driven actions. Shared runtime tools will own create/export behavior so CLI, Studio, and external agents can reuse the same capability surface.
+
+**Tech Stack:** TypeScript, Vitest, Hono, Commander, InkOS core pipeline/session tooling.
+
+---
+
+### Task 1: Add failing core tests for create/export runtime behavior
+
+**Files:**
+- Modify: `packages/core/src/__tests__/interaction-runtime.test.ts`
+- Modify: `packages/core/src/__tests__/project-interaction.test.ts`
+
+**Step 1: Write failing tests**
+
+- Add a runtime test proving `create_book` calls a new `createBook` tool, binds the created book, and returns its summary text.
+- Add a runtime test proving `export_book` calls a new `exportBook` tool with format/approved-only options and returns export metadata text.
+- Add a project-control test proving structured interaction requests persist session updates for `create_book`.
+
+**Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run \
+  src/__tests__/interaction-runtime.test.ts \
+  src/__tests__/project-interaction.test.ts
+```
+
+Expected: failing tests due to missing runtime tool methods / structured request helper.
+
+### Task 2: Implement shared create/export runtime support
+
+**Files:**
+- Modify: `packages/core/src/interaction/intents.ts`
+- Modify: `packages/core/src/interaction/runtime.ts`
+- Modify: `packages/core/src/interaction/project-control.ts`
+- Modify: `packages/core/src/interaction/project-tools.ts`
+
+**Step 1: Extend interaction request/runtime contracts**
+
+- Add structured fields for create/export:
+  - `title`
+  - `genre`
+  - `platform`
+  - `chapterWordCount`
+  - `targetChapters`
+  - `format`
+  - `approvedOnly`
+  - `outputPath`
+- Add runtime tool methods:
+  - `createBook`
+  - `exportBook`
+
+**Step 2: Implement structured project interaction helper**
+
+- Add `processProjectInteractionRequest(...)` alongside the existing natural-language helper.
+- It should:
+  - load session
+  - optionally bind `activeBookId`
+  - execute `runInteractionRequest`
+  - persist the updated session
+
+**Step 3: Implement create/export tool behavior**
+
+- `createBook`:
+  - derive a book config
+  - call `PipelineRunner.initBook`
+  - return interaction metadata with `responseText`
+- `exportBook`:
+  - centralize the existing CLI export logic into the shared tool
+  - return output path / exported chapter count in interaction metadata
+
+**Step 4: Run tests**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run \
+  src/__tests__/interaction-runtime.test.ts \
+  src/__tests__/project-interaction.test.ts
+```
+
+Expected: PASS.
+
+### Task 3: Add CLI routing/tests for shared create/export
+
+**Files:**
+- Modify: `packages/core/src/interaction/nl-router.ts`
+- Modify: `packages/core/src/__tests__/interaction-nl-router.test.ts`
+- Modify: `packages/cli/src/__tests__/interact-command.test.ts`
+
+**Step 1: Write failing tests**
+
+- Add NL routing coverage for:
+  - `/new <title>`
+  - `/export`
+  - `/export md`
+- Add interact command coverage for structured/shared export output where useful.
+
+**Step 2: Implement minimal routing**
+
+- Route slash commands into `create_book` / `export_book`.
+- Keep this scoped to explicit commands, not full ideation.
+
+**Step 3: Run tests**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/interaction-nl-router.test.ts
+pnpm --dir packages/cli exec vitest run src/__tests__/interact-command.test.ts
+```
+
+Expected: PASS.
+
+### Task 4: Move Studio create/export endpoints onto shared interaction execution
+
+**Files:**
+- Modify: `packages/studio/src/api/server.ts`
+- Modify: `packages/studio/src/api/server.test.ts`
+
+**Step 1: Write failing tests**
+
+- Add server tests proving:
+  - `/api/books/create` routes through shared structured interaction execution
+  - `/api/books/:id/export-save` routes through shared structured interaction execution
+
+**Step 2: Implement endpoint changes**
+
+- Use the same shared interaction tools built from pipeline + state.
+- Replace direct `initBook` / custom export-save logic with calls to the shared structured helper.
+- Preserve existing response shape expected by the Studio UI.
+
+**Step 3: Run tests**
+
+Run:
+
+```bash
+pnpm --dir packages/studio exec vitest run src/api/server.test.ts
+```
+
+Expected: PASS.
+
+### Task 5: Final verification
+
+**Files:**
+- No new files
+
+**Step 1: Run focused test/build suite**
+
+```bash
+pnpm --dir packages/core exec vitest run \
+  src/__tests__/interaction-runtime.test.ts \
+  src/__tests__/project-interaction.test.ts \
+  src/__tests__/interaction-nl-router.test.ts
+pnpm --dir packages/cli exec vitest run src/__tests__/interact-command.test.ts
+pnpm --dir packages/studio exec vitest run src/api/server.test.ts
+pnpm --dir packages/core run build
+pnpm --dir packages/cli run build
+pnpm --dir packages/studio run build
+```
+
+Expected: all green.
+
+**Step 2: Commit**
+
+```bash
+git add packages/core/src/interaction packages/core/src/__tests__ \
+  packages/cli/src/__tests__/interact-command.test.ts \
+  packages/studio/src/api/server.ts packages/studio/src/api/server.test.ts \
+  docs/plans/2026-04-10-interaction-create-export-runtime.md
+git commit -m "feat(interaction): add shared create and export flows"
+```

+ 76 - 0
docs/plans/2026-04-11-ink-tui-dashboard-design.md

@@ -0,0 +1,76 @@
+# Ink TUI Dashboard Design
+
+**Goal:** Replace the current readline-driven TUI shell with a fixed-layout Ink dashboard so conversation, execution status, and the composer remain visible at the same time.
+
+## Context
+
+The current TUI is a linear REPL layered on top of `readline` and ANSI helpers. That shape creates the exact UX problems reported by users:
+
+- the input area is only a prompt prefix, not a persistent composer
+- task status is printed inline below the conversation instead of being anchored near the composer
+- output pushes the input area downward, so the screen feels unstable
+- the shell looks like logs, not a workspace
+
+The interaction core itself is not the problem. Session state, routing, execution, and persistence already exist in `@actalk/inkos-core`.
+
+## Proposed Architecture
+
+Keep the existing interaction core and replace only the CLI shell:
+
+- `packages/cli/src/tui/app.ts`
+  becomes a thin launcher that prepares project/model/tools and mounts an Ink app
+- new Ink UI modules in `packages/cli/src/tui/`
+  own layout, input, and rendering
+- existing session persistence stays in `session-store.ts`
+- existing command execution still flows through `processProjectInteractionInput`
+
+## Layout
+
+The dashboard is a fixed vertical stack:
+
+1. `Header`
+   project, active book, automation mode, model, and a compact execution badge
+2. `Conversation`
+   recent user / assistant / system messages with stable visual separation
+3. `Status Rail`
+   current stage, pending decision, and recent events
+4. `Composer`
+   highlighted input box, helper text, and submit state
+
+The composer stays visually anchored at the bottom of the dashboard. Execution state sits directly above it.
+
+## Rendering Strategy
+
+Use Ink components instead of manual `console.log` output:
+
+- Ink layout primitives for fixed regions
+- Ink input handling for keyboard events
+- an Ink text input component for the composer
+- small view-model helpers that derive display data from the persisted interaction session
+
+No screen-level ANSI animation survives unless it can be expressed as component state. The redesign favors a stable, readable shell over decorative startup animation.
+
+## Interaction Model
+
+- user submits text in the composer
+- shell enters `submitting` state
+- `processProjectInteractionInput` runs with existing tools
+- returned session replaces local session state
+- conversation and status panes refresh from the updated session
+
+Errors are shown in the status rail and remain visible until the next successful interaction.
+
+## Non-Goals
+
+- changing the interaction runtime or natural-language router
+- changing daemon behavior
+- adding a full message history scroller in this pass
+- introducing Studio-style navigation into CLI
+
+## Success Criteria
+
+- the composer is always visually distinct and highlighted
+- the current execution state is visible without running `/status`
+- recent events appear above the composer instead of below it
+- new output no longer pushes the prompt into a visually unstable position
+- the CLI package still builds and TUI-focused tests cover the new layout contract

+ 153 - 0
docs/plans/2026-04-11-ink-tui-dashboard-implementation.md

@@ -0,0 +1,153 @@
+# Ink TUI Dashboard Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Rebuild the InkOS CLI TUI as a fixed-layout Ink dashboard with a persistent highlighted composer and a status rail directly above it.
+
+**Architecture:** Keep `@actalk/inkos-core` unchanged and swap the CLI shell from `readline + ANSI` to `Ink + React`. `app.ts` becomes the launcher; new dashboard components render header, conversation, status, and composer from persisted interaction session data.
+
+**Tech Stack:** TypeScript, React, Ink, Vitest
+
+---
+
+### Task 1: Add the Ink runtime dependencies
+
+**Files:**
+- Modify: `packages/cli/package.json`
+- Modify: `pnpm-lock.yaml`
+
+**Step 1: Add CLI dependencies**
+
+Add the runtime and test dependencies needed for the new shell.
+
+**Step 2: Verify installation**
+
+Run: `pnpm --dir packages/cli install`
+
+Expected: dependencies resolve and lockfile updates cleanly.
+
+### Task 2: Add failing tests for the dashboard contract
+
+**Files:**
+- Modify: `packages/cli/src/__tests__/tui-layout.test.ts`
+- Create: `packages/cli/src/__tests__/tui-dashboard.test.tsx`
+
+**Step 1: Write failing tests**
+
+Cover:
+- header displays project, book, mode, and model
+- status rail appears above the composer
+- composer placeholder / highlight shell renders
+- conversation pane renders user and assistant messages separately
+
+**Step 2: Run tests to verify they fail**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-layout.test.ts src/__tests__/tui-dashboard.test.tsx`
+
+Expected: FAIL because the Ink dashboard components do not exist yet.
+
+### Task 3: Build dashboard view-model helpers
+
+**Files:**
+- Create: `packages/cli/src/tui/dashboard-model.ts`
+- Create: `packages/cli/src/tui/dashboard-model.test.ts`
+
+**Step 1: Write failing tests**
+
+Verify session data is mapped into:
+- header badge data
+- conversation rows
+- recent event rows
+- pending decision summary
+
+**Step 2: Run tests to verify they fail**
+
+Run: `pnpm --dir packages/cli exec vitest run src/tui/dashboard-model.test.ts`
+
+Expected: FAIL because the helpers do not exist yet.
+
+**Step 3: Write minimal implementation**
+
+Create pure helpers so the Ink components can stay thin.
+
+**Step 4: Run tests to verify they pass**
+
+Run: `pnpm --dir packages/cli exec vitest run src/tui/dashboard-model.test.ts`
+
+Expected: PASS
+
+### Task 4: Implement the Ink dashboard shell
+
+**Files:**
+- Create: `packages/cli/src/tui/dashboard.tsx`
+- Modify: `packages/cli/src/tui/app.ts`
+- Optionally modify: `packages/cli/src/tui/output.ts`
+
+**Step 1: Write or extend a failing dashboard render test**
+
+Assert that the mounted dashboard contains:
+- a fixed header
+- conversation content
+- status rail
+- highlighted composer
+
+**Step 2: Run tests to verify they fail**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-dashboard.test.tsx`
+
+Expected: FAIL
+
+**Step 3: Write minimal implementation**
+
+Implement:
+- `InkTuiDashboard` component
+- local submit state
+- composer submission through `processProjectInteractionInput`
+- session-driven refresh after each interaction
+
+**Step 4: Run tests to verify they pass**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-dashboard.test.tsx src/__tests__/tui-layout.test.ts`
+
+Expected: PASS
+
+### Task 5: Reconcile launcher behavior and remove obsolete readline assumptions
+
+**Files:**
+- Modify: `packages/cli/src/tui/app.ts`
+- Modify: `packages/cli/src/tui/effects.ts`
+- Modify or remove: old prompt-related tests as needed
+
+**Step 1: Replace the old REPL entry**
+
+Make `launchTui()` mount Ink instead of starting a `readline` loop.
+
+**Step 2: Verify startup / setup flow**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-command.test.ts src/__tests__/tui-layout.test.ts src/__tests__/tui-dashboard.test.tsx`
+
+Expected: PASS
+
+### Task 6: Build verification
+
+**Files:**
+- Modify: any files touched above
+
+**Step 1: Run focused tests**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-layout.test.ts src/__tests__/tui-dashboard.test.tsx`
+
+Expected: PASS
+
+**Step 2: Run CLI build**
+
+Run: `pnpm --dir packages/cli run build`
+
+Expected: PASS
+
+**Step 3: Commit**
+
+```bash
+git add docs/plans/2026-04-11-ink-tui-dashboard-design.md docs/plans/2026-04-11-ink-tui-dashboard-implementation.md packages/cli/package.json pnpm-lock.yaml packages/cli/src/tui/app.ts packages/cli/src/tui/dashboard.tsx packages/cli/src/tui/dashboard-model.ts packages/cli/src/__tests__/tui-layout.test.ts packages/cli/src/__tests__/tui-dashboard.test.tsx
+git commit -m "feat(tui): rebuild CLI shell with Ink dashboard"
+```

+ 118 - 0
docs/plans/2026-04-13-book-draft-conversation-design.md

@@ -0,0 +1,118 @@
+# Book Draft Conversation Design
+
+## Goal
+
+Fill the missing front-half interaction layer inside `interaction-control-layer` by adding a shared book-draft conversation that works in both `TUI` and `Studio Chat`.
+
+This is not the full “creative operating system” yet. It is the first complete loop for:
+- vague idea input
+- iterative draft refinement
+- shared draft state across shells
+- explicit conversion from draft to book creation
+
+## Scope
+
+### Included
+- shared `creationDraft` state inside the project interaction session
+- natural-language ideation when no active book is bound
+- `/create` to create a book from the current draft
+- `/discard` to drop the current draft
+- Studio Chat / TUI synchronization through the same session file
+
+### Excluded
+- full title/blurb/outline negotiation UI
+- foundation diff / outline revision workflow
+- automatic truth/sqlite rebuild after manual chapter edits
+
+## Product Behavior
+
+### No active book
+
+When the project has no active book and the user enters freeform text, the interaction layer treats it as book ideation instead of generic chat.
+
+Example:
+
+```text
+我想写个港风商战悬疑,主角从灰产洗白。
+```
+
+The system responds with:
+- a tightened shared draft
+- one focused next question
+
+### Shared draft
+
+The draft lives in project session state and can be resumed from:
+- TUI
+- Studio Chat
+- external agent entrypoints
+
+### Explicit creation
+
+Users finalize the draft through:
+
+```text
+/create
+```
+
+This converts the shared draft into the existing structured `create_book` path.
+
+### Explicit discard
+
+Users can drop the draft through:
+
+```text
+/discard
+```
+
+## Architecture
+
+### Session
+
+`InteractionSession` gains `creationDraft`.
+
+The draft is the single shared source of truth for:
+- concept
+- optional title / genre / platform / language
+- optional chapter scale fields
+- blurb / author intent / current focus seeds
+- next question
+- missing fields
+- ready-to-create flag
+
+### Routing
+
+`routeNaturalLanguageIntent()` gets one new branch:
+- if no active book is bound, unmatched freeform input routes to `develop_book`
+
+Explicit commands:
+- `/new <idea>` -> `develop_book`
+- `/create` -> `create_book`
+- `/discard` -> `discard_book_draft`
+
+### Runtime
+
+New runtime intents:
+- `develop_book`
+- `discard_book_draft`
+
+`create_book` now fills missing fields from the current draft before calling the existing creation tool.
+
+### Tools
+
+Shared interaction tools gain `developBookDraft`.
+
+It uses the active model to:
+- merge the latest user turn into the current draft
+- return a compact assistant reply
+- return updated structured draft JSON
+
+## Why This Is The Right Cut
+
+This design gives us:
+- a real front-half conversation loop
+- zero duplicate shell logic
+- no new standalone wizard system
+- no collision with the already-working write/revise/export control paths
+
+It is intentionally smaller than the final vision, but it closes the biggest product gap: “I can control a book, but I still can’t converse a new book into existence.”

+ 58 - 0
docs/plans/2026-04-13-book-draft-conversation-implementation.md

@@ -0,0 +1,58 @@
+# Book Draft Conversation Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add a shared book-draft ideation loop so TUI and Studio Chat can converge a vague story idea into a structured create-ready draft.
+
+**Architecture:** Extend `InteractionSession` with `creationDraft`, route no-active-book freeform input into a new `develop_book` runtime intent, and reuse the existing `create_book` runtime/tool path for finalization.
+
+**Tech Stack:** TypeScript, Vitest, Zod, existing interaction runtime/session/tooling.
+
+---
+
+### Task 1: Add shared draft state and intents
+
+**Files:**
+- Modify: `packages/core/src/interaction/session.ts`
+- Modify: `packages/core/src/interaction/intents.ts`
+- Modify: `packages/core/src/index.ts`
+- Test: `packages/core/src/__tests__/interaction-models.test.ts`
+
+### Task 2: Route ideation input
+
+**Files:**
+- Modify: `packages/core/src/interaction/nl-router.ts`
+- Modify: `packages/core/src/interaction/project-control.ts`
+- Test: `packages/core/src/__tests__/interaction-nl-router.test.ts`
+
+### Task 3: Implement runtime + tools
+
+**Files:**
+- Modify: `packages/core/src/interaction/runtime.ts`
+- Modify: `packages/core/src/interaction/project-tools.ts`
+- Test: `packages/core/src/__tests__/interaction-runtime.test.ts`
+- Test: `packages/core/src/__tests__/project-interaction.test.ts`
+
+### Task 4: Surface shared draft in Studio Chat
+
+**Files:**
+- Modify: `packages/studio/src/components/ChatBar.tsx`
+- Test: `packages/studio/src/components/chatbar-state.test.ts`
+
+### Task 5: Verify
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run \
+  src/__tests__/interaction-models.test.ts \
+  src/__tests__/interaction-nl-router.test.ts \
+  src/__tests__/interaction-runtime.test.ts \
+  src/__tests__/project-interaction.test.ts
+pnpm --dir packages/studio exec vitest run \
+  src/components/chatbar-state.test.ts \
+  src/api/server.test.ts
+pnpm --dir packages/core run build
+pnpm --dir packages/cli run build
+pnpm --dir packages/studio run build
+```

+ 10 - 5
packages/cli/package.json

@@ -43,16 +43,21 @@
     "typecheck": "tsc --noEmit"
   },
   "dependencies": {
-    "@actalk/inkos-core": "1.1.1",
-    "@actalk/inkos-studio": "1.1.1",
+    "@actalk/inkos-core": "workspace:*",
+    "@actalk/inkos-studio": "workspace:*",
     "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.0",
+    "react": "^19.2.4"
   },
   "devDependencies": {
+    "@types/react": "^19.2.14",
+    "@types/node": "^22.0.0",
+    "ink-testing-library": "^4.0.0",
     "typescript": "^5.8.0",
-    "vitest": "^3.0.0",
-    "@types/node": "^22.0.0"
+    "vitest": "^3.0.0"
   }
 }

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

@@ -191,6 +191,51 @@ 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", "explain status"]);
+      const data = JSON.parse(output);
+
+      expect(data.session.activeBookId).toBe("harbor");
+    });
+  });
+
   describe("inkos config set-model", () => {
     it("rejects raw API keys passed to --api-key-env", async () => {
       const { exitCode, stderr } = runStderr([

+ 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");
+  });
+});

+ 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);
+  });
+});

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

@@ -0,0 +1,25 @@
+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",
+    });
+    expect(resolveChatDepthProfile("normal")).toEqual({
+      depth: "normal",
+      temperature: 0.4,
+      maxTokens: 240,
+      label: "normal",
+    });
+    expect(resolveChatDepthProfile("deep")).toEqual({
+      depth: "deep",
+      temperature: 0.45,
+      maxTokens: 420,
+      label: "deep",
+    });
+  });
+});

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

@@ -0,0 +1,43 @@
+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: [],
+  };
+}
+
+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");
+  });
+});

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

@@ -0,0 +1,29 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createProgram } from "../program.js";
+
+describe("tui command", () => {
+  const originalArgv = process.argv;
+
+  afterEach(() => {
+    process.argv = originalArgv;
+    vi.restoreAllMocks();
+  });
+
+  it("launches the TUI when no subcommand is provided", async () => {
+    const launchTui = vi.fn(async () => {});
+    const program = createProgram({ launchTui });
+
+    await program.parseAsync([], { from: "user" });
+
+    expect(launchTui).toHaveBeenCalledTimes(1);
+  });
+
+  it("launches the TUI when the explicit tui command is used", async () => {
+    const launchTui = vi.fn(async () => {});
+    const program = createProgram({ launchTui });
+
+    await program.parseAsync(["tui"], { from: "user" });
+
+    expect(launchTui).toHaveBeenCalledTimes(1);
+  });
+});

+ 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");
+  });
+});

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

@@ -0,0 +1,222 @@
+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.",
+      },
+    ],
+  };
+}
+
+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("Review chapter 12 before publishing.");
+    expect(frame).toContain("Ask InkOS to write, revise, or explain");
+    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("› │Ask InkOS");
+  });
+
+  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("Preparing chapter 12.");
+    expect(frame).toContain("continue");
+    expect(frame.indexOf("Preparing chapter 12.")).toBeLessThan(frame.indexOf("› continue"));
+    expect(frame.indexOf("writing chapter")).toBeLessThan(frame.indexOf("› 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│");
+  });
+
+  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("告诉 InkOS 要写什么、修改什么,或解释什么");
+    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("草稿 夜港账本");
+    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");
+  });
+});

+ 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`);
+      }
+    });
+}

+ 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());
+    });
+}

+ 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();

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

@@ -0,0 +1,99 @@
+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 { 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 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 () => {
+      if (hooks.launchTui) {
+        await hooks.launchTui(process.cwd());
+        return;
+      }
+      await launchTui(process.cwd());
+    });
+
+  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.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);
+}

+ 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 };
+  }
+}

+ 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));
+}

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

@@ -0,0 +1,165 @@
+import { basename } 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 };
+}
+
+export async function launchTui(
+  projectRoot: string,
+  toolsOverride?: InteractionRuntimeTools,
+): Promise<void> {
+  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);
+      },
+    }));
+  } 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;
+  }
+}

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

@@ -0,0 +1,20 @@
+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:
+      return { depth: "normal", temperature: 0.4, maxTokens: 240, 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,
+  };
+}

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

@@ -0,0 +1,159 @@
+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;
+}
+
+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 messageRows = params.session.messages
+    .filter((message) => message.timestamp >= sinceTimestamp)
+    .slice(-conversationLimit)
+    .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.mode} ${modeLabel} · ${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;
+}

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

@@ -0,0 +1,527 @@
+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 { 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 { 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,
+} from "./theme.js";
+
+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 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,
+  });
+  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>
+
+      {/* Status strip */}
+      <Box flexDirection="column" marginTop={1}>
+        <Text color={WARM_BORDER}>{thinRule}</Text>
+        <Box marginTop={1}>
+          <ExecutionBadge status={model.executionStatus} color={activeAccent} />
+          <Text color={activeAccent}> {model.statusPrimaryLine}</Text>
+        </Box>
+        <Text color={model.errorText ? STATUS_ERROR : props.isSubmitting ? WARM_ACCENT : WARM_MUTED}>
+          {"  " + model.statusSecondaryLine}
+        </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>
+          <Box>
+            <Text color={props.isSubmitting ? STATUS_ACTIVE : WARM_MUTED}>
+              {model.composerStatus}
+            </Text>
+            <Text color={WARM_BORDER}> │ </Text>
+            <Text color={WARM_MUTED}>{model.composerHelper}</Text>
+          </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>
+    </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 [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) => current.slice(0, -1));
+      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;
+    }
+
+    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),
+      });
+      const userTimestamp = Date.now();
+      const assistantDraftTimestamp = routed.intent === "chat" ? userTimestamp + 1 : null;
+      assistantDraftTimestampRef.current = assistantDraftTimestamp;
+      setActivityIntent(routed.intent);
+      setIsSubmitting(true);
+      setLastError(undefined);
+      setInputValue("");
+      setHistoryState({ cursor: null, draft: "" });
+      setSession((current) => createOptimisticUserMessageSession(current, input, userTimestamp));
+
+      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);
+    } 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}
+      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, label } = props.row;
+
+  if (role === "user") {
+    return (
+      <Box marginBottom={1}>
+        <Text color={ROLE_USER}>│ </Text>
+        <Text color={WARM_REPLY}>{content}</Text>
+      </Box>
+    );
+  }
+
+  if (role === "system") {
+    return (
+      <Box marginBottom={1}>
+        <Text color={ROLE_SYSTEM}>· </Text>
+        <Text color={ROLE_SYSTEM}>{content}</Text>
+      </Box>
+    );
+  }
+
+  // assistant
+  return (
+    <Box marginBottom={1}>
+      <Text color={WARM_ACCENT}>◆ </Text>
+      <Text color={WARM_REPLY}>{content}</Text>
+    </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;
+}

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

@@ -0,0 +1,324 @@
+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 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}。`,
+    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 • /draft • /create • /write • /books • /open • /mode • /depth • /help",
+    submitting: "Submitting…",
+    failed: "Last request failed",
+    ready: "Ready",
+  },
+  notes: {
+    help: "Commands: /new, /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}.`,
+    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";
+  }
+}

+ 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 <idea>",
+  "/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} `;
+}

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

@@ -0,0 +1,14 @@
+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";

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

@@ -0,0 +1,47 @@
+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 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();
+  const pipeline = new PipelineRunner(buildPipelineConfig(config, projectRoot));
+  const state = new StateManager(projectRoot);
+  return createInteractionToolsFromDeps(pipeline, state, hooks);
+}

+ 1 - 0
packages/cli/tsconfig.json

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

+ 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);
+  });
+});

+ 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" })).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);
+  });
+});

+ 61 - 0
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -348,6 +348,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({

+ 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 });
+    }
+  });
+});

+ 72 - 0
packages/core/src/index.ts

@@ -68,6 +68,78 @@ export {
 } from "./models/input-governance.js";
 export { PlannerAgent, type PlanChapterInput, type PlanChapterOutput } from "./agents/planner.js";
 export { ComposerAgent, type ComposeChapterInput, type ComposeChapterOutput } from "./agents/composer.js";
+export {
+  AutomationModeSchema,
+  type AutomationMode,
+  normalizeAutomationMode,
+} from "./interaction/modes.js";
+export {
+  InteractionIntentTypeSchema,
+  type InteractionIntentType,
+  InteractionRequestSchema,
+  type InteractionRequest,
+} from "./interaction/intents.js";
+export {
+  ExecutionStatusSchema,
+  ExecutionStateSchema,
+  InteractionEventSchema,
+  type ExecutionStatus,
+  type ExecutionState,
+  type InteractionEvent,
+  isTerminalExecutionStatus,
+} from "./interaction/events.js";
+export {
+  BookCreationDraftSchema,
+  PendingDecisionSchema,
+  InteractionMessageSchema,
+  InteractionSessionSchema,
+  type BookCreationDraft,
+  type PendingDecision,
+  type InteractionMessage,
+  type InteractionSession,
+  bindActiveBook,
+  clearCreationDraft,
+  clearPendingDecision,
+  updateAutomationMode,
+  updateCreationDraft,
+  appendInteractionMessage,
+  appendInteractionEvent,
+} from "./interaction/session.js";
+export {
+  resolveProjectSessionPath,
+  createProjectSession,
+  loadProjectSession,
+  persistProjectSession,
+  resolveSessionActiveBook,
+} from "./interaction/project-session-store.js";
+export { routeInteractionRequest } from "./interaction/request-router.js";
+export {
+  routeNaturalLanguageIntent,
+  type NaturalLanguageRoutingContext,
+} from "./interaction/nl-router.js";
+export {
+  processProjectInteractionInput,
+  processProjectInteractionRequest,
+} from "./interaction/project-control.js";
+export { createInteractionToolsFromDeps } from "./interaction/project-tools.js";
+export {
+  normalizeTruthFileName,
+  classifyTruthAuthority,
+  type TruthAuthority,
+} from "./interaction/truth-authority.js";
+export {
+  executeEditTransaction,
+  planEditTransaction,
+  type EditRequest,
+  type EditExecutionDeps,
+  type ExecutedEditTransaction,
+  type PlannedEditTransaction,
+} from "./interaction/edit-controller.js";
+export {
+  runInteractionRequest,
+  type InteractionRuntimeTools,
+  type InteractionRuntimeResult,
+} from "./interaction/runtime.js";
 
 // LLM
 export { createLLMClient, chatCompletion, chatWithTools, createStreamMonitor, PartialResponseError, type LLMClient, type LLMResponse, type LLMMessage, type ToolDefinition, type ToolCall, type AgentMessage, type ChatWithToolsResult, type StreamProgress, type OnStreamProgress } from "./llm/provider.js";

+ 270 - 0
packages/core/src/interaction/edit-controller.ts

@@ -0,0 +1,270 @@
+import { readdir, readFile, unlink, writeFile } from "node:fs/promises";
+import { join, relative } from "node:path";
+import type { ChapterMeta } from "../models/chapter.js";
+import { classifyTruthAuthority, normalizeTruthFileName, type TruthAuthority } from "./truth-authority.js";
+
+export type EditRequest =
+  | {
+      readonly kind: "entity-rename";
+      readonly bookId: string;
+      readonly entityType: "protagonist" | "character" | "location" | "organization";
+      readonly oldValue: string;
+      readonly newValue: string;
+    }
+  | {
+      readonly kind: "chapter-rewrite";
+      readonly bookId: string;
+      readonly chapterNumber: number;
+      readonly instruction: string;
+    }
+  | {
+      readonly kind: "chapter-local-edit";
+      readonly bookId: string;
+      readonly chapterNumber: number;
+      readonly instruction: string;
+      readonly targetText?: string;
+      readonly replacementText?: string;
+    }
+  | {
+      readonly kind: "truth-file-edit";
+      readonly bookId: string;
+      readonly fileName: string;
+      readonly instruction: string;
+    }
+  | {
+      readonly kind: "focus-edit";
+      readonly bookId: string;
+      readonly instruction: string;
+    };
+
+export interface PlannedEditTransaction {
+  readonly transactionType: EditRequest["kind"];
+  readonly bookId: string;
+  readonly chapterNumber?: number;
+  readonly truthAuthority?: TruthAuthority;
+  readonly normalizedFileName?: string;
+  readonly affectedScope: "chapter" | "downstream" | "future" | "book";
+  readonly requiresTruthRebuild: boolean;
+}
+
+export interface EditExecutionDeps {
+  readonly bookDir: (bookId: string) => string;
+  readonly loadChapterIndex: (bookId: string) => Promise<ReadonlyArray<ChapterMeta>>;
+  readonly saveChapterIndex: (bookId: string, index: ReadonlyArray<ChapterMeta>) => Promise<void>;
+}
+
+export interface ExecutedEditTransaction {
+  readonly transactionType: EditRequest["kind"];
+  readonly bookId: string;
+  readonly chapterNumber?: number;
+  readonly touchedFiles: ReadonlyArray<string>;
+  readonly reviewRequired: boolean;
+  readonly summary: string;
+}
+
+function isMissingDirectoryError(error: unknown): boolean {
+  return typeof error === "object"
+    && error !== null
+    && "code" in error
+    && (error as { code?: unknown }).code === "ENOENT";
+}
+
+export function planEditTransaction(request: EditRequest): PlannedEditTransaction {
+  switch (request.kind) {
+    case "entity-rename":
+      return {
+        transactionType: request.kind,
+        bookId: request.bookId,
+        affectedScope: "book",
+        requiresTruthRebuild: true,
+      };
+    case "chapter-rewrite":
+      return {
+        transactionType: request.kind,
+        bookId: request.bookId,
+        chapterNumber: request.chapterNumber,
+        affectedScope: "downstream",
+        requiresTruthRebuild: true,
+      };
+    case "chapter-local-edit":
+      return {
+        transactionType: request.kind,
+        bookId: request.bookId,
+        chapterNumber: request.chapterNumber,
+        affectedScope: "chapter",
+        requiresTruthRebuild: true,
+      };
+    case "truth-file-edit": {
+      const normalizedFileName = normalizeTruthFileName(request.fileName);
+      const truthAuthority = classifyTruthAuthority(normalizedFileName);
+      return {
+        transactionType: request.kind,
+        bookId: request.bookId,
+        normalizedFileName,
+        truthAuthority,
+        affectedScope: truthAuthority === "runtime-truth" ? "book" : truthAuthority === "memory" ? "book" : "book",
+        requiresTruthRebuild: truthAuthority === "runtime-truth" || truthAuthority === "memory",
+      };
+    }
+    case "focus-edit":
+      return {
+        transactionType: request.kind,
+        bookId: request.bookId,
+        truthAuthority: "direction",
+        normalizedFileName: "current_focus.md",
+        affectedScope: "future",
+        requiresTruthRebuild: false,
+      };
+  }
+}
+
+function escapeRegExp(text: string): string {
+  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+async function collectEditableFiles(dir: string): Promise<ReadonlyArray<string>> {
+  const entries = await readdir(dir, { withFileTypes: true }).catch((error) => {
+    if (isMissingDirectoryError(error)) {
+      return [];
+    }
+    throw error;
+  });
+  const files = await Promise.all(entries.map(async (entry) => {
+    const fullPath = join(dir, entry.name);
+    if (entry.isDirectory()) {
+      return collectEditableFiles(fullPath);
+    }
+    if (!/\.(md|json|ya?ml|txt)$/i.test(entry.name)) {
+      return [];
+    }
+    return [fullPath];
+  }));
+  return files.flat();
+}
+
+async function executeEntityRename(
+  deps: EditExecutionDeps,
+  request: Extract<EditRequest, { kind: "entity-rename" }>,
+): Promise<ExecutedEditTransaction> {
+  const root = deps.bookDir(request.bookId);
+  const files = await collectEditableFiles(root);
+  const touchedFiles: string[] = [];
+  const matcher = new RegExp(escapeRegExp(request.oldValue), "g");
+
+  for (const filePath of files) {
+    const content = await readFile(filePath, "utf-8");
+    const nextContent = content.replace(matcher, request.newValue);
+    if (nextContent === content) {
+      continue;
+    }
+    await writeFile(filePath, nextContent, "utf-8");
+    touchedFiles.push(relative(root, filePath));
+  }
+
+  if (touchedFiles.length === 0) {
+    throw new Error(`No occurrences of "${request.oldValue}" were found in "${request.bookId}".`);
+  }
+
+  return {
+    transactionType: request.kind,
+    bookId: request.bookId,
+    touchedFiles,
+    reviewRequired: false,
+    summary: `Renamed ${request.oldValue} to ${request.newValue} across ${touchedFiles.length} files.`,
+  };
+}
+
+async function executeChapterLocalEdit(
+  deps: EditExecutionDeps,
+  request: Extract<EditRequest, { kind: "chapter-local-edit" }>,
+): Promise<ExecutedEditTransaction> {
+  const root = deps.bookDir(request.bookId);
+  const chaptersDir = join(root, "chapters");
+  const paddedChapter = String(request.chapterNumber).padStart(4, "0");
+  const chapterFile = (await readdir(chaptersDir).catch((error) => {
+    if (isMissingDirectoryError(error)) {
+      return [];
+    }
+    throw error;
+  }))
+    .find((file) => file.startsWith(`${paddedChapter}_`) && file.endsWith(".md"));
+
+  if (!chapterFile) {
+    throw new Error(`Chapter ${request.chapterNumber} not found in "${request.bookId}".`);
+  }
+  if (!request.targetText || request.replacementText === undefined) {
+    throw new Error("Chapter-local edits require targetText and replacementText.");
+  }
+
+  const chapterPath = join(chaptersDir, chapterFile);
+  const content = await readFile(chapterPath, "utf-8");
+  const nextContent = content.split(request.targetText).join(request.replacementText);
+  if (nextContent === content) {
+    throw new Error(`Target text was not found in chapter ${request.chapterNumber}.`);
+  }
+  await writeFile(chapterPath, nextContent, "utf-8");
+
+  const runtimeDir = join(root, "story", "runtime");
+  const runtimeFiles = (await readdir(runtimeDir).catch((error) => {
+    if (isMissingDirectoryError(error)) {
+      return [];
+    }
+    throw error;
+  }))
+    .filter((file) => file.startsWith(`chapter-${paddedChapter}.`));
+  await Promise.all(runtimeFiles.map((file) => unlink(join(runtimeDir, file)).catch(() => undefined)));
+
+  const index = [...(await deps.loadChapterIndex(request.bookId))];
+  const updatedIndex = index.map((chapter) => chapter.number === request.chapterNumber
+    ? {
+        ...chapter,
+        status: "audit-failed" as const,
+        updatedAt: new Date().toISOString(),
+        auditIssues: [
+          ...chapter.auditIssues.filter((issue) => !issue.includes("Manual text edit requires review")),
+          "[warning] Manual text edit requires review before continuation.",
+        ],
+      }
+    : chapter);
+  await deps.saveChapterIndex(request.bookId, updatedIndex);
+
+  return {
+    transactionType: request.kind,
+    bookId: request.bookId,
+    chapterNumber: request.chapterNumber,
+    touchedFiles: [
+      relative(root, chapterPath),
+      ...runtimeFiles.map((file) => relative(root, join(runtimeDir, file))),
+      "chapters/index.json",
+    ],
+    reviewRequired: true,
+    summary: `Patched chapter ${request.chapterNumber} and marked it for review.`,
+  };
+}
+
+export async function executeEditTransaction(
+  deps: EditExecutionDeps,
+  request: EditRequest,
+): Promise<ExecutedEditTransaction> {
+  switch (request.kind) {
+    case "entity-rename":
+      return executeEntityRename(deps, request);
+    case "chapter-local-edit":
+      return executeChapterLocalEdit(deps, request);
+    case "truth-file-edit": {
+      const root = deps.bookDir(request.bookId);
+      const normalizedFileName = normalizeTruthFileName(request.fileName);
+      const filePath = join(root, "story", normalizedFileName);
+      await writeFile(filePath, request.instruction, "utf-8");
+      return {
+        transactionType: request.kind,
+        bookId: request.bookId,
+        touchedFiles: [relative(root, filePath)],
+        reviewRequired: false,
+        summary: `Updated ${normalizedFileName}.`,
+      };
+    }
+    default:
+      throw new Error(`Edit transaction "${request.kind}" is not executable yet.`);
+  }
+}

+ 41 - 0
packages/core/src/interaction/events.ts

@@ -0,0 +1,41 @@
+import { z } from "zod";
+
+export const ExecutionStatusSchema = z.enum([
+  "idle",
+  "planning",
+  "composing",
+  "writing",
+  "assessing",
+  "repairing",
+  "persisting",
+  "waiting_human",
+  "blocked",
+  "completed",
+  "failed",
+]);
+
+export type ExecutionStatus = z.infer<typeof ExecutionStatusSchema>;
+
+export const ExecutionStateSchema = z.object({
+  status: ExecutionStatusSchema,
+  bookId: z.string().min(1).optional(),
+  chapterNumber: z.number().int().min(1).optional(),
+  stageLabel: z.string().min(1).optional(),
+});
+
+export type ExecutionState = z.infer<typeof ExecutionStateSchema>;
+
+export const InteractionEventSchema = z.object({
+  kind: z.string().min(1),
+  timestamp: z.number().int().nonnegative(),
+  status: ExecutionStatusSchema,
+  bookId: z.string().min(1).optional(),
+  chapterNumber: z.number().int().min(1).optional(),
+  detail: z.string().min(1).optional(),
+});
+
+export type InteractionEvent = z.infer<typeof InteractionEventSchema>;
+
+export function isTerminalExecutionStatus(status: ExecutionStatus): boolean {
+  return status === "completed" || status === "failed" || status === "blocked";
+}

+ 63 - 0
packages/core/src/interaction/intents.ts

@@ -0,0 +1,63 @@
+import { z } from "zod";
+import { AutomationModeSchema } from "./modes.js";
+
+export const InteractionIntentTypeSchema = z.enum([
+  "develop_book",
+  "show_book_draft",
+  "create_book",
+  "discard_book_draft",
+  "list_books",
+  "select_book",
+  "continue_book",
+  "write_next",
+  "pause_book",
+  "resume_book",
+  "revise_chapter",
+  "rewrite_chapter",
+  "patch_chapter_text",
+  "edit_truth",
+  "rename_entity",
+  "update_focus",
+  "update_author_intent",
+  "chat",
+  "explain_status",
+  "explain_failure",
+  "export_book",
+  "switch_mode",
+]);
+
+export type InteractionIntentType = z.infer<typeof InteractionIntentTypeSchema>;
+
+export const InteractionRequestSchema = z.object({
+  intent: InteractionIntentTypeSchema,
+  bookId: z.string().min(1).optional(),
+  chapterNumber: z.number().int().min(1).optional(),
+  title: z.string().min(1).optional(),
+  genre: z.string().min(1).optional(),
+  platform: z.string().min(1).optional(),
+  language: z.enum(["zh", "en"]).optional(),
+  chapterWordCount: z.number().int().min(1).optional(),
+  targetChapters: z.number().int().min(1).optional(),
+  blurb: z.string().min(1).optional(),
+  worldPremise: z.string().min(1).optional(),
+  settingNotes: z.string().min(1).optional(),
+  protagonist: z.string().min(1).optional(),
+  supportingCast: z.string().min(1).optional(),
+  conflictCore: z.string().min(1).optional(),
+  volumeOutline: z.string().min(1).optional(),
+  constraints: z.string().min(1).optional(),
+  authorIntent: z.string().min(1).optional(),
+  currentFocus: z.string().min(1).optional(),
+  fileName: z.string().min(1).optional(),
+  format: z.enum(["txt", "md", "epub"]).optional(),
+  approvedOnly: z.boolean().optional(),
+  outputPath: z.string().min(1).optional(),
+  oldValue: z.string().min(1).optional(),
+  newValue: z.string().min(1).optional(),
+  targetText: z.string().min(1).optional(),
+  replacementText: z.string().min(1).optional(),
+  instruction: z.string().min(1).optional(),
+  mode: AutomationModeSchema.optional(),
+});
+
+export type InteractionRequest = z.infer<typeof InteractionRequestSchema>;

+ 13 - 0
packages/core/src/interaction/modes.ts

@@ -0,0 +1,13 @@
+import { z } from "zod";
+
+export const AutomationModeSchema = z.enum(["auto", "semi", "manual"]);
+
+export type AutomationMode = z.infer<typeof AutomationModeSchema>;
+
+export function normalizeAutomationMode(
+  mode: string | undefined,
+  fallback: AutomationMode = "semi",
+): AutomationMode {
+  const parsed = AutomationModeSchema.safeParse(mode);
+  return parsed.success ? parsed.data : fallback;
+}

+ 257 - 0
packages/core/src/interaction/nl-router.ts

@@ -0,0 +1,257 @@
+import type { InteractionRequest } from "./intents.js";
+
+export interface NaturalLanguageRoutingContext {
+  readonly activeBookId?: string;
+  readonly hasCreationDraft?: boolean;
+}
+
+export function routeNaturalLanguageIntent(
+  input: string,
+  context: NaturalLanguageRoutingContext = {},
+): InteractionRequest {
+  const trimmed = input.trim();
+  const bookId = context.activeBookId;
+
+  if (/^(hi|hello|hey|你好|嗨|哈喽)$/i.test(trimmed)) {
+    return {
+      intent: "chat",
+      ...(bookId ? { bookId } : {}),
+      instruction: trimmed,
+    };
+  }
+
+  if (/^(continue|继续|继续写|写下一章|write next)$/i.test(trimmed)) {
+    return {
+      intent: "write_next",
+      ...(bookId ? { bookId } : {}),
+    };
+  }
+
+  if (/^\/write$/i.test(trimmed)) {
+    return {
+      intent: "write_next",
+      ...(bookId ? { bookId } : {}),
+    };
+  }
+
+  if (/^\/books$/i.test(trimmed)) {
+    return {
+      intent: "list_books",
+    };
+  }
+
+  const newCommand = trimmed.match(/^\/new\s+(.+)$/i);
+  if (newCommand) {
+    return {
+      intent: "develop_book",
+      instruction: newCommand[1]!.trim(),
+    };
+  }
+
+  if (/^\/create$/i.test(trimmed)) {
+    return {
+      intent: "create_book",
+      ...(bookId ? { bookId } : {}),
+    };
+  }
+
+  if (/^\/draft$/i.test(trimmed)) {
+    return {
+      intent: "show_book_draft",
+    };
+  }
+
+  if (/^\/discard$/i.test(trimmed)) {
+    return {
+      intent: "discard_book_draft",
+    };
+  }
+
+  const openCommand = trimmed.match(/^\/open\s+(.+)$/i);
+  if (openCommand) {
+    return {
+      intent: "select_book",
+      bookId: openCommand[1]!.trim(),
+    };
+  }
+
+  if (/^(pause|pause this book|暂停|暂停这本书)$/i.test(trimmed)) {
+    return {
+      intent: "pause_book",
+      ...(bookId ? { bookId } : {}),
+    };
+  }
+
+  const modeCommand = trimmed.match(/^\/mode\s+(auto|semi|manual)$/i);
+  if (modeCommand) {
+    return {
+      intent: "switch_mode",
+      mode: modeCommand[1]!.toLowerCase() as "auto" | "semi" | "manual",
+    };
+  }
+
+  if (/(全自动|auto mode|switch to auto|切换到全自动)/i.test(trimmed)) {
+    return {
+      intent: "switch_mode",
+      mode: "auto",
+    };
+  }
+
+  if (/(半自动|semi mode|switch to semi)/i.test(trimmed)) {
+    return {
+      intent: "switch_mode",
+      mode: "semi",
+    };
+  }
+
+  if (/(全自主|手动模式|manual mode|switch to manual)/i.test(trimmed)) {
+    return {
+      intent: "switch_mode",
+      mode: "manual",
+    };
+  }
+
+  const slashRewrite = trimmed.match(/^\/rewrite\s+(\d+)$/i);
+  if (slashRewrite) {
+    return {
+      intent: "rewrite_chapter",
+      ...(bookId ? { bookId } : {}),
+      chapterNumber: parseInt(slashRewrite[1]!, 10),
+    };
+  }
+
+  const slashFocus = trimmed.match(/^\/focus\s+(.+)$/i);
+  if (slashFocus) {
+    return {
+      intent: "update_focus",
+      ...(bookId ? { bookId } : {}),
+      instruction: slashFocus[1]!.trim(),
+    };
+  }
+
+  const slashTruth = trimmed.match(/^\/truth\s+([^\s]+)\s+([\s\S]+)$/i);
+  if (slashTruth) {
+    return {
+      intent: "edit_truth",
+      ...(bookId ? { bookId } : {}),
+      fileName: slashTruth[1]!.trim(),
+      instruction: slashTruth[2]!.trim(),
+    };
+  }
+
+  const slashRename = trimmed.match(/^\/rename\s+(.+?)\s*=>\s*(.+)$/i);
+  if (slashRename) {
+    return {
+      intent: "rename_entity",
+      ...(bookId ? { bookId } : {}),
+      oldValue: slashRename[1]!.trim(),
+      newValue: slashRename[2]!.trim(),
+    };
+  }
+
+  const slashReplace = trimmed.match(/^\/replace\s+(\d+)\s+(.+?)\s*=>\s*(.+)$/i);
+  if (slashReplace) {
+    return {
+      intent: "patch_chapter_text",
+      ...(bookId ? { bookId } : {}),
+      chapterNumber: parseInt(slashReplace[1]!, 10),
+      targetText: slashReplace[2]!.trim(),
+      replacementText: slashReplace[3]!.trim(),
+    };
+  }
+
+  const slashExport = trimmed.match(/^\/export(?:\s+(txt|md|epub))?$/i);
+  if (slashExport) {
+    return {
+      intent: "export_book",
+      ...(bookId ? { bookId } : {}),
+      format: (slashExport[1]?.toLowerCase() as "txt" | "md" | "epub" | undefined) ?? "txt",
+    };
+  }
+
+  const rewriteMatch = trimmed.match(/(?:rewrite chapter|重写第)\s*(\d+)\s*(?:章)?/i);
+  if (rewriteMatch) {
+    return {
+      intent: "rewrite_chapter",
+      ...(bookId ? { bookId } : {}),
+      chapterNumber: parseInt(rewriteMatch[1]!, 10),
+    };
+  }
+
+  const reviseMatch = trimmed.match(/revise chapter\s*(\d+)\s*(.*)$/i);
+  if (reviseMatch) {
+    const trailing = reviseMatch[2]?.trim();
+    return {
+      intent: "revise_chapter",
+      ...(bookId ? { bookId } : {}),
+      chapterNumber: parseInt(reviseMatch[1]!, 10),
+      ...(trailing ? { instruction: trailing } : {}),
+    };
+  }
+
+  const zhRenameMatch = trimmed.match(/^把(.+?)改成(.+)$/);
+  if (zhRenameMatch) {
+    return {
+      intent: "rename_entity",
+      ...(bookId ? { bookId } : {}),
+      oldValue: zhRenameMatch[1]!.trim(),
+      newValue: zhRenameMatch[2]!.trim(),
+    };
+  }
+
+  const enRenameMatch = trimmed.match(/^rename\s+(.+?)\s+to\s+(.+)$/i);
+  if (enRenameMatch) {
+    return {
+      intent: "rename_entity",
+      ...(bookId ? { bookId } : {}),
+      oldValue: enRenameMatch[1]!.trim(),
+      newValue: enRenameMatch[2]!.trim(),
+    };
+  }
+
+  const openMatch = trimmed.match(/^open\s+(.+)$/i);
+  if (openMatch) {
+    return {
+      intent: "select_book",
+      bookId: openMatch[1]!.trim(),
+    };
+  }
+
+  if (/(focus|聚焦|主线|旧案线)/i.test(trimmed)) {
+    return {
+      intent: "update_focus",
+      ...(bookId ? { bookId } : {}),
+      instruction: trimmed,
+    };
+  }
+
+  if (/(为什么|why)/i.test(trimmed)) {
+    return {
+      intent: "explain_failure",
+      ...(bookId ? { bookId } : {}),
+      instruction: trimmed,
+    };
+  }
+
+  if (/^(导出全书(?:为\s*(epub|md|txt))?|export book(?: as)?\s*(epub|md|txt)?)$/i.test(trimmed)) {
+    const matchedFormat = trimmed.match(/(epub|md|txt)/i)?.[1]?.toLowerCase() as "txt" | "md" | "epub" | undefined;
+    return {
+      intent: "export_book",
+      ...(bookId ? { bookId } : {}),
+      format: matchedFormat ?? "txt",
+    };
+  }
+
+  if (!bookId || context.hasCreationDraft) {
+    return {
+      intent: "develop_book",
+      instruction: trimmed,
+    };
+  }
+
+  return {
+    intent: "chat",
+    ...(bookId ? { bookId } : {}),
+    instruction: trimmed,
+  };
+}

+ 150 - 0
packages/core/src/interaction/project-control.ts

@@ -0,0 +1,150 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { appendInteractionEvent, appendInteractionMessage } from "./session.js";
+import { routeNaturalLanguageIntent } from "./nl-router.js";
+import type { InteractionRequest } from "./intents.js";
+import type { InteractionRuntimeTools } from "./runtime.js";
+import { runInteractionRequest } from "./runtime.js";
+import {
+  loadProjectSession,
+  persistProjectSession,
+  resolveSessionActiveBook,
+} from "./project-session-store.js";
+
+async function processProjectInteractionRequestInternal(params: {
+  readonly projectRoot: string;
+  readonly request: InteractionRequest;
+  readonly tools: InteractionRuntimeTools;
+  readonly activeBookId?: string;
+}) {
+  const requestLanguage = await detectProjectInteractionLanguage(params.projectRoot);
+  const localizedRequest = attachRequestLanguage(params.request, requestLanguage);
+  const session = await loadProjectSession(params.projectRoot);
+  const restoredBookId = await resolveSessionActiveBook(params.projectRoot, session);
+  const resolvedBookId = params.activeBookId ?? localizedRequest.bookId ?? restoredBookId;
+  const sessionWithBook = resolvedBookId && session.activeBookId !== resolvedBookId
+    ? { ...session, activeBookId: resolvedBookId }
+    : session;
+
+  try {
+    const result = await runInteractionRequest({
+      session: sessionWithBook,
+      request: localizedRequest,
+      tools: params.tools,
+    });
+    await persistProjectSession(params.projectRoot, result.session);
+    return {
+      ...result,
+      request: localizedRequest,
+    };
+  } catch (error) {
+    const detail = error instanceof Error ? error.message : String(error);
+    const failedSession = appendInteractionEvent({
+      ...sessionWithBook,
+      currentExecution: {
+        status: "failed",
+        bookId: sessionWithBook.activeBookId,
+        chapterNumber: sessionWithBook.activeChapterNumber,
+        stageLabel: localizedRequest.language === "en" ? `failed ${localizedRequest.intent}` : `执行失败:${localizedRequest.intent}`,
+      },
+    }, {
+      kind: "task.failed",
+      timestamp: Date.now(),
+      status: "failed",
+      bookId: sessionWithBook.activeBookId,
+      chapterNumber: sessionWithBook.activeChapterNumber,
+      detail,
+    });
+    await persistProjectSession(params.projectRoot, failedSession);
+    throw error;
+  }
+}
+
+export async function processProjectInteractionInput(params: {
+  readonly projectRoot: string;
+  readonly input: string;
+  readonly tools: InteractionRuntimeTools;
+  readonly activeBookId?: string;
+}) {
+  const requestLanguage = await detectProjectInteractionLanguage(params.projectRoot);
+  const session = await loadProjectSession(params.projectRoot);
+  const restoredBookId = await resolveSessionActiveBook(params.projectRoot, session);
+  const resolvedBookId = params.activeBookId ?? restoredBookId;
+  const sessionWithBook = resolvedBookId && session.activeBookId !== resolvedBookId
+    ? { ...session, activeBookId: resolvedBookId }
+    : session;
+  const userSession = appendInteractionMessage(sessionWithBook, {
+    role: "user",
+    content: params.input,
+    timestamp: Date.now(),
+  });
+  const request = attachRequestLanguage(routeNaturalLanguageIntent(params.input, {
+    activeBookId: userSession.activeBookId,
+    hasCreationDraft: Boolean(userSession.creationDraft),
+  }), requestLanguage);
+  try {
+    const result = await runInteractionRequest({
+      session: userSession,
+      request,
+      tools: params.tools,
+    });
+    await persistProjectSession(params.projectRoot, result.session);
+    return {
+      ...result,
+      request,
+    };
+  } catch (error) {
+    const detail = error instanceof Error ? error.message : String(error);
+    const failedSession = appendInteractionEvent({
+      ...userSession,
+      currentExecution: {
+        status: "failed",
+        bookId: userSession.activeBookId,
+        chapterNumber: userSession.activeChapterNumber,
+        stageLabel: request.language === "en" ? `failed ${request.intent}` : `执行失败:${request.intent}`,
+      },
+    }, {
+      kind: "task.failed",
+      timestamp: Date.now(),
+      status: "failed",
+      bookId: userSession.activeBookId,
+      chapterNumber: userSession.activeChapterNumber,
+      detail,
+    });
+    await persistProjectSession(params.projectRoot, failedSession);
+    throw error;
+  }
+}
+
+export async function processProjectInteractionRequest(params: {
+  readonly projectRoot: string;
+  readonly request: InteractionRequest;
+  readonly tools: InteractionRuntimeTools;
+  readonly activeBookId?: string;
+}) {
+  return processProjectInteractionRequestInternal(params);
+}
+
+function attachRequestLanguage(
+  request: InteractionRequest,
+  language: "zh" | "en" | undefined,
+): InteractionRequest {
+  if (request.language || !language) {
+    return request;
+  }
+
+  return {
+    ...request,
+    language,
+  };
+}
+
+async function detectProjectInteractionLanguage(projectRoot: string): Promise<"zh" | "en" | undefined> {
+  try {
+    const raw = await readFile(join(projectRoot, "inkos.json"), "utf-8");
+    const parsed = JSON.parse(raw) as { language?: string };
+    return parsed.language === "en" ? "en" : parsed.language === "zh" ? "zh" : undefined;
+  } catch {
+    return undefined;
+  }
+}

+ 59 - 0
packages/core/src/interaction/project-session-store.ts

@@ -0,0 +1,59 @@
+import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { InteractionSessionSchema, type InteractionSession } from "./session.js";
+
+const SESSION_DIR = ".inkos";
+const SESSION_FILE = "session.json";
+
+export function resolveProjectSessionPath(projectRoot: string): string {
+  return join(projectRoot, SESSION_DIR, SESSION_FILE);
+}
+
+export function createProjectSession(projectRoot: string): InteractionSession {
+  return InteractionSessionSchema.parse({
+    sessionId: `${Date.now()}`,
+    projectRoot,
+    automationMode: "semi",
+    messages: [],
+  });
+}
+
+export async function loadProjectSession(projectRoot: string): Promise<InteractionSession> {
+  try {
+    const raw = await readFile(resolveProjectSessionPath(projectRoot), "utf-8");
+    return InteractionSessionSchema.parse(JSON.parse(raw));
+  } catch {
+    return createProjectSession(projectRoot);
+  }
+}
+
+export async function persistProjectSession(
+  projectRoot: string,
+  session: InteractionSession,
+): Promise<void> {
+  const dir = join(projectRoot, SESSION_DIR);
+  await mkdir(dir, { recursive: true });
+  await writeFile(resolveProjectSessionPath(projectRoot), JSON.stringify(session, null, 2), "utf-8");
+}
+
+export async function resolveSessionActiveBook(
+  projectRoot: string,
+  session: InteractionSession,
+): Promise<string | undefined> {
+  const booksDir = join(projectRoot, "books");
+  const entries = await readdir(booksDir, { withFileTypes: true }).catch(() => []);
+  const bookIds = entries
+    .filter((entry) => entry.isDirectory())
+    .map((entry) => entry.name)
+    .sort();
+
+  if (session.activeBookId && bookIds.includes(session.activeBookId)) {
+    return session.activeBookId;
+  }
+
+  if (bookIds.length === 1) {
+    return bookIds[0];
+  }
+
+  return undefined;
+}

+ 667 - 0
packages/core/src/interaction/project-tools.ts

@@ -0,0 +1,667 @@
+import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import type {
+  InteractionEvent,
+  Logger,
+  PipelineRunner,
+  StateManager,
+  ReviseMode,
+  LLMClient,
+  BookConfig,
+  Platform,
+} from "../index.js";
+import { chatCompletion } from "../index.js";
+import { executeEditTransaction } from "./edit-controller.js";
+import type { InteractionRuntimeTools } from "./runtime.js";
+import type { BookCreationDraft } from "./session.js";
+
+type PipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft"> & {
+  readonly initBook?: (
+    book: BookConfig,
+    options?: {
+      readonly externalContext?: string;
+      readonly authorIntent?: string;
+      readonly currentFocus?: string;
+    },
+  ) => Promise<void>;
+};
+type StateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadBookConfig" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
+type InstrumentablePipelineLike = PipelineLike & {
+  readonly config?: {
+    logger?: Logger;
+    client?: LLMClient;
+    model?: string;
+  };
+};
+
+function normalizePlatform(platform?: string): Platform {
+  switch (platform) {
+    case "tomato":
+    case "feilu":
+    case "qidian":
+      return platform;
+    default:
+      return "other";
+  }
+}
+
+function extractBalancedJsonObject(text: string): string | null {
+  const start = text.indexOf("{");
+  if (start < 0) {
+    return null;
+  }
+
+  let depth = 0;
+  let inString = false;
+  let escaped = false;
+
+  for (let index = start; index < text.length; index += 1) {
+    const char = text[index]!;
+
+    if (inString) {
+      if (escaped) {
+        escaped = false;
+        continue;
+      }
+      if (char === "\\") {
+        escaped = true;
+        continue;
+      }
+      if (char === "\"") {
+        inString = false;
+      }
+      continue;
+    }
+
+    if (char === "\"") {
+      inString = true;
+      continue;
+    }
+
+    if (char === "{") {
+      depth += 1;
+      continue;
+    }
+
+    if (char === "}") {
+      depth -= 1;
+      if (depth === 0) {
+        return text.slice(start, index + 1);
+      }
+      if (depth < 0) {
+        return null;
+      }
+    }
+  }
+
+  return null;
+}
+
+function parseCreationDraftResult(text: string): {
+  readonly assistantReply: string;
+  readonly draft: BookCreationDraft;
+} | null {
+  const candidate = extractBalancedJsonObject(text);
+  if (!candidate) {
+    return null;
+  }
+
+  try {
+    const parsed = JSON.parse(candidate) as {
+      assistantReply?: string;
+      draft?: BookCreationDraft;
+    };
+    if (!parsed.assistantReply || !parsed.draft) {
+      return null;
+    }
+    return {
+      assistantReply: parsed.assistantReply,
+      draft: parsed.draft,
+    };
+  } catch {
+    return null;
+  }
+}
+
+function deriveBookId(title: string): string {
+  return title
+    .toLowerCase()
+    .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
+    .replace(/-+/g, "-")
+    .slice(0, 30);
+}
+
+function buildBookConfig(input: {
+  readonly title: string;
+  readonly genre?: string;
+  readonly platform?: string;
+  readonly language?: "zh" | "en";
+  readonly chapterWordCount?: number;
+  readonly targetChapters?: number;
+}): BookConfig {
+  const now = new Date().toISOString();
+  return {
+    id: deriveBookId(input.title),
+    title: input.title,
+    platform: normalizePlatform(input.platform),
+    genre: input.genre ?? "other",
+    status: "outlining",
+    targetChapters: input.targetChapters ?? 200,
+    chapterWordCount: input.chapterWordCount ?? 3000,
+    ...(input.language ? { language: input.language } : {}),
+    createdAt: now,
+    updatedAt: now,
+  };
+}
+
+function buildCreationExternalContext(input: {
+  readonly blurb?: string;
+  readonly worldPremise?: string;
+  readonly settingNotes?: string;
+  readonly protagonist?: string;
+  readonly supportingCast?: string;
+  readonly conflictCore?: string;
+  readonly volumeOutline?: string;
+  readonly constraints?: string;
+}): string | undefined {
+  const sections = [
+    input.worldPremise ? `## 世界观与核心设定\n${input.worldPremise}` : undefined,
+    input.settingNotes ? `## 补充设定\n${input.settingNotes}` : undefined,
+    input.protagonist ? `## 主角设定\n${input.protagonist}` : undefined,
+    input.supportingCast ? `## 关键角色与势力\n${input.supportingCast}` : undefined,
+    input.conflictCore ? `## 核心冲突\n${input.conflictCore}` : undefined,
+    input.volumeOutline ? `## 卷纲方向\n${input.volumeOutline}` : undefined,
+    input.blurb ? `## 简介卖点\n${input.blurb}` : undefined,
+    input.constraints ? `## 创作约束\n${input.constraints}` : undefined,
+  ].filter((section): section is string => Boolean(section?.trim()));
+
+  if (sections.length === 0) {
+    return undefined;
+  }
+
+  return sections.join("\n\n");
+}
+
+export function buildChapterFileLookup(files: ReadonlyArray<string>): ReadonlyMap<number, string> {
+  const lookup = new Map<number, string>();
+  for (const file of files) {
+    if (!file.endsWith(".md") || !/^\d{4}/.test(file)) {
+      continue;
+    }
+    const chapterNumber = parseInt(file.slice(0, 4), 10);
+    if (!lookup.has(chapterNumber)) {
+      lookup.set(chapterNumber, file);
+    }
+  }
+  return lookup;
+}
+
+async function exportBookToPath(state: StateLike, bookId: string, options: {
+  readonly format?: "txt" | "md" | "epub";
+  readonly approvedOnly?: boolean;
+  readonly outputPath?: string;
+}) {
+  const format = options.format ?? "txt";
+  const index = await state.loadChapterIndex(bookId);
+  const book = await state.loadBookConfig(bookId);
+  const chapters = options.approvedOnly
+    ? index.filter((chapter) => chapter.status === "approved")
+    : index;
+
+  if (chapters.length === 0) {
+    throw new Error("No chapters to export.");
+  }
+
+  const bookDir = state.bookDir(bookId);
+  const chaptersDir = join(bookDir, "chapters");
+  const projectRoot = dirname(dirname(bookDir));
+  const outputPath = options.outputPath ?? join(projectRoot, `${bookId}_export.${format}`);
+  const chapterFiles = buildChapterFileLookup(await readdir(chaptersDir));
+
+  if (format === "epub") {
+    const sections: string[] = [
+      "<!DOCTYPE html>",
+      `<html><head><meta charset="utf-8"><title>${book.title}</title><style>body{font-family:serif;max-width:40em;margin:auto;padding:2em;line-height:1.8}h2{margin-top:3em}</style></head><body>`,
+      `<h1>${book.title}</h1>`,
+    ];
+
+    for (const chapter of chapters) {
+      const match = chapterFiles.get(chapter.number);
+      if (!match) {
+        continue;
+      }
+      const markdown = await readFile(join(chaptersDir, match), "utf-8");
+      const title = markdown.match(/^#\s+(.+)/m)?.[1] ?? match.replace(/\.md$/, "");
+      const htmlBody = markdown
+        .split("\n")
+        .filter((line) => !line.startsWith("#"))
+        .map((line) => line.trim() ? `<p>${line}</p>` : "")
+        .join("\n");
+      sections.push(`<h2>${title}</h2>`);
+      sections.push(htmlBody);
+    }
+    sections.push("</body></html>");
+    await mkdir(dirname(outputPath), { recursive: true });
+    await writeFile(outputPath, sections.join("\n"), "utf-8");
+  } else {
+    const parts: string[] = [];
+    parts.push(format === "md" ? `# ${book.title}\n\n---\n` : `${book.title}\n\n`);
+    for (const chapter of chapters) {
+      const match = chapterFiles.get(chapter.number);
+      if (!match) {
+        continue;
+      }
+      parts.push(await readFile(join(chaptersDir, match), "utf-8"));
+      parts.push("\n\n");
+    }
+    await mkdir(dirname(outputPath), { recursive: true });
+    await writeFile(outputPath, parts.join(format === "md" ? "\n---\n\n" : "\n"), "utf-8");
+  }
+
+  return {
+    outputPath,
+    chaptersExported: chapters.length,
+    totalWords: chapters.reduce((sum, chapter) => sum + chapter.wordCount, 0),
+    format,
+  };
+}
+
+function mapStageMessageToStatus(message: string): InteractionEvent["status"] | undefined {
+  const lower = message.trim().toLowerCase();
+  if (
+    lower.includes("planning next chapter")
+    || lower.includes("generating foundation")
+    || lower.includes("reviewing foundation")
+    || lower.includes("preparing chapter inputs")
+    || message.includes("规划下一章意图")
+    || message.includes("生成基础设定")
+    || message.includes("审核基础设定")
+    || message.includes("准备章节输入")
+  ) {
+    return "planning";
+  }
+  if (
+    lower.includes("composing chapter runtime context")
+    || message.includes("组装章节运行时上下文")
+  ) {
+    return "composing";
+  }
+  if (
+    lower.includes("writing chapter draft")
+    || message.includes("撰写章节草稿")
+  ) {
+    return "writing";
+  }
+  if (
+    lower.includes("auditing draft")
+    || message.includes("审计草稿")
+  ) {
+    return "assessing";
+  }
+  if (
+    lower.includes("fixing")
+    || lower.includes("revising chapter")
+    || lower.includes("rewrite")
+    || lower.includes("repair")
+    || message.includes("自动修复")
+    || message.includes("整章改写")
+    || message.includes("修订第")
+  ) {
+    return "repairing";
+  }
+  if (
+    lower.includes("persist")
+    || lower.includes("saving")
+    || lower.includes("snapshot")
+    || lower.includes("rebuilding final truth files")
+    || lower.includes("validating truth file updates")
+    || lower.includes("syncing memory indexes")
+    || message.includes("落盘")
+    || message.includes("保存")
+    || message.includes("快照")
+    || message.includes("校验真相文件变更")
+    || message.includes("生成最终真相文件")
+    || message.includes("同步记忆索引")
+  ) {
+    return "persisting";
+  }
+  return undefined;
+}
+
+function extractStageDetail(message: string): string | undefined {
+  if (message.startsWith("Stage: ")) {
+    return message.slice("Stage: ".length).trim();
+  }
+  if (message.startsWith("阶段:")) {
+    return message.slice("阶段:".length).trim();
+  }
+  return undefined;
+}
+
+function createInteractionLogger(
+  original: Logger | undefined,
+  events: InteractionEvent[],
+  bookId: string,
+): Logger {
+  const emit = (level: "debug" | "info" | "warn" | "error", message: string): void => {
+    const stageDetail = extractStageDetail(message);
+    const stageStatus = stageDetail ? mapStageMessageToStatus(stageDetail) : undefined;
+
+    if (stageDetail && stageStatus) {
+      events.push({
+        kind: "stage.changed",
+        timestamp: Date.now(),
+        status: stageStatus,
+        bookId,
+        detail: stageDetail,
+      });
+      return;
+    }
+
+    if (level === "warn") {
+      events.push({
+        kind: "task.warning",
+        timestamp: Date.now(),
+        status: "blocked",
+        bookId,
+        detail: message,
+      });
+      return;
+    }
+
+    if (level === "error") {
+      events.push({
+        kind: "task.failed",
+        timestamp: Date.now(),
+        status: "failed",
+        bookId,
+        detail: message,
+      });
+    }
+  };
+
+  const wrap = (base: Logger | undefined): Logger => ({
+    debug: (msg, ctx) => {
+      emit("debug", msg);
+      base?.debug(msg, ctx);
+    },
+    info: (msg, ctx) => {
+      emit("info", msg);
+      base?.info(msg, ctx);
+    },
+    warn: (msg, ctx) => {
+      emit("warn", msg);
+      base?.warn(msg, ctx);
+    },
+    error: (msg, ctx) => {
+      emit("error", msg);
+      base?.error(msg, ctx);
+    },
+    child: (tag, extraCtx) => wrap(base?.child(tag, extraCtx)),
+  });
+
+  return wrap(original);
+}
+
+async function withPipelineInteractionTelemetry<T extends { chapterNumber?: number }>(
+  pipeline: InstrumentablePipelineLike,
+  bookId: string,
+  executor: () => Promise<T>,
+): Promise<T & {
+  __interaction: {
+    events: ReadonlyArray<InteractionEvent>;
+    activeChapterNumber?: number;
+  };
+}> {
+  const events: InteractionEvent[] = [];
+  const originalLogger = pipeline.config?.logger;
+  if (pipeline.config) {
+    pipeline.config.logger = createInteractionLogger(originalLogger, events, bookId);
+  }
+
+  try {
+    const result = await executor();
+    return {
+      ...result,
+      __interaction: {
+        events,
+        ...(typeof result.chapterNumber === "number"
+          ? { activeChapterNumber: result.chapterNumber }
+          : {}),
+      },
+    };
+  } finally {
+    if (pipeline.config) {
+      pipeline.config.logger = originalLogger;
+    }
+  }
+}
+
+export function createInteractionToolsFromDeps(
+  pipeline: PipelineLike,
+  state: StateLike,
+  hooks?: {
+    readonly onChatTextDelta?: (text: string) => void;
+    readonly getChatRequestOptions?: () => {
+      readonly temperature?: number;
+      readonly maxTokens?: number;
+    };
+  },
+): InteractionRuntimeTools {
+  const instrumentedPipeline = pipeline as InstrumentablePipelineLike;
+
+  return {
+    listBooks: () => state.listBooks(),
+    developBookDraft: async (input, existingDraft) => {
+      if (!instrumentedPipeline.config?.client || !instrumentedPipeline.config?.model) {
+        const concept = existingDraft?.concept ?? input;
+        return {
+          __interaction: {
+            responseText: "先把这本书的大概方向收住。你更想写长篇连载,还是十来章能收住的版本?",
+            details: {
+              creationDraft: {
+                concept,
+                title: existingDraft?.title,
+                genre: existingDraft?.genre,
+                platform: existingDraft?.platform,
+                language: existingDraft?.language,
+                targetChapters: existingDraft?.targetChapters,
+                chapterWordCount: existingDraft?.chapterWordCount,
+                blurb: existingDraft?.blurb,
+                authorIntent: existingDraft?.authorIntent,
+                currentFocus: existingDraft?.currentFocus,
+                nextQuestion: "你更想写长篇连载,还是十来章能收住的版本?",
+                missingFields: existingDraft?.missingFields ?? ["title", "genre", "targetChapters"],
+                readyToCreate: existingDraft?.readyToCreate ?? false,
+              } satisfies BookCreationDraft,
+            },
+          },
+        };
+      }
+
+      const response = await chatCompletion(
+        instrumentedPipeline.config.client,
+        instrumentedPipeline.config.model,
+        [
+          {
+            role: "system",
+            content: [
+              "You are InkOS book ideation assistant.",
+              "Turn the user's latest message and the current draft into a tighter book creation draft.",
+              "Ask at most one sharp next question.",
+              "Default to concise Chinese unless the draft language is clearly English.",
+              "Return JSON only with keys assistantReply and draft.",
+              "draft must include concept and may include title, genre, platform, language, targetChapters, chapterWordCount, blurb, worldPremise, settingNotes, protagonist, supportingCast, conflictCore, volumeOutline, constraints, authorIntent, currentFocus, nextQuestion, missingFields, readyToCreate.",
+              "Help the user decide and revise worldview, setting, protagonist, supporting cast, core conflict, blurb, and volume direction.",
+              "Be conservative: only mark readyToCreate=true when the draft already has a workable title, genre, targetChapters, chapterWordCount, and enough setting/conflict detail to generate a foundation.",
+            ].join(" "),
+          },
+          {
+            role: "user",
+            content: JSON.stringify({
+              currentDraft: existingDraft ?? null,
+              latestMessage: input,
+            }, null, 2),
+          },
+        ],
+        { temperature: 0.4, maxTokens: 700 },
+      );
+
+      const parsed = parseCreationDraftResult(response.content);
+      if (!parsed) {
+        throw new Error("Book draft assistant returned invalid JSON.");
+      }
+
+      return {
+        __interaction: {
+          responseText: parsed.assistantReply,
+          details: {
+            creationDraft: parsed.draft,
+          },
+        },
+      };
+    },
+    createBook: async (input) => {
+      const book = buildBookConfig(input);
+      if (!pipeline.initBook) {
+        throw new Error("Pipeline does not support shared book creation.");
+      }
+      await pipeline.initBook(book, {
+        externalContext: buildCreationExternalContext(input),
+        authorIntent: input.authorIntent,
+        currentFocus: input.currentFocus,
+      });
+      return {
+        bookId: book.id,
+        title: book.title,
+        __interaction: {
+          responseText: `Created ${book.title} (${book.id}).`,
+          details: {
+            bookId: book.id,
+            title: book.title,
+          },
+        },
+      };
+    },
+    exportBook: async (bookId, options) => {
+      const result = await exportBookToPath(state, bookId, options);
+      return {
+        ...result,
+        __interaction: {
+          responseText: `Exported ${bookId} to ${result.outputPath} (${result.chaptersExported} chapters).`,
+          details: {
+            outputPath: result.outputPath,
+            chaptersExported: result.chaptersExported,
+            totalWords: result.totalWords,
+            format: result.format,
+          },
+        },
+      };
+    },
+    chat: async (input, options) => {
+      const bookLabel = options.bookId ?? "none";
+      const chatRequestOptions = hooks?.getChatRequestOptions?.() ?? {};
+      const response = instrumentedPipeline.config?.client && instrumentedPipeline.config?.model
+        ? await chatCompletion(
+          instrumentedPipeline.config.client,
+          instrumentedPipeline.config.model,
+          [
+            {
+              role: "system",
+              content: [
+                "You are InkOS inside the terminal workbench.",
+                "Respond conversationally and briefly.",
+                "If there is no active book, help the user decide what to write next.",
+                "If there is an active book, keep the answer grounded in that book context.",
+              ].join(" "),
+            },
+            {
+              role: "user",
+              content: `activeBook=${bookLabel}\nautomationMode=${options.automationMode}\nmessage=${input}`,
+            },
+          ],
+          {
+            temperature: chatRequestOptions.temperature ?? 0.4,
+            maxTokens: chatRequestOptions.maxTokens ?? 240,
+            onTextDelta: hooks?.onChatTextDelta,
+          },
+        )
+        : undefined;
+
+      return {
+        __interaction: {
+          responseText: response?.content?.trim()
+            || (options.bookId
+              ? `I’m here. Active book is ${options.bookId}.`
+              : "I’m here. No active book yet."),
+        },
+      };
+    },
+    writeNextChapter: (bookId) => withPipelineInteractionTelemetry(
+      instrumentedPipeline,
+      bookId,
+      () => pipeline.writeNextChapter(bookId),
+    ),
+    reviseDraft: (bookId, chapterNumber, mode) => withPipelineInteractionTelemetry(
+      instrumentedPipeline,
+      bookId,
+      () => pipeline.reviseDraft(bookId, chapterNumber, mode as ReviseMode),
+    ),
+    patchChapterText: async (bookId, chapterNumber, targetText, replacementText) => {
+      const execution = await executeEditTransaction(
+        {
+          bookDir: (targetBookId) => state.bookDir(targetBookId),
+          loadChapterIndex: (targetBookId) => state.loadChapterIndex(targetBookId),
+          saveChapterIndex: (targetBookId, index) => state.saveChapterIndex(targetBookId, index),
+        },
+        {
+          kind: "chapter-local-edit",
+          bookId,
+          chapterNumber,
+          instruction: `Replace ${targetText} with ${replacementText}`,
+          targetText,
+          replacementText,
+        },
+      );
+      return {
+        __interaction: {
+          activeChapterNumber: chapterNumber,
+          responseText: execution.summary,
+        },
+      };
+    },
+    renameEntity: async (bookId, oldValue, newValue) => {
+      const execution = await executeEditTransaction(
+        {
+          bookDir: (targetBookId) => state.bookDir(targetBookId),
+          loadChapterIndex: (targetBookId) => state.loadChapterIndex(targetBookId),
+          saveChapterIndex: (targetBookId, index) => state.saveChapterIndex(targetBookId, index),
+        },
+        {
+          kind: "entity-rename",
+          bookId,
+          entityType: "character",
+          oldValue,
+          newValue,
+        },
+      );
+      return {
+        __interaction: {
+          responseText: execution.summary,
+        },
+      };
+    },
+    updateCurrentFocus: async (bookId, content) => {
+      await state.ensureControlDocuments(bookId);
+      await writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), content, "utf-8");
+    },
+    updateAuthorIntent: async (bookId, content) => {
+      await state.ensureControlDocuments(bookId);
+      await writeFile(join(state.bookDir(bookId), "story", "author_intent.md"), content, "utf-8");
+    },
+    writeTruthFile: async (bookId, fileName, content) => {
+      await state.ensureControlDocuments(bookId);
+      await writeFile(join(state.bookDir(bookId), "story", fileName), content, "utf-8");
+    },
+  };
+}

+ 5 - 0
packages/core/src/interaction/request-router.ts

@@ -0,0 +1,5 @@
+import { InteractionRequestSchema, type InteractionRequest } from "./intents.js";
+
+export function routeInteractionRequest(input: InteractionRequest): InteractionRequest {
+  return InteractionRequestSchema.parse(input);
+}

+ 1154 - 0
packages/core/src/interaction/runtime.ts

@@ -0,0 +1,1154 @@
+import type { AutomationMode } from "./modes.js";
+import { routeInteractionRequest } from "./request-router.js";
+import type { InteractionRequest } from "./intents.js";
+import type { ExecutionState, InteractionEvent } from "./events.js";
+import type { PendingDecision, InteractionSession } from "./session.js";
+import {
+  appendInteractionEvent,
+  bindActiveBook,
+  clearCreationDraft,
+  clearPendingDecision,
+  updateCreationDraft,
+  updateAutomationMode,
+} from "./session.js";
+
+type ReviseMode = "local-fix" | "rewrite";
+type RuntimeLanguage = "zh" | "en";
+
+export interface InteractionRuntimeTools {
+  readonly listBooks: () => Promise<ReadonlyArray<string>>;
+  readonly developBookDraft?: (
+    input: string,
+    existingDraft?: InteractionSession["creationDraft"],
+  ) => Promise<unknown>;
+  readonly createBook?: (input: {
+    readonly title: string;
+    readonly genre?: string;
+    readonly platform?: string;
+    readonly language?: "zh" | "en";
+    readonly chapterWordCount?: number;
+    readonly targetChapters?: number;
+    readonly blurb?: string;
+    readonly worldPremise?: string;
+    readonly settingNotes?: string;
+    readonly protagonist?: string;
+    readonly supportingCast?: string;
+    readonly conflictCore?: string;
+    readonly volumeOutline?: string;
+    readonly constraints?: string;
+    readonly authorIntent?: string;
+    readonly currentFocus?: string;
+  }) => Promise<unknown>;
+  readonly exportBook?: (bookId: string, options: {
+    readonly format?: "txt" | "md" | "epub";
+    readonly approvedOnly?: boolean;
+    readonly outputPath?: string;
+  }) => Promise<unknown>;
+  readonly chat?: (
+    input: string,
+    options: {
+      readonly bookId?: string;
+      readonly automationMode: AutomationMode;
+    },
+  ) => Promise<unknown>;
+  readonly writeNextChapter: (bookId: string) => Promise<unknown>;
+  readonly reviseDraft: (bookId: string, chapterNumber: number, mode: ReviseMode) => Promise<unknown>;
+  readonly patchChapterText: (
+    bookId: string,
+    chapterNumber: number,
+    targetText: string,
+    replacementText: string,
+  ) => Promise<unknown>;
+  readonly renameEntity: (
+    bookId: string,
+    oldValue: string,
+    newValue: string,
+  ) => Promise<unknown>;
+  readonly updateCurrentFocus: (bookId: string, content: string) => Promise<unknown>;
+  readonly updateAuthorIntent: (bookId: string, content: string) => Promise<unknown>;
+  readonly writeTruthFile: (bookId: string, fileName: string, content: string) => Promise<unknown>;
+}
+
+export interface InteractionRuntimeResult {
+  readonly session: InteractionSession;
+  readonly responseText?: string;
+  readonly details?: Readonly<Record<string, unknown>>;
+}
+
+interface InteractionToolMetadata {
+  readonly events?: ReadonlyArray<InteractionEvent>;
+  readonly activeChapterNumber?: number;
+  readonly currentExecution?: ExecutionState;
+  readonly pendingDecision?: PendingDecision;
+  readonly responseText?: string;
+  readonly details?: Readonly<Record<string, unknown>>;
+}
+
+function extractToolMetadata(value: unknown): InteractionToolMetadata {
+  const chapterNumber = typeof value === "object" && value !== null && "chapterNumber" in value
+    && typeof (value as { chapterNumber?: unknown }).chapterNumber === "number"
+    ? (value as { chapterNumber: number }).chapterNumber
+    : undefined;
+
+  if (!value || typeof value !== "object" || !("__interaction" in value)) {
+    return {
+      ...(chapterNumber !== undefined ? { activeChapterNumber: chapterNumber } : {}),
+    };
+  }
+
+  const interaction = (value as {
+    readonly __interaction?: InteractionToolMetadata;
+  }).__interaction;
+
+  return {
+    ...interaction,
+    ...(interaction?.activeChapterNumber === undefined && chapterNumber !== undefined
+      ? { activeChapterNumber: chapterNumber }
+      : {}),
+  };
+}
+
+function resolveRuntimeLanguage(request: InteractionRequest): RuntimeLanguage {
+  return request.language === "en" ? "en" : "zh";
+}
+
+function localize<T>(language: RuntimeLanguage, messages: { zh: T; en: T }): T {
+  return language === "en" ? messages.en : messages.zh;
+}
+
+function localizeMode(mode: AutomationMode, language: RuntimeLanguage): string {
+  if (language === "en") {
+    return mode;
+  }
+
+  return {
+    auto: "自动",
+    semi: "半自动",
+    manual: "手动",
+  }[mode] ?? mode;
+}
+
+function renderCreationDraft(
+  draft: NonNullable<InteractionSession["creationDraft"]>,
+  language: RuntimeLanguage,
+): string {
+  const lines = language === "en"
+    ? [
+        "# Current Book Draft",
+        draft.title ? `- Title: ${draft.title}` : undefined,
+        draft.genre ? `- Genre: ${draft.genre}` : undefined,
+        draft.platform ? `- Platform: ${draft.platform}` : undefined,
+        draft.worldPremise ? `- World: ${draft.worldPremise}` : undefined,
+        draft.protagonist ? `- Protagonist: ${draft.protagonist}` : undefined,
+        draft.conflictCore ? `- Core Conflict: ${draft.conflictCore}` : undefined,
+        draft.volumeOutline ? `- Volume Direction: ${draft.volumeOutline}` : undefined,
+        draft.blurb ? `- Blurb: ${draft.blurb}` : undefined,
+        draft.nextQuestion ? `- Next: ${draft.nextQuestion}` : undefined,
+      ]
+    : [
+        "# 当前创作草案",
+        draft.title ? `- 书名:${draft.title}` : undefined,
+        draft.genre ? `- 题材:${draft.genre}` : undefined,
+        draft.platform ? `- 平台:${draft.platform}` : undefined,
+        draft.worldPremise ? `- 世界观:${draft.worldPremise}` : undefined,
+        draft.protagonist ? `- 主角:${draft.protagonist}` : undefined,
+        draft.conflictCore ? `- 核心冲突:${draft.conflictCore}` : undefined,
+        draft.volumeOutline ? `- 卷纲方向:${draft.volumeOutline}` : undefined,
+        draft.blurb ? `- 简介:${draft.blurb}` : undefined,
+        draft.nextQuestion ? `- 下一步:${draft.nextQuestion}` : undefined,
+      ];
+  return lines.filter(Boolean).join("\n");
+}
+
+function buildTaskStartedState(
+  session: InteractionSession,
+  request: InteractionRequest,
+  language: RuntimeLanguage,
+): ExecutionState {
+  switch (request.intent) {
+    case "write_next":
+    case "continue_book":
+      return {
+        status: "planning",
+        bookId: request.bookId ?? session.activeBookId,
+        chapterNumber: session.activeChapterNumber,
+        stageLabel: localize(language, {
+          zh: "准备章节输入",
+          en: "preparing chapter inputs",
+        }),
+      };
+    case "develop_book":
+      return {
+        status: "planning",
+        bookId: request.bookId ?? session.activeBookId,
+        stageLabel: localize(language, {
+          zh: "收敛创作草案",
+          en: "developing book draft",
+        }),
+      };
+    case "create_book":
+      return {
+        status: "planning",
+        bookId: request.bookId ?? session.activeBookId,
+        stageLabel: localize(language, {
+          zh: "创建作品基础",
+          en: "creating book foundation",
+        }),
+      };
+    case "export_book":
+      return {
+        status: "persisting",
+        bookId: request.bookId ?? session.activeBookId,
+        chapterNumber: session.activeChapterNumber,
+        stageLabel: localize(language, {
+          zh: "导出作品文件",
+          en: "exporting book artifacts",
+        }),
+      };
+    case "revise_chapter":
+    case "rewrite_chapter":
+      return {
+        status: "repairing",
+        bookId: request.bookId ?? session.activeBookId,
+        chapterNumber: request.chapterNumber ?? session.activeChapterNumber,
+        stageLabel: request.intent === "rewrite_chapter"
+          ? localize(language, { zh: "重写章节", en: "rewriting chapter" })
+          : localize(language, { zh: "修订章节", en: "revising chapter" }),
+      };
+    case "update_focus":
+    case "update_author_intent":
+    case "edit_truth":
+      return {
+        status: "persisting",
+        bookId: request.bookId ?? session.activeBookId,
+        chapterNumber: session.activeChapterNumber,
+        stageLabel: localize(language, {
+          zh: "应用项目修改",
+          en: "applying project edit",
+        }),
+      };
+    case "pause_book":
+    case "discard_book_draft":
+      return {
+        status: "blocked",
+        bookId: request.bookId ?? session.activeBookId,
+        chapterNumber: session.activeChapterNumber,
+        stageLabel: localize(language, {
+          zh: "已由用户暂停",
+          en: "paused by user",
+        }),
+      };
+    default:
+      return {
+        status: "planning",
+        bookId: request.bookId ?? session.activeBookId,
+        chapterNumber: session.activeChapterNumber,
+        stageLabel: localize(language, {
+          zh: `处理中:${request.intent}`,
+          en: `handling ${request.intent}`,
+        }),
+      };
+  }
+}
+
+function shouldWaitForHuman(
+  automationMode: AutomationMode,
+  request: InteractionRequest,
+): boolean {
+  const contentIntent = request.intent === "write_next"
+    || request.intent === "continue_book"
+    || request.intent === "revise_chapter"
+    || request.intent === "rewrite_chapter"
+    || request.intent === "patch_chapter_text";
+  const editIntent = request.intent === "update_focus"
+    || request.intent === "update_author_intent"
+    || request.intent === "edit_truth"
+    || request.intent === "rename_entity";
+
+  if (automationMode === "auto") {
+    return false;
+  }
+  if (automationMode === "semi") {
+    return contentIntent;
+  }
+  return contentIntent || editIntent;
+}
+
+function buildPendingDecision(
+  session: InteractionSession,
+  request: InteractionRequest,
+  language: RuntimeLanguage,
+  chapterNumber?: number,
+): PendingDecision | undefined {
+  if (!shouldWaitForHuman(session.automationMode, request)) {
+    return undefined;
+  }
+
+  const bookId = request.bookId ?? session.activeBookId;
+  if (!bookId) {
+    return undefined;
+  }
+
+  return {
+    kind: "review-next-step",
+    bookId,
+    ...(chapterNumber !== undefined ? { chapterNumber } : {}),
+    summary: session.automationMode === "manual"
+      ? localize(language, {
+          zh: "执行已完成。请明确选择下一步操作。",
+          en: "Execution finished. Choose the next action explicitly.",
+        })
+      : localize(language, {
+          zh: "执行已完成,等待你的下一步决定。",
+          en: "Execution finished. Waiting for your next decision.",
+        }),
+  };
+}
+
+function buildWaitingExecution(
+  session: InteractionSession,
+  request: InteractionRequest,
+  language: RuntimeLanguage,
+  chapterNumber?: number,
+): ExecutionState {
+  return {
+    status: "waiting_human",
+    bookId: request.bookId ?? session.activeBookId,
+    ...(chapterNumber !== undefined ? { chapterNumber } : {}),
+    stageLabel: localize(language, {
+      zh: "等待你的下一步决定",
+      en: "waiting for your next decision",
+    }),
+  };
+}
+
+function appendToolEvents(
+  session: InteractionSession,
+  events: ReadonlyArray<InteractionEvent> | undefined,
+): InteractionSession {
+  if (!events || events.length === 0) {
+    return session;
+  }
+
+  const baseTimestamp = Date.now();
+  return events.reduce((nextSession, event, index) => appendInteractionEvent(nextSession, {
+    ...event,
+    timestamp: baseTimestamp - events.length + index,
+  }), session);
+}
+
+interface RuntimeRequestHelpers {
+  readonly language: RuntimeLanguage;
+  readonly addEvent: (
+    nextSession: InteractionSession,
+    kind: string,
+    status: InteractionEvent["status"],
+    detail: string,
+  ) => InteractionSession;
+  readonly markCompleted: (nextSession: InteractionSession) => InteractionSession;
+}
+
+async function handleDraftLifecycleRequest(params: {
+  readonly session: InteractionSession;
+  readonly request: InteractionRequest;
+  readonly tools: InteractionRuntimeTools;
+  readonly helpers: RuntimeRequestHelpers;
+}): Promise<InteractionRuntimeResult | undefined> {
+  const { session, request, tools, helpers } = params;
+  const { language, addEvent, markCompleted } = helpers;
+
+  switch (request.intent) {
+    case "develop_book": {
+      if (!tools.developBookDraft) {
+        throw new Error(localize(language, {
+          zh: "创作草案会话暂未实现。",
+          en: "Book-draft ideation is not implemented yet.",
+        }));
+      }
+      if (!request.instruction) {
+        throw new Error(localize(language, {
+          zh: "创作草案需要一条用户输入。",
+          en: "Book-draft ideation requires user input.",
+        }));
+      }
+      const toolResult = await tools.developBookDraft(request.instruction, session.creationDraft);
+      const metadata = extractToolMetadata(toolResult);
+      const draft = metadata.details?.creationDraft as InteractionSession["creationDraft"] | undefined;
+      if (!draft) {
+        throw new Error(localize(language, {
+          zh: "创作草案工具没有返回草案数据。",
+          en: "Book-draft tool did not return draft data.",
+        }));
+      }
+      const nextSession = appendToolEvents(updateCreationDraft(session, draft), metadata.events);
+      const completed = {
+        ...markCompleted(nextSession),
+        currentExecution: metadata.currentExecution ?? markCompleted(nextSession).currentExecution,
+      };
+      return {
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: "已更新创作草案。",
+          en: "Updated the book draft.",
+        })),
+        responseText: metadata.responseText ?? localize(language, {
+          zh: "已更新创作草案。",
+          en: "Updated the book draft.",
+        }),
+        details: metadata.details,
+      };
+    }
+    case "show_book_draft": {
+      if (!session.creationDraft) {
+        return {
+          session: markCompleted(session),
+          responseText: localize(language, {
+            zh: "当前还没有创作草案。先告诉我你想写什么,再逐步把书收出来。",
+            en: "There is no active book draft yet. Start by telling me what you want to write.",
+          }),
+        };
+      }
+      return {
+        session: markCompleted(session),
+        responseText: renderCreationDraft(session.creationDraft, language),
+      };
+    }
+    case "create_book": {
+      if (!tools.createBook) {
+        throw new Error(localize(language, {
+          zh: "交互运行时暂未实现创建作品。",
+          en: "Book creation is not implemented in the interaction runtime yet.",
+        }));
+      }
+      const effectiveDraft = session.creationDraft;
+      const title = request.title ?? effectiveDraft?.title;
+      if (!title) {
+        throw new Error(localize(language, {
+          zh: "创建作品需要标题。",
+          en: "Book creation requires a title.",
+        }));
+      }
+      const toolResult = await tools.createBook({
+        title,
+        genre: request.genre ?? effectiveDraft?.genre,
+        platform: request.platform ?? effectiveDraft?.platform,
+        language: request.language ?? effectiveDraft?.language,
+        chapterWordCount: request.chapterWordCount ?? effectiveDraft?.chapterWordCount,
+        targetChapters: request.targetChapters ?? effectiveDraft?.targetChapters,
+        blurb: request.blurb ?? effectiveDraft?.blurb,
+        worldPremise: request.worldPremise ?? effectiveDraft?.worldPremise,
+        settingNotes: request.settingNotes ?? effectiveDraft?.settingNotes,
+        protagonist: request.protagonist ?? effectiveDraft?.protagonist,
+        supportingCast: request.supportingCast ?? effectiveDraft?.supportingCast,
+        conflictCore: request.conflictCore ?? effectiveDraft?.conflictCore,
+        volumeOutline: request.volumeOutline ?? effectiveDraft?.volumeOutline,
+        constraints: request.constraints ?? effectiveDraft?.constraints,
+        authorIntent: request.authorIntent ?? effectiveDraft?.authorIntent,
+        currentFocus: request.currentFocus ?? effectiveDraft?.currentFocus,
+      });
+      const metadata = extractToolMetadata(toolResult);
+      const createdBookId = typeof toolResult === "object" && toolResult !== null && "bookId" in toolResult
+        && typeof (toolResult as { bookId?: unknown }).bookId === "string"
+        ? (toolResult as { bookId: string }).bookId
+        : undefined;
+      if (!createdBookId) {
+        throw new Error(localize(language, {
+          zh: "创建作品工具没有返回作品 ID。",
+          en: "Create-book tool did not return a book id.",
+        }));
+      }
+      const nextSession = appendToolEvents(
+        clearCreationDraft(bindActiveBook(session, createdBookId)),
+        metadata.events,
+      );
+      const completed = {
+        ...markCompleted(nextSession),
+        currentExecution: metadata.currentExecution ?? markCompleted(nextSession).currentExecution,
+      };
+      return {
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已创建作品 ${createdBookId}。`,
+          en: `Created ${createdBookId}.`,
+        })),
+        responseText: metadata.responseText ?? localize(language, {
+          zh: `已创建作品 ${createdBookId}。`,
+          en: `Created ${createdBookId}.`,
+        }),
+        details: metadata.details,
+      };
+    }
+    case "discard_book_draft": {
+      const completed = markCompleted(clearCreationDraft(session));
+      return {
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: "已丢弃当前创作草案。",
+          en: "Discarded the current book draft.",
+        })),
+        responseText: localize(language, {
+          zh: "已丢弃当前创作草案。",
+          en: "Discarded the current book draft.",
+        }),
+      };
+    }
+    default:
+      return undefined;
+  }
+}
+
+async function handleBookSelectionRequest(params: {
+  readonly session: InteractionSession;
+  readonly request: InteractionRequest;
+  readonly tools: InteractionRuntimeTools;
+  readonly helpers: RuntimeRequestHelpers;
+}): Promise<InteractionRuntimeResult | undefined> {
+  const { session, request, tools, helpers } = params;
+  const { language, addEvent, markCompleted } = helpers;
+
+  switch (request.intent) {
+    case "list_books": {
+      const books = await tools.listBooks();
+      const completed = markCompleted(session);
+      return {
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已列出 ${books.length} 本作品。`,
+          en: `Listed ${books.length} book(s).`,
+        })),
+        responseText: books.length > 0
+          ? localize(language, {
+              zh: `作品列表:${books.join("、")}`,
+              en: `Books: ${books.join(", ")}`,
+            })
+          : localize(language, {
+              zh: "当前项目下没有作品。",
+              en: "No books found in this project.",
+            }),
+      };
+    }
+    case "select_book": {
+      if (!request.bookId) {
+        throw new Error(localize(language, {
+          zh: "切换作品需要提供作品 ID。",
+          en: "Book selection requires a book id.",
+        }));
+      }
+      const books = await tools.listBooks();
+      if (!books.includes(request.bookId)) {
+        throw new Error(localize(language, {
+          zh: `当前项目中找不到作品「${request.bookId}」。`,
+          en: `Book "${request.bookId}" not found in this project.`,
+        }));
+      }
+      const completed = markCompleted(bindActiveBook(session, request.bookId));
+      return {
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已切换当前作品到 ${request.bookId}。`,
+          en: `Bound active book to ${request.bookId}.`,
+        })),
+        responseText: localize(language, {
+          zh: `当前作品:${request.bookId}`,
+          en: `Active book: ${request.bookId}`,
+        }),
+      };
+    }
+    default:
+      return undefined;
+  }
+}
+
+export async function runInteractionRequest(params: {
+  readonly session: InteractionSession;
+  readonly request: InteractionRequest;
+  readonly tools: InteractionRuntimeTools;
+}): Promise<InteractionRuntimeResult> {
+  const request = routeInteractionRequest(params.request);
+  const language = resolveRuntimeLanguage(request);
+  let session = params.session;
+  const addEvent = (
+    nextSession: InteractionSession,
+    kind: string,
+    status: InteractionEvent["status"],
+    detail: string,
+  ): InteractionSession => appendInteractionEvent(nextSession, {
+    kind,
+    timestamp: Date.now(),
+    status,
+    bookId: nextSession.activeBookId,
+    chapterNumber: nextSession.activeChapterNumber,
+    detail,
+  });
+
+  if (request.mode) {
+    session = updateAutomationMode(session, request.mode as AutomationMode);
+  }
+
+  session = clearPendingDecision({
+    ...session,
+    currentExecution: buildTaskStartedState(session, request, language),
+  });
+  session = addEvent(session, "task.started", session.currentExecution!.status, localize(language, {
+    zh: `开始执行 ${request.intent}。`,
+    en: `Started ${request.intent}.`,
+  }));
+
+  const markCompleted = (nextSession: InteractionSession): InteractionSession => ({
+    ...nextSession,
+    currentExecution: {
+      status: "completed",
+      bookId: nextSession.activeBookId,
+      chapterNumber: nextSession.activeChapterNumber,
+      stageLabel: localize(language, {
+        zh: "已完成",
+        en: "completed",
+      }),
+    },
+  });
+
+  const helperContext: RuntimeRequestHelpers = {
+    language,
+    addEvent,
+    markCompleted,
+  };
+
+  const draftLifecycleResult = await handleDraftLifecycleRequest({
+    session,
+    request,
+    tools: params.tools,
+    helpers: helperContext,
+  });
+  if (draftLifecycleResult) {
+    return draftLifecycleResult;
+  }
+
+  const bookSelectionResult = await handleBookSelectionRequest({
+    session,
+    request,
+    tools: params.tools,
+    helpers: helperContext,
+  });
+  if (bookSelectionResult) {
+    return bookSelectionResult;
+  }
+
+  switch (request.intent) {
+    case "write_next":
+    case "continue_book": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!bookId) {
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
+      }
+      const toolResult = await params.tools.writeNextChapter(bookId);
+      const metadata = extractToolMetadata(toolResult);
+      session = bindActiveBook(session, bookId, metadata.activeChapterNumber);
+      session = appendToolEvents(session, metadata.events);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
+        session,
+        request,
+        language,
+        metadata.activeChapterNumber,
+      );
+      const completed = pendingDecision
+        ? {
+            ...session,
+            pendingDecision,
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language, metadata.activeChapterNumber),
+          }
+        : {
+            ...markCompleted(session),
+            currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+          };
+      return {
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已为 ${bookId} 完成下一章写作。`,
+          en: `Completed write_next for ${bookId}.`,
+        })),
+        responseText: metadata.responseText ?? (
+          pendingDecision
+            ? localize(language, {
+                zh: `已为 ${bookId} 完成下一章写作,等待你的下一步决定。`,
+                en: `Completed write_next for ${bookId}; waiting for your next decision.`,
+              })
+            : localize(language, {
+                zh: `已为 ${bookId} 完成下一章写作。`,
+                en: `Completed write_next for ${bookId}.`,
+              })
+        ),
+      };
+    }
+    case "revise_chapter":
+    case "rewrite_chapter": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!bookId) {
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
+      }
+      if (!request.chapterNumber) {
+        throw new Error(localize(language, {
+          zh: "修订章节需要章节号。",
+          en: "Chapter number is required for chapter revision.",
+        }));
+      }
+      const mode: ReviseMode = request.intent === "rewrite_chapter" ? "rewrite" : "local-fix";
+      const toolResult = await params.tools.reviseDraft(bookId, request.chapterNumber, mode);
+      const metadata = extractToolMetadata(toolResult);
+      const chapterNumber = metadata.activeChapterNumber ?? request.chapterNumber;
+      session = bindActiveBook(session, bookId, chapterNumber);
+      session = appendToolEvents(session, metadata.events);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
+        session,
+        request,
+        language,
+        chapterNumber,
+      );
+      const completed = pendingDecision
+        ? {
+            ...session,
+            pendingDecision,
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language, chapterNumber),
+          }
+        : {
+            ...markCompleted(session),
+            currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+          };
+      return {
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: request.intent === "rewrite_chapter"
+            ? `已为 ${bookId} 完成章节重写。`
+            : `已为 ${bookId} 完成章节修订。`,
+          en: `Completed ${request.intent} for ${bookId}.`,
+        })),
+        responseText: metadata.responseText ?? (
+          pendingDecision
+            ? localize(language, {
+                zh: request.intent === "rewrite_chapter"
+                  ? `已为 ${bookId} 完成章节重写,等待你的下一步决定。`
+                  : `已为 ${bookId} 完成章节修订,等待你的下一步决定。`,
+                en: `Completed ${request.intent} for ${bookId}; waiting for your next decision.`,
+              })
+            : localize(language, {
+                zh: request.intent === "rewrite_chapter"
+                  ? `已为 ${bookId} 完成章节重写。`
+                  : `已为 ${bookId} 完成章节修订。`,
+                en: `Completed ${request.intent} for ${bookId}.`,
+              })
+        ),
+      };
+    }
+    case "patch_chapter_text": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!bookId) {
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
+      }
+      if (!request.chapterNumber || !request.targetText || !request.replacementText) {
+        throw new Error(localize(language, {
+          zh: "正文修补需要章节号、目标文本和替换文本。",
+          en: "Chapter patch requires chapter number, target text, and replacement text.",
+        }));
+      }
+      const toolResult = await params.tools.patchChapterText(
+        bookId,
+        request.chapterNumber,
+        request.targetText,
+        request.replacementText,
+      );
+      const metadata = extractToolMetadata(toolResult);
+      const chapterNumber = metadata.activeChapterNumber ?? request.chapterNumber;
+      session = bindActiveBook(session, bookId, chapterNumber);
+      session = appendToolEvents(session, metadata.events);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
+        session,
+        request,
+        language,
+        chapterNumber,
+      );
+      const completed = pendingDecision
+        ? {
+            ...session,
+            pendingDecision,
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language, chapterNumber),
+          }
+        : {
+            ...markCompleted(session),
+            currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+          };
+      return {
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已修补 ${bookId} 的第 ${chapterNumber} 章。`,
+          en: `Patched chapter ${chapterNumber} for ${bookId}.`,
+        })),
+        responseText: metadata.responseText ?? (
+          pendingDecision
+            ? localize(language, {
+                zh: `已修补 ${bookId} 的第 ${chapterNumber} 章,等待你的下一步决定。`,
+                en: `Patched chapter ${chapterNumber} for ${bookId}; waiting for your next decision.`,
+              })
+            : localize(language, {
+                zh: `已修补 ${bookId} 的第 ${chapterNumber} 章。`,
+                en: `Patched chapter ${chapterNumber} for ${bookId}.`,
+              })
+        ),
+      };
+    }
+    case "rename_entity": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!bookId) {
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
+      }
+      if (!request.oldValue || !request.newValue) {
+        throw new Error(localize(language, {
+          zh: "实体改名需要旧值和新值。",
+          en: "Entity rename requires old and new values.",
+        }));
+      }
+      const toolResult = await params.tools.renameEntity(bookId, request.oldValue, request.newValue);
+      const metadata = extractToolMetadata(toolResult);
+      session = bindActiveBook(session, bookId, metadata.activeChapterNumber);
+      session = appendToolEvents(session, metadata.events);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
+        session,
+        request,
+        language,
+        metadata.activeChapterNumber,
+      );
+      const completed = pendingDecision
+        ? {
+            ...session,
+            pendingDecision,
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language, metadata.activeChapterNumber),
+          }
+        : {
+            ...markCompleted(session),
+            currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+          };
+      return {
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已在 ${bookId} 中把 ${request.oldValue} 改成 ${request.newValue}。`,
+          en: `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}.`,
+        })),
+        responseText: metadata.responseText ?? (
+          pendingDecision
+            ? localize(language, {
+                zh: `已在 ${bookId} 中把 ${request.oldValue} 改成 ${request.newValue},等待你的下一步决定。`,
+                en: `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}; waiting for your next decision.`,
+              })
+            : localize(language, {
+                zh: `已在 ${bookId} 中把 ${request.oldValue} 改成 ${request.newValue}。`,
+                en: `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}.`,
+              })
+        ),
+      };
+    }
+    case "update_focus": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!bookId) {
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
+      }
+      if (!request.instruction) {
+        throw new Error(localize(language, {
+          zh: "更新焦点需要提供内容。",
+          en: "Focus update requires instruction content.",
+        }));
+      }
+      const toolResult = await params.tools.updateCurrentFocus(bookId, request.instruction);
+      const metadata = extractToolMetadata(toolResult);
+      session = bindActiveBook(session, bookId);
+      session = appendToolEvents(session, metadata.events);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request, language);
+      const completed = pendingDecision
+        ? {
+            ...session,
+            pendingDecision,
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language),
+          }
+        : {
+            ...markCompleted(session),
+            currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+          };
+      return {
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已更新 ${bookId} 的当前焦点。`,
+          en: `Updated current focus for ${bookId}.`,
+        })),
+        responseText: metadata.responseText ?? (
+          pendingDecision
+            ? localize(language, {
+                zh: `已更新 ${bookId} 的当前焦点,等待你的下一步决定。`,
+                en: `Updated current focus for ${bookId}; waiting for your next decision.`,
+              })
+            : localize(language, {
+                zh: `已更新 ${bookId} 的当前焦点。`,
+                en: `Updated current focus for ${bookId}.`,
+              })
+        ),
+      };
+    }
+    case "update_author_intent": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!bookId) {
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
+      }
+      if (!request.instruction) {
+        throw new Error(localize(language, {
+          zh: "更新作者意图需要提供内容。",
+          en: "Author intent update requires instruction content.",
+        }));
+      }
+      const toolResult = await params.tools.updateAuthorIntent(bookId, request.instruction);
+      const metadata = extractToolMetadata(toolResult);
+      session = bindActiveBook(session, bookId);
+      session = appendToolEvents(session, metadata.events);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request, language);
+      const completed = pendingDecision
+        ? {
+            ...session,
+            pendingDecision,
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language),
+          }
+        : {
+            ...markCompleted(session),
+            currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+          };
+      return {
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已更新 ${bookId} 的作者意图。`,
+          en: `Updated author intent for ${bookId}.`,
+        })),
+        responseText: metadata.responseText ?? (
+          pendingDecision
+            ? localize(language, {
+                zh: `已更新 ${bookId} 的作者意图,等待你的下一步决定。`,
+                en: `Updated author intent for ${bookId}; waiting for your next decision.`,
+              })
+            : localize(language, {
+                zh: `已更新 ${bookId} 的作者意图。`,
+                en: `Updated author intent for ${bookId}.`,
+              })
+        ),
+      };
+    }
+    case "edit_truth": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!bookId) {
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
+      }
+      if (!request.fileName || !request.instruction) {
+        throw new Error(localize(language, {
+          zh: "编辑真相文件需要文件名和内容。",
+          en: "Truth-file edit requires a file name and content.",
+        }));
+      }
+      const toolResult = await params.tools.writeTruthFile(bookId, request.fileName, request.instruction);
+      const metadata = extractToolMetadata(toolResult);
+      session = bindActiveBook(session, bookId);
+      session = appendToolEvents(session, metadata.events);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request, language);
+      const completed = pendingDecision
+        ? {
+            ...session,
+            pendingDecision,
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language),
+          }
+        : {
+            ...markCompleted(session),
+            currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+          };
+      return {
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已更新 ${bookId} 的 ${request.fileName}。`,
+          en: `Updated ${request.fileName} for ${bookId}.`,
+        })),
+        responseText: metadata.responseText ?? (
+          pendingDecision
+            ? localize(language, {
+                zh: `已更新 ${bookId} 的 ${request.fileName},等待你的下一步决定。`,
+                en: `Updated ${request.fileName} for ${bookId}; waiting for your next decision.`,
+              })
+            : localize(language, {
+                zh: `已更新 ${bookId} 的 ${request.fileName}。`,
+                en: `Updated ${request.fileName} for ${bookId}.`,
+              })
+        ),
+      };
+    }
+    case "export_book": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!params.tools.exportBook) {
+        throw new Error(localize(language, {
+          zh: "交互运行时暂未实现导出作品。",
+          en: "Book export is not implemented in the interaction runtime yet.",
+        }));
+      }
+      if (!bookId) {
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
+      }
+      const toolResult = await params.tools.exportBook(bookId, {
+        format: request.format,
+        approvedOnly: request.approvedOnly,
+        outputPath: request.outputPath,
+      });
+      const metadata = extractToolMetadata(toolResult);
+      session = bindActiveBook(session, bookId, metadata.activeChapterNumber);
+      session = appendToolEvents(session, metadata.events);
+      const completed = {
+        ...markCompleted(session),
+        currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+      };
+      return {
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已导出 ${bookId}。`,
+          en: `Exported ${bookId}.`,
+        })),
+        responseText: metadata.responseText ?? localize(language, {
+          zh: `已导出 ${bookId}。`,
+          en: `Exported ${bookId}.`,
+        }),
+        details: metadata.details,
+      };
+    }
+    case "switch_mode":
+      session = markCompleted(session);
+      return {
+        session: addEvent(session, "task.completed", "completed", localize(language, {
+          zh: `已切换到${localizeMode(session.automationMode, language)}模式。`,
+          en: `Switched mode to ${session.automationMode}.`,
+        })),
+        responseText: localize(language, {
+          zh: `已切换到${localizeMode(session.automationMode, language)}模式。`,
+          en: `Switched mode to ${session.automationMode}.`,
+        }),
+      };
+    case "pause_book": {
+      const bookId = request.bookId ?? session.activeBookId;
+      const paused = {
+        ...session,
+        currentExecution: {
+          status: "blocked" as const,
+          bookId,
+          chapterNumber: session.activeChapterNumber,
+          stageLabel: localize(language, {
+            zh: "已由用户暂停",
+            en: "paused by user",
+          }),
+        },
+      };
+      return {
+        session: addEvent(paused, "task.completed", "blocked", localize(language, {
+          zh: `已暂停${bookId ?? "当前作品"}。`,
+          en: `Paused ${bookId ?? "current book"}.`,
+        })),
+        responseText: localize(language, {
+          zh: `已暂停${bookId ?? "当前作品"}。`,
+          en: `Paused ${bookId ?? "current book"}.`,
+        }),
+      };
+    }
+    case "resume_book": {
+      const bookId = request.bookId ?? session.activeBookId;
+      const resumed = {
+        ...session,
+        currentExecution: {
+          status: "completed" as const,
+          bookId,
+          chapterNumber: session.activeChapterNumber,
+          stageLabel: localize(language, {
+            zh: "可继续执行",
+            en: "ready to continue",
+          }),
+        },
+      };
+      return {
+        session: addEvent(resumed, "task.completed", "completed", localize(language, {
+          zh: `已恢复${bookId ?? "当前作品"}。`,
+          en: `Resumed ${bookId ?? "current book"}.`,
+        })),
+        responseText: localize(language, {
+          zh: `已恢复${bookId ?? "当前作品"}。`,
+          en: `Resumed ${bookId ?? "current book"}.`,
+        }),
+      };
+    }
+    case "chat": {
+      const bookId = request.bookId ?? session.activeBookId;
+      const prompt = request.instruction?.trim().toLowerCase() ?? "";
+      const toolResult = params.tools.chat
+        ? await params.tools.chat(request.instruction ?? "", {
+            bookId,
+            automationMode: session.automationMode,
+          })
+        : undefined;
+      const metadata = extractToolMetadata(toolResult);
+      const responseText = metadata.responseText ?? (
+        /^(hi|hello|hey|你好|嗨|哈喽)$/i.test(prompt)
+          ? (bookId
+              ? localize(language, {
+                  zh: `你好。当前作品是 ${bookId}。你可以让我继续写、修订章节,或者解释当前卡住的原因。`,
+                  en: `Hi. Active book is ${bookId}. Ask me to continue, revise a chapter, or explain what is blocked.`,
+                })
+              : localize(language, {
+                  zh: "你好。当前还没有激活作品。你可以先打开作品、列出作品,或者直接告诉我你要写什么。",
+                  en: "Hi. No active book yet. Open a book, list books, or tell me what you want to write.",
+                }))
+          : (bookId
+              ? localize(language, {
+                  zh: `我在。当前作品是 ${bookId}。你可以让我继续写、修订章节、重写、调整焦点,或者查看流水线为何停止。`,
+                  en: `I’m here. Active book is ${bookId}. You can ask me to continue, revise a chapter, rewrite, change focus, or inspect why the pipeline stopped.`,
+                })
+              : localize(language, {
+                  zh: "我在。当前还没有绑定作品。先打开作品、列出作品,或者直接描述你要写什么。",
+                  en: "I’m here. No active book is bound yet. Open a book, list books, or describe what you want to write.",
+                }))
+      );
+      const completed = markCompleted(session);
+      return {
+        session: addEvent(completed, "task.completed", "completed", responseText),
+        responseText,
+      };
+    }
+    case "explain_status":
+    case "explain_failure": {
+      const bookId = request.bookId ?? session.activeBookId;
+      const baselineExecution = params.session.currentExecution;
+      const stage = baselineExecution?.stageLabel ?? baselineExecution?.status ?? "idle";
+      const summary = request.intent === "explain_failure"
+        ? localize(language, {
+            zh: `当前失败上下文:${bookId ?? "当前无激活作品"} 处于 ${stage}。`,
+            en: `Current failure context: ${bookId ?? "no active book"} is at ${stage}.`,
+          })
+        : localize(language, {
+            zh: `当前状态:${bookId ?? "当前无激活作品"} 处于 ${stage}。`,
+            en: `Current status: ${bookId ?? "no active book"} is at ${stage}.`,
+          });
+      const completed = markCompleted(session);
+      return {
+        session: addEvent(completed, "task.completed", "completed", summary),
+        responseText: summary,
+      };
+    }
+    default:
+      throw new Error(localize(language, {
+        zh: `交互运行时暂未实现意图「${request.intent}」。`,
+        en: `Intent "${request.intent}" is not implemented in the interaction runtime yet.`,
+      }));
+  }
+}

+ 134 - 0
packages/core/src/interaction/session.ts

@@ -0,0 +1,134 @@
+import { z } from "zod";
+import { AutomationModeSchema, type AutomationMode } from "./modes.js";
+import { ExecutionStateSchema, InteractionEventSchema, type InteractionEvent } from "./events.js";
+
+export const PendingDecisionSchema = z.object({
+  kind: z.string().min(1),
+  bookId: z.string().min(1),
+  chapterNumber: z.number().int().min(1).optional(),
+  summary: z.string().min(1),
+});
+
+export type PendingDecision = z.infer<typeof PendingDecisionSchema>;
+
+export const InteractionMessageSchema = z.object({
+  role: z.enum(["user", "assistant", "system"]),
+  content: z.string().min(1),
+  timestamp: z.number().int().nonnegative(),
+});
+
+export type InteractionMessage = z.infer<typeof InteractionMessageSchema>;
+
+export const BookCreationDraftSchema = z.object({
+  concept: z.string().min(1),
+  title: z.string().min(1).optional(),
+  genre: z.string().min(1).optional(),
+  platform: z.string().min(1).optional(),
+  language: z.enum(["zh", "en"]).optional(),
+  targetChapters: z.number().int().min(1).optional(),
+  chapterWordCount: z.number().int().min(1).optional(),
+  blurb: z.string().min(1).optional(),
+  worldPremise: z.string().min(1).optional(),
+  settingNotes: z.string().min(1).optional(),
+  protagonist: z.string().min(1).optional(),
+  supportingCast: z.string().min(1).optional(),
+  conflictCore: z.string().min(1).optional(),
+  volumeOutline: z.string().min(1).optional(),
+  constraints: z.string().min(1).optional(),
+  authorIntent: z.string().min(1).optional(),
+  currentFocus: z.string().min(1).optional(),
+  nextQuestion: z.string().min(1).optional(),
+  missingFields: z.array(z.string().min(1)).default([]),
+  readyToCreate: z.boolean().default(false),
+});
+
+export type BookCreationDraft = z.infer<typeof BookCreationDraftSchema>;
+
+export const InteractionSessionSchema = z.object({
+  sessionId: z.string().min(1),
+  projectRoot: z.string().min(1),
+  activeBookId: z.string().min(1).optional(),
+  activeChapterNumber: z.number().int().min(1).optional(),
+  creationDraft: BookCreationDraftSchema.optional(),
+  automationMode: AutomationModeSchema.default("semi"),
+  messages: z.array(InteractionMessageSchema).default([]),
+  events: z.array(InteractionEventSchema).default([]),
+  pendingDecision: PendingDecisionSchema.optional(),
+  currentExecution: ExecutionStateSchema.optional(),
+});
+
+export type InteractionSession = z.infer<typeof InteractionSessionSchema>;
+
+export function bindActiveBook(
+  session: InteractionSession,
+  bookId: string,
+  chapterNumber?: number,
+): InteractionSession {
+  return {
+    ...session,
+    activeBookId: bookId,
+    ...(chapterNumber !== undefined ? { activeChapterNumber: chapterNumber } : {}),
+  };
+}
+
+export function clearPendingDecision(session: InteractionSession): InteractionSession {
+  if (!session.pendingDecision) {
+    return session;
+  }
+
+  return {
+    ...session,
+    pendingDecision: undefined,
+  };
+}
+
+export function updateCreationDraft(
+  session: InteractionSession,
+  draft: BookCreationDraft,
+): InteractionSession {
+  return {
+    ...session,
+    creationDraft: draft,
+  };
+}
+
+export function clearCreationDraft(session: InteractionSession): InteractionSession {
+  if (!session.creationDraft) {
+    return session;
+  }
+
+  return {
+    ...session,
+    creationDraft: undefined,
+  };
+}
+
+export function updateAutomationMode(
+  session: InteractionSession,
+  automationMode: AutomationMode,
+): InteractionSession {
+  return {
+    ...session,
+    automationMode,
+  };
+}
+
+export function appendInteractionMessage(
+  session: InteractionSession,
+  message: InteractionMessage,
+): InteractionSession {
+  return {
+    ...session,
+    messages: [...session.messages, message].sort((left, right) => left.timestamp - right.timestamp),
+  };
+}
+
+export function appendInteractionEvent(
+  session: InteractionSession,
+  event: InteractionEvent,
+): InteractionSession {
+  return {
+    ...session,
+    events: [...session.events, event],
+  };
+}

+ 45 - 0
packages/core/src/interaction/truth-authority.ts

@@ -0,0 +1,45 @@
+export type TruthAuthority =
+  | "direction"
+  | "foundation"
+  | "rules"
+  | "runtime-truth"
+  | "memory";
+
+const NORMALIZED_TRUTH_FILES = new Set([
+  "author_intent.md",
+  "current_focus.md",
+  "story_bible.md",
+  "volume_outline.md",
+  "book_rules.md",
+  "current_state.md",
+  "pending_hooks.md",
+  "chapter_summaries.md",
+]);
+
+export function normalizeTruthFileName(fileName: string): string {
+  const trimmed = fileName.trim().toLowerCase();
+  const normalized = trimmed.endsWith(".md") ? trimmed : `${trimmed}.md`;
+  if (!NORMALIZED_TRUTH_FILES.has(normalized)) {
+    return normalized;
+  }
+  return normalized;
+}
+
+export function classifyTruthAuthority(fileName: string): TruthAuthority {
+  switch (normalizeTruthFileName(fileName)) {
+    case "author_intent.md":
+    case "current_focus.md":
+      return "direction";
+    case "story_bible.md":
+    case "volume_outline.md":
+      return "foundation";
+    case "book_rules.md":
+      return "rules";
+    case "current_state.md":
+    case "pending_hooks.md":
+      return "runtime-truth";
+    case "chapter_summaries.md":
+    default:
+      return "memory";
+  }
+}

+ 20 - 6
packages/core/src/llm/provider.ts

@@ -301,6 +301,7 @@ export async function chatCompletion(
     readonly maxTokens?: number;
     readonly webSearch?: boolean;
     readonly onStreamProgress?: OnStreamProgress;
+    readonly onTextDelta?: (text: string) => void;
   },
 ): Promise<LLMResponse> {
   const perCallMax = options?.maxTokens ?? client.defaults.maxTokens;
@@ -314,22 +315,23 @@ export async function chatCompletion(
     extra: client.defaults.extra,
   };
   const onStreamProgress = options?.onStreamProgress;
+  const onTextDelta = options?.onTextDelta;
   const errorCtx = { baseUrl: client._openai?.baseURL ?? "(anthropic)", model };
 
   try {
     if (client.provider === "anthropic") {
       return client.stream
-        ? await chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onStreamProgress)
-        : await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget);
+        ? await chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onStreamProgress, onTextDelta)
+        : await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onTextDelta);
     }
     if (client.apiFormat === "responses") {
       return client.stream
-        ? await chatCompletionOpenAIResponses(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress)
-        : await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch);
+        ? await chatCompletionOpenAIResponses(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress, onTextDelta)
+        : await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch, onTextDelta);
     }
     return client.stream
-      ? await chatCompletionOpenAIChat(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress)
-      : await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch);
+      ? await chatCompletionOpenAIChat(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress, onTextDelta)
+      : await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch, onTextDelta);
   } catch (error) {
     // Stream interrupted but partial content is usable — return truncated response
     if (error instanceof PartialResponseError) {
@@ -433,6 +435,7 @@ async function chatCompletionOpenAIChat(
   options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
   webSearch?: boolean,
   onStreamProgress?: OnStreamProgress,
+  onTextDelta?: (text: string) => void,
 ): Promise<LLMResponse> {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   const createParams: any = {
@@ -458,6 +461,7 @@ async function chatCompletionOpenAIChat(
       if (delta) {
         chunks.push(delta);
         monitor.onChunk(delta);
+        onTextDelta?.(delta);
       }
       if (chunk.usage) {
         inputTokens = chunk.usage.prompt_tokens ?? 0;
@@ -494,6 +498,7 @@ async function chatCompletionOpenAIChatSync(
   messages: ReadonlyArray<LLMMessage>,
   options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
   _webSearch?: boolean,
+  onTextDelta?: (text: string) => void,
 ): Promise<LLMResponse> {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   const syncParams: any = {
@@ -508,6 +513,7 @@ async function chatCompletionOpenAIChatSync(
 
   const content = response.choices[0]?.message?.content ?? "";
   if (!content) throw new Error("LLM returned empty response");
+  onTextDelta?.(content);
 
   return {
     content,
@@ -621,6 +627,7 @@ async function chatCompletionOpenAIResponses(
   options: { readonly temperature: number; readonly maxTokens: number },
   webSearch?: boolean,
   onStreamProgress?: OnStreamProgress,
+  onTextDelta?: (text: string) => void,
 ): Promise<LLMResponse> {
   const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
     role: m.role as "system" | "user" | "assistant",
@@ -650,6 +657,7 @@ async function chatCompletionOpenAIResponses(
       if (event.type === "response.output_text.delta") {
         chunks.push(event.delta);
         monitor.onChunk(event.delta);
+        onTextDelta?.(event.delta);
       }
       if (event.type === "response.completed") {
         inputTokens = event.response.usage?.input_tokens ?? 0;
@@ -686,6 +694,7 @@ async function chatCompletionOpenAIResponsesSync(
   messages: ReadonlyArray<LLMMessage>,
   options: { readonly temperature: number; readonly maxTokens: number },
   _webSearch?: boolean,
+  onTextDelta?: (text: string) => void,
 ): Promise<LLMResponse> {
   const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
     role: m.role as "system" | "user" | "assistant",
@@ -708,6 +717,7 @@ async function chatCompletionOpenAIResponsesSync(
     .join("");
 
   if (!content) throw new Error("LLM returned empty response");
+  onTextDelta?.(content);
 
   return {
     content,
@@ -814,6 +824,7 @@ async function chatCompletionAnthropic(
   options: { readonly temperature: number; readonly maxTokens: number },
   thinkingBudget: number = 0,
   onStreamProgress?: OnStreamProgress,
+  onTextDelta?: (text: string) => void,
 ): Promise<LLMResponse> {
   const systemText = messages
     .filter((m) => m.role === "system")
@@ -845,6 +856,7 @@ async function chatCompletionAnthropic(
       if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
         chunks.push(event.delta.text);
         monitor.onChunk(event.delta.text);
+        onTextDelta?.(event.delta.text);
       }
       if (event.type === "message_start") {
         inputTokens = event.message.usage?.input_tokens ?? 0;
@@ -883,6 +895,7 @@ async function chatCompletionAnthropicSync(
   messages: ReadonlyArray<LLMMessage>,
   options: { readonly temperature: number; readonly maxTokens: number },
   thinkingBudget: number = 0,
+  onTextDelta?: (text: string) => void,
 ): Promise<LLMResponse> {
   const systemText = messages
     .filter((m) => m.role === "system")
@@ -909,6 +922,7 @@ async function chatCompletionAnthropicSync(
     .join("");
 
   if (!content) throw new Error("LLM returned empty response");
+  onTextDelta?.(content);
 
   return {
     content,

+ 16 - 3
packages/core/src/pipeline/runner.ts

@@ -171,6 +171,12 @@ export interface ImportChaptersResult {
   readonly nextChapter: number;
 }
 
+export interface InitBookOptions {
+  readonly externalContext?: string;
+  readonly authorIntent?: string;
+  readonly currentFocus?: string;
+}
+
 export class PipelineRunner {
   private readonly state: StateManager;
   private readonly config: PipelineConfig;
@@ -436,7 +442,7 @@ export class PipelineRunner {
     return radar.scan();
   }
 
-  async initBook(book: BookConfig): Promise<void> {
+  async initBook(book: BookConfig, options: InitBookOptions = {}): Promise<void> {
     const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
     const bookDir = this.state.bookDir(book.id);
     const stagingBookDir = join(
@@ -452,7 +458,7 @@ export class PipelineRunner {
     const foundation = await this.generateAndReviewFoundation({
       generate: (reviewFeedback) => architect.generateFoundation(
         book,
-        this.config.externalContext,
+        options.externalContext ?? this.config.externalContext,
         reviewFeedback,
       ),
       reviewer,
@@ -476,8 +482,15 @@ export class PipelineRunner {
       await this.state.ensureControlDocumentsAt(
         stagingBookDir,
         book.language ?? gp.language,
-        this.config.externalContext,
+        options.authorIntent ?? this.config.externalContext,
       );
+      if (options.currentFocus?.trim()) {
+        await writeFile(
+          join(stagingBookDir, "story", "current_focus.md"),
+          options.currentFocus.trimEnd() + "\n",
+          "utf-8",
+        );
+      }
 
       await this.state.saveChapterIndexAt(stagingBookDir, []);
 

+ 1 - 1
packages/studio/package.json

@@ -28,7 +28,7 @@
     "typecheck": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit"
   },
   "dependencies": {
-    "@actalk/inkos-core": "1.1.1",
+    "@actalk/inkos-core": "workspace:*",
     "@base-ui/react": "^1.3.0",
     "@hono/node-server": "^1.13.0",
     "clsx": "^2.1.1",

+ 3 - 7
packages/studio/src/App.tsx

@@ -20,7 +20,8 @@ import { useSSE } from "./hooks/use-sse";
 import { useTheme } from "./hooks/use-theme";
 import { useI18n } from "./hooks/use-i18n";
 import { postApi, useApi } from "./hooks/use-api";
-import { Sun, Moon, Bell, MessageSquare } from "lucide-react";
+import { Sun, Moon, MessageSquare } from "lucide-react";
+import { DEFAULT_CHAT_OPEN } from "./app-state";
 
 export type Route =
   | { page: "dashboard" }
@@ -52,7 +53,7 @@ export function App() {
   const { data: project, refetch: refetchProject } = useApi<{ language: string; languageExplicit: boolean }>("/project");
   const [showLanguageSelector, setShowLanguageSelector] = useState(false);
   const [ready, setReady] = useState(false);
-  const [chatOpen, setChatOpen] = useState(false);
+  const [chatOpen, setChatOpen] = useState(DEFAULT_CHAT_OPEN);
 
   const isDark = theme === "dark";
 
@@ -138,11 +139,6 @@ export function App() {
               {isDark ? <Sun size={16} /> : <Moon size={16} />}
             </button>
 
-            <button className="w-8 h-8 flex items-center justify-center rounded-lg bg-secondary text-muted-foreground hover:text-foreground transition-all relative">
-              <Bell size={16} />
-              <span className="absolute top-1 right-1 w-2 h-2 bg-primary rounded-full border-2 border-background" />
-            </button>
-
             {/* Chat Panel Toggle */}
             <button
               onClick={() => setChatOpen((prev) => !prev)}

+ 241 - 3
packages/studio/src/api/server.test.ts

@@ -12,10 +12,16 @@ const writeNextChapterMock = vi.fn();
 const rollbackToChapterMock = vi.fn();
 const saveChapterIndexMock = vi.fn();
 const loadChapterIndexMock = vi.fn();
+const loadBookConfigMock = vi.fn();
 const createLLMClientMock = vi.fn(() => ({}));
 const chatCompletionMock = vi.fn();
 const loadProjectConfigMock = vi.fn();
 const pipelineConfigs: unknown[] = [];
+const processProjectInteractionInputMock = vi.fn();
+const processProjectInteractionRequestMock = vi.fn();
+const createInteractionToolsFromDepsMock = vi.fn(() => ({}));
+const loadProjectSessionMock = vi.fn();
+const resolveSessionActiveBookMock = vi.fn();
 
 const logger = {
   child: () => logger,
@@ -33,7 +39,7 @@ vi.mock("@actalk/inkos-core", () => {
     }
 
     async loadBookConfig(): Promise<never> {
-      throw new Error("not implemented");
+      return await loadBookConfigMock() as never;
     }
 
     async loadChapterIndex(bookId: string): Promise<[]> {
@@ -52,6 +58,10 @@ vi.mock("@actalk/inkos-core", () => {
       return 1;
     }
 
+    async ensureControlDocuments(): Promise<void> {
+      // no-op in tests
+    }
+
     bookDir(id: string): string {
       return join(this.root, "books", id);
     }
@@ -97,6 +107,11 @@ vi.mock("@actalk/inkos-core", () => {
     computeAnalytics: vi.fn(() => ({})),
     chatCompletion: chatCompletionMock,
     loadProjectConfig: loadProjectConfigMock,
+    processProjectInteractionInput: processProjectInteractionInputMock,
+    processProjectInteractionRequest: processProjectInteractionRequestMock,
+    createInteractionToolsFromDeps: createInteractionToolsFromDepsMock,
+    loadProjectSession: loadProjectSessionMock,
+    resolveSessionActiveBook: resolveSessionActiveBookMock,
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
   };
 });
@@ -148,6 +163,9 @@ describe("createStudioServer daemon lifecycle", () => {
     rollbackToChapterMock.mockReset();
     saveChapterIndexMock.mockReset();
     loadChapterIndexMock.mockReset();
+    loadBookConfigMock.mockReset();
+    await mkdir(join(root, "books", "demo-book", "chapters"), { recursive: true });
+    await writeFile(join(root, "books", "demo-book", "chapters", "0003_Demo.md"), "# Demo\n\nBody", "utf-8");
     runRadarMock.mockResolvedValue({
       marketSummary: "Fresh market summary",
       recommendations: [],
@@ -183,6 +201,35 @@ describe("createStudioServer daemon lifecycle", () => {
       usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
     });
     loadProjectConfigMock.mockReset();
+    processProjectInteractionInputMock.mockReset();
+    processProjectInteractionRequestMock.mockReset();
+    createInteractionToolsFromDepsMock.mockReset();
+    loadProjectSessionMock.mockReset();
+    resolveSessionActiveBookMock.mockReset();
+    createInteractionToolsFromDepsMock.mockReturnValue({});
+    processProjectInteractionRequestMock.mockResolvedValue({
+      request: { intent: "create_book" },
+      session: {
+        sessionId: "session-structured",
+        projectRoot: root,
+        activeBookId: "new-book",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      },
+      details: {
+        bookId: "new-book",
+        outputPath: join(root, "books", "demo-book", "demo-book.txt"),
+        chaptersExported: 2,
+      },
+    });
+    loadProjectSessionMock.mockResolvedValue({
+      sessionId: "session-1",
+      projectRoot: root,
+      automationMode: "semi",
+      messages: [],
+    });
+    resolveSessionActiveBookMock.mockResolvedValue(undefined);
     loadProjectConfigMock.mockImplementation(async () => {
       const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")) as Record<string, unknown>;
       return {
@@ -201,6 +248,17 @@ describe("createStudioServer daemon lifecycle", () => {
       };
     });
     loadChapterIndexMock.mockResolvedValue([]);
+    loadBookConfigMock.mockResolvedValue({
+      id: "demo-book",
+      title: "Demo Book",
+      platform: "qidian",
+      genre: "xuanhuan",
+      status: "active",
+      targetChapters: 100,
+      chapterWordCount: 3000,
+      createdAt: "2026-04-12T00:00:00.000Z",
+      updatedAt: "2026-04-12T00:00:00.000Z",
+    });
     saveChapterIndexMock.mockResolvedValue(undefined);
     rollbackToChapterMock.mockResolvedValue([]);
     pipelineConfigs.length = 0;
@@ -400,12 +458,12 @@ describe("createStudioServer daemon lifecycle", () => {
     await expect(response.json()).resolves.toMatchObject({
       error: expect.stringContaining('Book "existing-book" already exists'),
     });
-    expect(initBookMock).not.toHaveBeenCalled();
+    expect(processProjectInteractionRequestMock).not.toHaveBeenCalled();
     await expect(access(join(root, "books", "existing-book", "story", "story_bible.md"))).resolves.toBeUndefined();
   });
 
   it("reports async create failures through the create-status endpoint", async () => {
-    initBookMock.mockRejectedValueOnce(new Error("INKOS_LLM_API_KEY not set"));
+    processProjectInteractionRequestMock.mockRejectedValueOnce(new Error("INKOS_LLM_API_KEY not set"));
 
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
@@ -476,6 +534,39 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(saveChapterIndexMock).not.toHaveBeenCalled();
   });
 
+  it("routes create requests through the shared structured interaction runtime", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/books/create", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        title: "New Book",
+        genre: "urban",
+        platform: "qidian",
+        language: "zh",
+        chapterWordCount: 2600,
+        targetChapters: 88,
+      }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(createInteractionToolsFromDepsMock).toHaveBeenCalledTimes(1);
+    expect(processProjectInteractionRequestMock).toHaveBeenCalledWith(expect.objectContaining({
+      projectRoot: root,
+      request: {
+        intent: "create_book",
+        title: "New Book",
+        genre: "urban",
+        language: "zh",
+        platform: "qidian",
+        chapterWordCount: 2600,
+        targetChapters: 88,
+      },
+    }));
+  });
+
   it("passes one-off brief into revise requests through pipeline config", async () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
@@ -505,4 +596,151 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(pipelineConfigs.at(-1)).toMatchObject({ externalContext: "以师债线为准同步状态。" });
     expect(resyncChapterArtifactsMock).toHaveBeenCalledWith("demo-book", 3);
   });
+
+  it("routes export-save through the shared structured interaction runtime", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/books/demo-book/export-save", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ format: "md", approvedOnly: true }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(processProjectInteractionRequestMock).toHaveBeenCalledWith(expect.objectContaining({
+      projectRoot: root,
+      activeBookId: "demo-book",
+      request: expect.objectContaining({
+        intent: "export_book",
+        bookId: "demo-book",
+        format: "md",
+        approvedOnly: true,
+      }),
+    }));
+    await expect(response.json()).resolves.toMatchObject({
+      ok: true,
+      chapters: 2,
+    });
+  });
+
+  it("routes /api/agent through the shared interaction control layer", async () => {
+    processProjectInteractionInputMock.mockResolvedValue({
+      request: { intent: "write_next", bookId: "demo-book" },
+      session: {
+        sessionId: "session-1",
+        projectRoot: root,
+        activeBookId: "demo-book",
+        automationMode: "semi",
+        messages: [
+          { role: "user", content: "continue", timestamp: 1 },
+          { role: "assistant", content: "Completed write_next for demo-book.", timestamp: 2 },
+        ],
+      },
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
+    });
+
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toMatchObject({
+      response: "Completed write_next for demo-book.",
+      request: { intent: "write_next", bookId: "demo-book" },
+      session: expect.objectContaining({
+        activeBookId: "demo-book",
+      }),
+    });
+    expect(createInteractionToolsFromDepsMock).toHaveBeenCalledTimes(1);
+    expect(processProjectInteractionInputMock).toHaveBeenCalledWith(expect.objectContaining({
+      projectRoot: root,
+      input: "continue",
+      activeBookId: "demo-book",
+    }));
+  });
+
+  it("returns 500 with an error payload when the shared agent execution fails", async () => {
+    processProjectInteractionInputMock.mockRejectedValueOnce(new Error("boom"));
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
+    });
+
+    expect(response.status).toBe(500);
+    await expect(response.json()).resolves.toEqual({
+      error: {
+        code: "INTERACTION_ERROR",
+        message: "boom",
+      },
+    });
+  });
+
+  it("returns the shared interaction session state", async () => {
+    loadProjectSessionMock.mockResolvedValue({
+      sessionId: "session-2",
+      projectRoot: root,
+      activeBookId: "demo-book",
+      automationMode: "auto",
+      messages: [
+        { role: "user", content: "continue", timestamp: 1 },
+      ],
+    });
+    resolveSessionActiveBookMock.mockResolvedValue("demo-book");
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/interaction/session");
+
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toMatchObject({
+      session: expect.objectContaining({
+        activeBookId: "demo-book",
+        automationMode: "auto",
+      }),
+      activeBookId: "demo-book",
+    });
+  });
+
+  it("returns creation-draft state through the shared interaction session endpoint", async () => {
+    loadProjectSessionMock.mockResolvedValue({
+      sessionId: "session-3",
+      projectRoot: root,
+      automationMode: "semi",
+      creationDraft: {
+        concept: "港风商战悬疑,主角从灰产洗白。",
+        title: "夜港账本",
+        nextQuestion: "你更想写长篇连载,还是十来章能收住?",
+        missingFields: ["targetChapters"],
+        readyToCreate: false,
+      },
+      messages: [],
+    });
+    resolveSessionActiveBookMock.mockResolvedValue(undefined);
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/interaction/session");
+
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toMatchObject({
+      session: expect.objectContaining({
+        creationDraft: expect.objectContaining({
+          title: "夜港账本",
+          nextQuestion: "你更想写长篇连载,还是十来章能收住?",
+        }),
+      }),
+    });
+  });
 });

+ 92 - 61
packages/studio/src/api/server.ts

@@ -7,8 +7,13 @@ import {
   PipelineRunner,
   createLLMClient,
   createLogger,
+  createInteractionToolsFromDeps,
   computeAnalytics,
   loadProjectConfig,
+  loadProjectSession,
+  processProjectInteractionInput,
+  processProjectInteractionRequest,
+  resolveSessionActiveBook,
   type PipelineConfig,
   type ProjectConfig,
   type LogSink,
@@ -182,12 +187,29 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     bookCreateStatus.set(bookId, { status: "creating" });
 
     const pipeline = new PipelineRunner(await buildPipelineConfig());
-    pipeline.initBook(bookConfig).then(
-      () => {
-        bookCreateStatus.delete(bookId);
-        broadcast("book:created", { bookId });
+    const tools = createInteractionToolsFromDeps(pipeline, state);
+    processProjectInteractionRequest({
+      projectRoot: root,
+      request: {
+        intent: "create_book",
+        title: body.title,
+        genre: body.genre,
+        language: body.language === "en" ? "en" : body.language === "zh" ? "zh" : undefined,
+        platform: body.platform,
+        chapterWordCount: body.chapterWordCount,
+        targetChapters: body.targetChapters,
       },
-      (e) => {
+      tools,
+    }).then(
+      (result: {
+        readonly session: { readonly activeBookId?: string };
+        readonly details?: Readonly<Record<string, unknown>>;
+      }) => {
+        const createdBookId = (result.details?.bookId as string | undefined) ?? result.session.activeBookId ?? bookId;
+        bookCreateStatus.delete(createdBookId);
+        broadcast("book:created", { bookId: createdBookId });
+      },
+      (e: unknown) => {
         const error = e instanceof Error ? e.message : String(e);
         bookCreateStatus.set(bookId, { status: "error", error });
         broadcast("book:error", { bookId, error });
@@ -542,28 +564,47 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
   // --- Agent chat ---
 
+  app.get("/api/interaction/session", async (c) => {
+    const session = await loadProjectSession(root);
+    const activeBookId = await resolveSessionActiveBook(root, session);
+    return c.json({
+      session: activeBookId && session.activeBookId !== activeBookId
+        ? { ...session, activeBookId }
+        : session,
+      activeBookId,
+    });
+  });
+
   app.post("/api/agent", async (c) => {
-    const { instruction } = await c.req.json<{ instruction: string }>();
+    const { instruction, activeBookId } = await c.req.json<{ instruction: string; activeBookId?: string }>();
     if (!instruction?.trim()) {
       return c.json({ error: "No instruction provided" }, 400);
     }
 
-    broadcast("agent:start", { instruction });
+    broadcast("agent:start", { instruction, activeBookId });
 
     try {
-      const { runAgentLoop } = await import("@actalk/inkos-core");
-
-      const result = await runAgentLoop(
-        await buildPipelineConfig(),
-        instruction
-      );
+      const pipeline = new PipelineRunner(await buildPipelineConfig());
+      const tools = createInteractionToolsFromDeps(pipeline, state);
+      const result = await processProjectInteractionInput({
+        projectRoot: root,
+        input: instruction,
+        tools,
+        activeBookId,
+      });
+      const response = result.session.messages.at(-1)?.content ?? "Acknowledged.";
 
-      broadcast("agent:complete", { instruction, response: result });
-      return c.json({ response: result });
+      broadcast("agent:complete", { instruction, activeBookId, response });
+      return c.json({ response, session: result.session, request: result.request });
     } catch (e) {
       const msg = e instanceof Error ? e.message : String(e);
-      broadcast("agent:error", { instruction, error: msg });
-      return c.json({ response: msg });
+      broadcast("agent:error", { instruction, activeBookId, error: msg });
+      return c.json({
+        error: {
+          code: "INTERACTION_ERROR",
+          message: msg,
+        },
+      }, 500);
     }
   });
 
@@ -623,19 +664,28 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   app.post("/api/books/:id/revise/:chapter", async (c) => {
     const id = c.req.param("id");
     const chapterNum = parseInt(c.req.param("chapter"), 10);
-    const body: { mode?: string; brief?: string } = await c.req
+    const bookDir = state.bookDir(id);
+    const body = await c.req
       .json<{ mode?: string; brief?: string }>()
-      .catch(() => ({ mode: "spot-fix" }));
+      .catch(() => ({ mode: "spot-fix", brief: undefined }));
 
     broadcast("revise:start", { bookId: id, chapter: chapterNum });
     try {
+      const book = await state.loadBookConfig(id);
+      const chaptersDir = join(bookDir, "chapters");
+      const files = await readdir(chaptersDir);
+      const paddedNum = String(chapterNum).padStart(4, "0");
+      const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
+      if (!match) return c.json({ error: "Chapter not found" }, 404);
+
       const pipeline = new PipelineRunner(await buildPipelineConfig({
         externalContext: body.brief,
       }));
+      const normalizedMode = body.mode ?? "spot-fix";
       const result = await pipeline.reviseDraft(
         id,
         chapterNum,
-        (body.mode ?? "spot-fix") as "spot-fix" | "polish" | "rewrite" | "rework" | "anti-detect",
+        normalizedMode as "polish" | "rewrite" | "rework" | "spot-fix" | "anti-detect",
       );
       broadcast("revise:complete", { bookId: id, chapter: chapterNum });
       return c.json(result);
@@ -716,50 +766,31 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   app.post("/api/books/:id/export-save", async (c) => {
     const id = c.req.param("id");
     const { format, approvedOnly } = await c.req.json<{ format?: string; approvedOnly?: boolean }>().catch(() => ({ format: "txt", approvedOnly: false }));
-    const bookDir = state.bookDir(id);
-    const chaptersDir = join(bookDir, "chapters");
     const fmt = format ?? "txt";
 
     try {
-      const book = await state.loadBookConfig(id);
-      const index = await state.loadChapterIndex(id);
-      const approvedNums = new Set(
-        approvedOnly ? index.filter((ch) => ch.status === "approved").map((ch) => ch.number) : [],
-      );
-
-      const files = await readdir(chaptersDir);
-      const mdFiles = files.filter((f) => f.endsWith(".md") && /^\d{4}/.test(f)).sort();
-      const filteredFiles = approvedOnly
-        ? mdFiles.filter((f) => approvedNums.has(parseInt(f.slice(0, 4), 10)))
-        : mdFiles;
-      const contents = await Promise.all(
-        filteredFiles.map((f) => readFile(join(chaptersDir, f), "utf-8")),
-      );
-
-      const { writeFile: writeFileFs } = await import("node:fs/promises");
-      let outputPath: string;
-      let body: string;
-
-      if (fmt === "md") {
-        body = contents.join("\n\n---\n\n");
-        outputPath = join(bookDir, `${id}.md`);
-      } else if (fmt === "epub") {
-        const chapters = contents.map((content, i) => {
-          const title = content.match(/^#\s+(.+)$/m)?.[1] ?? `Chapter ${i + 1}`;
-          const html = content.split("\n").filter((l) => !l.startsWith("#")).map((l) => l.trim() ? `<p>${l}</p>` : "").join("\n");
-          return { title, html };
-        });
-        const toc = chapters.map((ch, i) => `<li><a href="#ch${i}">${ch.title}</a></li>`).join("\n");
-        const chapterHtml = chapters.map((ch, i) => `<h2 id="ch${i}">${ch.title}</h2>\n${ch.html}`).join("\n<hr/>\n");
-        body = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${book.title}</title><style>body{font-family:serif;max-width:40em;margin:auto;padding:2em;line-height:1.8}h2{margin-top:3em}</style></head><body><h1>${book.title}</h1><nav><ol>${toc}</ol></nav><hr/>${chapterHtml}</body></html>`;
-        outputPath = join(bookDir, `${id}.html`);
-      } else {
-        body = contents.join("\n\n");
-        outputPath = join(bookDir, `${id}.txt`);
-      }
-
-      await writeFileFs(outputPath, body, "utf-8");
-      return c.json({ ok: true, path: outputPath, format: fmt, chapters: filteredFiles.length });
+      const pipeline = new PipelineRunner(await buildPipelineConfig());
+      const tools = createInteractionToolsFromDeps(pipeline, state);
+      const bookDir = state.bookDir(id);
+      const outputPath = join(bookDir, `${id}.${fmt === "epub" ? "epub" : fmt}`);
+      const result = await processProjectInteractionRequest({
+        projectRoot: root,
+        request: {
+          intent: "export_book",
+          bookId: id,
+          format: fmt as "txt" | "md" | "epub",
+          approvedOnly,
+          outputPath,
+        },
+        tools,
+        activeBookId: id,
+      });
+      return c.json({
+        ok: true,
+        path: (result.details?.outputPath as string | undefined) ?? outputPath,
+        format: fmt,
+        chapters: (result.details?.chaptersExported as number | undefined) ?? 0,
+      });
     } catch (e) {
       return c.json({ error: String(e) }, 500);
     }

+ 8 - 0
packages/studio/src/app-state.test.ts

@@ -0,0 +1,8 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_CHAT_OPEN } from "./app-state";
+
+describe("app shell state", () => {
+  it("opens the chat panel by default", () => {
+    expect(DEFAULT_CHAT_OPEN).toBe(true);
+  });
+});

+ 1 - 0
packages/studio/src/app-state.ts

@@ -0,0 +1 @@
+export const DEFAULT_CHAT_OPEN = true;

+ 151 - 29
packages/studio/src/components/ChatBar.tsx

@@ -41,10 +41,30 @@ interface ChatMessage {
   readonly timestamp: number;
 }
 
+interface SharedSessionMeta {
+  readonly activeBookId?: string;
+  readonly automationMode?: string;
+  readonly currentStage?: string;
+  readonly pendingSummary?: string;
+  readonly draftTitle?: string;
+}
+
 interface BookRef {
   readonly id: string;
 }
 
+export function coerceSharedSessionMessages(
+  messages: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string; timestamp: number }>,
+): ReadonlyArray<ChatMessage> {
+  return messages
+    .filter((message) => message.role === "user" || message.role === "assistant")
+    .map((message) => ({
+      role: message.role as "user" | "assistant",
+      content: message.content,
+      timestamp: message.timestamp,
+    }));
+}
+
 export function resolveDirectWriteTarget(
   activeBookId: string | undefined,
   books: ReadonlyArray<BookRef>,
@@ -61,6 +81,15 @@ export function resolveDirectWriteTarget(
   return { bookId: null, reason: "ambiguous" };
 }
 
+export function formatSharedSessionContext(meta: SharedSessionMeta): string {
+  return [
+    meta.activeBookId ?? "no-book",
+    meta.draftTitle ? `draft:${meta.draftTitle}` : undefined,
+    meta.automationMode ?? "semi",
+    meta.currentStage,
+  ].filter(Boolean).join(" · ");
+}
+
 // ── Sub-components ──
 
 function StatusIcon({ phase }: { readonly phase: string }) {
@@ -202,6 +231,7 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
   const [input, setInput] = useState("");
   const [messages, setMessages] = useState<ReadonlyArray<ChatMessage>>([]);
   const [loading, setLoading] = useState(false);
+  const [sessionMeta, setSessionMeta] = useState<SharedSessionMeta>({});
   const inputRef = useRef<HTMLInputElement>(null);
   const scrollRef = useRef<HTMLDivElement>(null);
 
@@ -219,7 +249,53 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
     }
   }, [open]);
 
+  useEffect(() => {
+    if (!open) return;
+
+    let cancelled = false;
+    void fetchJson<{
+      session?: {
+        activeBookId?: string;
+        automationMode?: string;
+        creationDraft?: {
+          title?: string;
+        };
+        currentExecution?: {
+          status?: string;
+          stageLabel?: string;
+        };
+        pendingDecision?: {
+          summary?: string;
+        };
+        messages?: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string; timestamp: number }>;
+      };
+      activeBookId?: string;
+    }>("/interaction/session").then((data) => {
+      if (cancelled) return;
+      setSessionMeta({
+        activeBookId: data.activeBookId ?? data.session?.activeBookId,
+        draftTitle: data.session?.creationDraft?.title,
+        automationMode: data.session?.automationMode,
+        currentStage: data.session?.currentExecution?.stageLabel ?? data.session?.currentExecution?.status,
+        pendingSummary: data.session?.pendingDecision?.summary,
+      });
+      const restored = coerceSharedSessionMessages(data.session?.messages ?? []);
+      if (restored.length > 0) {
+        setMessages(restored);
+      }
+    }).catch(() => {
+      // keep local empty state on session fetch failures
+    });
+
+    return () => {
+      cancelled = true;
+    };
+  }, [open]);
+
   // SSE events → assistant messages
+  const loadingRef = useRef(false);
+  loadingRef.current = loading;
+
   useEffect(() => {
     const recent = sse.messages.slice(-1)[0];
     if (!recent || recent.event === "ping") return;
@@ -243,7 +319,7 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
         timestamp: Date.now(),
       }]);
     }
-    if (recent.event === "log" && loading) {
+    if (recent.event === "log" && loadingRef.current) {
       const msg = d.message as string;
       if (msg && (msg.includes("Phase") || msg.includes("streaming") || msg.includes("Writing") || msg.includes("Audit") || msg.includes("Revis"))) {
         setMessages((prev) => {
@@ -263,14 +339,7 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
     return lastStatus?.content.replace("⋯ ", "") ?? "Initializing...";
   }, [messages]);
 
-  const handleSubmit = async () => {
-    const text = input.trim();
-    if (!text || loading) return;
-
-    setInput("");
-    setMessages((prev) => [...prev, { role: "user", content: text, timestamp: Date.now() }]);
-    setLoading(true);
-
+  const executeCommand = async (text: string) => {
     const lower = text.toLowerCase();
 
     try {
@@ -293,19 +362,44 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
           role: "assistant",
           content:
             target.reason === "missing"
-              ? (isZh ? "✗ 还没有书,先创建一本再写。" : "✗ No books yet. Create one first.")
-              : (isZh ? "✗ 当前有多本书,请先打开目标书籍后再执行“写下一章”。" : '✗ Multiple books found. Open the target book first, then run "write next".'),
+              ? (isZh ? "\u2717 \u8fd8\u6ca1\u6709\u4e66\uff0c\u5148\u521b\u5efa\u4e00\u672c\u518d\u5199\u3002" : "\u2717 No books yet. Create one first.")
+              : (isZh ? "\u2717 \u5f53\u524d\u6709\u591a\u672c\u4e66\uff0c\u8bf7\u5148\u6253\u5f00\u76ee\u6807\u4e66\u7c4d\u540e\u518d\u6267\u884c\u201c\u5199\u4e0b\u4e00\u7ae0\u201d\u3002" : '\u2717 Multiple books found. Open the target book first, then run "write next".'),
           timestamp: Date.now(),
         }]);
         return;
       }
 
-      const data = await fetchJson<{ response?: string; error?: string }>("/agent", {
+      const data = await fetchJson<{
+        response?: string;
+        error?: string;
+        session?: {
+          activeBookId?: string;
+          automationMode?: string;
+          creationDraft?: { title?: string };
+          currentExecution?: { status?: string; stageLabel?: string };
+          pendingDecision?: { summary?: string };
+          messages?: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string; timestamp: number }>;
+        };
+      }>("/agent", {
         method: "POST",
         headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ instruction: text }),
+        body: JSON.stringify({ instruction: text, activeBookId }),
       });
       setLoading(false);
+      if (data.session) {
+        setSessionMeta({
+          activeBookId: data.session.activeBookId ?? activeBookId,
+          draftTitle: data.session.creationDraft?.title,
+          automationMode: data.session.automationMode,
+          currentStage: data.session.currentExecution?.stageLabel ?? data.session.currentExecution?.status,
+          pendingSummary: data.session.pendingDecision?.summary,
+        });
+        const restored = coerceSharedSessionMessages(data.session.messages ?? []);
+        if (restored.length > 0) {
+          setMessages(restored);
+          return;
+        }
+      }
       setMessages((prev) => [...prev, {
         role: "assistant",
         content: data.response ?? data.error ?? "Acknowledged.",
@@ -321,11 +415,21 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
     }
   };
 
-  const handleQuickCommand = (command: string) => {
-    setInput(command);
-    setTimeout(() => {
-      handleSubmit();
-    }, 50);
+  const handleSubmit = async () => {
+    const text = input.trim();
+    if (!text || loading) return;
+    setInput("");
+    setMessages((prev) => [...prev, { role: "user", content: text, timestamp: Date.now() }]);
+    setLoading(true);
+    await executeCommand(text);
+  };
+
+  const handleQuickCommand = async (command: string) => {
+    if (loading) return;
+    setInput("");
+    setMessages((prev) => [...prev, { role: "user", content: command, timestamp: Date.now() }]);
+    setLoading(true);
+    await executeCommand(command);
   };
 
   const isZh = t("nav.connected") === "已连接";
@@ -361,16 +465,25 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
         <>
           {/* ── Section 1: Header ── */}
           <div className="h-12 shrink-0 px-4 flex items-center justify-between border-b border-border/40">
-            <div className="flex items-center gap-2.5">
-              <div className="relative">
+            <div className="flex items-center gap-2.5 min-w-0">
+              <div className="relative shrink-0">
                 <Sparkles size={15} className="text-primary chat-icon-glow" />
                 {loading && (
                   <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-primary rounded-full animate-ping" />
                 )}
               </div>
-              <span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-foreground">
-                InkOS Assistant
-              </span>
+              <div className="min-w-0">
+                <div className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-foreground">
+                  InkOS Assistant
+                </div>
+                <div className="text-[10px] text-muted-foreground/60 truncate">
+                  {formatSharedSessionContext({
+                    activeBookId: sessionMeta.activeBookId ?? activeBookId,
+                    automationMode: sessionMeta.automationMode,
+                    currentStage: sessionMeta.currentStage,
+                  })}
+                </div>
+              </div>
             </div>
             <div className="flex items-center gap-1">
               <button
@@ -407,6 +520,15 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
             </div>
           )}
 
+          {!loading && sessionMeta.pendingSummary && (
+            <div className="shrink-0 px-4 py-2 border-b border-border/30 bg-amber-500/[0.06] fade-in">
+              <div className="flex items-center gap-2 text-[11px] text-amber-700 dark:text-amber-300">
+                <AlertTriangle size={12} />
+                <span className="truncate">{sessionMeta.pendingSummary}</span>
+              </div>
+            </div>
+          )}
+
           {/* ── Section 3: Messages ── */}
           <div
             ref={scrollRef}
@@ -414,8 +536,8 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
           >
             {messages.length === 0 && !loading && <EmptyState />}
 
-            {messages.map((msg) => (
-              <MessageBubble key={msg.timestamp} msg={msg} />
+            {messages.map((msg, i) => (
+              <MessageBubble key={`${msg.timestamp}-${i}`} msg={msg} />
             ))}
 
             {loading && !messages.some((m) => m.content.startsWith("⋯")) && (
@@ -428,22 +550,22 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
             <QuickChip
               icon={<Zap size={11} />}
               label={t("dash.writeNext")}
-              onClick={() => handleQuickCommand(isZh ? "写下一章" : "write next")}
+              onClick={() => void handleQuickCommand(isZh ? "写下一章" : "write next")}
             />
             <QuickChip
               icon={<Search size={11} />}
               label={t("book.audit")}
-              onClick={() => handleQuickCommand(isZh ? "审计第1章" : "audit chapter 1")}
+              onClick={() => void handleQuickCommand(isZh ? "审计第1章" : "audit chapter 1")}
             />
             <QuickChip
               icon={<FileOutput size={11} />}
               label={t("book.export")}
-              onClick={() => handleQuickCommand(isZh ? "导出全书" : "export book as epub")}
+              onClick={() => void handleQuickCommand(isZh ? "导出全书" : "export book as epub")}
             />
             <QuickChip
               icon={<TrendingUp size={11} />}
               label={t("nav.radar")}
-              onClick={() => handleQuickCommand(isZh ? "扫描市场趋势" : "scan market trends")}
+              onClick={() => void handleQuickCommand(isZh ? "扫描市场趋势" : "scan market trends")}
             />
           </div>
 

+ 32 - 1
packages/studio/src/components/chatbar-state.test.ts

@@ -1,5 +1,9 @@
 import { describe, expect, it } from "vitest";
-import { resolveDirectWriteTarget } from "./ChatBar";
+import {
+  coerceSharedSessionMessages,
+  formatSharedSessionContext,
+  resolveDirectWriteTarget,
+} from "./ChatBar";
 
 describe("resolveDirectWriteTarget", () => {
   it("prefers the active book when the user is already inside a book flow", () => {
@@ -35,4 +39,31 @@ describe("resolveDirectWriteTarget", () => {
       reason: "ambiguous",
     });
   });
+
+  it("coerces shared session messages into chat bubbles", () => {
+    expect(coerceSharedSessionMessages([
+      { role: "user", content: "continue", timestamp: 1 },
+      { role: "assistant", content: "Completed write_next for harbor.", timestamp: 2 },
+      { role: "system", content: "internal", timestamp: 3 },
+    ])).toEqual([
+      { role: "user", content: "continue", timestamp: 1 },
+      { role: "assistant", content: "Completed write_next for harbor.", timestamp: 2 },
+    ]);
+  });
+
+  it("formats shared session context with mode and stage", () => {
+    expect(formatSharedSessionContext({
+      activeBookId: "harbor",
+      automationMode: "semi",
+      currentStage: "waiting for your next decision",
+    })).toBe("harbor · semi · waiting for your next decision");
+  });
+
+  it("surfaces creation-draft context when no active book is bound yet", () => {
+    expect(formatSharedSessionContext({
+      draftTitle: "夜港账本",
+      automationMode: "semi",
+      currentStage: "developing book draft",
+    })).toBe("no-book · draft:夜港账本 · semi · developing book draft");
+  });
 });

+ 336 - 122
packages/studio/src/pages/BookCreate.tsx

@@ -1,5 +1,6 @@
-import { useEffect, useState } from "react";
-import { fetchJson, useApi, postApi } from "../hooks/use-api";
+import { useEffect, useMemo, useState } from "react";
+import type { BookCreationDraft } from "@actalk/inkos-core";
+import { fetchJson, useApi } from "../hooks/use-api";
 import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
 import { useColors } from "../hooks/use-colors";
@@ -21,6 +22,48 @@ interface PlatformOption {
   readonly label: string;
 }
 
+export interface DraftSummaryRow {
+  readonly key: string;
+  readonly label: string;
+  readonly value: string;
+}
+
+interface InteractionSessionResponse {
+  readonly session?: {
+    readonly activeBookId?: string;
+    readonly creationDraft?: BookCreationDraft;
+  };
+  readonly activeBookId?: string;
+}
+
+interface AgentResponse {
+  readonly response?: string;
+  readonly error?: string;
+  readonly session?: {
+    readonly activeBookId?: string;
+    readonly creationDraft?: BookCreationDraft;
+  };
+}
+
+interface PlatformCopy {
+  readonly idleTitle: string;
+  readonly idleBody: string;
+  readonly promptLabel: string;
+  readonly promptPlaceholder: string;
+  readonly promptPlaceholderFollowup: string;
+  readonly submit: string;
+  readonly submitting: string;
+  readonly create: string;
+  readonly creating: string;
+  readonly discard: string;
+  readonly draftHeading: string;
+  readonly missingHeading: string;
+  readonly missingHint: string;
+  readonly syncedHint: string;
+  readonly helperTitle: string;
+  readonly helperBody: string;
+}
+
 const PLATFORMS_ZH: ReadonlyArray<PlatformOption> = [
   { value: "tomato", label: "番茄小说" },
   { value: "qidian", label: "起点中文网" },
@@ -35,6 +78,45 @@ const PLATFORMS_EN: ReadonlyArray<PlatformOption> = [
   { value: "other", label: "Other" },
 ];
 
+const PAGE_COPY: Record<"zh" | "en", PlatformCopy> = {
+  zh: {
+    idleTitle: "从一句模糊想法开始",
+    idleBody: "直接描述题材、世界观、主角、核心冲突,或告诉我你想先改哪一块。共享草案会在 TUI 和 Studio Chat 之间同步。",
+    promptLabel: "继续打磨这本书",
+    promptPlaceholder: "例如:我想写个港风商战悬疑,主角先做灰产再洗白。",
+    promptPlaceholderFollowup: "例如:世界观改成近未来港口城;女主不要太早出场;卷一先查账再砸场。",
+    submit: "更新草案",
+    submitting: "处理中…",
+    create: "按当前草案建书",
+    creating: "创建中…",
+    discard: "丢弃草案",
+    draftHeading: "当前 foundation 草案",
+    missingHeading: "还缺这些关键信息",
+    missingHint: "这些字段未必都要一次填满,但缺得太多时不要急着建书。",
+    syncedHint: "这份草案和 TUI / Studio Chat 共享。",
+    helperTitle: "建议这样推进",
+    helperBody: "先定世界观和主角,再定核心冲突、简介和卷一方向。想看当前草案时,可以在 TUI 里用 /draft。",
+  },
+  en: {
+    idleTitle: "Start from a rough idea",
+    idleBody: "Describe the genre, world, protagonist, and core conflict. The shared draft stays in sync across TUI and Studio Chat.",
+    promptLabel: "Refine this book",
+    promptPlaceholder: "Example: I want a harbor-noir business thriller about a fixer trying to go legit.",
+    promptPlaceholderFollowup: "Example: move the world to a near-future port city; delay the heroine; make volume one about chasing ledgers first.",
+    submit: "Update draft",
+    submitting: "Working…",
+    create: "Create book from draft",
+    creating: "Creating…",
+    discard: "Discard draft",
+    draftHeading: "Current foundation draft",
+    missingHeading: "Still missing",
+    missingHint: "You do not need every field immediately, but do not create the book while the foundation is still vague.",
+    syncedHint: "This draft is shared with TUI and Studio Chat.",
+    helperTitle: "Recommended flow",
+    helperBody: "Lock the world and protagonist first, then settle the conflict, blurb, and volume-one direction. In TUI, use /draft to inspect the same draft.",
+  },
+};
+
 export function pickValidValue(current: string, available: ReadonlyArray<string>): string {
   if (current && available.includes(current)) {
     return current;
@@ -50,6 +132,56 @@ export function platformOptionsForLanguage(language: "zh" | "en"): ReadonlyArray
   return language === "en" ? PLATFORMS_EN : PLATFORMS_ZH;
 }
 
+export function resolveDraftInstruction(input: string, hasDraft: boolean): string {
+  const trimmed = input.trim();
+  if (!trimmed) {
+    return "";
+  }
+  return hasDraft ? trimmed : `/new ${trimmed}`;
+}
+
+export function canCreateFromDraft(draft?: BookCreationDraft): boolean {
+  if (!draft) {
+    return false;
+  }
+  if (draft.readyToCreate) {
+    return true;
+  }
+  return Boolean(
+    draft.title?.trim()
+      && draft.genre?.trim()
+      && typeof draft.targetChapters === "number"
+      && typeof draft.chapterWordCount === "number",
+  );
+}
+
+export function buildCreationDraftSummary(
+  draft: BookCreationDraft,
+  language: "zh" | "en",
+): ReadonlyArray<DraftSummaryRow> {
+  const rows = language === "en"
+    ? [
+        draft.title ? { key: "title", label: "Title", value: draft.title } : undefined,
+        draft.worldPremise ? { key: "worldPremise", label: "World", value: draft.worldPremise } : undefined,
+        draft.protagonist ? { key: "protagonist", label: "Protagonist", value: draft.protagonist } : undefined,
+        draft.conflictCore ? { key: "conflictCore", label: "Core Conflict", value: draft.conflictCore } : undefined,
+        draft.volumeOutline ? { key: "volumeOutline", label: "Volume Direction", value: draft.volumeOutline } : undefined,
+        draft.blurb ? { key: "blurb", label: "Blurb", value: draft.blurb } : undefined,
+        draft.nextQuestion ? { key: "nextQuestion", label: "Next", value: draft.nextQuestion } : undefined,
+      ]
+    : [
+        draft.title ? { key: "title", label: "书名", value: draft.title } : undefined,
+        draft.worldPremise ? { key: "worldPremise", label: "世界观", value: draft.worldPremise } : undefined,
+        draft.protagonist ? { key: "protagonist", label: "主角", value: draft.protagonist } : undefined,
+        draft.conflictCore ? { key: "conflictCore", label: "核心冲突", value: draft.conflictCore } : undefined,
+        draft.volumeOutline ? { key: "volumeOutline", label: "卷纲方向", value: draft.volumeOutline } : undefined,
+        draft.blurb ? { key: "blurb", label: "简介", value: draft.blurb } : undefined,
+        draft.nextQuestion ? { key: "nextQuestion", label: "下一步", value: draft.nextQuestion } : undefined,
+      ];
+
+  return rows.filter((row): row is DraftSummaryRow => Boolean(row));
+}
+
 interface WaitForBookReadyOptions {
   readonly fetchBook?: (bookId: string) => Promise<unknown>;
   readonly fetchStatus?: (bookId: string) => Promise<{ status: string; error?: string }>;
@@ -60,6 +192,7 @@ interface WaitForBookReadyOptions {
 
 const DEFAULT_BOOK_READY_MAX_ATTEMPTS = 120;
 const DEFAULT_BOOK_READY_DELAY_MS = 250;
+const CREATION_DRAFT_SYNC_INTERVAL_MS = 2500;
 
 export async function waitForBookReady(
   bookId: string,
@@ -112,79 +245,143 @@ export async function waitForBookReady(
 
 export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
   const c = useColors(theme);
-  const { data: genreData } = useApi<{ genres: ReadonlyArray<GenreInfo> }>("/genres");
   const { data: project } = useApi<{ language: string }>("/project");
-
   const projectLang = (project?.language ?? "zh") as "zh" | "en";
+  const copy = PAGE_COPY[projectLang];
 
-  const [title, setTitle] = useState("");
-  const [genre, setGenre] = useState("");
-  const [platform, setPlatform] = useState("");
-  const [chapterWords, setChapterWords] = useState(defaultChapterWordsForLanguage(projectLang));
-  const [chapterWordsTouched, setChapterWordsTouched] = useState(false);
-  const [targetChapters, setTargetChapters] = useState("200");
+  const [draft, setDraft] = useState<BookCreationDraft | undefined>();
+  const [input, setInput] = useState("");
+  const [loadingDraft, setLoadingDraft] = useState(true);
+  const [submitting, setSubmitting] = useState(false);
   const [creating, setCreating] = useState(false);
   const [error, setError] = useState<string | null>(null);
+  const [status, setStatus] = useState<string | null>(null);
 
-  // Filter genres by project language + custom genres (always show)
-  const allGenres = genreData?.genres ?? [];
-  const genres = allGenres.filter((g) => g.language === projectLang || g.source === "project");
-  const platforms = platformOptionsForLanguage(projectLang);
-  const genreSignature = genres.map((g) => g.id).join("|");
-  const platformSignature = platforms.map((p) => `${p.value}:${p.label}`).join("|");
+  const summaryRows = useMemo(
+    () => (draft ? buildCreationDraftSummary(draft, projectLang) : []),
+    [draft, projectLang],
+  );
 
-  useEffect(() => {
-    setGenre((current) => pickValidValue(current, genres.map((g) => g.id)));
-  }, [genreSignature]);
+  const refreshDraft = async (): Promise<BookCreationDraft | undefined> => {
+    const data = await fetchJson<InteractionSessionResponse>("/interaction/session");
+    const nextDraft = data.session?.creationDraft;
+    setDraft(nextDraft);
+    return nextDraft;
+  };
 
   useEffect(() => {
-    setPlatform((current) => pickValidValue(current, platforms.map((p) => p.value)));
-  }, [platformSignature]);
+    let cancelled = false;
+    setLoadingDraft(true);
+    void refreshDraft()
+      .catch((cause) => {
+        if (!cancelled) {
+          setError(cause instanceof Error ? cause.message : String(cause));
+        }
+      })
+      .finally(() => {
+        if (!cancelled) {
+          setLoadingDraft(false);
+        }
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, []);
 
   useEffect(() => {
-    if (!chapterWordsTouched) {
-      setChapterWords(defaultChapterWordsForLanguage(projectLang));
+    if (submitting || creating) {
+      return;
     }
-  }, [projectLang, chapterWordsTouched]);
 
-  const handleCreate = async () => {
-    if (!title.trim()) {
-      setError(t("create.titleRequired"));
+    const timer = setInterval(() => {
+      void refreshDraft().catch(() => undefined);
+    }, CREATION_DRAFT_SYNC_INTERVAL_MS);
+
+    return () => {
+      clearInterval(timer);
+    };
+  }, [submitting, creating]);
+
+  const runAgentInstruction = async (instruction: string): Promise<AgentResponse> => {
+    return fetchJson<AgentResponse>("/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction }),
+    });
+  };
+
+  const handleDraftSubmit = async () => {
+    const instruction = resolveDraftInstruction(input, Boolean(draft));
+    if (!instruction) {
       return;
     }
-    if (!genre) {
-      setError(t("create.genreRequired"));
+
+    setSubmitting(true);
+    setError(null);
+    try {
+      const data = await runAgentInstruction(instruction);
+      setInput("");
+      setStatus(data.response ?? null);
+      setDraft(data.session?.creationDraft);
+    } catch (cause) {
+      setError(cause instanceof Error ? cause.message : String(cause));
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  const handleCreate = async () => {
+    if (!canCreateFromDraft(draft)) {
       return;
     }
+
     setCreating(true);
     setError(null);
     try {
-      const result = await postApi<{ bookId: string }>("/books/create", {
-        title: title.trim(),
-        genre,
-        language: projectLang,
-        platform,
-        chapterWordCount: parseInt(chapterWords, 10),
-        targetChapters: parseInt(targetChapters, 10),
-      });
-      await waitForBookReady(result.bookId);
-      nav.toBook(result.bookId);
-    } catch (e) {
-      setError(e instanceof Error ? e.message : "Failed to create book");
+      const data = await runAgentInstruction("/create");
+      const bookId = data.session?.activeBookId;
+      if (!bookId) {
+        throw new Error(projectLang === "zh" ? "创建完成后没有返回书籍 ID。" : "Create succeeded but no book id was returned.");
+      }
+      setStatus(data.response ?? null);
+      setDraft(undefined);
+      await waitForBookReady(bookId);
+      nav.toBook(bookId);
+    } catch (cause) {
+      setError(cause instanceof Error ? cause.message : String(cause));
     } finally {
       setCreating(false);
     }
   };
 
+  const handleDiscard = async () => {
+    setSubmitting(true);
+    setError(null);
+    try {
+      const data = await runAgentInstruction("/discard");
+      setStatus(data.response ?? null);
+      setDraft(undefined);
+      setInput("");
+      await refreshDraft().catch(() => undefined);
+    } catch (cause) {
+      setError(cause instanceof Error ? cause.message : String(cause));
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
   return (
-    <div className="max-w-lg mx-auto space-y-8">
+    <div className="max-w-4xl mx-auto space-y-8">
       <div className="flex items-center gap-2 text-sm text-muted-foreground">
         <button onClick={nav.toDashboard} className={c.link}>{t("bread.books")}</button>
         <span className="text-border">/</span>
         <span>{t("bread.newBook")}</span>
       </div>
 
-      <h1 className="font-serif text-3xl">{t("create.title")}</h1>
+      <div className="space-y-3">
+        <h1 className="font-serif text-3xl">{t("create.title")}</h1>
+        <p className="text-sm text-muted-foreground leading-7">{copy.idleBody}</p>
+      </div>
 
       {error && (
         <div className={`border ${c.error} rounded-md px-4 py-3`}>
@@ -192,95 +389,112 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
         </div>
       )}
 
-      <div className="space-y-5">
-        {/* Title */}
-        <div>
-          <label className="block text-sm text-muted-foreground mb-2">{t("create.bookTitle")}</label>
-          <input
-            type="text"
-            value={title}
-            onChange={(e) => setTitle(e.target.value)}
-            className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none text-base`}
-            placeholder={t("create.placeholder")}
-          />
+      {status && (
+        <div className="border border-primary/20 bg-primary/5 rounded-md px-4 py-3 text-sm text-primary">
+          {status}
         </div>
+      )}
+
+      <div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
+        <section className="space-y-4">
+          <div className="rounded-2xl border border-border/60 bg-card/70 p-5 space-y-4">
+            <div className="space-y-1">
+              <div className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold">
+                {copy.draftHeading}
+              </div>
+              <div className="text-xs text-muted-foreground">
+                {copy.syncedHint}
+              </div>
+            </div>
+
+            {loadingDraft ? (
+              <div className="text-sm text-muted-foreground">{projectLang === "zh" ? "读取共享草案中…" : "Loading shared draft…"}</div>
+            ) : draft ? (
+              <div className="space-y-4">
+                {summaryRows.length > 0 ? (
+                  <div className="space-y-3">
+                    {summaryRows.map((row) => (
+                      <div key={row.key} className="rounded-xl border border-border/50 bg-background/70 px-4 py-3">
+                        <div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground font-semibold">{row.label}</div>
+                        <div className="mt-1 text-sm leading-7 whitespace-pre-wrap">{row.value}</div>
+                      </div>
+                    ))}
+                  </div>
+                ) : null}
 
-        {/* Genre — filtered by language */}
-        <div>
-          <label className="block text-sm text-muted-foreground mb-2">{t("create.genre")}</label>
-          <div className="grid grid-cols-3 gap-2">
-            {genres.map((g) => (
+                {draft.missingFields.length > 0 ? (
+                  <div className="space-y-2">
+                    <div className="text-xs font-medium text-foreground">{copy.missingHeading}</div>
+                    <div className="flex flex-wrap gap-2">
+                      {draft.missingFields.map((field) => (
+                        <span
+                          key={field}
+                          className="rounded-full border border-border/70 bg-secondary/50 px-3 py-1 text-xs text-muted-foreground"
+                        >
+                          {field}
+                        </span>
+                      ))}
+                    </div>
+                    <p className="text-xs text-muted-foreground">{copy.missingHint}</p>
+                  </div>
+                ) : null}
+              </div>
+            ) : (
+              <div className="rounded-xl border border-dashed border-border/70 bg-background/50 px-5 py-6">
+                <div className="font-medium">{copy.idleTitle}</div>
+                <p className="mt-2 text-sm text-muted-foreground leading-7">
+                  {copy.helperBody}
+                </p>
+              </div>
+            )}
+          </div>
+        </section>
+
+        <section className="space-y-4">
+          <div className="rounded-2xl border border-border/60 bg-card/70 p-5 space-y-4">
+            <div className="space-y-1">
+              <div className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold">
+                {copy.promptLabel}
+              </div>
+              <div className="text-xs text-muted-foreground">
+                {copy.helperTitle}
+              </div>
+            </div>
+
+            <textarea
+              value={input}
+              onChange={(event) => setInput(event.target.value)}
+              rows={10}
+              className={`w-full ${c.input} rounded-xl px-4 py-3 focus:outline-none text-sm leading-7 resize-y`}
+              placeholder={draft ? copy.promptPlaceholderFollowup : copy.promptPlaceholder}
+            />
+
+            <div className="flex flex-wrap gap-3">
               <button
-                key={g.id}
-                onClick={() => setGenre(g.id)}
-                className={`px-3 py-2.5 rounded-md text-sm text-left transition-all ${
-                  genre === g.id
-                    ? "bg-primary/15 text-primary border border-primary/30 font-medium"
-                    : "bg-secondary text-secondary-foreground border border-transparent hover:border-border"
-                }`}
+                onClick={handleDraftSubmit}
+                disabled={submitting || creating || !input.trim()}
+                className={`px-4 py-3 ${c.btnPrimary} rounded-md disabled:opacity-50 font-medium text-sm`}
               >
-                {g.name}
-                {g.source === "project" && <span className="text-xs text-muted-foreground ml-1">✦</span>}
+                {submitting ? copy.submitting : copy.submit}
               </button>
-            ))}
-          </div>
-        </div>
-
-        {/* Platform — filtered by language */}
-        <div>
-          <label className="block text-sm text-muted-foreground mb-2">
-            {t("create.platform")}
-          </label>
-          <div className="flex gap-2">
-            {platforms.map((p) => (
               <button
-                key={p.value}
-                onClick={() => setPlatform(p.value)}
-                className={`px-3 py-2 rounded-md text-sm transition-all ${
-                  platform === p.value
-                    ? "bg-primary/15 text-primary border border-primary/30"
-                    : "bg-secondary text-secondary-foreground border border-transparent hover:border-border"
-                }`}
+                onClick={handleCreate}
+                disabled={!canCreateFromDraft(draft) || creating || submitting}
+                className={`px-4 py-3 rounded-md border border-border bg-secondary text-secondary-foreground disabled:opacity-50 font-medium text-sm`}
               >
-                {p.label}
+                {creating ? copy.creating : copy.create}
               </button>
-            ))}
-          </div>
-        </div>
-
-        {/* Word count + chapters */}
-        <div className="grid grid-cols-2 gap-4">
-          <div>
-            <label className="block text-sm text-muted-foreground mb-2">{t("create.wordsPerChapter")}</label>
-            <input
-              type="number"
-              value={chapterWords}
-              onChange={(e) => {
-                setChapterWordsTouched(true);
-                setChapterWords(e.target.value);
-              }}
-              className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none`}
-            />
-          </div>
-          <div>
-            <label className="block text-sm text-muted-foreground mb-2">{t("create.targetChapters")}</label>
-            <input
-              type="number"
-              value={targetChapters}
-              onChange={(e) => setTargetChapters(e.target.value)}
-              className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none`}
-            />
+              <button
+                onClick={handleDiscard}
+                disabled={!draft || submitting || creating}
+                className="px-4 py-3 rounded-md border border-border text-muted-foreground hover:text-foreground hover:border-foreground/20 disabled:opacity-50 font-medium text-sm"
+              >
+                {copy.discard}
+              </button>
+            </div>
           </div>
-        </div>
+        </section>
       </div>
-
-      <button
-        onClick={handleCreate}
-        disabled={creating || !title.trim()}
-        className={`w-full px-4 py-3 ${c.btnPrimary} rounded-md disabled:opacity-50 font-medium text-base`}
-      >
-        {creating ? t("create.creating") : t("create.submit")}
-      </button>
     </div>
   );
 }

+ 27 - 8
packages/studio/src/pages/BookDetail.tsx

@@ -57,6 +57,7 @@ interface Nav {
   toDashboard: () => void;
   toChapter: (bookId: string, num: number) => void;
   toAnalytics: (bookId: string) => void;
+  toTruth: (bookId: string) => void;
 }
 
 function translateChapterStatus(status: string, t: TFunction): string {
@@ -266,8 +267,16 @@ export function BookDetail({
   const handleApproveAll = async () => {
     if (!data) return;
     const reviewable = data.chapters.filter((ch) => ch.status === "ready-for-review");
-    for (const ch of reviewable) {
-      await postApi(`/books/${bookId}/chapters/${ch.number}/approve`);
+    let failed = 0;
+    for (const chapter of reviewable) {
+      try {
+        await postApi(`/books/${bookId}/chapters/${chapter.number}/approve`);
+      } catch {
+        failed += 1;
+      }
+    }
+    if (failed > 0) {
+      alert(`${failed}/${reviewable.length} approve(s) failed`);
     }
     refetch();
   };
@@ -395,7 +404,7 @@ export function BookDetail({
             </button>
           )}
           <button
-            onClick={() => (nav as { toTruth?: (id: string) => void }).toTruth?.(bookId)}
+            onClick={() => nav.toTruth(bookId)}
             className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg hover:text-foreground hover:bg-secondary transition-all border border-border/50"
           >
             <Database size={14} />
@@ -534,14 +543,20 @@ export function BookDetail({
                       {ch.status === "ready-for-review" && (
                         <>
                           <button
-                            onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/approve`); refetch(); }}
+                            onClick={async () => {
+                              try { await postApi(`/books/${bookId}/chapters/${ch.number}/approve`); refetch(); }
+                              catch (e) { alert(e instanceof Error ? e.message : "Approve failed"); }
+                            }}
                             className="p-2 rounded-lg bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500 hover:text-white transition-all shadow-sm"
                             title={t("book.approve")}
                           >
                             <Check size={14} />
                           </button>
                           <button
-                            onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/reject`); refetch(); }}
+                            onClick={async () => {
+                              try { await postApi(`/books/${bookId}/chapters/${ch.number}/reject`); refetch(); }
+                              catch (e) { alert(e instanceof Error ? e.message : "Reject failed"); }
+                            }}
                             className="p-2 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive hover:text-white transition-all shadow-sm"
                             title={t("book.reject")}
                           >
@@ -551,9 +566,13 @@ export function BookDetail({
                       )}
                       <button
                         onClick={async () => {
-                          const auditResult = await fetchJson<{ passed?: boolean; issues?: unknown[] }>(`/books/${bookId}/audit/${ch.number}`, { method: "POST" });
-                          alert(auditResult.passed ? "Audit passed" : `Audit failed: ${auditResult.issues?.length ?? 0} issues`);
-                          refetch();
+                          try {
+                            const auditResult = await fetchJson<{ passed?: boolean; issues?: unknown[] }>(`/books/${bookId}/audit/${ch.number}`, { method: "POST" });
+                            alert(auditResult.passed ? "Audit passed" : `Audit failed: ${auditResult.issues?.length ?? 0} issues`);
+                            refetch();
+                          } catch (e) {
+                            alert(e instanceof Error ? e.message : "Audit failed");
+                          }
                         }}
                         className="p-2 rounded-lg bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all shadow-sm"
                         title={t("book.audit")}

+ 12 - 4
packages/studio/src/pages/ChapterReader.tsx

@@ -94,13 +94,21 @@ export function ChapterReader({ bookId, chapterNumber, nav, theme, t }: {
     .trim();
 
   const handleApprove = async () => {
-    await postApi(`/books/${bookId}/chapters/${chapterNumber}/approve`);
-    nav.toBook(bookId);
+    try {
+      await postApi(`/books/${bookId}/chapters/${chapterNumber}/approve`);
+      nav.toBook(bookId);
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Approve failed");
+    }
   };
 
   const handleReject = async () => {
-    await postApi(`/books/${bookId}/chapters/${chapterNumber}/reject`);
-    nav.toBook(bookId);
+    try {
+      await postApi(`/books/${bookId}/chapters/${chapterNumber}/reject`);
+      nav.toBook(bookId);
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Reject failed");
+    }
   };
 
   const paragraphs = body.split(/\n\n+/).filter(Boolean);

+ 4 - 1
packages/studio/src/pages/Dashboard.tsx

@@ -244,7 +244,10 @@ export function Dashboard({ nav, sse, theme, t }: { nav: Nav; sse: { messages: R
 
                 <div className="flex items-center gap-3 shrink-0 ml-6">
                   <button
-                    onClick={() => postApi(`/books/${book.id}/write-next`)}
+                    onClick={async () => {
+                      try { await postApi(`/books/${book.id}/write-next`); }
+                      catch (e) { alert(e instanceof Error ? e.message : "Write failed"); }
+                    }}
                     disabled={isWriting}
                     className={`flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-bold transition-all shadow-sm ${
                       isWriting

+ 16 - 7
packages/studio/src/pages/GenreManager.tsx

@@ -4,6 +4,7 @@ import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
 import { useI18n } from "../hooks/use-i18n";
 import { useColors } from "../hooks/use-colors";
+import { ConfirmDialog } from "../components/ConfirmDialog";
 import { Plus, Pencil, Trash2 } from "lucide-react";
 
 interface GenreInfo {
@@ -209,6 +210,7 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
   const [selected, setSelected] = useState<string | null>(null);
   const [formMode, setFormMode] = useState<"hidden" | "create" | "edit">("hidden");
   const [form, setForm] = useState<GenreFormData>(EMPTY_FORM);
+  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
 
   // Only show genres matching current language, plus custom project genres
   const filteredGenres = data?.genres.filter((g) => g.language === lang || g.source === "project") ?? [];
@@ -300,13 +302,9 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
 
   const handleDelete = async () => {
     if (!validSelected) return;
-    if (!window.confirm(`Delete genre "${validSelected}"?`)) return;
+    setConfirmDeleteOpen(false);
     try {
-      const res = await fetch(`/api/genres/${validSelected}`, { method: "DELETE" });
-      if (!res.ok) {
-        const json = await res.json() as { error?: string };
-        throw new Error(json.error ?? `${res.status}`);
-      }
+      await fetchJson(`/genres/${validSelected}`, { method: "DELETE" });
       setSelected(null);
       refetch();
     } catch (e) {
@@ -393,7 +391,7 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
                   </button>
                   {selectedGenre?.source === "project" && (
                     <button
-                      onClick={handleDelete}
+                      onClick={() => setConfirmDeleteOpen(true)}
                       className={`flex items-center gap-1.5 px-3 py-1.5 text-sm ${c.btnDanger} rounded-md`}
                     >
                       <Trash2 size={14} />
@@ -449,6 +447,17 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
           )}
         </div>
       </div>
+
+      <ConfirmDialog
+        open={confirmDeleteOpen}
+        title="Delete Genre"
+        message={`Delete genre "${validSelected}"?`}
+        confirmLabel={t("common.delete") ?? "Delete"}
+        cancelLabel={t("genre.cancel") ?? "Cancel"}
+        variant="danger"
+        onConfirm={() => void handleDelete()}
+        onCancel={() => setConfirmDeleteOpen(false)}
+      />
     </div>
   );
 }

+ 66 - 0
packages/studio/src/pages/page-state.test.ts

@@ -1,8 +1,11 @@
 import { describe, expect, it } from "vitest";
 import {
+  buildCreationDraftSummary,
+  canCreateFromDraft,
   defaultChapterWordsForLanguage,
   platformOptionsForLanguage,
   pickValidValue,
+  resolveDraftInstruction,
   waitForBookReady,
 } from "./BookCreate";
 
@@ -93,3 +96,66 @@ describe("waitForBookReady", () => {
     })).rejects.toThrow("INKOS_LLM_API_KEY not set");
   });
 });
+
+describe("resolveDraftInstruction", () => {
+  it("forces the first ideation turn through /new so an active book does not hijack the flow", () => {
+    expect(resolveDraftInstruction("我想写个港风商战悬疑", false)).toBe("/new 我想写个港风商战悬疑");
+    expect(resolveDraftInstruction("把世界观改成近未来港口城", true)).toBe("把世界观改成近未来港口城");
+  });
+});
+
+describe("canCreateFromDraft", () => {
+  it("accepts drafts explicitly marked ready", () => {
+    expect(canCreateFromDraft({
+      concept: "港风商战悬疑",
+      readyToCreate: true,
+      missingFields: [],
+    })).toBe(true);
+  });
+
+  it("accepts drafts that already have the minimum creation fields", () => {
+    expect(canCreateFromDraft({
+      concept: "港风商战悬疑",
+      title: "夜港账本",
+      genre: "urban",
+      targetChapters: 120,
+      chapterWordCount: 2800,
+      readyToCreate: false,
+      missingFields: [],
+    })).toBe(true);
+  });
+
+  it("rejects incomplete drafts", () => {
+    expect(canCreateFromDraft({
+      concept: "港风商战悬疑",
+      title: "夜港账本",
+      readyToCreate: false,
+      missingFields: ["genre", "targetChapters"],
+    })).toBe(false);
+  });
+});
+
+describe("buildCreationDraftSummary", () => {
+  it("surfaces the shared foundation draft in a user-facing order", () => {
+    expect(buildCreationDraftSummary({
+      concept: "港风商战悬疑,主角从灰产洗白。",
+      title: "夜港账本",
+      worldPremise: "近未来港口城,账本牵出多方势力。",
+      protagonist: "林砚,水货账房出身,擅长记账和看人。",
+      conflictCore: "洗白与旧债回潮的对撞。",
+      volumeOutline: "卷一先查账,再暴露港口旧案。",
+      blurb: "一个做灰产生意的人,准备在夜港洗白,却先被旧账拖回去。",
+      nextQuestion: "卷一先查账还是先砸场?",
+      missingFields: ["targetChapters"],
+      readyToCreate: false,
+    }, "zh")).toEqual([
+      { key: "title", label: "书名", value: "夜港账本" },
+      { key: "worldPremise", label: "世界观", value: "近未来港口城,账本牵出多方势力。" },
+      { key: "protagonist", label: "主角", value: "林砚,水货账房出身,擅长记账和看人。" },
+      { key: "conflictCore", label: "核心冲突", value: "洗白与旧债回潮的对撞。" },
+      { key: "volumeOutline", label: "卷纲方向", value: "卷一先查账,再暴露港口旧案。" },
+      { key: "blurb", label: "简介", value: "一个做灰产生意的人,准备在夜港洗白,却先被旧账拖回去。" },
+      { key: "nextQuestion", label: "下一步", value: "卷一先查账还是先砸场?" },
+    ]);
+  });
+});

+ 350 - 12
pnpm-lock.yaml

@@ -11,10 +11,10 @@ importers:
   packages/cli:
     dependencies:
       '@actalk/inkos-core':
-        specifier: 1.1.1
+        specifier: workspace:*
         version: link:../core
       '@actalk/inkos-studio':
-        specifier: 1.1.1
+        specifier: workspace:*
         version: link:../studio
       commander:
         specifier: ^13.0.0
@@ -25,13 +25,28 @@ importers:
       epub-gen-memory:
         specifier: ^1.0.10
         version: 1.1.2
+      ink:
+        specifier: ^7.0.0
+        version: 7.0.0(@types/react@19.2.14)(react@19.2.4)
+      ink-text-input:
+        specifier: ^6.0.0
+        version: 6.0.0(ink@7.0.0(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
       marked:
         specifier: ^15.0.0
         version: 15.0.12
+      react:
+        specifier: ^19.2.4
+        version: 19.2.4
     devDependencies:
       '@types/node':
         specifier: ^22.0.0
         version: 22.19.15
+      '@types/react':
+        specifier: ^19.2.14
+        version: 19.2.14
+      ink-testing-library:
+        specifier: ^4.0.0
+        version: 4.0.0(@types/react@19.2.14)
       typescript:
         specifier: ^5.8.0
         version: 5.9.3
@@ -52,7 +67,7 @@ importers:
         version: 4.1.1
       openai:
         specifier: ^4.80.0
-        version: 4.104.0(zod@3.25.76)
+        version: 4.104.0(ws@8.20.0)(zod@3.25.76)
       zod:
         specifier: ^3.24.0
         version: 3.25.76
@@ -70,7 +85,7 @@ importers:
   packages/studio:
     dependencies:
       '@actalk/inkos-core':
-        specifier: 1.1.1
+        specifier: workspace:*
         version: link:../core
       '@base-ui/react':
         specifier: ^1.3.0
@@ -133,6 +148,10 @@ importers:
 
 packages:
 
+  '@alcalzone/ansi-tokenize@0.3.0':
+    resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==}
+    engines: {node: '>=18'}
+
   '@anthropic-ai/sdk@0.78.0':
     resolution: {integrity: sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==}
     hasBin: true
@@ -973,14 +992,26 @@ packages:
     resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
     engines: {node: '>= 8.0.0'}
 
+  ansi-escapes@7.3.0:
+    resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
+    engines: {node: '>=18'}
+
   ansi-regex@5.0.1:
     resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
     engines: {node: '>=8'}
 
+  ansi-regex@6.2.2:
+    resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
+    engines: {node: '>=12'}
+
   ansi-styles@4.3.0:
     resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
     engines: {node: '>=8'}
 
+  ansi-styles@6.2.3:
+    resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
+    engines: {node: '>=12'}
+
   argparse@2.0.1:
     resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
 
@@ -994,6 +1025,10 @@ packages:
   asynckit@0.4.0:
     resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
 
+  auto-bind@5.0.1:
+    resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
   autoprefixer@10.4.27:
     resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==}
     engines: {node: ^10 || ^12 || >=14}
@@ -1039,10 +1074,26 @@ packages:
     resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
     engines: {node: '>=18'}
 
+  chalk@5.6.2:
+    resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
+    engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+
   check-error@2.1.3:
     resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
     engines: {node: '>= 16'}
 
+  cli-boxes@4.0.1:
+    resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==}
+    engines: {node: '>=18.20 <19 || >=20.10'}
+
+  cli-cursor@4.0.0:
+    resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
+  cli-truncate@6.0.0:
+    resolution: {integrity: sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==}
+    engines: {node: '>=22'}
+
   cli-width@4.1.0:
     resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
     engines: {node: '>= 12'}
@@ -1055,6 +1106,10 @@ packages:
     resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
     engines: {node: '>=6'}
 
+  code-excerpt@4.0.0:
+    resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
   color-convert@2.0.1:
     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
     engines: {node: '>=7.0.0'}
@@ -1073,6 +1128,10 @@ packages:
   convert-source-map@2.0.0:
     resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
 
+  convert-to-spaces@2.0.1:
+    resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
   cookie@1.1.1:
     resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
     engines: {node: '>=18'}
@@ -1161,6 +1220,10 @@ packages:
     resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==}
     engines: {node: '>=0.12'}
 
+  environment@1.1.0:
+    resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
+    engines: {node: '>=18'}
+
   epub-gen-memory@1.1.2:
     resolution: {integrity: sha512-vwGM6MVNqKIskFzPZqhi4ZOs0ZTUXco9oDuHFX1vB2Il9pTAkaHWFBFgHrrl832dYmBPb/raGVUZXFvZYueRyw==}
     engines: {node: '>=10.0.0'}
@@ -1184,6 +1247,9 @@ packages:
     resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
     engines: {node: '>= 0.4'}
 
+  es-toolkit@1.45.1:
+    resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==}
+
   esbuild@0.25.12:
     resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
     engines: {node: '>=18'}
@@ -1198,6 +1264,10 @@ packages:
     resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
     engines: {node: '>=6'}
 
+  escape-string-regexp@2.0.0:
+    resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==}
+    engines: {node: '>=8'}
+
   estree-walker@3.0.3:
     resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
 
@@ -1251,6 +1321,10 @@ packages:
     resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
     engines: {node: 6.* || 8.* || >= 10.*}
 
+  get-east-asian-width@1.5.0:
+    resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==}
+    engines: {node: '>=18'}
+
   get-intrinsic@1.3.0:
     resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
     engines: {node: '>= 0.4'}
@@ -1301,13 +1375,55 @@ packages:
   immediate@3.0.6:
     resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
 
+  indent-string@5.0.0:
+    resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==}
+    engines: {node: '>=12'}
+
   inherits@2.0.4:
     resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
 
+  ink-testing-library@4.0.0:
+    resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/react': '>=18.0.0'
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  ink-text-input@6.0.0:
+    resolution: {integrity: sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      ink: '>=5'
+      react: '>=18'
+
+  ink@7.0.0:
+    resolution: {integrity: sha512-fMie5/VwIYXofMyND0s+fOVhwVBBPYx+uuqJ6V6rUBGjui+2UYp+0fWtvhSeKT4z+X1uH98a4ge5Vj3aTlL6mg==}
+    engines: {node: '>=22'}
+    peerDependencies:
+      '@types/react': '>=19.2.0'
+      react: '>=19.2.0'
+      react-devtools-core: '>=6.1.2'
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      react-devtools-core:
+        optional: true
+
   is-fullwidth-code-point@3.0.0:
     resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
     engines: {node: '>=8'}
 
+  is-fullwidth-code-point@5.1.0:
+    resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
+    engines: {node: '>=18'}
+
+  is-in-ci@2.0.0:
+    resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==}
+    engines: {node: '>=20'}
+    hasBin: true
+
   is-node-process@1.2.0:
     resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==}
 
@@ -1471,6 +1587,10 @@ packages:
     engines: {node: '>=4.0.0'}
     hasBin: true
 
+  mimic-fn@2.1.0:
+    resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
+    engines: {node: '>=6'}
+
   minimatch@5.1.9:
     resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
     engines: {node: '>=10'}
@@ -1517,6 +1637,10 @@ packages:
   nth-check@2.1.1:
     resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
 
+  onetime@5.1.2:
+    resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
+    engines: {node: '>=6'}
+
   openai@4.104.0:
     resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==}
     hasBin: true
@@ -1539,6 +1663,10 @@ packages:
   pako@1.0.11:
     resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
 
+  patch-console@2.0.0:
+    resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
   path-to-regexp@6.3.0:
     resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
 
@@ -1571,6 +1699,12 @@ packages:
     peerDependencies:
       react: ^19.2.4
 
+  react-reconciler@0.33.0:
+    resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==}
+    engines: {node: '>=0.10.0'}
+    peerDependencies:
+      react: ^19.2.0
+
   react-refresh@0.17.0:
     resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
     engines: {node: '>=0.10.0'}
@@ -1592,6 +1726,10 @@ packages:
   resolve-pkg-maps@1.0.0:
     resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
 
+  restore-cursor@4.0.0:
+    resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
   rettime@0.10.1:
     resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==}
 
@@ -1616,10 +1754,17 @@ packages:
   siginfo@2.0.0:
     resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
 
+  signal-exit@3.0.7:
+    resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+
   signal-exit@4.1.0:
     resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
     engines: {node: '>=14'}
 
+  slice-ansi@9.0.0:
+    resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==}
+    engines: {node: '>=22'}
+
   slugify@1.6.8:
     resolution: {integrity: sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==}
     engines: {node: '>=8.0.0'}
@@ -1628,6 +1773,10 @@ packages:
     resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
     engines: {node: '>=0.10.0'}
 
+  stack-utils@2.0.6:
+    resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
+    engines: {node: '>=10'}
+
   stackback@0.0.2:
     resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
 
@@ -1645,6 +1794,10 @@ packages:
     resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
     engines: {node: '>=8'}
 
+  string-width@8.2.0:
+    resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==}
+    engines: {node: '>=20'}
+
   string_decoder@1.1.1:
     resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
 
@@ -1652,6 +1805,10 @@ packages:
     resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
     engines: {node: '>=8'}
 
+  strip-ansi@7.2.0:
+    resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
+    engines: {node: '>=12'}
+
   strip-literal@3.1.0:
     resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
 
@@ -1672,6 +1829,10 @@ packages:
     resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
     engines: {node: '>=6'}
 
+  terminal-size@4.0.1:
+    resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==}
+    engines: {node: '>=18'}
+
   tinybench@2.9.0:
     resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
 
@@ -1716,8 +1877,12 @@ packages:
     engines: {node: '>=18.0.0'}
     hasBin: true
 
-  type-fest@5.4.4:
-    resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==}
+  type-fest@4.41.0:
+    resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
+    engines: {node: '>=16'}
+
+  type-fest@5.5.0:
+    resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==}
     engines: {node: '>=20'}
 
   typescript@5.9.3:
@@ -1880,6 +2045,14 @@ packages:
     engines: {node: '>=8'}
     hasBin: true
 
+  widest-line@6.0.0:
+    resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==}
+    engines: {node: '>=20'}
+
+  wrap-ansi@10.0.0:
+    resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==}
+    engines: {node: '>=20'}
+
   wrap-ansi@6.2.0:
     resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
     engines: {node: '>=8'}
@@ -1888,6 +2061,18 @@ packages:
     resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
     engines: {node: '>=10'}
 
+  ws@8.20.0:
+    resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
+    engines: {node: '>=10.0.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: '>=5.0.2'
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+
   y18n@5.0.8:
     resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
     engines: {node: '>=10'}
@@ -1907,11 +2092,19 @@ packages:
     resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
     engines: {node: '>=18'}
 
+  yoga-layout@3.2.1:
+    resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
+
   zod@3.25.76:
     resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
 
 snapshots:
 
+  '@alcalzone/ansi-tokenize@0.3.0':
+    dependencies:
+      ansi-styles: 6.2.3
+      is-fullwidth-code-point: 5.1.0
+
   '@anthropic-ai/sdk@0.78.0(zod@3.25.76)':
     dependencies:
       json-schema-to-ts: 3.1.1
@@ -2573,14 +2766,22 @@ snapshots:
     dependencies:
       humanize-ms: 1.2.1
 
+  ansi-escapes@7.3.0:
+    dependencies:
+      environment: 1.1.0
+
   ansi-regex@5.0.1:
     optional: true
 
+  ansi-regex@6.2.2: {}
+
   ansi-styles@4.3.0:
     dependencies:
       color-convert: 2.0.1
     optional: true
 
+  ansi-styles@6.2.3: {}
+
   argparse@2.0.1: {}
 
   assertion-error@2.0.1: {}
@@ -2589,6 +2790,8 @@ snapshots:
 
   asynckit@0.4.0: {}
 
+  auto-bind@5.0.1: {}
+
   autoprefixer@10.4.27(postcss@8.5.8):
     dependencies:
       browserslist: 4.28.1
@@ -2635,8 +2838,21 @@ snapshots:
       loupe: 3.2.1
       pathval: 2.0.1
 
+  chalk@5.6.2: {}
+
   check-error@2.1.3: {}
 
+  cli-boxes@4.0.1: {}
+
+  cli-cursor@4.0.0:
+    dependencies:
+      restore-cursor: 4.0.0
+
+  cli-truncate@6.0.0:
+    dependencies:
+      slice-ansi: 9.0.0
+      string-width: 8.2.0
+
   cli-width@4.1.0:
     optional: true
 
@@ -2649,6 +2865,10 @@ snapshots:
 
   clsx@2.1.1: {}
 
+  code-excerpt@4.0.0:
+    dependencies:
+      convert-to-spaces: 2.0.1
+
   color-convert@2.0.1:
     dependencies:
       color-name: 1.1.4
@@ -2665,6 +2885,8 @@ snapshots:
 
   convert-source-map@2.0.0: {}
 
+  convert-to-spaces@2.0.1: {}
+
   cookie@1.1.1:
     optional: true
 
@@ -2742,6 +2964,8 @@ snapshots:
 
   entities@3.0.1: {}
 
+  environment@1.1.0: {}
+
   epub-gen-memory@1.1.2:
     dependencies:
       abort-controller: 3.0.0
@@ -2777,6 +3001,8 @@ snapshots:
       has-tostringtag: 1.0.2
       hasown: 2.0.2
 
+  es-toolkit@1.45.1: {}
+
   esbuild@0.25.12:
     optionalDependencies:
       '@esbuild/aix-ppc64': 0.25.12
@@ -2837,6 +3063,8 @@ snapshots:
 
   escalade@3.2.0: {}
 
+  escape-string-regexp@2.0.0: {}
+
   estree-walker@3.0.3:
     dependencies:
       '@types/estree': 1.0.8
@@ -2880,6 +3108,8 @@ snapshots:
   get-caller-file@2.0.5:
     optional: true
 
+  get-east-asian-width@1.5.0: {}
+
   get-intrinsic@1.3.0:
     dependencies:
       call-bind-apply-helpers: 1.0.2
@@ -2937,11 +3167,64 @@ snapshots:
 
   immediate@3.0.6: {}
 
+  indent-string@5.0.0: {}
+
   inherits@2.0.4: {}
 
+  ink-testing-library@4.0.0(@types/react@19.2.14):
+    optionalDependencies:
+      '@types/react': 19.2.14
+
+  ink-text-input@6.0.0(ink@7.0.0(@types/react@19.2.14)(react@19.2.4))(react@19.2.4):
+    dependencies:
+      chalk: 5.6.2
+      ink: 7.0.0(@types/react@19.2.14)(react@19.2.4)
+      react: 19.2.4
+      type-fest: 4.41.0
+
+  ink@7.0.0(@types/react@19.2.14)(react@19.2.4):
+    dependencies:
+      '@alcalzone/ansi-tokenize': 0.3.0
+      ansi-escapes: 7.3.0
+      ansi-styles: 6.2.3
+      auto-bind: 5.0.1
+      chalk: 5.6.2
+      cli-boxes: 4.0.1
+      cli-cursor: 4.0.0
+      cli-truncate: 6.0.0
+      code-excerpt: 4.0.0
+      es-toolkit: 1.45.1
+      indent-string: 5.0.0
+      is-in-ci: 2.0.0
+      patch-console: 2.0.0
+      react: 19.2.4
+      react-reconciler: 0.33.0(react@19.2.4)
+      scheduler: 0.27.0
+      signal-exit: 3.0.7
+      slice-ansi: 9.0.0
+      stack-utils: 2.0.6
+      string-width: 8.2.0
+      terminal-size: 4.0.1
+      type-fest: 5.5.0
+      widest-line: 6.0.0
+      wrap-ansi: 10.0.0
+      ws: 8.20.0
+      yoga-layout: 3.2.1
+    optionalDependencies:
+      '@types/react': 19.2.14
+    transitivePeerDependencies:
+      - bufferutil
+      - utf-8-validate
+
   is-fullwidth-code-point@3.0.0:
     optional: true
 
+  is-fullwidth-code-point@5.1.0:
+    dependencies:
+      get-east-asian-width: 1.5.0
+
+  is-in-ci@2.0.0: {}
+
   is-node-process@1.2.0:
     optional: true
 
@@ -3062,6 +3345,8 @@ snapshots:
 
   mime@2.6.0: {}
 
+  mimic-fn@2.1.0: {}
+
   minimatch@5.1.9:
     dependencies:
       brace-expansion: 2.0.2
@@ -3085,7 +3370,7 @@ snapshots:
       statuses: 2.0.2
       strict-event-emitter: 0.5.1
       tough-cookie: 6.0.1
-      type-fest: 5.4.4
+      type-fest: 5.5.0
       until-async: 3.0.2
       yargs: 17.7.2
     optionalDependencies:
@@ -3111,7 +3396,11 @@ snapshots:
     dependencies:
       boolbase: 1.0.0
 
-  openai@4.104.0(zod@3.25.76):
+  onetime@5.1.2:
+    dependencies:
+      mimic-fn: 2.1.0
+
+  openai@4.104.0(ws@8.20.0)(zod@3.25.76):
     dependencies:
       '@types/node': 18.19.130
       '@types/node-fetch': 2.6.13
@@ -3121,6 +3410,7 @@ snapshots:
       formdata-node: 4.4.1
       node-fetch: 2.7.0
     optionalDependencies:
+      ws: 8.20.0
       zod: 3.25.76
     transitivePeerDependencies:
       - encoding
@@ -3138,6 +3428,8 @@ snapshots:
 
   pako@1.0.11: {}
 
+  patch-console@2.0.0: {}
+
   path-to-regexp@6.3.0:
     optional: true
 
@@ -3164,6 +3456,11 @@ snapshots:
       react: 19.2.4
       scheduler: 0.27.0
 
+  react-reconciler@0.33.0(react@19.2.4):
+    dependencies:
+      react: 19.2.4
+      scheduler: 0.27.0
+
   react-refresh@0.17.0: {}
 
   react@19.2.4: {}
@@ -3185,6 +3482,11 @@ snapshots:
 
   resolve-pkg-maps@1.0.0: {}
 
+  restore-cursor@4.0.0:
+    dependencies:
+      onetime: 5.1.2
+      signal-exit: 3.0.7
+
   rettime@0.10.1:
     optional: true
 
@@ -3229,13 +3531,24 @@ snapshots:
 
   siginfo@2.0.0: {}
 
+  signal-exit@3.0.7: {}
+
   signal-exit@4.1.0:
     optional: true
 
+  slice-ansi@9.0.0:
+    dependencies:
+      ansi-styles: 6.2.3
+      is-fullwidth-code-point: 5.1.0
+
   slugify@1.6.8: {}
 
   source-map-js@1.2.1: {}
 
+  stack-utils@2.0.6:
+    dependencies:
+      escape-string-regexp: 2.0.0
+
   stackback@0.0.2: {}
 
   statuses@2.0.2:
@@ -3253,6 +3566,11 @@ snapshots:
       strip-ansi: 6.0.1
     optional: true
 
+  string-width@8.2.0:
+    dependencies:
+      get-east-asian-width: 1.5.0
+      strip-ansi: 7.2.0
+
   string_decoder@1.1.1:
     dependencies:
       safe-buffer: 5.1.2
@@ -3262,14 +3580,17 @@ snapshots:
       ansi-regex: 5.0.1
     optional: true
 
+  strip-ansi@7.2.0:
+    dependencies:
+      ansi-regex: 6.2.2
+
   strip-literal@3.1.0:
     dependencies:
       js-tokens: 9.0.1
 
   tabbable@6.4.0: {}
 
-  tagged-tag@1.0.0:
-    optional: true
+  tagged-tag@1.0.0: {}
 
   tailwind-merge@3.5.0: {}
 
@@ -3277,6 +3598,8 @@ snapshots:
 
   tapable@2.3.0: {}
 
+  terminal-size@4.0.1: {}
+
   tinybench@2.9.0: {}
 
   tinyexec@0.3.2: {}
@@ -3316,10 +3639,11 @@ snapshots:
     optionalDependencies:
       fsevents: 2.3.3
 
-  type-fest@5.4.4:
+  type-fest@4.41.0: {}
+
+  type-fest@5.5.0:
     dependencies:
       tagged-tag: 1.0.0
-    optional: true
 
   typescript@5.9.3: {}
 
@@ -3450,6 +3774,16 @@ snapshots:
       siginfo: 2.0.0
       stackback: 0.0.2
 
+  widest-line@6.0.0:
+    dependencies:
+      string-width: 8.2.0
+
+  wrap-ansi@10.0.0:
+    dependencies:
+      ansi-styles: 6.2.3
+      string-width: 8.2.0
+      strip-ansi: 7.2.0
+
   wrap-ansi@6.2.0:
     dependencies:
       ansi-styles: 4.3.0
@@ -3464,6 +3798,8 @@ snapshots:
       strip-ansi: 6.0.1
     optional: true
 
+  ws@8.20.0: {}
+
   y18n@5.0.8:
     optional: true
 
@@ -3486,4 +3822,6 @@ snapshots:
   yoctocolors-cjs@2.1.3:
     optional: true
 
+  yoga-layout@3.2.1: {}
+
   zod@3.25.76: {}

+ 21 - 1
skills/SKILL.md

@@ -112,7 +112,27 @@ inkos status
    inkos review approve-all
    ```
 
-### Workflow 2.5: Steering Chapter Focus Before Writing
+### Workflow 2.5: Shared Natural-Language Control (Recommended For OpenClaw)
+
+When InkOS is being driven by OpenClaw or another external agent, prefer the shared interaction executor instead of stitching together many ad-hoc CLI calls:
+
+```bash
+inkos interact --json --message "continue the current book, but keep the pacing tighter"
+inkos interact --json --message "rewrite chapter 3"
+inkos interact --json --book my-book --message "switch to auto mode"
+```
+
+This returns a structured payload containing:
+- the routed request
+- assistant response text
+- updated interaction session
+- execution state
+- pending decision
+- recent interaction events
+
+Use this as the primary OpenClaw entry because it shares the same control layer as the project TUI.
+
+### Workflow 2.6: Steering Chapter Focus Before Writing
 
 Use this when the user says things like "pull focus back to the mentor conflict", "pause the merchant guild subplot", or "change what the next chapter should prioritize".