Prechádzať zdrojové kódy

feat(WIP): run cancellation, zombie reaper, physics67 gate, AgentWorkspace

打包提交本地积累的 5 块在改的功能。每块都跑通过本地手测,
但还没出独立 PR。CI 走绿后可以拆。

## 1. Run cancellation (cross-cutting)

让正在跑的 react loop + claude_code 子进程能被中断,不用等到超时。

- lambdagent/src/lambdagent/agentruntime/cancel.py (NEW, 182 lines)
  per-run 取消注册表。begin_run / signal_cancel / is_cancelled / end_run。
  agentpaas 在 HTTP handler 线程调 signal_cancel,
  react loop 在每步开头 is_cancelled() 自检。
- agentpaas/db/models.py: runs 表 ALTER ADD cancel_requested INTEGER + subprocess_pid INTEGER
  subprocess_pid 是 restart-resilient cancel: 内存里 registry 因为 backend
  重启丢了 → 退到 os.kill(pid) 兜底。run_5cbbb1d1dc54 那次孤儿子进程还
  在写 10min 才发现就是这么来的。
- agentpaas/api/v1/agents.py: POST /{agent_id}/runs/{run_id}/cancel
- webui/src/pages/Chat.tsx: AbortController + 90s IDLE_TIMEOUT_MS + 取消按钮

## 2. Zombie reaper at startup

agentpaas/api/app.py startup hook 急切构造 Database 单例,
触发 agentpaas/db/models.py:223 的 reaper 迁移:

  UPDATE runs    SET status='failed' WHERE status='running' AND created_at < now-6h
  UPDATE kb_jobs SET status='failed' WHERE status='running' AND created_at < now-1h

否则 prior-crashed 的 run/job 会在 UI 上一直 hang 在 "running" — workspace67
wiki 编译那次挂了 9 天 UI 还显示 running 就是这个 bug。
关联调整: agentpaas/api/v1/knowledge.py (KB job 侧的清理逻辑)

## 3. physics67 pipeline 升级: 加产物验收门禁

12 步 = 6 sub-agent + 6 gate (原来 6 步无验收, 经常下游拿到上游脏产物继续算):

  PhysIdea     >> PhysGatePlan >>
  PhysLit      >> PhysGateLit >>
  PhysSim      >> PhysGateSim >>
  PhysAnalyst  >> PhysGateAnalysis >>
  PhysWriter   >> PhysGatePaper >>
  PhysReview   >> PhysGateReview

- agentexample/physics67/agents/artifact-gate.yml (NEW): 通用 gate sub-agent,
  检查 workspace 必要文件 / JSON 可解析性 / 状态字段, 输出 gate_result.json。
  gate fail → 不进入下一阶段, 回到 owner 阶段重跑。
- orchestrator.yml: 12 步 routing 逻辑 + gate-fail 回退规则
- agents/{idea-planner,analyst,reviewer,writer}.yml: 微调 prompt 配合 gate

## 4. lambdagent hardening

- lambdagent/src/lambdagent/fromconfig/compiler.py:
  * cfg._config_dir 显式值 TRUMPS from_config 推断, 修复 agentpaas 把 config
    dump 到 workspace/config.yml 后, ./agents/X.yml 错相对 workspace 解析
    导致 [SubAgent X not found] (run_9aaf32f7aff4 / run_20260606_003521)。
  * enforceLoop 文档化 counter mode (legacy) vs sequence mode 两种语义。

- lambdagent/src/lambdagent/providers/claude_code_provider.py:
  * 加 _StallError + _run_with_idle_timeout: 子进程 stdout 静默 > N 秒
    抛 stall 而不是无限挂。配合 (1) 的 cancel.py 在 stall 时也能干净退出。

## 5. webui new page: AgentWorkspace

- webui/src/pages/AgentWorkspace.tsx (NEW, 666 lines)
  Run workspace 文件浏览器: 左侧 lazy tree, 右侧文件预览 (代码 / json / md),
  下载, gate result 高亮 (pass/fail/pending icons), refresh, expand/collapse。
  跟 (1) cancel + (2) reaper 配合: 运行中可以打开看 sub-agent 写到哪了,
  失败后能直接 inspect workspace artifacts 而不用 ssh。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kenny67nju 3 mesiacov pred
rodič
commit
094160e086

+ 9 - 5
agentexample/physics67/agents/analyst.yml

@@ -30,16 +30,20 @@ systemPrompt: |
      - `{workspace}/02_sim/data_manifest.json`
      - `{workspace}/02_sim/validation_report.json`
      - `{workspace}/02_sim/sim_report.md`
+     - `{workspace}/_gates/sim_gate_result.json`
   2. 创建 `{workspace}/03_analysis/`。
-  3. 写入 `{workspace}/03_analysis/analyze_results.py`,用它读取 02_sim 中的数据文件。
-  4. 执行:`cd {workspace} && python3 03_analysis/analyze_results.py`。
-  5. 若失败,读取错误、修复脚本并重试,最多 3 次。
-  6. 写入:
+  3. 如果 sim_gate_result.json 的 status="fail",不要做拟合、不要生成论文图。
+     只写 `analysis_results.json`,其中 status="fail",并在
+     `analysis_report.md` 中列出模拟失败原因,然后 terminate。
+  4. 写入 `{workspace}/03_analysis/analyze_results.py`,用它读取 02_sim 中的数据文件。
+  5. 执行:`cd {workspace} && python3 03_analysis/analyze_results.py`。
+  6. 若失败,读取错误、修复脚本并重试,最多 3 次。
+  7. 写入:
      - `{workspace}/03_analysis/analysis_results.json`
      - `{workspace}/03_analysis/analysis_report.md`
      - `{workspace}/03_analysis/figure_manifest.json`
      - 至少一张主图的 PDF + PNG
-  7. 读回 analysis_results.json 和 analysis_report.md,确认存在后 terminate。
+  8. 读回 analysis_results.json 和 analysis_report.md,确认存在后 terminate。
 
   ## 分析脚本要求
   `analyze_results.py` 必须:

+ 132 - 0
agentexample/physics67/agents/artifact-gate.yml

@@ -0,0 +1,132 @@
+# ════════════════════════════════════════
+# PhysGate — 产物验收门禁
+# ════════════════════════════════════════
+agentId: phys-artifact-gate
+name: PhysGate 产物验收
+description: >
+  物理研究流水线的硬门禁。按阶段检查当前 workspace 中的必要文件、
+  JSON 可解析性、状态字段和关键产物是否存在。输出 gate_result.json,
+  status=fail 时后续阶段不得继续写论文或审稿。
+
+type: react
+
+model:
+  provider: claude-code
+  name: sonnet
+  temperature: 0.0
+  maxTokens: 4096
+
+systemPrompt: |
+  你是 Physics67 的产物验收门禁。你不是研究员,不写论文,不做模拟。
+  你的唯一任务是检查当前阶段产物是否足以进入下一阶段。
+
+  ## 输入契约
+  你会收到 JSON:
+  {
+    "workspace": "<绝对路径>/cycle_N",
+    "cycle": N,
+    "stage": "plan|lit|sim|analysis|paper|review"
+  }
+
+  ## 强制工作流程
+  1. 解析输入,创建 `{workspace}/_gates/`。
+  2. 根据 stage 检查必需文件。
+  3. 用 Bash/Python 验证 JSON 文件能解析,必要列表非空,状态不是 fail。
+  4. 写入 `{workspace}/_gates/{stage}_gate_result.json`。
+  5. 读回该 JSON。如果 status="fail",最终输出必须包含精确字符串 `"status": "fail"`。
+     如果 status="ok" 或 "warn",最终输出必须包含精确字符串 `"status": "ok"` 或 `"status": "warn"`。
+
+  ## 阶段检查规则
+
+  ### plan
+  必需文件:
+  - `00_plan/idea_plan.md`
+  - `00_plan/idea_plan.json`
+  JSON 要求:
+  - `experiments` 是非空数组
+  - `model` 存在
+
+  ### lit
+  必需文件:
+  - `01_lit/lit_report.md`
+  - `01_lit/papers.json`
+  - `01_lit/paper.bib`
+  - `01_lit/claim_evidence_map.json`
+  JSON 要求:
+  - `papers.json` 至少 8 条
+
+  ### sim
+  必需文件:
+  - `02_sim/sim_results.json`
+  - `02_sim/data_manifest.json`
+  - `02_sim/validation_report.json`
+  - `02_sim/sim_report.md`
+  JSON 要求:
+  - `sim_results.json` 至少包含一个 experiment
+  - `data_manifest.json` 中 files 非空;每个列出的文件必须存在
+  - `validation_report.json` 中不能有 status="fail"
+
+  ### analysis
+  必需文件:
+  - `03_analysis/analysis_results.json`
+  - `03_analysis/analysis_report.md`
+  - `03_analysis/figure_manifest.json`
+  JSON 要求:
+  - `analysis_results.json.status` 不能是 "fail"
+  - `main_claims` 非空
+  - figures 或 figure_manifest 中至少一张 PDF/PNG 图存在
+
+  ### paper
+  必需文件:
+  - `04_paper/paper.tex`
+  - `04_paper/paper.bib`
+  - `04_paper/paper_summary.json`
+  - `04_paper/revision_response.md`
+  - `04_paper/compile_report.md`
+  JSON 要求:
+  - `paper_summary.json.status` 不能是 "fail"
+  - paper.tex 不能包含 `../../cycle_`,禁止引用旧轮次图
+  - 如果 `paper.pdf` 不存在,status 至少为 warn
+
+  ### review
+  必需文件:
+  - `05_review/review_report.md`
+  - `05_review/review_result.json`
+  - `05_review/revision_tasks.json`
+  JSON 要求:
+  - `review_result.json.score` 存在
+  - `revision_tasks.json.tasks` 存在
+
+  ## 输出 JSON 结构
+  ```json
+  {
+    "stage": "sim",
+    "cycle": 1,
+    "status": "ok|warn|fail",
+    "missing_files": [],
+    "json_errors": [],
+    "failed_checks": [],
+    "warnings": [],
+    "next_allowed": true
+  }
+  ```
+
+  ## 判定规则
+  - 缺必需文件、JSON 解析失败、status=fail、列出的数据/图不存在 → status="fail"。
+  - 有 validation warn、PDF 缺失但 tex/summary 存在 → status="warn"。
+  - 全部通过 → status="ok"。
+
+react:
+  maxSteps: 12
+  observationEnabled: true
+  toolTimeout: 120
+
+mcp:
+  localTools:
+    - ReadFile
+    - WriteFile
+    - ListFiles
+    - Bash
+    - terminate
+  policy:
+    mode: auto

+ 1 - 0
agentexample/physics67/agents/idea-planner.yml

@@ -33,6 +33,7 @@ systemPrompt: |
   2. 如果 prev_workspace 非空,读取:
      - `{prev_workspace}/05_review/review_result.json`
      - `{prev_workspace}/05_review/review_report.md`
+     - `{prev_workspace}/05_review/revision_tasks.json`
      - `{prev_workspace}/00_plan/idea_plan.json`
      用它们确定本轮需要修订的模型、实验或论证。
   3. 解析 research_question,明确物理系统、哈密顿量、参数、可观测量、目标结论。

+ 7 - 0
agentexample/physics67/agents/reviewer.yml

@@ -36,6 +36,11 @@ systemPrompt: |
      - `{workspace}/04_paper/paper_summary.json`
      - `{workspace}/04_paper/revision_response.md`
      - `{workspace}/04_paper/compile_report.md`
+     - `{workspace}/_gates/plan_gate_result.json`
+     - `{workspace}/_gates/lit_gate_result.json`
+     - `{workspace}/_gates/sim_gate_result.json`
+     - `{workspace}/_gates/analysis_gate_result.json`
+     - `{workspace}/_gates/paper_gate_result.json`
   2. 创建 `{workspace}/05_review/`。
   3. 检查论文中的主要 claim 是否都能追溯到 analysis_results 或文献证据。
   4. 检查 validation/analysis 中的 warn/fail 是否在论文中诚实披露。
@@ -126,6 +131,8 @@ systemPrompt: |
   - 不因 PDF 编译失败自动否定科学内容,但必须在质量/完整性中扣分。
   - 如果论文 claim 没有数据或文献支撑,必须列为 major revision。
   - 如果核心验证失败且论文仍强结论,必须列为 fatal_flaw。
+  - 如果任一 gate status="fail",recommendation 必须是 reject 或 major_revision,
+    并在 revision_tasks.json 中把对应 owner 列为必须修复。
   - score 只是评估;总指挥会跑满三轮,不要要求提前停止。
 
 react:

+ 17 - 6
agentexample/physics67/agents/writer.yml

@@ -35,27 +35,38 @@ systemPrompt: |
      - `{workspace}/03_analysis/analysis_report.md`
      - `{workspace}/03_analysis/analysis_results.json`
      - `{workspace}/03_analysis/figure_manifest.json`
+     - `{workspace}/_gates/plan_gate_result.json`
+     - `{workspace}/_gates/lit_gate_result.json`
+     - `{workspace}/_gates/sim_gate_result.json`
+     - `{workspace}/_gates/analysis_gate_result.json`
   2. 如果 prev_workspace 非空,读取:
      - `{prev_workspace}/05_review/review_result.json`
      - `{prev_workspace}/05_review/revision_tasks.json`
      - `{prev_workspace}/05_review/review_report.md`
   3. 创建 `{workspace}/04_paper/`。
-  4. 写入:
+  4. 如果任何上游 gate 的 status="fail",或 analysis_results.json 的
+     status="fail",禁止撰写投稿论文。只写:
+     - `{workspace}/04_paper/paper_summary.json`,status="fail"
+     - `{workspace}/04_paper/revision_response.md`
+     - `{workspace}/04_paper/compile_report.md`,说明未编译原因
+     然后 terminate。
+  5. 写入:
      - `{workspace}/04_paper/paper.tex`
      - `{workspace}/04_paper/paper.bib`
      - `{workspace}/04_paper/paper_summary.json`
      - `{workspace}/04_paper/revision_response.md`
-  5. 编译英文 PDF。成功后必须确认 `paper.pdf` 存在。
-  6. 生成完整中文 LaTeX:`paper_zh.tex`。中文正文必须完整翻译,不能只翻译标题。
-  7. 用 xelatex 或 latexmk 编译 `paper_zh.pdf`。如果环境缺少 CJK/TeX,必须记录失败原因到 `compile_report.md`,但仍保留 `paper_zh.tex`。
-  8. 写入 `{workspace}/04_paper/compile_report.md`,列出成功/失败命令和产物大小。
-  9. 读回 `paper_summary.json` 和 `compile_report.md` 后 terminate。
+  6. 编译英文 PDF。成功后必须确认 `paper.pdf` 存在。
+  7. 生成完整中文 LaTeX:`paper_zh.tex`。中文正文必须完整翻译,不能只翻译标题。
+  8. 用 xelatex 或 latexmk 编译 `paper_zh.pdf`。如果环境缺少 CJK/TeX,必须记录失败原因到 `compile_report.md`,但仍保留 `paper_zh.tex`。
+  9. 写入 `{workspace}/04_paper/compile_report.md`,列出成功/失败命令和产物大小。
+  10. 读回 `paper_summary.json` 和 `compile_report.md` 后 terminate。
 
   ## 写作依据
   - 只使用 analysis_results.json 中的主结论和数值。
   - 只引用 papers.json / paper.bib 中真实存在的文献。
   - claim_evidence_map.json 中证据弱的论点必须用保守语气。
   - validation_report 或 analysis_results 中 status=warn/fail 的项目必须在 Discussion 或 Limitations 中说明。
+  - paper.tex 不能包含 `../../cycle_`,禁止引用旧轮次图表或数据。
 
   ## LaTeX 要求
   - PRL/PRB 优先用 revtex4-2;如果本机缺少 revtex,降级到 article 并在 compile_report.md 说明。

+ 67 - 19
agentexample/physics67/orchestrator.yml

@@ -4,15 +4,16 @@
 #
 # Lambda 语义:
 #   λidea. Loop_N=3(
-#     PhysIdea  >>
-#     PhysLit   >>
-#     PhysSim   >>
-#     PhysAnalyst >>
-#     PhysWriter >>
-#     PhysReview
+#     PhysIdea     >> PhysGatePlan >>
+#     PhysLit      >> PhysGateLit >>
+#     PhysSim      >> PhysGateSim >>
+#     PhysAnalyst  >> PhysGateAnalysis >>
+#     PhysWriter   >> PhysGatePaper >>
+#     PhysReview   >> PhysGateReview
 #   )
 #
-# 每轮调用全部 6 个 sub-agent;3 轮跑完后 terminate。
+# 每轮调用全部 6 个 sub-agent,并在每个阶段后进行产物验收;
+# 3 轮跑完后 terminate。
 # 每一轮使用独立 workspace: {base_workspace}/cycle_{n}。
 
 agentId: physics67-orchestrator
@@ -37,10 +38,19 @@ systemPrompt: |
   你是物理研究流水线的路由器。你不是执行器;每次只输出一行 JSON 工具调用。
 
   ## 固定目标
-  跑满 3 轮 × 6 步 = 18 个 sub-agent 调用后再 terminate。
+  跑满 3 轮 × 12 步 = 6 个 sub-agent + 6 次 gate 验收后再 terminate。
 
   每轮固定顺序:
-  call_physidea → call_physlit → call_physsim → call_physanalyst → call_physwriter → call_physreview
+  call_physidea → call_physgate_plan →
+  call_physlit → call_physgate_lit →
+  call_physsim → call_physgate_sim →
+  call_physanalyst → call_physgate_analysis →
+  call_physwriter → call_physgate_paper →
+  call_physreview → call_physgate_review
+
+  如果任何 gate 返回 `"status": "fail"`,不要进入下一阶段。
+  必须回到对应 owner 阶段重跑或修复。例如 sim gate fail → call_physsim;
+  analysis gate fail → call_physanalyst;paper gate fail → call_physwriter。
 
   ## 轮次和工作区
   - base_workspace: 用户指定的绝对路径;如果用户未给出,使用当前项目下 `physics_runs/{安全化标题}`。
@@ -58,8 +68,12 @@ systemPrompt: |
     "target_venue": "PRL"
   }
 
+  每次调用 gate 工具时必须传入:
+  {"workspace":"<绝对路径>/cycle_N","cycle":N,"stage":"plan|lit|sim|analysis|paper|review"}
+
   ## 如何判断进度
-  数输入里 `[Step K] call_physreview done` 出现的次数 = 已完成轮数。
+  数输入里完整出现 `call_physreview done` 后再出现 `call_physgate_review done`
+  的次数 = 已完成轮数。若不确定,继续按固定顺序推进,不要 terminate。
   - 0 次:第 1 轮
   - 1 次:第 2 轮
   - 2 次:第 3 轮
@@ -67,14 +81,21 @@ systemPrompt: |
 
   看最后一条 `[Step K] X done`:
   - 没有任何 done → call_physidea
-  - X = call_physreview 且已完成 < 3 轮 → 下一轮 call_physidea
-  - X = call_physreview 且已完成 = 3 轮 → terminate
+  - X = call_physidea → call_physgate_plan
+  - X = call_physlit → call_physgate_lit
+  - X = call_physsim → call_physgate_sim
+  - X = call_physanalyst → call_physgate_analysis
+  - X = call_physwriter → call_physgate_paper
+  - X = call_physreview → call_physgate_review
+  - X = call_physgate_review 且已完成 < 3 轮 → 下一轮 call_physidea
+  - X = call_physgate_review 且已完成 = 3 轮 → terminate
   - 其他 X → 按固定顺序调用下一个 sub-agent
 
   ## 禁止
   - 禁止直接调用 ReadFile、WriteFile、Bash、ListFiles。
   - 禁止自己写论文、写代码、搜索文献或审稿。
   - 禁止因为 score >= 7 提前终止;score 只作为下一轮修订信号。
+  - 禁止跳过 gate;每个阶段产物必须先验收再进入下一阶段。
   - 禁止输出 markdown、注释、解释、思考过程。
 
   ## 输出格式
@@ -90,7 +111,7 @@ react:
   toolTimeout: 1200
   verbose: true
   # Engine-level hard floor (sequence mode): terminate is REJECTED until
-  # the full 6-tool sequence has fired 3 times, in order. Counter mode
+  # the full stage+gate sequence has fired 3 times. Counter mode
   # (`tool` + `minCount`) was gamed in run_ba0d79701903 — orchestrator
   # learned to spam call_physreview 3× without ever calling sim/analyst/
   # writer. Sequence mode requires each prereq tool to actually appear
@@ -98,11 +119,17 @@ react:
   enforceLoop:
     sequence:
       - call_physidea
+      - call_physgate_plan
       - call_physlit
+      - call_physgate_lit
       - call_physsim
+      - call_physgate_sim
       - call_physanalyst
+      - call_physgate_analysis
       - call_physwriter
+      - call_physgate_paper
       - call_physreview
+      - call_physgate_review
     minCycles: 3
 
 memory:
@@ -120,17 +147,20 @@ mcp:
     - call_physanalyst
     - call_physwriter
     - call_physreview
+    - call_physgate_plan
+    - call_physgate_lit
+    - call_physgate_sim
+    - call_physgate_analysis
+    - call_physgate_paper
+    - call_physgate_review
     - terminate
   policy:
     mode: auto
     maxConcurrent: 1
 
-# NOTE: compiler.py at line 249 looks for `subAgents` (camelCase) ONLY.
-# Using `sub_agents` (snake_case) here silently falls back to placeholder
-# stub tools that return "[local:call_physidea](...)" without invoking
-# anything — observed in run_40aab2706a1a where 18 sub-agent "calls"
-# produced an empty workspace. Likewise the inner key must be `tool`
-# (not `tool_name`).
+# NOTE: current compiler accepts both `subAgents` and `sub_agents`, but this
+# config uses the canonical camelCase form. The inner key must be `tool`
+# (not `tool_name`) so the desired public tool name is registered.
 subAgents:
   physidea:
     config: ./agents/idea-planner.yml
@@ -150,6 +180,24 @@ subAgents:
   physreview:
     config: ./agents/reviewer.yml
     tool: call_physreview
+  physgate_plan:
+    config: ./agents/artifact-gate.yml
+    tool: call_physgate_plan
+  physgate_lit:
+    config: ./agents/artifact-gate.yml
+    tool: call_physgate_lit
+  physgate_sim:
+    config: ./agents/artifact-gate.yml
+    tool: call_physgate_sim
+  physgate_analysis:
+    config: ./agents/artifact-gate.yml
+    tool: call_physgate_analysis
+  physgate_paper:
+    config: ./agents/artifact-gate.yml
+    tool: call_physgate_paper
+  physgate_review:
+    config: ./agents/artifact-gate.yml
+    tool: call_physgate_review
 
 runtime:
   engine: cek

+ 7 - 0
agentpaas/api/app.py

@@ -131,6 +131,13 @@ app.include_router(knowledge_router.router, prefix="/api/v1")
 @app.on_event("startup")
 async def _on_startup():
     """Start background services on server startup."""
+    # Eagerly construct the singleton Database so the zombie-reaper migration
+    # (Database.__init__) runs NOW instead of on the first authenticated
+    # request. Otherwise a 'running' kb_job from a prior crashed backend
+    # keeps showing as in-flight in the UI until someone happens to hit a
+    # DB-touching endpoint.
+    from agentpaas.db.session import get_db
+    get_db()
     knowledge_router._start_schedule_checker()
 
 

+ 406 - 10
agentpaas/api/v1/agents.py

@@ -23,6 +23,7 @@ from agentpaas.api.deps import get_tenant, get_database
 from agentpaas.api.errors import api_error
 from agentpaas.api.middleware.auth import TenantContext
 from agentpaas.db.models import Database, gen_id, now_utc
+from agentpaas.observability.logging import logger
 from agentpaas.tenant.rbac import require_permission
 from agentpaas.tenant.quota import check_concurrency, check_and_reserve
 
@@ -58,6 +59,15 @@ class RunRequest(BaseModel):
     input: str = Field(..., max_length=102400)  # 100KB max
     parameters: Dict[str, Any] = Field(default_factory=dict)
     context: Dict[str, Any] = Field(default_factory=dict)
+    # Continue-run UI modes — see `_apply_mode_prompt()` below.
+    # iterate: full pipeline (default, unchanged behavior)
+    # edit:    bypass orchestrator's free decision, call target_subagent once
+    # chat:    read workspace, answer question, never invoke sub-agents
+    mode: str = Field(default="iterate", description="iterate | edit | chat")
+    target_subagent: str = Field(
+        default="",
+        description="Required for mode=edit: name of the sub-agent to invoke (e.g. 'call_physwriter')",
+    )
 
 class RollbackRequest(BaseModel):
     target_version: int
@@ -363,11 +373,17 @@ async def run_agent(
         if kb_ctx:
             enriched_input = f"{kb_ctx}\n\n[用户问题]\n{req.input}"
 
+    # Continue-run not currently wired on the sync /run endpoint — only
+    # /run/stream supports it. Initialize empty so _execute_agent's
+    # kwarg-pass below doesn't NameError.
+    continue_workspace = ""
+
     # Execute via lambdagent
     t0 = time.time()
     try:
         result, trace_info = _execute_agent(
             config, enriched_input, agent_dir=agent_dir, run_id=run_id,
+            continue_workspace=continue_workspace,
         )
         duration_ms = int((time.time() - t0) * 1000)
         workspace_path = trace_info.get("workspace_path", "")
@@ -466,6 +482,139 @@ async def run_agent_stream(
         if kb_ctx:
             stream_input = f"{kb_ctx}\n\n[用户问题]\n{req.input}"
 
+    # Default empty — the continue-run injection block (when present) sets
+    # this to the previous run's workspace. Initialized here so the inner
+    # _run() closure doesn't NameError when no continue context is sent.
+    continue_workspace = ""
+
+    # ── Continue-run context injection ──
+    # When the UI sends context.run_id, the user clicked "继续此 run". We
+    # look up the previous run's workspace, point _execute_agent at it so
+    # cycle dirs are shared, compute the next cycle number, and inject a
+    # prompt prefix telling the orchestrator the workspace path + cycle to
+    # use. Skipped silently if the prior run / workspace can't be found.
+    ctx_run_id = (req.context or {}).get("run_id", "")
+    if ctx_run_id:
+        logger.info("continue-run requested: ctx_run_id=%s agent=%s", ctx_run_id, agent_id)
+        prev = db.fetchone(
+            "SELECT workspace_path, input FROM runs WHERE id = ? AND agent_id = ?",
+            (ctx_run_id, agent_id),
+        )
+        if prev and prev.get("workspace_path"):
+            wp = prev["workspace_path"]
+            prev_input = (prev.get("input") or "")[:200]
+            continue_workspace = wp
+            logger.info("continue-run injecting workspace: %s", wp)
+
+            # Discover existing cycle_N dirs so we can name the next one.
+            import os as _os, re as _re
+            next_cycle = 1
+            try:
+                existing = [
+                    int(m.group(1))
+                    for n in _os.listdir(wp)
+                    if (m := _re.match(r"^cycle_(\d+)$", n))
+                    and _os.path.isdir(_os.path.join(wp, n))
+                ]
+                if existing:
+                    next_cycle = max(existing) + 1
+            except OSError:
+                pass
+            next_workspace = _os.path.join(wp, f"cycle_{next_cycle}")
+            prev_cycle_path = (
+                _os.path.join(wp, f"cycle_{next_cycle - 1}")
+                if next_cycle > 1 else ""
+            )
+
+            stream_input = (
+                f"[本次执行是【上一次的延续】 — 工作目录已复用,禁止新建顶层目录]\n"
+                f"工作根目录(不要修改不要新建):{wp}\n"
+                f"本次必须使用的工作区(第 {next_cycle} 轮的隔离目录):\n"
+                f"  workspace = {next_workspace}\n"
+                f"上一轮的目录(读取上下文用):\n"
+                f"  prev_workspace = {prev_cycle_path}\n"
+                f"  cycle = {next_cycle}\n"
+                f"\n"
+                f"上一次输入摘要(仅供参考):{prev_input}\n"
+                f"\n"
+                f"⚠️ 调用 sub-agent 时输入 JSON 里的 workspace/prev_workspace/cycle 字段"
+                f"必须严格使用上面给的 3 个值,**不要凭直觉改名**"
+                f"(例如不要加 _revision、_v2、_new 等后缀)。\n"
+                f"⚠️ 不要因为'看到已有 cycle_*' 就直接 terminate;本次有【新的指令】,"
+                f"必须按指令调用相关 sub-agent。\n"
+                f"\n"
+                f"[本次新指令]\n{stream_input}"
+            )
+
+            # The 18-step enforceLoop floor is for fresh runs only — drop
+            # it on continues so a "fix the references" follow-up doesn't
+            # force a full pipeline rerun.
+            if isinstance(config.get("react"), dict) and config["react"].get("enforceLoop"):
+                config["react"] = {**config["react"]}
+                config["react"].pop("enforceLoop", None)
+                logger.info("continue-run: enforceLoop disabled for follow-up turn")
+        else:
+            logger.warning("continue-run prev run not found or has no workspace: %s", ctx_run_id)
+
+    # ── Mode-specific prompt + config tweaks (Chat/Edit/Iterate) ──
+    # These are LOCAL overrides applied to the in-memory config dict for
+    # this single run, never persisted. iterate is a no-op.
+    mode = (req.mode or "iterate").lower()
+    target_sub = (req.target_subagent or "").strip()
+    if mode == "edit":
+        if not target_sub:
+            api_error(400, "EDIT_REQUIRES_TARGET",
+                      "mode=edit requires target_subagent (e.g. 'call_physwriter')")
+        stream_input = (
+            f"[MODE=EDIT — 只调用一次 sub-agent,然后立刻 terminate]\n"
+            f"目标 sub-agent:{target_sub}\n"
+            f"⚠️ 严格规则:\n"
+            f"  - 只输出一个 JSON 调用 {target_sub},input 字段照搬用户指令。\n"
+            f"  - 调用完成后立刻 terminate,不要补凑其他 sub-agent。\n"
+            f"  - 禁止全 pipeline、禁止循环、禁止 enforceLoop 的 18 步硬卡。\n"
+            f"\n[用户指令]\n{stream_input}"
+        )
+        # Disable any enforceLoop floor for this run — Edit mode should
+        # terminate after a single sub-agent call.
+        if isinstance(config.get("react"), dict):
+            config["react"] = {**config["react"]}
+            config["react"].pop("enforceLoop", None)
+    elif mode == "chat":
+        stream_input = (
+            f"[MODE=CHAT — 不调用任何 sub-agent,直接回答用户问题]\n"
+            f"工作目录(可读):{continue_workspace or '(无 continue 上下文)'}\n"
+            f"⚠️ 严格规则:\n"
+            f"  - 你可以用 ListFiles / ReadFile / Bash 查看 workspace 文件。\n"
+            f"  - **禁止**调用任何 call_phys* sub-agent。\n"
+            f"  - **禁止**修改任何文件(不要用 WriteFile/EditFile)。\n"
+            f"  - 看够文件理解上下文后,立刻输出 terminate,"
+            f"summary 字段写完整答案给用户。\n"
+            f"\n[用户问题]\n{stream_input}"
+        )
+        # Temporarily expand localTools so the model can read workspace
+        # without going through a sub-agent. We don't add Write/Edit —
+        # chat mode is read-only.
+        if isinstance(config.get("mcp"), dict):
+            config["mcp"] = {**config["mcp"]}
+            old_tools = list(config["mcp"].get("localTools", []))
+            chat_tools = []
+            for t in old_tools:
+                # Drop call_phys* sub-agents — chat shouldn't route through them.
+                if not t.startswith("call_"):
+                    chat_tools.append(t)
+            # Add read-only tools if not already there.
+            for t in ("ReadFile", "ListFiles", "Bash"):
+                if t not in chat_tools:
+                    chat_tools.append(t)
+            if "terminate" not in chat_tools:
+                chat_tools.append("terminate")
+            config["mcp"]["localTools"] = chat_tools
+        # Always drop enforceLoop in chat — terminate after first useful answer.
+        if isinstance(config.get("react"), dict):
+            config["react"] = {**config["react"]}
+            config["react"].pop("enforceLoop", None)
+    # mode == "iterate" or unknown → no changes, current behavior
+
     # Create run record before streaming starts
     run_id = gen_id("run_")
     now = now_utc()
@@ -483,31 +632,134 @@ async def run_agent_stream(
 
     def generate():
         import threading
+        from lambdagent.agentruntime import cancel as _cancel
+
+        # Emit run_id to the client immediately so the Stop button can call
+        # /runs/<id>/cancel without having to wait for the final 'done' event.
+        # Without this the user might click Stop before any other event lands
+        # and the frontend wouldn't know which run to cancel.
+        yield f"event: started\ndata: {json.dumps({'run_id': run_id}, ensure_ascii=False)}\n\n"
+
+        def _persist_subprocess_pid(pid):
+            """Hook fired by cancel.bind_proc/unbind_proc — write the live
+            claude pid to runs.subprocess_pid so a *future* backend (post
+            restart) can still kill the orphan subprocess via os.kill().
+            Cleared back to NULL on unbind / run end."""
+            try:
+                db.execute(
+                    "UPDATE runs SET subprocess_pid=? WHERE id=?",
+                    (pid, run_id),
+                )
+                db.commit()
+            except Exception:
+                # Persisting pid is best-effort. A failure here doesn't
+                # break the in-process cancel path, only the restart-
+                # resilient fallback.
+                pass
 
         def _run():
+            # Register this run with the cancel registry so a POST to the
+            # cancel endpoint can reach in, kill the active claude subprocess,
+            # and unwind the engine via CancelledRun. Without this the only
+            # way to stop a wedged run is pkill + manual DB update.
+            #
+            # The persist hook closes the second hole: cross-backend-restart
+            # cancellation. Without it, killing an orphan subprocess after
+            # the backend that started it has died requires looking up its
+            # pid via lsof / pgrep and `kill -9` by hand.
+            _cancel.begin_run(run_id, on_proc_change=_persist_subprocess_pid)
             try:
                 result, trace_info = _execute_agent(
                     config, stream_input, on_step=event_queue.put,
                     agent_dir=agent_dir, run_id=run_id,
+                    continue_workspace=continue_workspace,
                 )
                 duration_ms = int((time.time() - t0) * 1000)
                 workspace_path = trace_info.get("workspace_path", "")
-                db.execute(
-                    "UPDATE runs SET status='completed', output=?, duration_ms=?, "
-                    "input_tokens=?, output_tokens=?, steps=?, workspace_path=?, trace_json=?, completed_at=? WHERE id=?",
-                    (str(result), duration_ms,
-                     trace_info.get("input_tokens", 0), trace_info.get("output_tokens", 0),
-                     trace_info.get("steps", 0), workspace_path,
-                     trace_info.get("trace_json", "[]"), now_utc(), run_id)
+
+                # ── status semantic correction ──
+                # _execute_agent returns "successfully" even when every
+                # claude call stalled — the engine catches ProviderError,
+                # records the error message as the "thought", and lets
+                # terminate fire because no tool was selected. From the
+                # user's POV the run produced nothing usable. Mark such
+                # cases as 'failed' so the UI doesn't show a green
+                # "completed" badge for runs where the model literally
+                # said "[CLAUDE-CODE_ERROR] stalled".
+                final = str(result)
+                _ERROR_PREFIXES = (
+                    "[CLAUDE-CODE_ERROR]",
+                    "[STALL]",
+                    "[ProviderError]",
+                    "[ERROR]",
+                )
+                output_is_error = any(
+                    final.lstrip().startswith(p) for p in _ERROR_PREFIXES
                 )
+                # Scan trace entries for evidence of any successful tool
+                # call ("[Step K] X done" — see _compress_state in the
+                # engine). If even one tool completed, we treat the run
+                # as partial success and keep status='completed'.
+                trace_json = trace_info.get("trace_json", "[]")
+                made_progress = False
+                try:
+                    import re as _re
+                    for entry in json.loads(trace_json) or []:
+                        out = str(entry.get("output", ""))
+                        if _re.search(r"\[Step \d+\] [^\n]*\bdone\b", out):
+                            made_progress = True
+                            break
+                except Exception:
+                    pass
+
+                if output_is_error and not made_progress:
+                    final_status = "failed"
+                    err_payload = json.dumps(
+                        {"code": "AGENT_FAILED_NO_PROGRESS",
+                         "message": final[:500]},
+                        ensure_ascii=False,
+                    )
+                    db.execute(
+                        "UPDATE runs SET status='failed', output=?, error=?, duration_ms=?, "
+                        "input_tokens=?, output_tokens=?, steps=?, workspace_path=?, "
+                        "trace_json=?, completed_at=? WHERE id=?",
+                        (final, err_payload, duration_ms,
+                         trace_info.get("input_tokens", 0),
+                         trace_info.get("output_tokens", 0),
+                         trace_info.get("steps", 0), workspace_path,
+                         trace_json, now_utc(), run_id),
+                    )
+                else:
+                    final_status = "completed"
+                    db.execute(
+                        "UPDATE runs SET status='completed', output=?, duration_ms=?, "
+                        "input_tokens=?, output_tokens=?, steps=?, workspace_path=?, "
+                        "trace_json=?, completed_at=? WHERE id=?",
+                        (final, duration_ms,
+                         trace_info.get("input_tokens", 0),
+                         trace_info.get("output_tokens", 0),
+                         trace_info.get("steps", 0), workspace_path,
+                         trace_json, now_utc(), run_id),
+                    )
                 db.commit()
                 event_queue.put({"event": "done", "data": {
                     "run_id": run_id,
-                    "status": "completed", "output": str(result),
+                    "status": final_status, "output": final,
                     "steps": trace_info.get("steps", 0),
                     "total_tokens": trace_info.get("total_tokens", 0),
                     "workspace_path": workspace_path,
                 }})
+            except _cancel.CancelledRun:
+                # User-requested stop. Run was already marked cancel_requested=1
+                # by the /cancel endpoint; transition status to 'cancelled'.
+                duration_ms = int((time.time() - t0) * 1000)
+                db.execute(
+                    "UPDATE runs SET status='cancelled', duration_ms=?, completed_at=? WHERE id=?",
+                    (duration_ms, now_utc(), run_id)
+                )
+                db.commit()
+                event_queue.put({"event": "cancelled", "data": {"run_id": run_id}})
+                event_queue.put({"event": "done", "data": {"run_id": run_id, "status": "cancelled"}})
             except Exception as e:
                 duration_ms = int((time.time() - t0) * 1000)
                 db.execute(
@@ -518,6 +770,8 @@ async def run_agent_stream(
                 db.commit()
                 event_queue.put({"event": "error", "data": {"message": str(e)}})
                 event_queue.put({"event": "done", "data": {"run_id": run_id, "status": "failed"}})
+            finally:
+                _cancel.end_run(run_id)
             event_queue.put(None)  # sentinel
 
         threading.Thread(target=_run, daemon=True).start()
@@ -604,6 +858,98 @@ async def record_run(
     }
 
 
+# ── Run Cancellation ──
+
+@router.post("/{agent_id}/runs/{run_id}/cancel")
+async def cancel_run(
+    agent_id: str,
+    run_id: str,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    """Request cancellation of a running run.
+
+    Sets `runs.cancel_requested=1` and signals the in-process cancel
+    registry. The agent's background thread polls cancel state both
+    before each ReAct step and inside the claude subprocess polling
+    loop, so latency from click → kill is typically <1s.
+
+    Returns 200 even if the run already finished (idempotent). The
+    response includes `was_running` so the UI can distinguish
+    "successfully signaled" from "already gone".
+    """
+    run = db.fetchone(
+        "SELECT id, status FROM runs WHERE id = ? AND agent_id = ? AND tenant_id = ?",
+        (run_id, agent_id, tenant.tenant_id),
+    )
+    if not run:
+        api_error(404, "RUN_NOT_FOUND", f"Run {run_id} not found")
+
+    # Mark intent in DB unconditionally (cheap, idempotent).
+    db.execute(
+        "UPDATE runs SET cancel_requested = 1 WHERE id = ?",
+        (run_id,),
+    )
+    db.commit()
+
+    # Signal the in-process registry — kills the active claude subprocess
+    # immediately. No-op if the run isn't in this process's registry (e.g.
+    # already finished, or running on another worker, or started by a
+    # previous backend instance that has since restarted).
+    from lambdagent.agentruntime import cancel as _cancel
+    was_running = _cancel.request_cancel(run_id)
+    killed_via_pid = False
+
+    # Restart-resilient fallback: if the in-memory registry didn't know
+    # about this run but a subprocess_pid is persisted, the subprocess
+    # is almost certainly an orphan (its launching backend died). Kill
+    # it directly by pid. This closes the "restart loses cancel reach"
+    # gap exposed by run_5cbbb1d1dc54.
+    if not was_running:
+        pid_row = db.fetchone(
+            "SELECT subprocess_pid FROM runs WHERE id=?", (run_id,)
+        )
+        pid = (pid_row or {}).get("subprocess_pid")
+        if pid:
+            import os as _os
+            import signal as _signal
+            try:
+                _os.kill(int(pid), _signal.SIGKILL)
+                killed_via_pid = True
+                # Clear the stale pid + mark cancelled. The original _run
+                # thread is gone (its backend died), so no one else will
+                # do this for us.
+                db.execute(
+                    "UPDATE runs SET subprocess_pid=NULL, status='cancelled', "
+                    "completed_at=strftime('%Y-%m-%dT%H:%M:%S+00:00','now'), "
+                    "error=COALESCE(NULLIF(error,''),'') || "
+                    "  '[orphan killed by pid fallback after backend restart]' "
+                    "WHERE id=? AND status='running'",
+                    (run_id,),
+                )
+                db.commit()
+            except ProcessLookupError:
+                # Already dead, just clear the stale pid.
+                db.execute(
+                    "UPDATE runs SET subprocess_pid=NULL WHERE id=?",
+                    (run_id,),
+                )
+                db.commit()
+            except (PermissionError, ValueError):
+                # Wrong user / corrupt pid — give up gracefully.
+                pass
+
+    return {
+        "run_id": run_id,
+        "status": run["status"],
+        "cancel_requested": True,
+        "was_running": was_running,
+        # NEW: tells the UI we found + killed an orphan even though the
+        # in-process registry was empty. Useful for telemetry.
+        "killed_via_pid": killed_via_pid,
+    }
+
+
 # ── Runs History ──
 
 @router.get("/{agent_id}/runs")
@@ -988,7 +1334,8 @@ def _compile_agent(config: dict):
 
 
 def _execute_agent(config: dict, input_text: str, on_step=None,
-                   agent_dir: str = "", run_id: str = ""):
+                   agent_dir: str = "", run_id: str = "",
+                   continue_workspace: str = ""):
     """Execute agent via lambdagent. Returns (result, trace_info).
 
     Args:
@@ -997,15 +1344,64 @@ def _execute_agent(config: dict, input_text: str, on_step=None,
         on_step: Optional callback for streaming.
         agent_dir: Agent directory path — if set, creates workspace/run_{timestamp}/.
         run_id: Run ID for workspace metadata.
+        continue_workspace: If set, reuse this existing workspace dir directly
+            instead of creating a fresh one. Used when the user clicks
+            "continue this run" — sub-agents see prior cycle_1/cycle_2/...
+            files via the normal {workspace} path, not via prompt indirection.
     """
     import yaml, os
     from agentpaas.engine.sandbox import create_run_workspace, save_run_artifacts
 
     # 创建 run workspace(如果有 agent_dir)
     workspace_path = ""
-    if agent_dir:
+    if continue_workspace and os.path.isdir(continue_workspace):
+        # Reuse the previous run's workspace dir. Don't call
+        # create_run_workspace — it'd build a new timestamped sibling and
+        # the sub-agents would write into an empty tree, exactly the
+        # confusing behavior the user reported. We still drop a small
+        # marker file recording which run_id is iterating in here.
+        workspace_path = os.path.abspath(continue_workspace)
+        # CRITICAL: overwrite the stale config.yml in the reused workspace.
+        # Otherwise from_config(workspace/config.yml) below picks up the
+        # config snapshot from the ORIGINAL run, missing any DB-level edits
+        # made since (toolTimeout bumps, enforceLoop tweaks, etc.).
+        # Observed once with toolTimeout still 600 after we'd bumped to 1200.
+        try:
+            with open(os.path.join(workspace_path, "config.yml"), "w", encoding="utf-8") as f:
+                yaml.dump(config, f, allow_unicode=True, default_flow_style=False)
+        except Exception as _e:
+            logger.warning("could not refresh config.yml in reused workspace: %s", _e)
+        try:
+            with open(os.path.join(workspace_path, f"continue_{run_id}.json"), "w", encoding="utf-8") as f:
+                import json as _json, time as _time
+                _json.dump({
+                    "run_id": run_id,
+                    "input": input_text,
+                    "timestamp": _time.strftime("%Y%m%d_%H%M%S"),
+                    "mode": "continue",
+                }, f, ensure_ascii=False, indent=2)
+        except Exception as _e:
+            logger.warning("could not write continue marker: %s", _e)
+        logger.info("reusing workspace for continue-run: %s", workspace_path)
+    elif agent_dir:
         workspace_path = create_run_workspace(agent_dir, run_id, input_text, config)
 
+    # CRITICAL: align the Bash tool's session CWD with this run's workspace.
+    # shell_tools._session_cwd is a *module-level* global captured at import
+    # time (= the backend's startup CWD, typically the repo root). Without
+    # this override every `{"tool":"Bash","input":{"command":"mkdir foo"}}`
+    # creates files under the repo, not the run workspace. This is process-
+    # global state — concurrent runs in the same process would race on it —
+    # which is acceptable for the current single-tenant deployment but worth
+    # revisiting (thread-local _session_cwd) before enabling parallel runs.
+    if workspace_path:
+        try:
+            from lambdagent.builtin_tools.shell_tools import _set_cwd as _shell_set_cwd
+            _shell_set_cwd(workspace_path)
+            logger.info("aligned shell_tools session CWD to workspace: %s", workspace_path)
+        except Exception as _e:
+            logger.warning("failed to align shell session CWD: %s", _e)
+
     # 配置写入 workspace 或 /tmp
     if workspace_path:
         config_path = os.path.join(workspace_path, "config.yml")

+ 42 - 10
agentpaas/api/v1/knowledge.py

@@ -512,19 +512,48 @@ def _find_scripts_dir(kb_path: Path) -> Optional[Path]:
     return None
 
 
-def _get_index_status(kb_root: str) -> Dict[str, Any]:
-    """Read index file presence/stats from kb_root."""
+# Subdirs to consult when the index files aren't found directly at root.
+# Index-build scripts conventionally drop their output one level below the
+# raw-data root (so the KB root_dir can stay focused on source content).
+# `KnowledgeSpace` is the canonical name; the others are historical aliases
+# kept for robustness with older deployments.
+_INDEX_OUTPUT_SUBDIRS = ("KnowledgeSpace", "knowledge_space", ".index", "index")
+
+
+def _resolve_index_dir(kb_root: str, marker: str) -> Path:
+    """Return the path where `marker` (e.g. 'rag_index.json' or 'wiki') is
+    actually present, preferring the KB's root_dir but falling back to known
+    subdirs where build scripts conventionally write output."""
     root = Path(kb_root)
-    bm25_file = root / "rag_index.json"
-    vector_file = root / "rag_vectors_v2.npy"
-    wiki_dir = root / "wiki"
+    direct = root / marker
+    if direct.exists():
+        return root
+    for sub in _INDEX_OUTPUT_SUBDIRS:
+        cand = root / sub
+        if (cand / marker).exists():
+            return cand
+    # Nothing found — return root anyway so callers see "exists: false".
+    return root
+
+
+def _get_index_status(kb_root: str) -> Dict[str, Any]:
+    """Read index file presence/stats. Indexes may live at kb_root or one
+    level below in a conventional output subdir (e.g. KnowledgeSpace/).
+    Each artifact is resolved independently — a deployment that has BM25 at
+    root but wiki under KnowledgeSpace/ works fine."""
 
     def _fstat(p: Path) -> Optional[Dict]:
         if p.exists():
             s = p.stat()
-            return {"exists": True, "size": s.st_size, "mtime": s.st_mtime}
+            return {"exists": True, "size": s.st_size, "mtime": s.st_mtime,
+                    "path": str(p)}
         return {"exists": False}
 
+    bm25_file = _resolve_index_dir(kb_root, "rag_index.json") / "rag_index.json"
+    vector_file = _resolve_index_dir(kb_root, "rag_vectors_v2.npy") / "rag_vectors_v2.npy"
+    wiki_dir = _resolve_index_dir(kb_root, "wiki") / "wiki"
+    page_file = _resolve_index_dir(kb_root, "rag_page_index.json") / "rag_page_index.json"
+
     bm25 = _fstat(bm25_file)
     if bm25["exists"]:
         try:
@@ -539,6 +568,7 @@ def _get_index_status(kb_root: str) -> Dict[str, Any]:
     vector = _fstat(vector_file)
     wiki = {"exists": wiki_dir.is_dir()}
     if wiki["exists"]:
+        wiki["path"] = str(wiki_dir)
         subdirs = {}
         for sub in ["sources", "entities", "topics", "analyses"]:
             d = wiki_dir / sub
@@ -546,8 +576,6 @@ def _get_index_status(kb_root: str) -> Dict[str, Any]:
         wiki["subdirs"] = subdirs
         wiki["total_pages"] = sum(subdirs.values())
 
-    # PageIndex status
-    page_file = root / "rag_page_index.json"
     pageindex = _fstat(page_file)
     if pageindex["exists"]:
         try:
@@ -948,7 +976,9 @@ async def wiki_tree(
     if not kb:
         raise HTTPException(status_code=404, detail="Knowledge base not found")
 
-    wiki_path = Path(kb["root_dir"]) / "wiki"
+    # Honor build scripts that drop output into a subdir like
+    # KnowledgeSpace/ instead of straight under root_dir.
+    wiki_path = _resolve_index_dir(kb["root_dir"], "wiki") / "wiki"
     result: Dict[str, Any] = {}
     for sub in ["sources", "entities", "topics", "analyses"]:
         d = wiki_path / sub
@@ -1012,7 +1042,9 @@ async def wiki_page(
     if not kb:
         raise HTTPException(status_code=404, detail="Knowledge base not found")
 
-    wiki_path = Path(kb["root_dir"]) / "wiki"
+    # Honor build scripts that drop output into a subdir like
+    # KnowledgeSpace/ instead of straight under root_dir.
+    wiki_path = _resolve_index_dir(kb["root_dir"], "wiki") / "wiki"
     full = (wiki_path / page_path).resolve()
     # Path traversal check
     if not str(full).startswith(str(wiki_path.resolve())):

+ 45 - 0
agentpaas/db/models.py

@@ -121,6 +121,7 @@ class Database:
                 trace_id TEXT,
                 workspace_path TEXT,
                 idempotency_key TEXT,
+                cancel_requested INTEGER DEFAULT 0,
                 created_at TEXT,
                 completed_at TEXT
             );
@@ -200,6 +201,18 @@ class Database:
             # Agent-KB integration (2026-06-02)
             ("agents", "kb_ids", "ALTER TABLE agents ADD COLUMN kb_ids TEXT DEFAULT '[]'"),
             ("agents", "kb_search_mode", "ALTER TABLE agents ADD COLUMN kb_search_mode TEXT DEFAULT 'bm25'"),
+            # Run cancellation (2026-06-05): a non-zero value here is the
+            # signal the cancel registry watches for to kill the active
+            # subprocess + unwind the engine cleanly.
+            ("runs", "cancel_requested", "ALTER TABLE runs ADD COLUMN cancel_requested INTEGER DEFAULT 0"),
+            # Restart-resilient cancellation (2026-06-06): persist the live
+            # claude subprocess PID so the cancel endpoint can fall back to
+            # os.kill() even when the in-process cancel registry was lost
+            # to a backend restart. Without this, run_5cbbb1d1dc54's orphan
+            # subprocess kept writing for ~10 min after `agentpaas serve`
+            # was re-launched, and the new instance's registry had no idea
+            # the run existed.
+            ("runs", "subprocess_pid", "ALTER TABLE runs ADD COLUMN subprocess_pid INTEGER"),
         ]
         for table, column, sql in migrations:
             try:
@@ -208,6 +221,38 @@ class Database:
                 self.conn.execute(sql)
         self.conn.commit()
 
+        # Reap zombie jobs/runs left over from a prior crash or kill.
+        # A row stuck in 'running' state for more than ZOMBIE_THRESHOLD must
+        # belong to a process that no longer exists (since this Database
+        # instance is constructed exactly once per process startup). Mark
+        # them failed so the UI doesn't show "running" forever.
+        # Without this, the workspace67 wiki compile from 9 days ago kept
+        # appearing as "running" indefinitely. Threshold matches typical
+        # safe completion windows: runs ≤ 6 h, kb_jobs ≤ 1 h.
+        try:
+            self.conn.execute(
+                "UPDATE runs SET status='failed', "
+                "  error=COALESCE(NULLIF(error,''),'') || "
+                "    '[reaped at startup: process not running]', "
+                "  completed_at=strftime('%Y-%m-%dT%H:%M:%S+00:00','now') "
+                "WHERE status='running' "
+                "  AND created_at < datetime('now','-6 hours')"
+            )
+            self.conn.execute(
+                "UPDATE kb_jobs SET status='failed', "
+                "  error=COALESCE(NULLIF(error,''),'') || "
+                "    '[reaped at startup: process not running]', "
+                "  completed_at=strftime('%Y-%m-%dT%H:%M:%S+00:00','now') "
+                "WHERE status='running' "
+                "  AND created_at < datetime('now','-1 hour')"
+            )
+            self.conn.commit()
+        except sqlite3.OperationalError:
+            # Tables may not exist yet on a brand-new DB (this migrate runs
+            # before the kb tables are ensured below). Safe to skip — fresh
+            # DB has no zombies by definition.
+            pass
+
         # Knowledge base tables (added 2026-05-27)
         self._ensure_table("knowledge_bases", """
             CREATE TABLE IF NOT EXISTS knowledge_bases (

+ 182 - 0
lambdagent/src/lambdagent/agentruntime/cancel.py

@@ -0,0 +1,182 @@
+"""
+lambdagent.agentruntime.cancel — per-run cancellation registry.
+
+Lets the host (e.g. agentpaas) signal cancellation to a running react loop +
+provider subprocess from a different thread, without having to plumb a
+`cancel_check_fn` through every level of the call stack.
+
+Usage:
+    # Host (HTTP handler thread): before starting agent execution
+    cancel.begin_run(run_id)
+    try:
+        result = compiled_term.apply(input_text, ctx)
+    except cancel.CancelledRun:
+        # The user clicked stop; run was killed mid-step.
+        ...
+    finally:
+        cancel.end_run(run_id)
+
+    # Host (separate thread, e.g. cancel endpoint): request stop
+    cancel.request_cancel(run_id)
+
+    # Provider (inside the run thread): check + register subprocess
+    proc = subprocess.Popen(...)
+    cancel.bind_proc(proc)
+    try:
+        while proc.poll() is None:
+            if cancel.is_cancelled():
+                proc.kill()
+                raise cancel.CancelledRun()
+            ...
+    finally:
+        cancel.unbind_proc()
+
+The registry is per-run (keyed by run_id); the current run is tracked via a
+thread-local set by `begin_run()`. Providers don't need to know the run_id —
+they just call `is_cancelled()` / `bind_proc()` which look it up.
+
+Sub-agent calls inherit the parent thread's run_id (single-threaded ReAct
+loop), so the same registry covers nested compiled-term invocations.
+"""
+from __future__ import annotations
+
+import subprocess
+import threading
+from typing import Callable, Dict, Optional
+
+
+_lock = threading.Lock()
+_events: Dict[str, threading.Event] = {}
+_procs: Dict[str, subprocess.Popen] = {}
+# Per-run hook fired whenever the bound subprocess pid changes (bind/unbind).
+# Lets the host persist the pid somewhere durable (DB) so a restarted backend
+# can still find and kill the orphan subprocess.
+_on_proc_change: Dict[str, Callable[[Optional[int]], None]] = {}
+_thread_local = threading.local()
+
+
+class CancelledRun(Exception):
+    """Raised when the host has requested cancellation of the current run."""
+
+
+def begin_run(
+    run_id: str,
+    on_proc_change: Optional[Callable[[Optional[int]], None]] = None,
+) -> None:
+    """Mark `run_id` as cancellable and bind it to the calling thread.
+
+    Idempotent: re-binding the same run_id replaces any prior event (e.g. if
+    a previous attempt for this id crashed without end_run).
+
+    `on_proc_change`, if provided, is invoked synchronously with the bound
+    Popen.pid (int) each time bind_proc runs, and with None on unbind_proc.
+    Use it to persist the live pid so cancellation survives a host restart.
+    """
+    if not run_id:
+        return
+    with _lock:
+        _events[run_id] = threading.Event()
+        # Drop any stale proc registration carrying over from a prior attempt.
+        _procs.pop(run_id, None)
+        if on_proc_change is not None:
+            _on_proc_change[run_id] = on_proc_change
+        else:
+            _on_proc_change.pop(run_id, None)
+    _thread_local.run_id = run_id
+
+
+def end_run(run_id: str) -> None:
+    """Release per-run resources. Safe to call multiple times."""
+    if not run_id:
+        return
+    with _lock:
+        _events.pop(run_id, None)
+        _procs.pop(run_id, None)
+        cb = _on_proc_change.pop(run_id, None)
+    # Notify outside the lock — callback may touch DB / IO.
+    if cb is not None:
+        try:
+            cb(None)
+        except Exception:
+            pass
+    if getattr(_thread_local, "run_id", None) == run_id:
+        _thread_local.run_id = None
+
+
+def request_cancel(run_id: str) -> bool:
+    """Signal cancellation + kill bound subprocess (if any).
+
+    Returns True if `run_id` was registered and we attempted to stop it,
+    False if `run_id` is unknown (already finished, or never registered).
+    """
+    if not run_id:
+        return False
+    with _lock:
+        ev = _events.get(run_id)
+        proc = _procs.get(run_id)
+    if ev is None:
+        return False
+    ev.set()
+    if proc is not None and proc.poll() is None:
+        try:
+            proc.kill()
+        except Exception:
+            # Process may have died between poll() and kill() — fine.
+            pass
+    return True
+
+
+def is_cancelled(run_id: Optional[str] = None) -> bool:
+    """Check whether the given run (or thread-local current run) has been
+    asked to cancel. Returns False if no run is bound."""
+    if run_id is None:
+        run_id = getattr(_thread_local, "run_id", None)
+    if not run_id:
+        return False
+    with _lock:
+        ev = _events.get(run_id)
+    return ev.is_set() if ev is not None else False
+
+
+def bind_proc(proc: subprocess.Popen, run_id: Optional[str] = None) -> None:
+    """Associate a subprocess with the current run so request_cancel() can
+    kill it. The most recently bound proc replaces any prior one — providers
+    typically run one subprocess at a time per run.
+
+    Also fires the per-run on_proc_change callback (if any) with proc.pid so
+    the host can persist it durably (e.g. write to DB) for restart-resilient
+    cancellation.
+    """
+    if run_id is None:
+        run_id = getattr(_thread_local, "run_id", None)
+    if not run_id:
+        return
+    with _lock:
+        _procs[run_id] = proc
+        cb = _on_proc_change.get(run_id)
+    if cb is not None:
+        try:
+            cb(proc.pid)
+        except Exception:
+            pass
+
+
+def unbind_proc(run_id: Optional[str] = None) -> None:
+    """Release the proc binding (call in finally after subprocess.wait)."""
+    if run_id is None:
+        run_id = getattr(_thread_local, "run_id", None)
+    if not run_id:
+        return
+    with _lock:
+        _procs.pop(run_id, None)
+        cb = _on_proc_change.get(run_id)
+    if cb is not None:
+        try:
+            cb(None)
+        except Exception:
+            pass
+
+
+def current_run_id() -> Optional[str]:
+    """Diagnostic: what run is this thread bound to?"""
+    return getattr(_thread_local, "run_id", None)

+ 168 - 13
lambdagent/src/lambdagent/fromconfig/compiler.py

@@ -245,6 +245,16 @@ def build_agent(cfg: Dict[str, Any], overrides: Dict = None, _depth: int = 0) ->
     overrides = overrides or {}
     agent_type = cfg.get("type", "simple")
 
+    # An explicit cfg._config_dir TRUMPS whatever from_config inferred from
+    # the yaml's filesystem location. Otherwise agentpaas (which dumps the
+    # config to workspace/config.yml and compiles from there) would resolve
+    # `./agents/lit-searcher.yml` against the workspace dir instead of the
+    # agent's actual config dir — and every sub-agent call comes back with
+    # "[SubAgent X not found: ... ./agents/X.yml]". Observed in
+    # run_9aaf32f7aff4 / workspace run_20260606_003521.
+    if cfg.get("_config_dir"):
+        overrides = {**overrides, "_config_dir": cfg["_config_dir"]}
+
     # Step 0: Compile subAgents if present (multi-agent orchestrator support)
     # subAgents 节定义了子代理,编译后作为 call_* 工具注入到协调者
     # Accept both `subAgents` (camelCase) and `sub_agents` (snake_case) —
@@ -673,6 +683,39 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
     enforce_loop_sequence = list(enforce_loop_cfg.get("sequence", []) or [])
     enforce_loop_min_cycles = int(enforce_loop_cfg.get("minCycles", 0))
 
+    # enforceLoop: hard floor on tool execution before terminate is allowed.
+    # Two modes:
+    #
+    #  (1) Counter mode (legacy):
+    #        enforceLoop: {tool: call_physreview, minCount: 3}
+    #      Requires `tool` to have been called minCount times. Easy to game —
+    #      orchestrator learned to spam `call_physreview` 3× back-to-back
+    #      without ever calling the other 4 sub-agents (run_ba0d79701903).
+    #
+    #  (2) Sequence mode (new — closes the loophole):
+    #        enforceLoop: {sequence: [t1, t2, ..., t_last], minCycles: 3}
+    #      A "cycle" is bounded by adjacent firings of `sequence[-1]`. Before
+    #      each `sequence[-1]` call counts as a complete cycle, every other
+    #      tool in `sequence` must have appeared at least once since the
+    #      previous cycle boundary (or run start). Cycles that don't satisfy
+    #      this don't count toward minCycles. On premature terminate the
+    #      push-back message names exactly which prereqs the current partial
+    #      cycle is still missing.
+    enforce_loop_cfg = react_cfg.get("enforceLoop", {}) or {}
+    enforce_loop_tool = enforce_loop_cfg.get("tool", "")
+    enforce_loop_min = int(enforce_loop_cfg.get("minCount", 0))
+    enforce_loop_sequence = list(enforce_loop_cfg.get("sequence", []) or [])
+    enforce_loop_min_cycles = int(enforce_loop_cfg.get("minCycles", 0))
+
+    # Give-up budget: after this many CONSECUTIVE error observations for
+    # the same tool, the engine injects a SYSTEM message in the next state
+    # naming the dead tool and telling the orchestrator to either pick a
+    # different one or terminate. Prevents the death-spiral observed in
+    # run_4e07736d7f60 where Anthropic STALLed every `call_physsim` call
+    # 5+ times in a row and the orchestrator kept picking it.
+    # 0 disables the mechanism.
+    give_up_after_failures = int(react_cfg.get("giveUpAfterFailures", 3) or 0)
+
     # Streaming callback (injected via overrides["on_step"])
     _on_step = overrides.get("on_step")
 
@@ -682,10 +725,48 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
     _tool_log = []  # Full tool execution log
     _user_input = [None]  # Original user input (captured on first step)
     _last_observation = [None]  # Latest tool observation for session-resume mode
+    # Per-tool consecutive-failure counter for `give_up_after_failures`.
+    # Reset to 0 when the tool succeeds; incremented when its observation
+    # matches `_observation_failed`. See logic right after tool execution.
+    _consecutive_failures: Dict[str, int] = {}
+
+    def _observation_failed(observation: str) -> bool:
+        """Best-effort failure detector for loop accounting.
+
+        ReAct tool calls return strings rather than typed status objects.  For
+        enforceLoop sequence mode, a failed sub-agent call must not satisfy a
+        cycle requirement just because the tool name appeared in the trace.
+        """
+        text = (observation or "").lower()
+        fail_markers = (
+            "[tool_error]",
+            "[validation_error]",
+            "[subagent ",
+            " not found",
+            " compile error",
+            " timed out",
+            " timeout",
+            "[claude code error]",
+            "[error]",
+            "[mcp_error",
+            "[mcp_timeout",
+            '"status": "fail"',
+            '"status":"fail"',
+            '"ok": false',
+            '"ok":false',
+        )
+        return any(marker in text for marker in fail_markers)
 
     # Detect if ClaudeLam supports session persistence (--resume)
     _has_session = hasattr(think, '_session_id')
 
+    # Lazy import — keeps the engine independent of any host-provided cancel
+    # mechanism; works whether or not agentpaas (or another host) is around.
+    try:
+        from lambdagent.agentruntime import cancel as _cancel_mod
+    except Exception:   # pragma: no cover
+        _cancel_mod = None
+
     def react_step(state):
         """
         One step of ReAct: think -> extract tool -> execute -> observe.
@@ -696,6 +777,14 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
         Without session persistence (standard Lam):
           - Every step: pass full compressed state (backward compatible)
         """
+        # Cancellation check at the top of each step. If the host called
+        # request_cancel() between steps (or the user clicked Stop), we
+        # raise here so the run unwinds cleanly without starting another
+        # claude subprocess. The provider also checks mid-subprocess for
+        # tighter latency once a child is already running.
+        if _cancel_mod is not None and _cancel_mod.is_cancelled():
+            raise _cancel_mod.CancelledRun()
+
         ctx = _shared_ctx
         step = _step_counter[0]
         _step_counter[0] += 1
@@ -716,12 +805,31 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
             # Session mode: only pass latest observation (Claude has full memory)
             obs = _last_observation[0]
             remaining = max_steps - step
-            llm_input = (
-                f"[工具执行结果]\n{obs}\n\n"
-                f"[步骤 {step+1}/{max_steps},剩余 {remaining} 步]\n"
-                f"请基于结果决定下一步。输出一个JSON工具调用。\n"
-                f"注意:工具名是 ReadFile/WriteFile/EditFile/Bash/ListFiles(不是 Read/Write/Edit)。"
-            )
+            # If the underlying provider lost its session (e.g. a transport
+            # timeout cleared session_id), the next claude-code subprocess
+            # has zero memory of the original user instruction. Without
+            # re-injecting it, the orchestrator hallucinates a fresh task
+            # off whatever tiny snippet remains — observed in run_f2c04dec…
+            # where it drifted to "Spectral statistics..." after physidea
+            # timed out. We re-prepend captured user_input when we detect
+            # session_id has become None.
+            session_lost = getattr(think, "_session_id", None) is None
+            if session_lost and _user_input[0]:
+                llm_input = (
+                    f"{_user_input[0]}\n\n"
+                    f"[最新工具结果 — 上一会话已超时重置,本次必须严格按上方原始指令推进]\n"
+                    f"{obs}\n\n"
+                    f"[步骤 {step+1}/{max_steps},剩余 {remaining} 步]\n"
+                    f"请基于上述【原始指令 + 最新工具结果】决定下一步。"
+                    f"输出一个 JSON 工具调用。不要凭空创造新的研究主题或工作目录。"
+                )
+            else:
+                llm_input = (
+                    f"[工具执行结果]\n{obs}\n\n"
+                    f"[步骤 {step+1}/{max_steps},剩余 {remaining} 步]\n"
+                    f"请基于结果决定下一步。输出一个JSON工具调用。\n"
+                    f"注意:工具名是 ReadFile/WriteFile/EditFile/Bash/ListFiles(不是 Read/Write/Edit)。"
+                )
         else:
             # First step (with CWD) or stateless mode
             llm_input = _user_input[0] if step == 0 else str(state)
@@ -759,16 +867,27 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
                     prereqs = set(enforce_loop_sequence[:-1])
                     complete_cycles = 0
                     seen_in_cycle = set()
+                    failed_in_cycle = []
                     for e in _tool_log:
                         t = e.get("tool")
+                        ok = not _observation_failed(e.get("observation", ""))
                         if t == boundary_tool:
-                            if prereqs.issubset(seen_in_cycle):
+                            if ok and prereqs.issubset(seen_in_cycle):
                                 complete_cycles += 1
-                            # Either way the cycle boundary resets — a bare
-                            # boundary call doesn't carry over to the next.
-                            seen_in_cycle = set()
+                                failed_in_cycle = []
+                                seen_in_cycle = set()
+                            elif ok:
+                                # A successful boundary call without prereqs
+                                # starts a new attempted cycle.
+                                seen_in_cycle = set()
+                                failed_in_cycle = []
+                            else:
+                                failed_in_cycle.append(t)
                         elif t in prereqs:
-                            seen_in_cycle.add(t)
+                            if ok:
+                                seen_in_cycle.add(t)
+                            else:
+                                failed_in_cycle.append(t)
 
                     if complete_cycles < enforce_loop_min_cycles:
                         missing = sorted(prereqs - seen_in_cycle)
@@ -780,6 +899,7 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
                             f"[SYSTEM] 循环未完成。已完成 "
                             f"{complete_cycles}/{enforce_loop_min_cycles} 轮。\n"
                             f"当前轮还缺:{', '.join(missing) if missing else '(只差 ' + boundary_tool + ')'}。\n"
+                            f"失败/未计入的工具:{', '.join(failed_in_cycle) if failed_in_cycle else '无'}。\n"
                             f"禁止 terminate。请按顺序调用 "
                             f"{' → '.join(enforce_loop_sequence)},下一步应该是 "
                             f"{next_tool}。"
@@ -913,17 +1033,52 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
         ctx.log(f"Tool:{tool_name}", "", str(tool_input)[:200], observation[:200],
                 think_ms, think.model if hasattr(think, 'model') else "")
 
+        # ── Phase 5a: Consecutive-failure budget ──
+        # Track this tool's recent track record. When the same tool fails
+        # `give_up_after_failures` times in a row, the orchestrator clearly
+        # isn't going to recover by retrying — inject a strong SYSTEM
+        # message into the next observation telling it the tool is dead
+        # and forcing a pivot (different tool, or terminate).
+        give_up_hint = ""
+        if give_up_after_failures > 0:
+            if _observation_failed(observation):
+                _consecutive_failures[tool_name] = _consecutive_failures.get(tool_name, 0) + 1
+                cnt = _consecutive_failures[tool_name]
+                if cnt >= give_up_after_failures:
+                    give_up_hint = (
+                        f"\n\n[SYSTEM] `{tool_name}` 已连续失败 {cnt} 次"
+                        f"(上游 timeout / stall / error)。"
+                        f"\n继续调同一个 tool 不会有用。"
+                        f"\n请选择以下之一:"
+                        f"\n  (a) 换一个 sub-agent 尝试;"
+                        f"\n  (b) 输出 terminate,input.summary 写清楚"
+                        f" `{tool_name}` 不可用、本次未能完成任务。"
+                        f"\n禁止:再次调用 `{tool_name}` 或同样会失败的兄弟 tool。"
+                    )
+                    if verbose:
+                        print(
+                            f"  B[{step}] give-up hint injected — {tool_name} "
+                            f"failed {cnt}/{give_up_after_failures} consecutive"
+                        )
+            else:
+                # Success → reset that tool's counter.
+                if tool_name in _consecutive_failures:
+                    _consecutive_failures.pop(tool_name, None)
+
         # ── Phase 5: Prepare next state ──
         # Store observation for session-resume mode
-        _last_observation[0] = f"Tool: {tool_name}\nResult:\n{observation}"
+        _last_observation[0] = f"Tool: {tool_name}\nResult:\n{observation}{give_up_hint}"
 
         # Return state with step marker (for stop_condition detection)
+        # observation_with_hint preserves the give-up SYSTEM message so the
+        # hint reaches both branches (session and stateless).
+        observation_with_hint = observation + give_up_hint
         if _has_session:
             # Session mode: minimal state (Claude has full memory via --resume)
             return f"{_user_input[0]}\n[Step {step+1}] {tool_name} done"
         else:
             # Stateless mode: full compressed state (backward compatible)
-            return _compress_state(str(state), str(thought), tool_name, observation)
+            return _compress_state(str(state), str(thought), tool_name, observation_with_hint)
 
     body = Tool(f"{agent_name}.react_step", react_step)
 

+ 237 - 37
lambdagent/src/lambdagent/providers/claude_code_provider.py

@@ -11,15 +11,194 @@ Optimizations:
 """
 from __future__ import annotations
 
+import fcntl
 import json
 import logging
 import os
+import select
 import shutil
 import subprocess
+import time
 from typing import Dict, List, Optional
 
 from .base import LLMProvider, ProviderConfig, ProviderError
 
+# Lazy import to avoid hard dependency cycle if someone uses providers
+# stand-alone. cancel module is small + side-effect-free.
+try:
+    from lambdagent.agentruntime import cancel as _cancel
+except Exception:   # pragma: no cover
+    _cancel = None
+
+
+# Default idle-timeout: if claude produces no new bytes for this many seconds
+# we conclude it has silently stalled (the common claude-code API failure
+# mode — exit code 0 never comes, no error written, just dead air for the
+# full hard timeout). Killing fast turns 10-min disasters into 1-min ones.
+# Override per-provider via config.extra["claude_idle_timeout"].
+_DEFAULT_IDLE_TIMEOUT_S = 60.0
+# Initial idle window is more generous since cold start of claude-code's
+# session can take 30-60s before the first byte (model load + auth).
+_DEFAULT_FIRST_BYTE_TIMEOUT_S = 120.0
+
+
+class _StallError(Exception):
+    """Raised when claude subprocess produced no bytes within idle_timeout
+    (or exceeded the hard timeout). Caller converts to ProviderError with
+    context (first-turn vs resume, session_id, etc.)."""
+
+    def __init__(self, kind: str, elapsed: float,
+                 stdout: str, stderr: str, returncode: Optional[int] = None):
+        self.kind = kind            # "idle" | "hard" | "killed"
+        self.elapsed = elapsed
+        self.stdout = stdout
+        self.stderr = stderr
+        self.returncode = returncode
+        super().__init__(f"claude {kind}-timeout after {elapsed:.1f}s")
+
+
+def _run_with_idle_timeout(
+    cmd: List[str],
+    *,
+    idle_timeout: float,
+    first_byte_timeout: float,
+    hard_timeout: float,
+) -> subprocess.CompletedProcess:
+    """subprocess.run replacement that kills the child the moment it stops
+    producing output.
+
+    Three independent watchdogs:
+      - first_byte_timeout: before any bytes arrive (cold start)
+      - idle_timeout:       between subsequent bytes
+      - hard_timeout:       total wall-clock ceiling (existing behavior)
+
+    Returns a CompletedProcess (text-decoded) on clean exit. Raises
+    _StallError on any timeout, with whatever partial output was captured.
+
+    Implementation: select() on non-blocking stdout/stderr pipes, loop
+    until poll() returns or a watchdog fires. macOS/Linux only — Windows
+    has no select-on-pipe, but agentpaas doesn't target Windows anyway.
+    """
+    proc = subprocess.Popen(
+        cmd,
+        stdin=subprocess.DEVNULL,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+    )
+
+    # Bind to per-run cancel registry — request_cancel(run_id) can now reach
+    # in and kill this specific subprocess from another thread without
+    # process-wide pkill. The bind is no-op if no run is registered on the
+    # current thread (e.g. someone using the provider stand-alone).
+    if _cancel is not None:
+        _cancel.bind_proc(proc)
+
+    # Make the read pipes non-blocking so select() + read() can't accidentally
+    # park waiting for more bytes once the kernel buffer is drained.
+    for fd in (proc.stdout.fileno(), proc.stderr.fileno()):
+        flags = fcntl.fcntl(fd, fcntl.F_GETFL)
+        fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
+
+    out_chunks: List[bytes] = []
+    err_chunks: List[bytes] = []
+    start = time.time()
+    last_byte_at: Optional[float] = None   # None = haven't seen the first byte
+
+    try:
+        while proc.poll() is None:
+            now = time.time()
+            elapsed = now - start
+
+            # Cancellation check — fires at most ~2 Hz (the select polling
+            # cadence) so user "Stop" reaches the killing path within ~0.5s
+            # even when claude is mid-thought.
+            if _cancel is not None and _cancel.is_cancelled():
+                raise _cancel.CancelledRun()
+
+            # Watchdogs
+            if elapsed > hard_timeout:
+                raise _StallError(
+                    "hard", elapsed,
+                    b"".join(out_chunks).decode(errors="replace"),
+                    b"".join(err_chunks).decode(errors="replace"),
+                )
+            if last_byte_at is None:
+                if elapsed > first_byte_timeout:
+                    raise _StallError(
+                        "first-byte", elapsed,
+                        b"".join(out_chunks).decode(errors="replace"),
+                        b"".join(err_chunks).decode(errors="replace"),
+                    )
+            else:
+                idle = now - last_byte_at
+                if idle > idle_timeout:
+                    raise _StallError(
+                        "idle", elapsed,
+                        b"".join(out_chunks).decode(errors="replace"),
+                        b"".join(err_chunks).decode(errors="replace"),
+                    )
+
+            # Poll. Short select timeout so the watchdogs above run regularly.
+            ready, _, _ = select.select(
+                [proc.stdout, proc.stderr], [], [], 0.5
+            )
+            for f in ready:
+                try:
+                    chunk = f.read()
+                except (BlockingIOError, OSError):
+                    chunk = b""
+                if chunk:
+                    (out_chunks if f is proc.stdout else err_chunks).append(chunk)
+                    last_byte_at = now
+
+        # Process exited — drain anything still buffered.
+        try:
+            rem_out, rem_err = proc.communicate(timeout=5)
+            if rem_out:
+                out_chunks.append(rem_out)
+            if rem_err:
+                err_chunks.append(rem_err)
+        except subprocess.TimeoutExpired:
+            pass
+
+    except _StallError:
+        # Kill the stalled child before re-raising; otherwise it sits
+        # around eating tokens (and money).
+        try:
+            proc.kill()
+            proc.wait(timeout=2)
+        except Exception:
+            pass
+        raise
+    except BaseException:
+        # Cancellation (or any other exception) — kill before re-raising.
+        # CancelledRun is BaseException-safe since it inherits from Exception,
+        # but using BaseException here also covers KeyboardInterrupt cleanly.
+        try:
+            proc.kill()
+            proc.wait(timeout=2)
+        except Exception:
+            pass
+        raise
+    finally:
+        if proc.poll() is None:
+            try:
+                proc.kill()
+                proc.wait(timeout=2)
+            except Exception:
+                pass
+        # Always release the proc binding so a subsequent run doesn't see
+        # a dead Popen registered for it.
+        if _cancel is not None:
+            _cancel.unbind_proc()
+
+    return subprocess.CompletedProcess(
+        args=cmd,
+        returncode=proc.returncode,
+        stdout=b"".join(out_chunks).decode("utf-8", errors="replace"),
+        stderr=b"".join(err_chunks).decode("utf-8", errors="replace"),
+    )
+
 # Attach under "agentpaas" so the host's logging handler picks us up.
 # Using __name__ ("lambdagent.providers.claude_code_provider") gives a
 # silent logger when running inside agentpaas — its setup_logging() only
@@ -161,41 +340,51 @@ class ClaudeCodeProvider(LLMProvider):
             "--output-format", "json",
             "--model", self.config.model,
             "--system-prompt", effective_system,
+            # Disable claude-code's native tools entirely. The orchestrator
+            # is supposed to OUTPUT a JSON tool call for the engine to route
+            # through agentpaas's tool registry — it should never execute
+            # Bash/WriteFile etc. itself. Without this, --dangerously-skip-
+            # permissions lets claude run native tools whenever it pleases,
+            # bypassing mcp.localTools entirely (observed in run_4e07736d7f60:
+            # orchestrator fabricated 150 KB of fake `sim_results.json` via
+            # native WriteFile while every `call_physsim` invocation STALLed).
+            "--tools", "",
             "--dangerously-skip-permissions",
         ]
 
         # Diagnostics: log argv sizes before spawning. argv on macOS is
         # capped at ARG_MAX (~1MB); large system prompts can push us close.
         total_argv = sum(len(s) for s in cmd)
+        extra = self.config.extra or {}
+        idle_timeout = float(extra.get("claude_idle_timeout", _DEFAULT_IDLE_TIMEOUT_S))
+        first_byte_timeout = float(extra.get("claude_first_byte_timeout", _DEFAULT_FIRST_BYTE_TIMEOUT_S))
         logger.info(
-            "claude-code first-turn spawn: prompt=%d sys=%d total_argv=%d model=%s timeout=%ds",
+            "claude-code first-turn spawn: prompt=%d sys=%d total_argv=%d model=%s "
+            "hard=%ds idle=%.0fs first-byte=%.0fs",
             len(prompt_arg), len(effective_system), total_argv,
-            self.config.model, self.config.timeout,
+            self.config.model, self.config.timeout, idle_timeout, first_byte_timeout,
         )
 
-        import time as _time
-        t0 = _time.time()
+        t0 = time.time()
         try:
-            result = subprocess.run(
+            result = _run_with_idle_timeout(
                 cmd,
-                stdin=subprocess.DEVNULL,   # avoid 3-s stdin-wait warning
-                capture_output=True, text=True,
-                timeout=self.config.timeout,
+                idle_timeout=idle_timeout,
+                first_byte_timeout=first_byte_timeout,
+                hard_timeout=float(self.config.timeout),
             )
-        except subprocess.TimeoutExpired as e:
-            elapsed = _time.time() - t0
-            partial_out = (e.stdout.decode(errors="replace") if e.stdout else "")[:300]
-            partial_err = (e.stderr.decode(errors="replace") if e.stderr else "")[:300]
+        except _StallError as exc:
             logger.warning(
-                "claude-code TIMEOUT after %.1fs (limit=%ds) partial_stdout=%r partial_stderr=%r",
-                elapsed, self.config.timeout, partial_out, partial_err,
+                "claude-code STALL (%s) after %.1fs partial_stdout=%r partial_stderr=%r",
+                exc.kind, exc.elapsed, exc.stdout[:300], exc.stderr[:300],
             )
             raise ProviderError(
-                f"Claude Code timeout ({self.config.timeout}s) on first turn",
+                f"Claude Code stalled ({exc.kind}-timeout after {exc.elapsed:.0f}s, "
+                f"no output produced) on first turn",
                 "claude-code", retryable=True,
             )
 
-        elapsed = _time.time() - t0
+        elapsed = time.time() - t0
         if result.returncode != 0:
             stderr = (result.stderr or "").strip()
             stdout_hint = (result.stdout or "").strip()[:200]
@@ -241,17 +430,21 @@ class ClaudeCodeProvider(LLMProvider):
             "--output-format", "text",
             "--model", self.config.model,
             "--resume", self._session_id,
+            # See _call_new comment — keep parity on resume so claude can
+            # never run native tools, regardless of session phase.
+            "--tools", "",
             "--dangerously-skip-permissions",
         ]
 
+        extra_top = self.config.extra or {}
+        _it = float(extra_top.get("claude_idle_timeout", _DEFAULT_IDLE_TIMEOUT_S))
+        _fbt = float(extra_top.get("claude_first_byte_timeout", _DEFAULT_FIRST_BYTE_TIMEOUT_S))
         logger.info(
-            "claude-code resume spawn: prompt=%d session=%s timeout=%ds",
+            "claude-code resume spawn: prompt=%d session=%s hard=%ds idle=%.0fs first-byte=%.0fs",
             len(prompt_arg), self._session_id[:8] if self._session_id else "?",
-            self.config.timeout,
+            self.config.timeout, _it, _fbt,
         )
 
-        import time as _time
-
         # Transport-error patterns worth retrying once. Long-running sessions
         # sporadically die from Anthropic-side socket drops or 5xx — losing the
         # whole 22-turn paper-writing session over one flake is unacceptable.
@@ -270,34 +463,41 @@ class ClaudeCodeProvider(LLMProvider):
             low = text.lower()
             return any(m.lower() in low for m in _TRANSPORT_RETRY_MARKERS)
 
+        extra = self.config.extra or {}
+        idle_timeout = float(extra.get("claude_idle_timeout", _DEFAULT_IDLE_TIMEOUT_S))
+        first_byte_timeout = float(extra.get("claude_first_byte_timeout", _DEFAULT_FIRST_BYTE_TIMEOUT_S))
+
         def _run_once():
-            t0 = _time.time()
+            t0 = time.time()
             try:
-                result = subprocess.run(
+                result = _run_with_idle_timeout(
                     cmd,
-                    stdin=subprocess.DEVNULL,
-                    capture_output=True, text=True,
-                    timeout=self.config.timeout,
+                    idle_timeout=idle_timeout,
+                    first_byte_timeout=first_byte_timeout,
+                    hard_timeout=float(self.config.timeout),
                 )
-                return result, _time.time() - t0, None
-            except subprocess.TimeoutExpired as exc:
-                return None, _time.time() - t0, exc
+                return result, time.time() - t0, None
+            except _StallError as exc:
+                return None, time.time() - t0, exc
 
         for attempt in (1, 2):
-            result, elapsed, timeout_exc = _run_once()
+            result, elapsed, stall_exc = _run_once()
 
-            if timeout_exc is not None:
-                partial_out = (timeout_exc.stdout.decode(errors="replace") if timeout_exc.stdout else "")[:300]
-                partial_err = (timeout_exc.stderr.decode(errors="replace") if timeout_exc.stderr else "")[:300]
+            if stall_exc is not None:
                 logger.warning(
-                    "claude-code RESUME TIMEOUT (attempt %d) after %.1fs partial_stdout=%r partial_stderr=%r",
-                    attempt, elapsed, partial_out, partial_err,
+                    "claude-code RESUME STALL (%s, attempt %d) after %.1fs "
+                    "partial_stdout=%r partial_stderr=%r",
+                    stall_exc.kind, attempt, elapsed,
+                    stall_exc.stdout[:300], stall_exc.stderr[:300],
                 )
-                # Timeouts indicate the model itself is wedged — don't retry,
-                # would just hang another 600s. Clear session and fail.
+                # Stalls (especially "idle" with no bytes) indicate the
+                # upstream is wedged — retrying just burns another ~60s.
+                # Clear the session and fail; the engine will inject a
+                # fresh first-turn that preserves the user_input.
                 self._session_id = None
                 raise ProviderError(
-                    f"Claude Code timeout ({self.config.timeout}s) on resume",
+                    f"Claude Code stalled ({stall_exc.kind}-timeout after "
+                    f"{stall_exc.elapsed:.0f}s, no output) on resume",
                     "claude-code", retryable=True,
                 )
 

+ 666 - 0
webui/src/pages/AgentWorkspace.tsx

@@ -0,0 +1,666 @@
+import { useState, useEffect } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import { useQuery } from '@tanstack/react-query'
+import {
+  ArrowLeft, Folder, FolderOpen, File, FileText, FileCode2,
+  Download, Eye, EyeOff, CheckCircle, XCircle, Clock,
+  RefreshCw, ChevronRight, ChevronDown, Inbox, AlertCircle,
+  Settings2, Info, MousePointerClick,
+} from 'lucide-react'
+import { agentsApi, type Run, type WorkspaceFile } from '../api/agents'
+import { useAppStore } from '../store/app'
+import { Spinner } from '../components/ui'
+import { clsx } from '../lib/clsx'
+
+// ── helpers ──────────────────────────────────────────────────────
+
+function fmtSize(bytes: number) {
+  if (bytes < 1024) return `${bytes} B`
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+  return `${(bytes / 1024 / 1024).toFixed(1)} MB`
+}
+
+function fmtMs(ms: number) {
+  if (ms < 1000) return `${ms} ms`
+  return `${(ms / 1000).toFixed(1)} s`
+}
+
+function fmtDate(iso: string) {
+  return new Date(iso).toLocaleString('zh-CN', {
+    month: 'numeric', day: 'numeric',
+    hour: '2-digit', minute: '2-digit', second: '2-digit',
+  })
+}
+
+function isPreviewable(name: string) {
+  return /\.(txt|md|json|jsonl|yaml|yml|py|js|ts|tsx|jsx|sh|log|csv|toml|ini|cfg|xml|html|css|sql|r|rb|go|rs|c|cpp|h|java)$/i.test(name)
+}
+
+function fileIcon(name: string) {
+  if (/\.(py|js|ts|tsx|jsx|sh|go|rs|c|cpp|h|java|rb|r)$/i.test(name))
+    return <FileCode2 size={13} className="text-blue-500 shrink-0" />
+  if (/\.(json|jsonl|yaml|yml|toml|ini|cfg|xml)$/i.test(name))
+    return <FileText size={13} className="text-yellow-500 shrink-0" />
+  if (/\.(md|txt|log|csv|sql)$/i.test(name))
+    return <FileText size={13} className="text-gray-400 shrink-0" />
+  return <File size={13} className="text-gray-300 shrink-0" />
+}
+
+// group flat file list into { dir → files[] } map; root-level files go under ""
+function groupFiles(files: WorkspaceFile[]) {
+  const map = new Map<string, WorkspaceFile[]>()
+  for (const f of files) {
+    const slash = f.path.indexOf('/')
+    const dir = slash >= 0 ? f.path.slice(0, slash) : ''
+    if (!map.has(dir)) map.set(dir, [])
+    map.get(dir)!.push(f)
+  }
+  // sort: root first, then dirs alphabetically
+  const sorted = [...map.entries()].sort(([a], [b]) => {
+    if (a === '') return -1
+    if (b === '') return 1
+    return a.localeCompare(b)
+  })
+  return sorted
+}
+
+// ── Run status badge ──────────────────────────────────────────────
+
+function RunBadge({ status }: { status: string }) {
+  const cfg: Record<string, { icon: React.ReactNode; cls: string; label: string }> = {
+    completed: { icon: <CheckCircle size={11} />, cls: 'text-green-600 bg-green-50', label: '完成' },
+    failed:    { icon: <XCircle size={11} />,    cls: 'text-red-600 bg-red-50',      label: '失败' },
+    running:   { icon: <RefreshCw size={11} className="animate-spin" />, cls: 'text-blue-600 bg-blue-50', label: '运行中' },
+    pending:   { icon: <Clock size={11} />,      cls: 'text-yellow-600 bg-yellow-50', label: '等待' },
+  }
+  const c = cfg[status] ?? { icon: null, cls: 'text-gray-500 bg-gray-100', label: status }
+  return (
+    <span className={clsx('inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium', c.cls)}>
+      {c.icon}{c.label}
+    </span>
+  )
+}
+
+// ── File tree (left part of file browser) ────────────────────────
+
+function DirGroup({
+  dir, files, selected, onSelect,
+}: {
+  dir: string
+  files: WorkspaceFile[]
+  selected: WorkspaceFile | null
+  onSelect: (f: WorkspaceFile) => void
+}) {
+  const [open, setOpen] = useState(true)
+  const label = dir || '根目录'
+  const isRoot = dir === ''
+
+  return (
+    <div>
+      {/* Directory header — only show for non-root */}
+      {!isRoot && (
+        <button
+          onClick={() => setOpen(o => !o)}
+          className="flex items-center gap-1.5 w-full px-3 py-1.5 text-xs font-semibold text-gray-600 hover:bg-gray-50 transition-colors"
+        >
+          {open
+            ? <FolderOpen size={13} className="text-amber-400 shrink-0" />
+            : <Folder size={13} className="text-amber-400 shrink-0" />}
+          <span className="font-mono">{label}/</span>
+          {open
+            ? <ChevronDown size={11} className="ml-auto text-gray-300" />
+            : <ChevronRight size={11} className="ml-auto text-gray-300" />}
+        </button>
+      )}
+
+      {open && (
+        <div className={clsx('divide-y divide-gray-50', !isRoot && 'pl-4')}>
+          {files.map(f => {
+            const baseName = f.path.includes('/') ? f.path.split('/').pop()! : f.path
+            const isSelected = selected?.path === f.path
+            return (
+              <div
+                key={f.path}
+                onClick={() => onSelect(f)}
+                className={clsx(
+                  'flex items-center gap-2 px-3 py-1.5 cursor-pointer transition-colors text-xs',
+                  isSelected
+                    ? 'bg-indigo-50 border-l-2 border-indigo-400'
+                    : 'hover:bg-gray-50 border-l-2 border-transparent',
+                )}
+              >
+                {fileIcon(baseName)}
+                <span className={clsx('flex-1 font-mono truncate', isSelected ? 'text-indigo-700' : 'text-gray-700')}>
+                  {baseName}
+                </span>
+                <span className="text-gray-400 shrink-0 tabular-nums">{fmtSize(f.size)}</span>
+              </div>
+            )
+          })}
+        </div>
+      )}
+    </div>
+  )
+}
+
+// ── File preview ──────────────────────────────────────────────────
+
+function FilePreview({
+  agentId, runId, file, apiKey,
+}: {
+  agentId: string
+  runId: string
+  file: WorkspaceFile
+  apiKey: string
+}) {
+  const [content, setContent] = useState<string | null>(null)
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+  const [shown, setShown] = useState(true)
+
+  useEffect(() => {
+    if (!isPreviewable(file.name)) { setContent(null); return }
+    if (file.size > 512 * 1024) { setContent(null); return }  // skip > 512 KB
+    setContent(null)
+    setError(null)
+    setLoading(true)
+    const url = agentsApi.workspaceDownloadUrl(agentId, runId, file.path)
+    fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } })
+      .then(r => r.text())
+      .then(text => {
+        // Try JSON pretty-print
+        if (/\.jsonl?$/i.test(file.name)) {
+          try { setContent(JSON.stringify(JSON.parse(text), null, 2)) }
+          catch { setContent(text) }
+        } else {
+          setContent(text)
+        }
+      })
+      .catch(e => setError(String(e)))
+      .finally(() => setLoading(false))
+  }, [agentId, runId, file.path])
+
+  async function download() {
+    const url = agentsApi.workspaceDownloadUrl(agentId, runId, file.path)
+    const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } })
+    if (!res.ok) return
+    const blob = await res.blob()
+    const a = document.createElement('a')
+    a.href = URL.createObjectURL(blob)
+    a.download = file.name
+    a.click()
+    URL.revokeObjectURL(a.href)
+  }
+
+  return (
+    <div className="flex flex-col h-full">
+      {/* Preview header */}
+      <div className="flex items-center gap-2 px-4 py-2 border-b border-gray-100 bg-gray-50 shrink-0">
+        {fileIcon(file.name)}
+        <span className="text-xs font-mono font-medium text-gray-700 flex-1 truncate">{file.path}</span>
+        <span className="text-xs text-gray-400">{fmtSize(file.size)}</span>
+        {isPreviewable(file.name) && (
+          <button
+            onClick={() => setShown(s => !s)}
+            className="text-gray-400 hover:text-gray-700 transition-colors"
+            title={shown ? '收起预览' : '展开预览'}
+          >
+            {shown ? <EyeOff size={13} /> : <Eye size={13} />}
+          </button>
+        )}
+        <button
+          onClick={download}
+          className="text-gray-400 hover:text-indigo-600 transition-colors"
+          title="下载文件"
+        >
+          <Download size={13} />
+        </button>
+      </div>
+
+      {/* Preview content */}
+      <div className="flex-1 overflow-auto">
+        {loading && (
+          <div className="flex items-center gap-2 p-4 text-xs text-gray-400">
+            <Spinner size={14} /> 加载预览…
+          </div>
+        )}
+        {error && (
+          <div className="flex items-center gap-2 p-4 text-xs text-red-500">
+            <AlertCircle size={14} /> {error}
+          </div>
+        )}
+        {!loading && !error && content !== null && shown && (
+          <pre className="text-xs font-mono p-4 text-gray-800 whitespace-pre-wrap leading-relaxed">
+            {content}
+          </pre>
+        )}
+        {!loading && !error && content === null && (
+          <div className="p-6 text-center text-xs text-gray-400">
+            {!isPreviewable(file.name)
+              ? '二进制文件,不支持预览'
+              : file.size > 512 * 1024
+                ? `文件过大(${fmtSize(file.size)}),请下载查看`
+                : '点击上方眼睛图标展开预览'}
+          </div>
+        )}
+        {!loading && !error && content !== null && !shown && (
+          <div className="p-4 text-xs text-gray-400 text-center">已折叠(共 {content.split('\n').length} 行)</div>
+        )}
+      </div>
+    </div>
+  )
+}
+
+// ── Main page ─────────────────────────────────────────────────────
+
+export default function AgentWorkspace() {
+  const { agentId } = useParams<{ agentId: string }>()
+  const navigate = useNavigate()
+  const { apiKey } = useAppStore()
+
+  const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
+  const [selectedFile, setSelectedFile] = useState<WorkspaceFile | null>(null)
+
+  // Load agent info
+  const { data: agent } = useQuery({
+    queryKey: ['agents', agentId],
+    queryFn: () => agentsApi.get(agentId!),
+    enabled: !!agentId,
+  })
+
+  // Load run history
+  const { data: runsData, isLoading: runsLoading } = useQuery({
+    queryKey: ['runs', agentId],
+    queryFn: () => agentsApi.runs(agentId!, 100),
+    enabled: !!agentId,
+    refetchInterval: 10_000,
+  })
+
+  const allRuns = runsData?.runs ?? []
+  // Auto-select first run with workspace on load
+  useEffect(() => {
+    if (selectedRunId) return
+    const first = allRuns.find(r => r.workspace_path)
+    if (first) setSelectedRunId(first.id)
+  }, [allRuns, selectedRunId])
+
+  // Reset file selection when run changes
+  useEffect(() => { setSelectedFile(null) }, [selectedRunId])
+
+  const selectedRun = allRuns.find(r => r.id === selectedRunId) ?? null
+
+  // Load workspace files for selected run
+  const { data: wsData, isLoading: wsLoading } = useQuery({
+    queryKey: ['workspace', agentId, selectedRunId],
+    queryFn: () => agentsApi.workspaceFiles(agentId!, selectedRunId!),
+    enabled: !!selectedRunId && !!selectedRun?.workspace_path,
+  })
+
+  const files = wsData?.files ?? []
+  const groups = groupFiles(files)
+
+  // Derived state
+  const hasRuns = allRuns.length > 0
+  const hasAnyWorkspace = allRuns.some(r => !!r.workspace_path)
+
+  // Auto-select output.json or first previewable file
+  useEffect(() => {
+    if (!files.length || selectedFile) return
+    const preferred = files.find(f => f.name === 'output.json')
+      ?? files.find(f => isPreviewable(f.name))
+    if (preferred) setSelectedFile(preferred)
+  }, [files])
+
+  return (
+    <div className="flex flex-col h-screen bg-gray-50">
+      {/* Header */}
+      <div className="flex items-center gap-3 px-6 py-4 bg-white border-b border-gray-200 shrink-0">
+        <button onClick={() => navigate('/agents')} className="text-gray-400 hover:text-gray-700 transition-colors">
+          <ArrowLeft size={18} />
+        </button>
+        <div className="flex items-center gap-2">
+          <Folder size={16} className="text-amber-500" />
+          <span className="text-sm font-semibold text-gray-900">
+            {agent?.name ?? '…'} 的工作区
+          </span>
+        </div>
+        <span className="text-xs text-gray-400 ml-1">
+          {allRuns.length} 次运行 · {allRuns.filter(r => r.workspace_path).length} 个工作区
+        </span>
+      </div>
+
+      {/* Body */}
+      <div className="flex flex-1 overflow-hidden">
+
+        {/* ── Run list sidebar ─────────────────────────────── */}
+        <div className="w-64 shrink-0 border-r border-gray-200 bg-white flex flex-col overflow-hidden">
+          <div className="px-4 py-2.5 border-b border-gray-100 shrink-0">
+            <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">运行记录</p>
+          </div>
+
+          <div className="flex-1 overflow-y-auto">
+            {runsLoading && (
+              <div className="flex justify-center py-8"><Spinner size={20} /></div>
+            )}
+            {!runsLoading && allRuns.length === 0 && (
+              <div className="text-center py-12 px-4">
+                <Inbox size={28} className="mx-auto mb-2 text-gray-300" />
+                <p className="text-xs text-gray-400">暂无运行记录</p>
+                <p className="text-xs text-gray-300 mt-1">先在对话页发送消息</p>
+              </div>
+            )}
+            {/* Banner: runs exist but none have workspace */}
+            {!runsLoading && hasRuns && !hasAnyWorkspace && (
+              agent?.agent_dir ? (
+                /* agent_dir set — just needs a fresh run */
+                <div className="mx-3 mt-3 rounded-lg border border-green-200 bg-green-50 p-3 text-xs text-green-700 space-y-1.5">
+                  <div className="flex items-center gap-1.5 font-semibold">
+                    <CheckCircle size={12} /> 工作目录已配置
+                  </div>
+                  <p className="leading-relaxed text-green-600">
+                    发送一条新消息后,工作区文件将自动出现。
+                  </p>
+                  <button
+                    onClick={() => navigate(`/chat/${agentId}`)}
+                    className="flex items-center gap-1 font-medium text-green-700 hover:text-green-900 transition-colors"
+                  >
+                    <MousePointerClick size={11} /> 去对话页 →
+                  </button>
+                </div>
+              ) : (
+                /* agent_dir not set — need to configure */
+                <div className="mx-3 mt-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700 space-y-1.5">
+                  <div className="flex items-center gap-1.5 font-semibold">
+                    <AlertCircle size={12} /> 未设置工作目录
+                  </div>
+                  <p className="leading-relaxed text-amber-600">
+                    在编辑页的「基本设置」中填写工作目录后重新运行即可。
+                  </p>
+                  <button
+                    onClick={() => navigate(`/agents/${agentId}/edit`)}
+                    className="flex items-center gap-1 font-medium text-amber-700 hover:text-amber-900 transition-colors"
+                  >
+                    <Settings2 size={11} /> 去设置 →
+                  </button>
+                </div>
+              )
+            )}
+            {allRuns.map(run => {
+              const hasWS = !!run.workspace_path
+              const isSelected = run.id === selectedRunId
+              return (
+                <button
+                  key={run.id}
+                  onClick={() => hasWS && setSelectedRunId(run.id)}
+                  disabled={!hasWS}
+                  className={clsx(
+                    'w-full text-left px-4 py-3 border-b border-gray-50 transition-colors',
+                    isSelected
+                      ? 'bg-indigo-50 border-l-2 border-indigo-400'
+                      : 'hover:bg-gray-50 border-l-2 border-transparent',
+                    !hasWS && 'opacity-40 cursor-default',
+                  )}
+                >
+                  <div className="flex items-center gap-2 mb-1">
+                    <RunBadge status={run.status} />
+                    {run.duration_ms > 0 && (
+                      <span className="text-xs text-gray-400 ml-auto">{fmtMs(run.duration_ms)}</span>
+                    )}
+                  </div>
+                  <p className="text-xs text-gray-500 mb-0.5">{fmtDate(run.created_at)}</p>
+                  {run.input && (
+                    <p className="text-xs text-gray-400 truncate leading-relaxed">
+                      {run.input.slice(0, 50)}
+                    </p>
+                  )}
+                  {run.input_tokens > 0 && (
+                    <p className="text-xs text-gray-300 mt-0.5">{run.input_tokens} tokens</p>
+                  )}
+                  {!hasWS && (
+                    <p className="text-xs text-gray-300 italic mt-0.5">无工作区</p>
+                  )}
+                </button>
+              )
+            })}
+          </div>
+        </div>
+
+        {/* ── No workspace runs yet ─── */}
+        {!runsLoading && hasRuns && !hasAnyWorkspace && (
+          <div className="flex-1 flex items-center justify-center bg-gray-50 p-8">
+            <div className="max-w-md w-full space-y-6">
+
+              {agent?.agent_dir ? (
+                /* ── Case A: agent_dir IS set, just no runs with workspace yet ── */
+                <>
+                  <div className="text-center">
+                    <div className="w-14 h-14 rounded-2xl bg-green-50 border border-green-200 flex items-center justify-center mx-auto mb-4">
+                      <CheckCircle size={28} className="text-green-500" />
+                    </div>
+                    <h2 className="text-base font-semibold text-gray-800">工作目录已配置</h2>
+                    <p className="text-sm text-gray-500 mt-1">
+                      历史运行均在配置工作目录之前产生,因此没有工作区文件。
+                      <br />下次运行会自动生成。
+                    </p>
+                  </div>
+
+                  <div className="rounded-xl border border-gray-200 bg-white p-4 space-y-3">
+                    <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">当前工作目录</p>
+                    <code className="block text-xs bg-gray-50 border border-gray-200 rounded-lg px-3 py-2.5 font-mono text-indigo-600 break-all">
+                      {agent.agent_dir}
+                    </code>
+                    <p className="text-xs text-gray-400 leading-relaxed">
+                      每次运行会在此目录下自动创建:
+                      <br /><span className="font-mono text-gray-500">workspace/run_YYYYMMDD_HHMMSS/</span>
+                    </p>
+                  </div>
+
+                  <div className="flex gap-3 justify-center">
+                    <button
+                      onClick={() => navigate(`/chat/${agentId}`)}
+                      className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors shadow-sm"
+                    >
+                      <MousePointerClick size={14} />
+                      去对话页发消息
+                    </button>
+                    <button
+                      onClick={() => navigate(`/agents/${agentId}/edit`)}
+                      className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-gray-200 bg-white text-gray-600 text-sm font-medium hover:bg-gray-50 transition-colors"
+                    >
+                      <Settings2 size={14} />
+                      修改路径
+                    </button>
+                  </div>
+                </>
+              ) : (
+                /* ── Case B: agent_dir NOT set — full setup guide ── */
+                <>
+                  <div className="text-center">
+                    <div className="w-14 h-14 rounded-2xl bg-amber-50 border border-amber-200 flex items-center justify-center mx-auto mb-4">
+                      <Folder size={28} className="text-amber-400" />
+                    </div>
+                    <h2 className="text-base font-semibold text-gray-800">工作区未启用</h2>
+                    <p className="text-sm text-gray-500 mt-1">
+                      为智能体设置工作目录后,每次运行的输入、输出、推理轨迹和产物文件都会被持久化保存。
+                    </p>
+                  </div>
+
+                  <div className="rounded-xl border border-gray-200 bg-white divide-y divide-gray-100">
+                    <div className="flex gap-3 p-4">
+                      <span className="w-5 h-5 rounded-full bg-indigo-100 text-indigo-600 text-xs font-bold flex items-center justify-center shrink-0 mt-0.5">1</span>
+                      <div>
+                        <p className="text-sm font-medium text-gray-700">设置工作目录</p>
+                        <p className="text-xs text-gray-500 mt-0.5">
+                          点击下方按钮 → 编辑 → 基本设置 → <span className="font-mono bg-gray-100 px-1 rounded">工作目录(agent_dir)</span> 填入本地路径,例如:
+                        </p>
+                        <code className="block mt-1.5 text-xs bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 font-mono text-gray-600">
+                          /home/user/my-agent
+                        </code>
+                      </div>
+                    </div>
+                    <div className="flex gap-3 p-4">
+                      <span className="w-5 h-5 rounded-full bg-indigo-100 text-indigo-600 text-xs font-bold flex items-center justify-center shrink-0 mt-0.5">2</span>
+                      <div>
+                        <p className="text-sm font-medium text-gray-700">保存并重新运行</p>
+                        <p className="text-xs text-gray-500 mt-0.5">
+                          保存后,在对话页发送一条消息,系统会自动在工作目录下创建:
+                        </p>
+                        <code className="block mt-1.5 text-xs bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 font-mono text-gray-600 leading-relaxed">
+                          workspace/run_20260602_224935/<br />
+                          {'├── input.json  ├── output.json'}<br />
+                          {'├── trace.json  └── cost.json'}
+                        </code>
+                      </div>
+                    </div>
+                    <div className="flex gap-3 p-4">
+                      <span className="w-5 h-5 rounded-full bg-indigo-100 text-indigo-600 text-xs font-bold flex items-center justify-center shrink-0 mt-0.5">3</span>
+                      <div>
+                        <p className="text-sm font-medium text-gray-700">回到此页查看</p>
+                        <p className="text-xs text-gray-500 mt-0.5">
+                          左栏选运行记录 → 中栏点文件 → 右栏即时预览,支持 JSON 格式化,可下载。
+                        </p>
+                      </div>
+                    </div>
+                  </div>
+
+                  <div className="flex justify-center">
+                    <button
+                      onClick={() => navigate(`/agents/${agentId}/edit`)}
+                      className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors shadow-sm"
+                    >
+                      <Settings2 size={14} />
+                      去设置工作目录
+                    </button>
+                  </div>
+                </>
+              )}
+            </div>
+          </div>
+        )}
+
+        {/* ── File browser + preview (only when workspaces exist) ── */}
+        {(hasAnyWorkspace || !hasRuns) && (
+        <div className="flex flex-1 overflow-hidden">
+
+          {/* File tree */}
+          <div className="w-60 shrink-0 border-r border-gray-200 bg-white flex flex-col overflow-hidden">
+            <div className="px-4 py-2.5 border-b border-gray-100 shrink-0 flex items-center gap-2">
+              <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide flex-1">文件</p>
+              {wsData && (
+                <span className="text-xs text-gray-400">{files.length} 个</span>
+              )}
+            </div>
+
+            <div className="flex-1 overflow-y-auto">
+              {!selectedRunId && (
+                <div className="text-center py-12 px-4">
+                  <Folder size={28} className="mx-auto mb-2 text-gray-200" />
+                  <p className="text-xs text-gray-400">选择左侧运行记录</p>
+                </div>
+              )}
+              {selectedRunId && !selectedRun?.workspace_path && (
+                <div className="text-center py-12 px-4">
+                  <AlertCircle size={28} className="mx-auto mb-2 text-gray-200" />
+                  <p className="text-xs text-gray-400">该次运行无工作区</p>
+                </div>
+              )}
+              {wsLoading && (
+                <div className="flex justify-center py-8"><Spinner size={20} /></div>
+              )}
+              {!wsLoading && files.length === 0 && selectedRun?.workspace_path && (
+                <div className="text-center py-12 px-4">
+                  <Inbox size={28} className="mx-auto mb-2 text-gray-200" />
+                  <p className="text-xs text-gray-400">工作区为空</p>
+                </div>
+              )}
+              {groups.map(([dir, dirFiles]) => (
+                <DirGroup
+                  key={dir}
+                  dir={dir}
+                  files={dirFiles}
+                  selected={selectedFile}
+                  onSelect={setSelectedFile}
+                />
+              ))}
+            </div>
+          </div>
+
+          {/* Preview area */}
+          <div className="flex-1 bg-white overflow-hidden flex flex-col">
+            {!selectedFile ? (
+              <div className="flex flex-col items-center justify-center h-full gap-6 px-8">
+                {/* How-to hint when workspace files are available */}
+                {hasAnyWorkspace && selectedRunId && files.length > 0 ? (
+                  <div className="text-center">
+                    <MousePointerClick size={36} className="mx-auto mb-3 text-gray-200" />
+                    <p className="text-sm text-gray-400">点击左侧文件名即可预览</p>
+                    <p className="text-xs text-gray-300 mt-1">支持 JSON / 代码 / 文本,右上角可下载</p>
+                  </div>
+                ) : hasAnyWorkspace ? (
+                  <div className="text-center">
+                    <Folder size={36} className="mx-auto mb-3 text-amber-200" />
+                    <p className="text-sm text-gray-400">点击左侧有工作区的运行记录</p>
+                    <p className="text-xs text-gray-300 mt-1">只有标记了"完成"的运行才有文件</p>
+                  </div>
+                ) : (
+                  <div className="text-center">
+                    <Eye size={36} className="mx-auto mb-3 text-gray-200" />
+                    <p className="text-sm text-gray-400">暂无可预览的文件</p>
+                  </div>
+                )}
+
+                {/* Inline usage guide */}
+                {hasAnyWorkspace && (
+                  <div className="w-full max-w-sm rounded-xl border border-gray-100 bg-gray-50 p-4 space-y-3">
+                    <div className="flex items-center gap-2 text-xs font-semibold text-gray-500">
+                      <Info size={12} /> 工作区文件说明
+                    </div>
+                    <div className="space-y-2 text-xs text-gray-500">
+                      {[
+                        ['input.json',  '本次运行的原始输入和时间戳'],
+                        ['output.json', '最终回答、状态码、耗时统计'],
+                        ['trace.json',  '完整推理轨迹(每个工具调用和结果)'],
+                        ['cost.json',   'Token 用量和估算费用'],
+                        ['config.yml',  '本次运行时使用的配置快照'],
+                        ['code/',       'Agent 产生的代码文件'],
+                        ['results/',    'Agent 产生的数据或分析结果'],
+                        ['final/',      '最终输出产物'],
+                      ].map(([name, desc]) => (
+                        <div key={name} className="flex gap-2">
+                          <code className="font-mono text-indigo-500 shrink-0 w-24">{name}</code>
+                          <span className="text-gray-400">{desc}</span>
+                        </div>
+                      ))}
+                    </div>
+                  </div>
+                )}
+              </div>
+            ) : (
+              <>
+                {/* Run context bar */}
+                {selectedRun && (
+                  <div className="flex items-center gap-3 px-4 py-2 bg-gray-50 border-b border-gray-100 text-xs text-gray-500 shrink-0">
+                    <RunBadge status={selectedRun.status} />
+                    <span>{fmtDate(selectedRun.created_at)}</span>
+                    {selectedRun.duration_ms > 0 && <span>{fmtMs(selectedRun.duration_ms)}</span>}
+                    {selectedRun.steps > 0 && <span>{selectedRun.steps} 步</span>}
+                    {selectedRun.input_tokens > 0 && <span>{selectedRun.input_tokens} tokens</span>}
+                  </div>
+                )}
+                <div className="flex-1 overflow-hidden">
+                  <FilePreview
+                    agentId={agentId!}
+                    runId={selectedRunId!}
+                    file={selectedFile}
+                    apiKey={apiKey ?? ''}
+                  />
+                </div>
+              </>
+            )}
+          </div>
+        </div>
+        )}
+      </div>
+    </div>
+  )
+}

+ 449 - 32
webui/src/pages/Chat.tsx

@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
 import {
   ArrowLeft, Send, Square, ChevronDown, ChevronRight,
   Bot, User, Clock, Brain, Folder, Download, Trash2, X,
-  Save, RotateCcw, History, Key, BookMarked,
+  Save, RotateCcw, History, Key, BookMarked, LayoutDashboard,
 } from 'lucide-react'
 import { agentsApi, type WorkspaceFile, type MemoryEntry, type CoreMemory, type RecallEntry } from '../api/agents'
 import { knowledgeApi } from '../api/knowledge'
@@ -18,8 +18,9 @@ type Role = 'user' | 'assistant'
 type MsgStatus = 'streaming' | 'done' | 'error'
 
 interface ThinkStep {
-  type: 'think' | 'tool_call' | 'tool_result' | 'error'
+  type: 'think' | 'tool_call' | 'tool_result' | 'error' | 'think_chunk' | 'think_start'
   content: string
+  tool?: string
 }
 
 interface Message {
@@ -35,9 +36,17 @@ interface Message {
 // ── Step block ────────────────────────────────────────────────────
 
 function StepBlock({ step }: { step: ThinkStep }) {
-  const [open, setOpen] = useState(false)
-  const icons = { think: '💭', tool_call: '🔧', tool_result: '✅', error: '❌' }
-  const labels = { think: '推理', tool_call: '工具调用', tool_result: '工具结果', error: '错误' }
+  const [open, setOpen] = useState(step.type === 'think')  // think blocks open by default
+  const icons: Record<string, string> = {
+    think: '💭', tool_call: '🔧', tool_result: '✅', error: '❌', think_chunk: '✨',
+  }
+  const labels: Record<string, string> = {
+    think: '推理过程', tool_call: '工具调用', tool_result: '工具结果', error: '错误', think_chunk: '流式输出',
+  }
+  // For tool events show tool name in header
+  const headerLabel = step.tool
+    ? `${labels[step.type]} · ${step.tool}`
+    : labels[step.type]
 
   return (
     <div className="border border-gray-100 rounded-lg overflow-hidden text-xs">
@@ -45,12 +54,12 @@ function StepBlock({ step }: { step: ThinkStep }) {
         onClick={() => setOpen(o => !o)}
         className="flex items-center gap-2 w-full px-3 py-2 bg-gray-50 hover:bg-gray-100 text-gray-600 transition-colors"
       >
-        <span>{icons[step.type]}</span>
-        <span className="font-medium">{labels[step.type]}</span>
+        <span>{icons[step.type] ?? '📌'}</span>
+        <span className="font-medium">{headerLabel}</span>
         {open ? <ChevronDown size={12} className="ml-auto" /> : <ChevronRight size={12} className="ml-auto" />}
       </button>
       {open && (
-        <pre className="px-3 py-2 text-gray-700 whitespace-pre-wrap break-words font-mono leading-relaxed bg-white max-h-48 overflow-auto">
+        <pre className="px-3 py-2 text-gray-700 whitespace-pre-wrap break-words font-mono leading-relaxed bg-white max-h-64 overflow-auto">
           {step.content}
         </pre>
       )}
@@ -130,7 +139,13 @@ function WorkspacePanel({ agentId, runId, apiKey }: { agentId: string; runId: st
 
 // ── Message bubble ────────────────────────────────────────────────
 
-function MessageBubble({ msg, agentId, apiKey }: { msg: Message; agentId: string; apiKey: string }) {
+function MessageBubble({
+  msg, agentId, apiKey,
+  onContinue,
+}: {
+  msg: Message; agentId: string; apiKey: string
+  onContinue?: (runId: string, workspacePath: string) => void
+}) {
   const isUser = msg.role === 'user'
 
   return (
@@ -165,9 +180,22 @@ function MessageBubble({ msg, agentId, apiKey }: { msg: Message; agentId: string
           </div>
         )}
 
-        {/* Workspace file panel — appears below assistant messages with workspace */}
+        {/* Workspace file panel + Continue button */}
         {!isUser && msg.status === 'done' && msg.runId && (
-          <WorkspacePanel agentId={agentId} runId={msg.runId} apiKey={apiKey} />
+          <div className="space-y-1 w-full">
+            <WorkspacePanel agentId={agentId} runId={msg.runId} apiKey={apiKey} />
+            {onContinue && (
+              <button
+                onClick={() => onContinue(msg.runId!, msg.workspacePath ?? '')}
+                className="flex items-center gap-1.5 text-xs text-indigo-600 hover:text-indigo-800 bg-indigo-50 hover:bg-indigo-100 border border-indigo-200 rounded-lg px-3 py-1.5 transition-colors"
+                title={`在此 run 的工作区基础上继续提问\n${msg.workspacePath ?? ''}`}
+              >
+                <RotateCcw size={11} />
+                继续此 run
+                <span className="text-indigo-400 font-mono">{msg.runId?.slice(-8)}</span>
+              </button>
+            )}
+          </div>
         )}
       </div>
     </div>
@@ -532,10 +560,54 @@ export default function Chat() {
     ta.style.height = `${Math.min(ta.scrollHeight, 160)}px`
   }, [input])
 
+  // Track which run to continue from (useRef avoids stale-closure in useCallback)
+  const [continueFromRunId, setContinueFromRunId] = useState<string | null>(null)
+  const [continueFromPath, setContinueFromPath] = useState<string | null>(null)
+  const continueRunIdRef  = useRef<string | null>(null)
+  const continuePathRef   = useRef<string | null>(null)
+
+  // Mode selector for continue-this-run flows.
+  //   iterate (default): full pipeline, new cycle_N+1
+  //   edit:              call ONE sub-agent on the same cycle
+  //   chat:              read-only Q&A, no sub-agents
+  type Mode = 'iterate' | 'edit' | 'chat'
+  const [mode, setMode] = useState<Mode>('iterate')
+  const [targetSubagent, setTargetSubagent] = useState<string>('')
+  const modeRef            = useRef<Mode>('iterate')
+  const targetSubagentRef  = useRef<string>('')
+
+  // Available sub-agents read straight from the agent's config — backend
+  // accepts both camelCase `subAgents` and snake_case `sub_agents`.
+  const subAgents: string[] = (() => {
+    const cfg = (agent?.config ?? {}) as Record<string, unknown>
+    const dict = (cfg.subAgents ?? cfg.sub_agents ?? {}) as Record<string, { tool?: string; tool_name?: string }>
+    const out: string[] = []
+    for (const [name, def] of Object.entries(dict)) {
+      const toolName = (def?.tool || def?.tool_name) ?? `call_${name.replace('-agent', '')}`
+      if (!out.includes(toolName)) out.push(toolName)
+    }
+    return out
+  })()
+
   const sendMessage = useCallback(async () => {
     if (!input.trim() || streaming || !agentId) return
 
     const userText = input.trim()
+    // Read from refs — always current, no stale-closure issue
+    const ctxRunId = continueRunIdRef.current
+    const ctxPath  = continuePathRef.current
+    const ctxMode  = modeRef.current
+    const ctxTarget = targetSubagentRef.current
+    // Clear continue context after capturing
+    continueRunIdRef.current  = null
+    continuePathRef.current   = null
+    modeRef.current           = 'iterate'
+    targetSubagentRef.current = ''
+    setContinueFromRunId(null)
+    setContinueFromPath(null)
+    setMode('iterate')
+    setTargetSubagent('')
+
     setInput('')
     setMessages(prev => [
       ...prev,
@@ -551,25 +623,71 @@ export default function Chat() {
 
     let cancelled = false
     let finalText = ''
+    let idleTimer: ReturnType<typeof setInterval> | null = null
+    let currentRunId: string | null = null   // captured from the SSE 'started' event
+    const abortCtrl = new AbortController()
 
     try {
       const res = await fetch(`/api/v1/agents/${agentId}/run/stream`, {
         method: 'POST',
         headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
-        body: JSON.stringify({ input: userText, parameters: {}, context: {} }),
+        body: JSON.stringify({
+          input: userText,
+          parameters: {},
+          context: ctxRunId ? { run_id: ctxRunId, workspace_path: ctxPath } : {},
+          // Only meaningful when continuing — fresh runs always run as iterate
+          mode: ctxRunId ? ctxMode : 'iterate',
+          target_subagent: ctxRunId && ctxMode === 'edit' ? ctxTarget : '',
+        }),
+        signal: abortCtrl.signal,
       })
 
       if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
 
-      abortRef.current = () => { cancelled = true }
-
       const reader = res.body.getReader()
       const decoder = new TextDecoder()
       let buffer = ''
 
+      // Stop button: setting `cancelled` alone isn't enough — reader.read()
+      // can park forever while the backend is still computing (no token to
+      // wake it). We also call reader.cancel() to release the read and
+      // abortCtrl.abort() to tear down the underlying connection so the
+      // backend sees the client disconnect.
+      //
+      // For TRUE cancellation we also POST /runs/<id>/cancel — without
+      // that the agent's background thread keeps spawning claude
+      // subprocesses even after the SSE connection is dead. We fire the
+      // POST in the background (no await) so the UI unblocks immediately.
+      abortRef.current = () => {
+        cancelled = true
+        if (currentRunId) {
+          fetch(`/api/v1/agents/${agentId}/runs/${currentRunId}/cancel`, {
+            method: 'POST',
+            headers: { Authorization: `Bearer ${apiKey}` },
+          }).catch(() => { /* nop — best-effort */ })
+        }
+        try { reader.cancel('user stop') } catch { /* nop */ }
+        try { abortCtrl.abort() } catch { /* nop */ }
+      }
+
+      // Idle-timeout watchdog: if no SSE chunk arrives for this long, assume
+      // the upstream connection is dead (e.g. backend restarted mid-stream).
+      // Without this the reader.read() can park forever and freeze the input
+      // box until the user hard-refreshes the page.
+      const IDLE_TIMEOUT_MS = 90_000
+      let lastChunkAt = Date.now()
+      idleTimer = setInterval(() => {
+        if (Date.now() - lastChunkAt > IDLE_TIMEOUT_MS) {
+          cancelled = true
+          try { reader.cancel('idle timeout') } catch { /* nop */ }
+          try { abortCtrl.abort() } catch { /* nop */ }
+        }
+      }, 5_000)
+
       while (!cancelled) {
         const { done, value } = await reader.read()
         if (done) break
+        lastChunkAt = Date.now()
 
         buffer += decoder.decode(value, { stream: true })
         const parts = buffer.split('\n\n')
@@ -589,26 +707,130 @@ export default function Chat() {
           let payload: Record<string, unknown>
           try { payload = JSON.parse(dataLine) } catch { continue }
 
-          if (eventType === 'answer' || eventType === 'done') {
+          if (eventType === 'started') {
+            // Backend emits this immediately after creating the run row,
+            // long before any work happens. Capture run_id so the Stop
+            // button can call /cancel even if the user clicks before any
+            // other event arrives.
+            const rid = payload.run_id as string | undefined
+            if (rid) currentRunId = rid
+
+          } else if (eventType === 'answer') {
+            // answer event: backend sends {content, step, tool, duration_ms}
+            const text = (payload.content ?? payload.output ?? payload.message ?? '') as string
+            if (text) finalText = text
+
+          } else if (eventType === 'cancelled') {
+            // Backend confirmed the run was cancelled. Mark the bubble.
+            setMessages(prev => prev.map(m =>
+              m.id === assistantId
+                ? { ...m, status: 'done', text: m.text || '(已取消)' }
+                : m,
+            ))
+            qc.invalidateQueries({ queryKey: ['runs', agentId] })
+
+          } else if (eventType === 'done') {
             const text = (payload.output ?? payload.message ?? '') as string
             if (text) finalText = text
-            if (eventType === 'done') {
-              const runId = payload.run_id as string | undefined
-              const workspacePath = (payload.workspace_path as string) || undefined
+            const runId = payload.run_id as string | undefined
+            const workspacePath = (payload.workspace_path as string) || undefined
+            setMessages(prev => prev.map(m =>
+              m.id === assistantId
+                ? { ...m, text: finalText || m.text, status: 'done', runId, workspacePath }
+                : m,
+            ))
+            qc.invalidateQueries({ queryKey: ['runs', agentId] })
+            break
+
+          } else if (eventType === 'think_start') {
+            // Immediately show "thinking…" in the bubble before LLM call returns
+            const content = (payload.content as string) || '正在推理…'
+            setMessages(prev => prev.map(m =>
+              m.id === assistantId ? { ...m, text: `⏳ ${content}` } : m,
+            ))
+
+          } else if (eventType === 'think') {
+            // LLM call done — parse JSON tool call and show human-readable summary in bubble
+            const content = (payload.content as string) || JSON.stringify(payload, null, 2)
+            // Try to render JSON tool call as readable text
+            let bubbleText = content
+            try {
+              // Strip markdown code fences if present
+              const cleaned = content.replace(/^```(?:json)?\s*/m, '').replace(/```\s*$/m, '').trim()
+              const obj = JSON.parse(cleaned)
+              if (obj.tool) {
+                const TOOL_NAMES: Record<string, string> = {
+                  call_physlit: '📚 文献调研',
+                  call_physsim: '🔬 数值模拟',
+                  call_physanalyst: '📊 数据分析',
+                  call_physwriter: '✍️ 论文写作',
+                  terminate: '✅ 完成',
+                }
+                const label = TOOL_NAMES[obj.tool] || `🔧 ${obj.tool}`
+                const inputPreview = typeof obj.input === 'string'
+                  ? obj.input.slice(0, 100)
+                  : JSON.stringify(obj.input).slice(0, 100)
+                bubbleText = `${label}\n${inputPreview}${inputPreview.length >= 100 ? '…' : ''}`
+              }
+            } catch { /* not JSON, show raw */ }
+            setMessages(prev => prev.map(m =>
+              m.id === assistantId
+                ? { ...m, text: bubbleText, steps: [...m.steps, { type: 'think', content }] }
+                : m,
+            ))
+
+          } else if (eventType === 'think_chunk') {
+            // Streaming text chunks — accumulate into message bubble
+            const chunk = (payload.content as string) || ''
+            if (chunk) {
               setMessages(prev => prev.map(m =>
                 m.id === assistantId
-                  ? { ...m, text: finalText || m.text, status: 'done', runId, workspacePath }
+                  ? { ...m, text: (m.text || '') + chunk }
                   : m,
               ))
-              // Invalidate runs cache so history is fresh on next load
-              qc.invalidateQueries({ queryKey: ['runs', agentId] })
-              break
             }
-          } else if (['think', 'tool_call', 'tool_result', 'error'].includes(eventType)) {
-            const content = JSON.stringify(payload, null, 2)
+
+          } else if (eventType === 'tool_call') {
+            // Show tool call immediately in bubble
+            const tool = (payload.tool as string) || '工具'
+            const content = (payload.content as string) || ''
+            const TOOL_NAMES: Record<string, string> = {
+              call_physlit: '📚 文献调研', call_physsim: '🔬 数值模拟',
+              call_physanalyst: '📊 数据分析', call_physwriter: '✍️ 论文写作',
+              terminate: '✅ 完成', Bash: '💻 执行命令', WriteFile: '📝 写入文件',
+              ReadFile: '📖 读取文件',
+            }
+            const label = TOOL_NAMES[tool] || `🔧 ${tool}`
+            const preview = content.slice(0, 80) + (content.length > 80 ? '…' : '')
             setMessages(prev => prev.map(m =>
               m.id === assistantId
-                ? { ...m, steps: [...m.steps, { type: eventType as ThinkStep['type'], content }] }
+                ? {
+                    ...m,
+                    text: `${label}\n${preview}`,
+                    steps: [...m.steps, { type: 'tool_call', content, tool }],
+                  }
+                : m,
+            ))
+
+          } else if (eventType === 'tool_result') {
+            const content = (payload.content as string) || JSON.stringify(payload, null, 2)
+            const tool = (payload.tool as string) || undefined
+            const preview = content.slice(0, 60) + (content.length > 60 ? '…' : '')
+            setMessages(prev => prev.map(m =>
+              m.id === assistantId
+                ? {
+                    ...m,
+                    text: `✅ 返回结果: ${preview}`,
+                    steps: [...m.steps, { type: 'tool_result', content, tool }],
+                  }
+                : m,
+            ))
+
+          } else if (eventType === 'error') {
+            const content = (payload.content as string) || JSON.stringify(payload, null, 2)
+            setMessages(prev => prev.map(m =>
+              m.id === assistantId
+                ? { ...m, steps: [...m.steps, { type: 'error', content }] }
                 : m,
             ))
           }
@@ -621,17 +843,38 @@ export default function Chat() {
         ))
       }
     } catch (e) {
+      // User-initiated stop / abort throws AbortError — treat as graceful
+      // stop, not as an error red bubble.
+      const err = e as Error
+      const isAbort = err.name === 'AbortError' || cancelled
       setMessages(prev => prev.map(m =>
         m.id === assistantId
-          ? { ...m, status: 'error', text: `错误:${(e as Error).message}` }
+          ? isAbort
+            ? { ...m, status: 'done', text: m.text || '(已停止)' }
+            : { ...m, status: 'error', text: `错误:${err.message}` }
           : m,
       ))
     } finally {
+      if (idleTimer) clearInterval(idleTimer)
       setStreaming(false)
       abortRef.current = null
     }
   }, [input, streaming, agentId, apiKey, qc])
 
+  // Belt-and-suspenders safety net: if the SSE stream silently drops (eg. the
+  // backend was restarted), the page would otherwise leave `streaming` stuck
+  // at true and the input box disabled. Pressing Escape now force-unsticks.
+  useEffect(() => {
+    function onKey(e: KeyboardEvent) {
+      if (e.key === 'Escape' && streaming) {
+        abortRef.current?.()
+        setStreaming(false)
+      }
+    }
+    window.addEventListener('keydown', onKey)
+    return () => window.removeEventListener('keydown', onKey)
+  }, [streaming])
+
   function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
     if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() }
   }
@@ -669,6 +912,15 @@ export default function Chat() {
             ))}
           </div>
         </div>
+        {/* Workspace link */}
+        <button
+          onClick={() => navigate(`/agents/${agentId}/workspace`)}
+          className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors"
+          title="查看运行工作区"
+        >
+          <LayoutDashboard size={13} />
+          工作区
+        </button>
         {/* Memory toggle */}
         <button
           onClick={() => setMemoryOpen(o => !o)}
@@ -735,6 +987,13 @@ export default function Chat() {
               msg={msg}
               agentId={agentId!}
               apiKey={apiKey ?? ''}
+              onContinue={(runId, workspacePath) => {
+                continueRunIdRef.current = runId
+                continuePathRef.current  = workspacePath
+                setContinueFromRunId(runId)
+                setContinueFromPath(workspacePath)
+                textareaRef.current?.focus()
+              }}
             />
           ))}
           <div ref={bottomRef} />
@@ -746,18 +1005,158 @@ export default function Chat() {
         )}
       </div>
 
+      {/* Continue-from-run banner — pinned above input so it's always visible
+          right before the user sends. Distinguishes "continue this run"
+          from "start a fresh conversation". */}
+      {continueFromRunId && (() => {
+        const prevRun = runsData?.runs?.find(r => r.id === continueFromRunId)
+        const prevInputPreview = prevRun?.input?.slice(0, 120) ?? ''
+        return (
+          <div className="px-6 pt-3 bg-white shrink-0">
+            <div className="max-w-4xl mx-auto rounded-xl border border-indigo-300 bg-indigo-50/70 overflow-hidden">
+              <div className="flex items-center gap-2 px-4 py-2 bg-indigo-100/60 border-b border-indigo-200">
+                <RotateCcw size={13} className="text-indigo-600 shrink-0" />
+                <span className="text-xs font-semibold text-indigo-900">
+                  继续模式 — 下一条消息会在此 run 的工作区基础上执行
+                </span>
+                <button
+                  onClick={() => {
+                    continueRunIdRef.current = null
+                    continuePathRef.current = null
+                    setContinueFromRunId(null)
+                    setContinueFromPath(null)
+                  }}
+                  className="ml-auto flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-900 hover:bg-indigo-200/60 px-2 py-0.5 rounded transition-colors"
+                  title="取消继续,下一条作为全新对话"
+                >
+                  <X size={12} />
+                  改为新对话
+                </button>
+              </div>
+              <div className="px-4 py-2.5 text-xs text-indigo-900/90 space-y-1.5">
+                <div className="flex items-baseline gap-2">
+                  <span className="font-medium text-indigo-700/70 shrink-0 w-16">Run ID</span>
+                  <code className="font-mono text-indigo-900 bg-white/80 px-1.5 py-0.5 rounded border border-indigo-200">
+                    {continueFromRunId.slice(-12)}
+                  </code>
+                </div>
+                {continueFromPath && (
+                  <div className="flex items-baseline gap-2">
+                    <span className="font-medium text-indigo-700/70 shrink-0 w-16">工作区</span>
+                    <code
+                      className="font-mono text-indigo-900/90 truncate flex-1"
+                      title={continueFromPath}
+                    >
+                      {continueFromPath}
+                    </code>
+                    <button
+                      onClick={() => navigator.clipboard?.writeText(continueFromPath)}
+                      className="text-indigo-500 hover:text-indigo-700 shrink-0 text-xs underline-offset-2 hover:underline"
+                      title="复制路径"
+                    >
+                      复制
+                    </button>
+                  </div>
+                )}
+                {prevInputPreview && (
+                  <div className="flex items-baseline gap-2">
+                    <span className="font-medium text-indigo-700/70 shrink-0 w-16">上一轮</span>
+                    <span className="text-indigo-800/80 line-clamp-2 leading-snug">
+                      {prevInputPreview}
+                      {(prevRun?.input?.length ?? 0) > 120 ? '…' : ''}
+                    </span>
+                  </div>
+                )}
+
+                {/* Mode selector — chat / edit / iterate */}
+                <div className="pt-1 border-t border-indigo-200/60 mt-1">
+                  <div className="flex items-baseline gap-2">
+                    <span className="font-medium text-indigo-700/70 shrink-0 w-16">模式</span>
+                    <div className="flex gap-1 flex-wrap">
+                      {([
+                        { id: 'chat',    icon: '💬', label: 'Chat',    desc: '只读问答,< 2 min' },
+                        { id: 'edit',    icon: '✏️', label: 'Edit',    desc: '调一个 sub-agent,~10 min' },
+                        { id: 'iterate', icon: '🔄', label: 'Iterate', desc: '新 cycle,~1-2 h' },
+                      ] as { id: Mode; icon: string; label: string; desc: string }[]).map(opt => (
+                        <button
+                          key={opt.id}
+                          onClick={() => {
+                            modeRef.current = opt.id
+                            setMode(opt.id)
+                            if (opt.id !== 'edit') {
+                              targetSubagentRef.current = ''
+                              setTargetSubagent('')
+                            }
+                          }}
+                          className={clsx(
+                            'flex items-center gap-1 text-xs px-2 py-0.5 rounded border transition-colors',
+                            mode === opt.id
+                              ? 'bg-indigo-600 text-white border-indigo-600 shadow-sm'
+                              : 'bg-white text-indigo-700 border-indigo-200 hover:border-indigo-400'
+                          )}
+                          title={opt.desc}
+                        >
+                          <span>{opt.icon}</span>
+                          <span className="font-medium">{opt.label}</span>
+                        </button>
+                      ))}
+                    </div>
+                  </div>
+                  {/* Edit-mode sub-agent picker */}
+                  {mode === 'edit' && (
+                    <div className="flex items-baseline gap-2 mt-1.5">
+                      <span className="font-medium text-indigo-700/70 shrink-0 w-16">调用</span>
+                      <select
+                        value={targetSubagent}
+                        onChange={e => {
+                          targetSubagentRef.current = e.target.value
+                          setTargetSubagent(e.target.value)
+                        }}
+                        className="text-xs bg-white border border-indigo-200 rounded px-2 py-0.5 focus:outline-none focus:border-indigo-500"
+                      >
+                        <option value="">选择一个 sub-agent…</option>
+                        {subAgents.map(sa => (
+                          <option key={sa} value={sa}>{sa}</option>
+                        ))}
+                      </select>
+                      {subAgents.length === 0 && (
+                        <span className="text-xs text-indigo-500/70">
+                          (此 agent 未配置 sub-agents — Edit 模式不可用)
+                        </span>
+                      )}
+                    </div>
+                  )}
+                </div>
+              </div>
+            </div>
+          </div>
+        )
+      })()}
+
       {/* Input bar */}
-      <div className="px-6 py-4 border-t border-gray-200 bg-white shrink-0">
+      <div className={clsx(
+        'px-6 py-4 border-t bg-white shrink-0 transition-colors',
+        continueFromRunId ? 'border-indigo-200' : 'border-gray-200',
+      )}>
         <div className="flex items-end gap-3 max-w-4xl mx-auto">
           <textarea
             ref={textareaRef}
             value={input}
             onChange={e => setInput(e.target.value)}
             onKeyDown={handleKeyDown}
-            placeholder="发消息… (Enter 发送,Shift+Enter 换行)"
+            placeholder={
+              continueFromRunId
+                ? '继续此 run — 输入下一步指令…(Enter 发送)'
+                : '发消息… (Enter 发送,Shift+Enter 换行)'
+            }
             rows={1}
             disabled={streaming}
-            className="flex-1 resize-none rounded-xl border border-gray-300 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:bg-gray-50 max-h-40 leading-relaxed"
+            className={clsx(
+              'flex-1 resize-none rounded-xl border px-4 py-3 text-sm focus:outline-none focus:ring-2 disabled:bg-gray-50 max-h-40 leading-relaxed transition-colors',
+              continueFromRunId
+                ? 'border-indigo-400 focus:ring-indigo-500 bg-indigo-50/30'
+                : 'border-gray-300 focus:ring-indigo-500',
+            )}
           />
           {streaming ? (
             <button
@@ -770,11 +1169,29 @@ export default function Chat() {
           ) : (
             <button
               onClick={sendMessage}
-              disabled={!input.trim()}
-              className="flex items-center gap-1.5 px-4 py-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-sm font-medium"
+              disabled={
+                !input.trim() ||
+                // Edit mode requires a chosen sub-agent.
+                (!!continueFromRunId && mode === 'edit' && !targetSubagent)
+              }
+              className={clsx(
+                'flex items-center gap-1.5 px-4 py-3 text-white rounded-xl disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-sm font-medium',
+                continueFromRunId
+                  ? 'bg-indigo-700 hover:bg-indigo-800 ring-2 ring-indigo-300'
+                  : 'bg-indigo-600 hover:bg-indigo-700',
+              )}
+              title={
+                continueFromRunId
+                  ? (mode === 'edit' && !targetSubagent
+                      ? '请先选择要调用的 sub-agent'
+                      : `继续 run ${continueFromRunId.slice(-8)} — 模式: ${mode}`)
+                  : '发送'
+              }
             >
               <Send size={14} />
-              发送
+              {continueFromRunId
+                ? (mode === 'chat' ? '💬 问' : mode === 'edit' ? '✏️ 改' : '🔄 继续')
+                : '发送'}
             </button>
           )}
         </div>