dsh-archify-projects 0.1.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/DESIGN.md CHANGED
@@ -903,3 +903,84 @@ DiagramState {
903
903
 
904
904
  OpenDesign 原型同步更新到 v0.7:`docs/design/workbench-v07.html`
905
905
  (项目 `archify-workbench-v07`),截图 `docs/design/v07-prototype.png`,条高与实现一致(35px)。
906
+
907
+ ---
908
+
909
+ ## 18. v0.2.0:子代理会话 + 设置(模型)
910
+
911
+ ### 18.1 一个决定:会话归谁所有
912
+
913
+ | 方案 | 会话形态 | 能不能按次指定模型 | 结论 |
914
+ | -------------------------------------------------------------------- | -------------------------------- | -------------------------------- | ----------------------------------------------------------------- |
915
+ | `sessionController.create()`(v0.1) | 普通会话,进左侧会话列表 | 只能 `selectModel`(改全局默认) | 淘汰 |
916
+ | `ctx.agents.create()` + `agentPresets.mount()`(v0.2,本插件自己建) | `origin: 'subagent'`,被侧栏过滤 | `agentOptions` 只作用于这条会话 | **采用** |
917
+ | `ctx.subagents.startActivation()` | 真子代理 | 支持 | 不可用:`request.parent` 必须是**活着的父 agent**,插件没有父会话 |
918
+
919
+ 第三条是原本最「正确」的答案,但 DSH 的子代理接口要求一个活着的父 Agent
920
+ (父会话用于目录归属与结果投递),而插件的分析是路由触发的,没有父会话。
921
+ 插件自己建一个 `origin: 'subagent'` 的会话能达到同样的可见性效果(侧栏按
922
+ origin 过滤),并且绕开「父会话必须存在」这个前提。
923
+
924
+ 代价与边界:
925
+
926
+ - 这条会话没有 `parentSession`,所以它不会出现在任何父会话的子代理目录里 ——
927
+ 用户看不见它的对话。插件用 todos / usage / qa 三个投影把它「翻译」到自己的
928
+ 页面上,这是刻意的取舍;
929
+ - 通用会话接口(`sessionController.prompt` / `observe`)会拒绝驱动它
930
+ (`session/agent-busy`),所以提示词只能走 `agent.followup()`;
931
+ - 宿主缺 `agents.create` / `agentPresets` 时退回 v0.1 的路,功能不消失。
932
+
933
+ ### 18.2 设置为什么只有一个字段
934
+
935
+ 用户的原话是「弹窗仅展示模型」。所以设置里**只有**一项:分析模型。没有开关、
936
+ 没有超时、没有并发 —— 其它可调项都留在 `cordis.patch.yml` / 插件配置里,
937
+ 那是部署者的领域,不是页面上随手能改的东西。
938
+
939
+ 模型列表不复用「宿主设置页」的 UI,只复用它的**数据源**:
940
+ `sessionController.modelCatalog()`。理由:插件页面是独立 bundle,拿不到 DSH
941
+ 设置页的组件;而 catalog 是宿主公开的 Host API,返回的就是「当前真能路由」的
942
+ 模型集合,正好是用户要的「复用 DSH 配好的模型」。
943
+
944
+ 三层校验:
945
+
946
+ 1. 页面:下拉里只有目录里的项 + 「跟随宿主默认」;
947
+ 2. 路由:形状不对 → 400;目录非空且路由不在目录里 → 400;
948
+ 3. 运行期:`agentOptions` 交给宿主,路由不可用时宿主自己报错,插件把失败记进
949
+ `runs[]` 的 error,不吞。
950
+
951
+ ### 18.3 一个 UI 取舍:原生 `<select>` 而不是自制下拉
952
+
953
+ 模型可能有几十个(多 provider × 多模型),自绘下拉要处理键盘、滚动、遮挡、
954
+ 可访问性;原生 `<select>` 全都有,而且跟随宿主主题。代价是样式受限(只有
955
+ `.dap-select` 一层壳),但设置面板本来就是低频入口,不值得为它写一个组件。
956
+
957
+ ---
958
+
959
+ ## 19. v0.8 原型:设置入口(OpenDesign 同步)
960
+
961
+ ![v0.8 设置弹窗](./design/v08-settings.png)
962
+
963
+ 实现落地(`src/host/settings.ts` / `routes.ts` / `runner.ts`)之后,原型同步到 **v0.8**:
964
+
965
+ | 项 | 内容 |
966
+ | -------------- | -------------------------------------------------------------------------------------------------------- |
967
+ | 原型文件 | [`docs/design/workbench-v08.html`](./design/workbench-v08.html)(v0.7 + 齿轮 + 设置弹窗) |
968
+ | OpenDesign | 项目 **`archify-workbench-v08`**(`index.html` 与上面那份逐字节一致) |
969
+ | 截图(浅色) | [`design/v08-settings.png`](./design/v08-settings.png)、[`design/v08-atlas.png`](./design/v08-atlas.png) |
970
+ | 截图(深色) | [`design/v08-settings-dark.png`](./design/v08-settings-dark.png) |
971
+ | 入口位置 | 总览头部「全部更新」左侧、工作台头部「更新图册」左侧,33px(工作台 30px)方形图标按钮 |
972
+ | 弹窗里有什么 | **只有一**:分析模型下拉(原生 `<select>`)+ 「会话形态 = 子代理会话」一行说明 |
973
+ | 底部原型切换条 | 「总览 / 工作区 / 新建图 / **设置** / 深色」 |
974
+
975
+ ### 19.1 为什么原型里要画「会话形态」
976
+
977
+ 用户能看见的唯一区别就是**左侧会话列表里不再多出一条**。原型里把它写死在弹窗底部
978
+ (`子代理会话 · 不占用左侧会话列表`),是提醒后来改 UI 的人:这一行不是装饰,
979
+ 它对应 `runner.ts` 里 `meta.origin = 'subagent'` 这个事实。
980
+
981
+ ### 19.2 生成方式
982
+
983
+ 原型是「v0.7 + 三处增量」手改出来的(不是让模型重新设计):CSS 加 `.iconbtn` /
984
+ `.select`、两个头部各插一个齿轮、弹窗与 JS 照抄新建图弹窗的开关模式。
985
+ 截图用 `google-chrome-stable --headless=new --window-size=1440,900 --screenshot` 出,
986
+ 深色那张是把 `<html data-theme="dark">` 打开后再截一次 —— 和 v0.4–v0.7 的做法一致。
package/docs/HANDOVER.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > 面向接手的 agent。读完这一份就能改代码、跑验证、装/卸插件、看懂当初为什么这么写。
4
4
  > 面向使用者的上手说明在 [README](../README.md),设计取舍在 [DESIGN.md](./DESIGN.md)。
5
5
 
6
- 最后更新:2026-11-07(v0.1.0,功能完备、已在真机跑通)
6
+ 最后更新:2026-11-11(v0.2.1:装机后两个展示问题的修复,见 §14)
7
7
 
8
8
  ---
9
9
 
@@ -22,7 +22,7 @@ DSH 的左栏插件「架构建模」:把一个代码库登记进台账,用*
22
22
  | 方面 | 状态 |
23
23
  | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24
24
  | 功能 | 新增/删除项目、Git 探测(分支/commit/上游/脏)、首次生成、更新(切分支+快进+重跑)、重新生成单图、运行记录、深/浅色、非 Git 目录、iframe 内嵌预览、新窗口打开 |
25
- | 测试 | 174 个用例 / 12 个文件,`vitest` 全绿(约 2 s) |
25
+ | 测试 | 213 个用例 / 15 个文件,`vitest` 全绿(约 1 s;`NODE_ENV` 不能是 production,见 §13.5) |
26
26
  | 静态门 | `tsc`(两份 tsconfig)、`eslint`、`prettier --check`、`dependency-cruiser`、`artifact:check`(51.5 KB / 220 KB 预算)全绿 |
27
27
  | 真机 | 在 `127.0.0.1:3080` 的真实宿主里跑通过「Git 项目」与「非 Git 目录」两条路径,深浅色都验过;截图见 [`docs/verify/`](./verify) |
28
28
  | 产物 | `~/.dsh/dsh-archify-projects/` 下 1 个项目(`dsh-word-learn`),两张图 ready |
@@ -30,6 +30,13 @@ DSH 的左栏插件「架构建模」:把一个代码库登记进台账,用*
30
30
 
31
31
  **没有半成品功能**。已知未做的都在 §8,属于可选增强,不是缺陷。
32
32
 
33
+ > **截图配方**(`docs/verify/shot-*.png`,README 与市场都用它们):按 **1440×900** 渲染,
34
+ > 再 Lanczos 缩到 **1280×800**——现存四张图的侧栏右边界在 x=247、侧栏行距 34px,直接
35
+ > 1280×800 截会大 1.12 倍,四张放一起看得出不是一套。改了任何影响成图画面的渲染(例如
36
+ > `hideArtifactHeader()` 去掉成品页头),**覆盖它的每一张都要重截**,别只重截手上那张。
37
+ > 市场详情页的截图画廊读仓库根 `screenshots.json`(见 [`docs/market/README.md`](./market/README.md)),
38
+ > 不从条目 YAML 来,也不从 README 抽。
39
+
33
40
  ---
34
41
 
35
42
  ## 2. 五分钟跑起来
@@ -78,8 +85,9 @@ pnpm verify # build → artifact:check → typecheck/lint/format/deps →
78
85
  └─ POST /dsh-archify-projects/projects/:id/update src/host/routes.ts
79
86
  ├─ git.ts 切回记录的分支 → fetch → FF 到最新 → 重新探测快照
80
87
  ├─ store.ts 台账里记一条 run(running),两张图置 queued
81
- └─ runner.ts 起受限会话
82
- ├─ sessions.create({ presetId: 'archify-projects' })
88
+ └─ runner.ts 起受限会话(v0.2:子代理会话,见 §13)
89
+ ├─ agents.create({ meta.origin:'subagent', agentOptions:{provider,model},
90
+ │ setup: agentPresets.mount(preset) }) ← 旧路:sessionController.create
83
91
  ├─ policy.ts agent.ctx 上装 tools.restrict(allow) + tools.guard(fn)
84
92
  ├─ prompt.ts 拼 prompt(代码库路径、分支/commit、上次产物、产物标记约定)
85
93
  ├─ 投 prompt → whenIdle(或 runTimeoutMs 超时)
@@ -180,6 +188,10 @@ pnpm verify # build → artifact:check → typecheck/lint/format/deps →
180
188
  2. **时钟按本地时区格式化**(`clockText()`)。早期 `startedAt.slice(11,19)` 切的是 UTC,东八区显示成 05:51。
181
189
  3. **`isRunning()` 有 25 分钟兜底**(`isRunAlive` / `hasStaleRun`)。宿主被 kill 时 `running` 会留在台账里,
182
190
  没有兜底页面就永久「生成中」且按钮全禁用。
191
+ 4. **宿主没有全局 `box-sizing: border-box`**(`dsh-web-frontend` 里逐条写,没有 `*` 重置)。
192
+ 自己写 `width:100% + padding` 的卡片会**比栅格列宽出 30px**:卡片本身压到隔壁、
193
+ 里面那行「横线」跟着越界。`.dap-card` / `.dap-thumb` 都显式写了 `box-sizing: border-box`,别删;
194
+ 图条用 `repeat(auto-fit, minmax(165px, 1fr))` 而不是 `1fr 1fr`(后者会被子项 min-content 撑开)。
183
195
 
184
196
  ---
185
197
 
@@ -395,3 +407,213 @@ cacheWriteTokens`)写 `RunRecord.usage`、`QaEntry.usage`,并累加 `Project
395
407
  分支与时间。`page.tsx` 的 `hideArtifactHeader()` 在 iframe `onLoad` 时往同源文档 head
396
408
  注入一条 `.header{display:none!important}`,只影响嵌入视图;点「新窗口」仍是成品原样。
397
409
  同源被沙箱挡住就静默跳过,不影响看图。
410
+
411
+ ---
412
+
413
+ ## 13. v0.2.0:分析跑在子代理会话里 + 设置(模型选择)
414
+
415
+ > 本节描述 2026-11-09 之后的形态。两个改动是同一条线索的两半:**要能给每次运行
416
+ > 单独指定模型,就不能再用「新建一个普通会话」那条路**。
417
+
418
+ ### 13.1 为什么「用子代理执行」
419
+
420
+ 原来 `runner.openSession` 走 `sessionController.create()` —— 那是「开一个新对话」的
421
+ 接口,后果有两个:分析会话会出现在用户的左侧会话列表里;而要给会话换模型只能用
422
+ `sessionController.selectModel()`,它的契约是**顺手把选择存成宿主的全局默认**(会把
423
+ 用户自己的默认模型改掉)。
424
+
425
+ 改成自己建 agent(`ctx.agents.create`):
426
+
427
+ ```ts
428
+ const handle = await agents.create({
429
+ sessionId: `session-${randomUUID()}`,
430
+ meta: {
431
+ cwd: workspaceDir, // 插件工作区,提示词约定产物落这里
432
+ agentPreset: preset.id, // 与 cordis.patch.yml 里的受限 preset 同一个
433
+ origin: 'subagent', // DSH 侧边栏 sessionVisible() 直接过滤掉
434
+ delegationDepth: 1, // 本插件不再往下派子代理
435
+ },
436
+ agentOptions: { provider, model }, // ← 设置里选的模型,只影响这条会话
437
+ setup: async (agentCtx) => {
438
+ await presets.mount(agentCtx, preset.id)
439
+ },
440
+ })
441
+ agent.followup({ id, role: 'user', content, source: { kind: 'user' } })
442
+ ```
443
+
444
+ 要点(改这段前务必知道):
445
+
446
+ 1. **`mount()` 必须在 `setup` 里调**:preset 的工具面 / persona / skill 目录都是
447
+ 在 agent 的 scoped ctx 里注册的,`setup` 是唯一能装上去的时机。
448
+ 2. **`origin: 'subagent'` 的会话不能被通用会话接口驱动**:`sessionController.prompt`
449
+ 会以 `session/agent-busy`("subagent Sessions require their durable parent address")
450
+ 拒绝。所以提示词用 `agent.followup()`,不要混着用。
451
+ 3. **不要给这种会话调 `selectModel`**:既被 (2) 拒,也会改全局默认。
452
+ 4. 召回/收尾完全没变:`agents.get(sessionId)` 仍然拿得到 agent,`whenIdle`、`todos`、
453
+ `tokenUsage` 三个 projection 都照旧。
454
+ 5. 兜底顺序:`agents.create` 建失败 → 记一条 warn → 退回 `sessionController.create` +(如果 agent 暴露了 `agent.model.select`)直接改这条会话的路由。功能不会因为
455
+ 宿主版本变化而消失,只是会话回到「普通会话 + 宿主默认模型」。
456
+
457
+ ### 13.2 设置:只有一个字段
458
+
459
+ | 位置 | 内容 |
460
+ | ----------------------------------------------- | --------------------------------------------------------- |
461
+ | `src/host/settings.ts` | `settings.json` 读写(原子 rename)、形状校验、坏文件回落 |
462
+ | `src/host/models.ts` | `controller.modelCatalog()` 的防御式收纳(分组 → 扁平) |
463
+ | `GET/PUT /dsh-archify-projects/settings` | 页面拿/存设置;PUT 在目录非空时校验路由真的存在 |
464
+ | `src/client/components.tsx` 的 `SettingsDialog` | 齿轮弹窗:**只有一个模型下拉**,没有第二个可输入控件 |
465
+
466
+ `ModelRef = { provider, model, reasoningEffort? }`,字段名与 DSH 的 `ModelSelection`
467
+ 一致,宿主侧可以原样塞进 `agentOptions`,不做翻译。
468
+
469
+ **模型不在插件里定义**:选项全部来自 `sessionController.modelCatalog()`
470
+ (`{ default, groups: [{ id, name, models: [{ id, name }] }], failures }`),
471
+ 也就是用户在 DSH 里配好、当前真能路由到的那些。目录拿不到时页面只说
472
+ 「跟随宿主默认」,不显示空下拉假装能选。
473
+
474
+ ### 13.3 真机验证(2026-11-09,dsh-dev 容器 127.0.0.1:3080)
475
+
476
+ | 步骤 | 结果 |
477
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------- |
478
+ | `GET /settings`(升级前) | `not-found 未知路由:GET /settings`(确认是新代码才有) |
479
+ | `GET /settings`(重启后) | 200,目录里是容器真配的两个路由:`self::…flash`、`zifei::…flash`,`fallback=zifei/deepseek-v4.1-flash` |
480
+ | `PUT /settings` | 200,`<dataDir>/settings.json` 落盘 `{ model: { provider, model } }` |
481
+ | 提问一次(只读会话) | 45 s 返回带文件行的回答;会话头 `"origin":"subagent","delegationDepth":1`,`config.provider=zifei` |
482
+ | 重画一张图(出图会话) | `ready` + 产物路径 + usage(160k in / 90k out);会话头同样 `origin:subagent`,`agentPreset=archify-projects` |
483
+ | 会话日志里的工具名 | `read / write / edit / glob / grep / skill / bash / todo_write` —— 白名单原样生效,没有 `web_search`/`task` |
484
+
485
+ 结论:`mount()` 在真机的两个 preset 上都装对了(工具面、persona、archify skill 全在),
486
+ `agentOptions` 也真的生效;`/settings` 的探测在请求期(而不是插件 apply 期)才准,
487
+ 所以 `RouteState.subagentSessions` 是**惰性函数**,别改回布尔快照 —— apply 时宿主
488
+ 还没装好 `sessionController`,快照会永远显示「宿主不支持」。
489
+
490
+ ### 13.4 原型同步(OpenDesign)
491
+
492
+ 实现改完必须把**原型**也改掉,否则会出现「页面上有齿轮、原型里没有」的错位:
493
+
494
+ - `docs/design/workbench-v08.html` = v0.7 + 齿轮按钮 + 设置弹窗(CSS `.iconbtn`/`.select`,
495
+ 两个头部各插一个,弹窗开关照抄「新建图」的模式);
496
+ - OpenDesign 项目 **`archify-workbench-v08`**(`index.html` 与上面那份相同);
497
+ - 截图:`docs/design/v08-settings.png`(浅色)、`v08-settings-dark.png`(深色)、
498
+ `v08-atlas.png`(齿轮在头部的位置);
499
+ - 出图方式:`google-chrome-stable --headless=new --window-size=1440,900 --screenshot`,
500
+ 深色把 `<html data-theme="dark">` 打开后再截一次。
501
+
502
+ ### 13.5 测试与两个坑
503
+
504
+ 新增:`tests/unit/settings.test.ts`(9)、`tests/unit/models.test.ts`(6)、
505
+ `tests/unit/routes.test.ts` 的「设置(分析模型)」(7)、
506
+ `tests/integration/subagent.test.ts`(6,含「建会话失败要退回普通会话」)、
507
+ `tests/component/page.test.tsx` 的「设置入口」(3)。基线:**206 passed / 15 files**。
508
+
509
+ - **坑 1:`NODE_ENV=production` 会让组件测试全红**(`React.act is not a function`,
510
+ 9 个用例全挂在 `render()` 上)。React 19 的 `act` 只在 development 构建里导出,
511
+ vitest 又是按 `NODE_ENV` 选包的。跑 verify 前确认 `NODE_ENV` 不是 `production`
512
+ (`NODE_ENV=test pnpm verify`)。这不是本插件的 bug,但看起来一模一样。
513
+ - **坑 2:组件测试的 fetch 桩是按「METHOD url」精确匹配的**,未命中就回
514
+ `not-found`。`StudioPage` 现在挂载时会 `GET /settings`,所以设置拉取**必须静默
515
+ 失败**(`try/catch` 里什么都不做),否则每条老用例都会多出一条错误提示。
516
+
517
+ ---
518
+
519
+ ## 14. v0.2.1 修复(2026-11-11,装机后暴露的两个问题)
520
+
521
+ 用户在**本机 dsh(13080,装在 `~/.dsh/profiles/web`,npm 包 `0.2.0` 真装不是 link)**上
522
+ 报了两条,都在真宿主里复现并修掉:
523
+
524
+ ### 14.1 总览卡片的「横线太长」
525
+
526
+ - 现象:项目卡片里「项目结构图」那一行比卡片还宽,压过右边框、破坏留白节奏。
527
+ - 真因(CDP 实测 `getComputedStyle(.dap-card).boxSizing === 'content-box'`):
528
+ 宿主没有全局 border-box,`width:100% + padding:14px + border` 让卡片比栅格列宽 30px;
529
+ 再叠上 `1fr 1fr` 的轨道被 min-content 撑开,`.dap-thumb--wide` 那行就顶出卡片。
530
+ - 改法(`src/client/styles.ts` + `page.tsx`):
531
+ `.dap-card` / `.dap-thumb` 显式 `box-sizing: border-box`;图条改
532
+ `repeat(auto-fit, minmax(165px, 1fr))`,删掉 `.dap-thumb--wide`(不再单独拉长结构图那一行);
533
+ 标题 `b` 加 `flex:1 1 auto; min-width:0`,长图名只截断不撑宽;底栏 `<span>` 也收 `min-width:0`。
534
+ - 验证:CDP 量 `rowWidths = [185.5, 185.5]`、`maxOverflowRight = 0`(2 列 407px 卡);
535
+ 1500 宽 3 列模式同样 0 溢出。**改前端不用重启**,`dsh-client-hmr` 500 ms stat 轮询会自动重挂。
536
+
537
+ ### 14.2 最后一个 todo 永远「进行中」
538
+
539
+ - 现象:运行 `succeeded` 了,台账里最后一条 todo 仍是 `in_progress`,页面一直转圈,
540
+ 看起来像跑了几天没结束。
541
+ - 真因:`todo_write` 的最后一次更新常常发生在 `whenIdle()` 之后/之间,3 秒轮询抓不到;
542
+ `finishRun()` 又原样把快照存了进去。
543
+ - 改法:
544
+ 1. `shared/types.ts` 新增 `settleTodos(todos, status)`:`succeeded` → 残留 `in_progress` 记成
545
+ `completed`;`failed` → 退回 `pending`;`running` → 原样。
546
+ 2. `runner.ts`:`watchTodos` 返回 `{stop, flush}`,`follow()` 在 `whenIdle()` 之后先
547
+ `await watch.flush()` 再收尾;`finishRun()` 与 `finishQa()` 落盘前都过一遍 `settleTodos`。
548
+ 3. 客户端 `TodoList` 收 `status`,渲染前同样过 `settleTodos` —— **老台账不用改也能显示对**。
549
+ 4. 已把本机台账里 4 条历史 `in_progress` 收敛成 `completed`(备份 `projects.json.bak-20261111`)。
550
+ - 测试:`tests/unit/shared.test.ts` 4 条、`tests/integration/runner.test.ts` 2 条(成功收敛 /
551
+ 失败退回)、`tests/component/page.test.tsx` 2 条(卡片没有 `--wide`、结束后没有 `.dap-todo--now`)。
552
+ 基线升到 **213 passed / 15 files**。
553
+
554
+ > 验证路径(2026-11-11):`dsh-dev` 容器里 `pnpm verify` 全绿 → `docker compose restart dsh`
555
+ > → CDP 量到 `boxSizing=border-box`、三张卡 `maxOverflow=0`、残留 `in_progress` 显示成 ✓;
556
+ > 再触发一次真实重画(agent 写入 6 步 todo),运行中 runbar 正常(`正在:加载 archify skill(0/6)`),
557
+ > 结束后台账里没有 `in_progress`。
558
+ >
559
+ > 宿主侧(`lib/**`)改动要**重启 dsh** 才生效;客户端改动同步到
560
+ > `~/.dsh/profiles/web/node_modules/dsh-archify-projects/dist/` 后走热更即可。
561
+ > 本机这份安装是 npm 包而不是 `link:`,所以直接改仓库**不会**自动生效,必须重新
562
+ > `pnpm build` 再把 `dist/` 与 `lib/` 抄进 profile 的 `node_modules`(或把依赖改回 `link:`)。
563
+
564
+ ---
565
+
566
+ ## 15. v0.2.2 修复(2026-11-11,子代理会话没有模型)
567
+
568
+ ### 15.1 现象
569
+
570
+ 本机宿主(npm 包 `0.2.0`)上新建项目 / 重画,运行「刚开始就结束」:台账写
571
+ `没有找到这次运行的产物文件`、`部分图谱没有产出`,产物目录是空的,连续四次全一样。
572
+
573
+ ### 15.2 真因
574
+
575
+ `agents.create`(子代理会话那条路)**不会**去读宿主默认模型。DSH 里注入
576
+ `agentOptions` 的只有 `sessionController.createAgent()`(它用 `agentOptions()` getter
577
+ 读 `agentDefaultModel.currentSelection()`)。v0.2.0 把分析改成子代理会话后,
578
+ `model === null` 时干脆不传 `agentOptions` —— 建出来的 agent 一个 provider/model
579
+ 都没有,`dsh-agent-loop` 在 `preStep` 里 `this.model.current()` 拿到 `undefined` 就抛:
580
+
581
+ ```
582
+ agent "session-…" has no provider/model:
583
+ set AgentOptions.provider and AgentOptions.model or record a model selection
584
+ ```
585
+
586
+ `kick()` 把这个异常吞进 `turn/end: {reason: {kind: 'error'}}`,外面只剩「没有产物」。
587
+ 11-09 那两张图能出,是因为当时还在 `sessionController` 那条老路上。
588
+
589
+ ### 15.3 改法
590
+
591
+ 1. `runner.ts` 新增 `resolveModel(ctx, config)`,三级取源:
592
+ **插件设置 → `ctx.agentDefaultModel.currentSelection()` → `modelCatalog().default`**。
593
+ 第二级就是「DSH 里配的模型」,`HostContext` 里补了 `AgentDefaultModelLike`。
594
+ 2. 仍然为 `null`(老宿主没有这两样)时**不建子代理会话**,退回 `sessionController`
595
+ —— 那条路自带注入,功能不消失,代价是会话会出现在侧栏。
596
+ 3. `agentOptions` 从「可能为空」变成必然非空;`index.ts` 的 `VERSION` 常量同步到
597
+ `package.json`(0.2.1 发版时漏改,`/health` 一直报 0.2.0),并加
598
+ `tests/unit/version.test.ts` 守着。
599
+
600
+ ### 15.4 验证(dsh-dev 容器,2026-11-11)
601
+
602
+ 删掉容器里的 `<dataDir>/settings.json`(逼出「跟随默认」这条路)→ 重画一张图:
603
+
604
+ | 检查点 | 结果 |
605
+ | ----------------------- | --------------------------------------------------------------------------------- |
606
+ | 会话头 | `origin: subagent`、`delegationDepth: 1`、`agentPreset: archify-projects` |
607
+ | `request/header.config` | `{provider: "zifei", model: "deepseek-v4.1-flash", maxTokens: 64000}` —— 继承成功 |
608
+ | 首轮 | 无 `no provider/model`;连续 26 步、42 次工具调用 |
609
+ | 兜底 | 单元/集成用例覆盖「没有默认模型时退回普通会话」 |
610
+ | 质量门 | **215 passed / 15 files**(`NODE_ENV=test pnpm verify`,容器内) |
611
+
612
+ > 同日观察到另一类失败:该会话第 27 步的长推理请求长时间无响应,最终撞上
613
+ > `等待 agent 结束超时(1200000 ms)`。与本节无关(模型路由已正常),属网关侧慢。
614
+
615
+ ### 15.5 别再犯
616
+
617
+ - **任何 `agents.create` 都必须自己带 `provider/model`**:宿主不会替你补,
618
+ 缺了的表现是「运行秒结束 + 没有产物」,而不是一条好看的错误。
619
+ - 插件设置里的 `model` 是**覆盖**,不是默认值来源;默认值只有 `agentDefaultModel`。
@@ -1 +1 @@
1
- {"version":3,"file":"context.js","sourceRoot":"","sources":["../../src/host/context.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AA0MH,oCAAoC;AACpC,MAAM,UAAU,SAAS,CAAI,GAAgB,EAAE,IAAY;IACzD,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QAC5B,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,IAAI;YAAE,OAAO,MAAW,CAAA;IACjE,CAAC;IAAC,MAAM,CAAC;QACP,6BAA6B;IAC/B,CAAC;IACD,kDAAkD;IAClD,mEAAmE;IACnE,IAAI,CAAC;QACH,MAAM,MAAM,GAAI,GAA0C,CAAC,IAAI,CAAC,CAAA;QAChE,OAAQ,MAAwB,IAAI,SAAS,CAAA;IAC/C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAA;IAClB,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../../src/host/context.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAsQH,oCAAoC;AACpC,MAAM,UAAU,SAAS,CAAI,GAAgB,EAAE,IAAY;IACzD,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QAC5B,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,IAAI;YAAE,OAAO,MAAW,CAAA;IACjE,CAAC;IAAC,MAAM,CAAC;QACP,6BAA6B;IAC/B,CAAC;IACD,kDAAkD;IAClD,mEAAmE;IACnE,IAAI,CAAC;QACH,MAAM,MAAM,GAAI,GAA0C,CAAC,IAAI,CAAC,CAAA;QAChE,OAAQ,MAAwB,IAAI,SAAS,CAAA;IAC/C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAA;IAClB,CAAC;AACH,CAAC"}
package/lib/host/index.js CHANGED
@@ -16,11 +16,11 @@ import { ledgerPath, resolveConfig, workspaceRoot } from './config.js';
16
16
  import { serviceOf } from './context.js';
17
17
  import { makeFence } from './http.js';
18
18
  import { createDispatcher } from './routes.js';
19
- import { reconcileArtifacts, Runner } from './runner.js';
19
+ import { canCreateSubagentSessions, reconcileArtifacts, Runner } from './runner.js';
20
20
  import { ProjectStore } from './store.js';
21
21
  import { ensureWorkspace } from './workspace.js';
22
22
  /** 与 package.json 的 version 保持一致(有单测守着)。 */
23
- export const VERSION = '0.1.1';
23
+ export const VERSION = '0.2.2';
24
24
  /** 插件入口。 */
25
25
  export function apply(ctx, rawConfig) {
26
26
  const warnings = [];
@@ -47,6 +47,8 @@ export function apply(ctx, rawConfig) {
47
47
  skillDir,
48
48
  version: VERSION,
49
49
  hasSessionService,
50
+ // 惰性探测:此刻宿主服务可能还没装好(见 RouteState 的注释)
51
+ subagentSessions: () => canCreateSubagentSessions(ctx),
50
52
  });
51
53
  // 路由必须挂在 inject 出来的子 ctx 上:cordis 的 proxy 在没 inject 时读
52
54
  // `ctx.webServer` 会直接抛,注册就会被静默跳过(页面表现为「HTTP 404」)。
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/host/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/host/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,IAAI,MAAM,WAAW,CAAA;AAC5B,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAA;AACpD,OAAO,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,aAAa,EAAwB,MAAM,aAAa,CAAA;AAC5F,OAAO,EAAE,SAAS,EAAoB,MAAM,cAAc,CAAA;AAC1D,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AACrC,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAC9C,OAAO,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACnF,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAEhD,4CAA4C;AAC5C,MAAM,CAAC,MAAM,OAAO,GAAG,OAAO,CAAA;AAE9B,YAAY;AACZ,MAAM,UAAU,KAAK,CAAC,GAAgB,EAAE,SAAmB;IACzD,MAAM,QAAQ,GAAa,EAAE,CAAA;IAC7B,MAAM,MAAM,GAAG,aAAa,CAAC,SAAwC,EAAE,QAAQ,CAAC,CAAA;IAChF,KAAK,MAAM,OAAO,IAAI,QAAQ;QAAE,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,CAAA;IAEzD,MAAM,KAAK,GAAG,IAAI,YAAY,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAA;IAElD,IAAI,WAA+C,CAAA;IACnD,MAAM,QAAQ,GAAG,GAA2B,EAAE;QAC5C,WAAW,KAAK,sBAAsB,CAAC,MAAM,CAAC,eAAe,CAAC,CAAA;QAC9D,OAAO,WAAW,CAAA;IACpB,CAAC,CAAA;IAED,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,CAAA;IAC3D,MAAM,iBAAiB,GACrB,SAAS,CAAC,GAAG,EAAE,mBAAmB,CAAC,KAAK,SAAS,IAAI,SAAS,CAAC,GAAG,EAAE,QAAQ,CAAC,KAAK,SAAS,CAAA;IAE7F,IAAI,CAAC,iBAAiB,EAAE,CAAC;QACvB,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,iDAAiD,CAAC,CAAA;IACrE,CAAC;IAED,MAAM,UAAU,GAAG,gBAAgB,CAAC;QAClC,GAAG;QACH,KAAK;QACL,MAAM;QACN,MAAM;QACN,KAAK,EAAE,SAAS,CAAC,GAAG,CAAC;QACrB,QAAQ;QACR,OAAO,EAAE,OAAO;QAChB,iBAAiB;QACjB,sCAAsC;QACtC,gBAAgB,EAAE,GAAG,EAAE,CAAC,yBAAyB,CAAC,GAAG,CAAC;KACvD,CAAC,CAAA;IAEF,uDAAuD;IACvD,mDAAmD;IACnD,GAAG,CAAC,MAAM,CAAC,CAAC,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,EAAE;QACnC,MAAM,CAAC,MAAM,CACX,GAAG,EAAE,CACH,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC;YACxB,IAAI,EAAE,QAAQ;YACd,IAAI,EAAE,YAAY;YAClB,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;gBACpB,KAAK,UAAU,CAAC,GAAG,EAAE,GAAG,CAAC,CAAA;YAC3B,CAAC;SACF,CAAC,EACJ,mCAAmC,CACpC,CAAA;QACD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,cAAc,YAAY,EAAE,CAAC,CAAA;IAChD,CAAC,CAAC,CAAA;IAEF,6CAA6C;IAC7C,KAAK,kBAAkB,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,IAAI,CACzC,CAAC,OAAO,EAAE,EAAE;QACV,KAAK,MAAM,IAAI,IAAI,OAAO;YAAE,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,aAAa,IAAI,EAAE,CAAC,CAAA;IACnE,CAAC,EACD,CAAC,GAAY,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAC9D,CAAA;IAED,2BAA2B;IAC3B,KAAK,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE;QAChC,IAAI,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC;KAC7C,CAAC,CAAC,IAAI,CACL,CAAC,IAAI,EAAE,EAAE;QACP,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,YAAY,GAAG,CAAC,CAAA;QACtE,MAAM,OAAO,GAAG,KAAK,CAAC,aAAa,EAAE,CAAA;QACrC,KAAK,MAAM,OAAO,IAAI,OAAO;YAAE,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,CAAA;IAC1D,CAAC,EACD,CAAC,GAAY,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,aAAa,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CACjE,CAAA;IAED,GAAG,CACD,GAAG,EACH,MAAM,EACN,QAAQ,OAAO,OAAO,UAAU,CAAC,MAAM,CAAC,QAAQ,aAAa,CAAC,MAAM,CAAC,EAAE,CACxE,CAAA;AACH,CAAC;AAED,SAAS,GAAG,CAAC,GAAgB,EAAE,KAAsB,EAAE,OAAe;IACpE,IAAI,CAAC;QACH,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,0BAA0B,OAAO,EAAE,CAAC,CAAA;IACxD,CAAC;IAAC,MAAM,CAAC;QACP,cAAc;IAChB,CAAC;AACH,CAAC;AAED,SAAS,QAAQ,CAAC,GAAY;IAC5B,OAAO,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;AACzD,CAAC"}
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 宿主模型目录:把「DSH 里配好的模型」搬进本插件的设置弹窗。
3
+ *
4
+ * **插件不定义任何模型**。选项全部来自 `sessionController.modelCatalog()`——
5
+ * 也就是用户在 DSH 设置里配过凭据、当前真的能路由到的那些 provider/model。
6
+ * 这里只做两件事:
7
+ * 1. 把宿主的分组结构**收成扁平**(页面只有一个下拉,不需要分组头);
8
+ * 2. 形状不对/宿主没有这个能力时,明确返回 `source: 'unavailable'`,
9
+ * 让页面退化成「跟随宿主默认」,而不是显示一个空下拉假装能选。
10
+ *
11
+ * 为什么需要防御式解析:宿主版本会变,`modelCatalog()` 的返回是宿主生成的
12
+ * 结构。这里逐字段读、读不到就跳过,永远不因为一个字段缺失把整个设置面板打挂。
13
+ */
14
+ import { modelKey } from '../shared/model-key.js';
15
+ import { serviceOf } from './context.js';
16
+ /** 宿主没有模型目录能力时回这个(页面据此显示「跟随宿主默认」)。 */
17
+ export const NO_CATALOG = {
18
+ options: [],
19
+ fallback: null,
20
+ failures: [],
21
+ source: 'unavailable',
22
+ };
23
+ /**
24
+ * 读一次宿主模型目录。
25
+ *
26
+ * 不抛错:缺服务、宿主版本没有这个方法、provider 集体超时,都变成
27
+ * `source: 'unavailable'` 或一条 `failures`。调用方(路由)只关心结果形状。
28
+ */
29
+ export async function readModelCatalog(ctx) {
30
+ const controller = serviceOf(ctx, 'sessionController');
31
+ if (controller === undefined || typeof controller.modelCatalog !== 'function') {
32
+ return NO_CATALOG;
33
+ }
34
+ let raw;
35
+ try {
36
+ raw = await controller.modelCatalog();
37
+ }
38
+ catch (err) {
39
+ return {
40
+ options: [],
41
+ fallback: null,
42
+ failures: [
43
+ {
44
+ provider: '*',
45
+ providerName: 'DSH',
46
+ message: err instanceof Error ? err.message : String(err),
47
+ },
48
+ ],
49
+ source: 'host',
50
+ };
51
+ }
52
+ return normalizeCatalog(raw);
53
+ }
54
+ /** 把宿主的 `ModelCatalog` 收成插件的扁平形状。纯函数,单测直接打。 */
55
+ export function normalizeCatalog(raw) {
56
+ if (typeof raw !== 'object' || raw === null)
57
+ return { ...NO_CATALOG, source: 'host' };
58
+ const source = raw;
59
+ const options = [];
60
+ const seen = new Set();
61
+ const groups = Array.isArray(source.groups) ? source.groups : [];
62
+ for (const group of groups) {
63
+ if (typeof group !== 'object' || group === null)
64
+ continue;
65
+ const g = group;
66
+ const provider = text(g.id);
67
+ if (provider === null)
68
+ continue;
69
+ const providerName = text(g.name) ?? provider;
70
+ const models = Array.isArray(g.models) ? g.models : [];
71
+ for (const model of models) {
72
+ if (typeof model !== 'object' || model === null)
73
+ continue;
74
+ const m = model;
75
+ const id = text(m.id);
76
+ if (id === null)
77
+ continue;
78
+ const key = modelKey({ provider, model: id });
79
+ if (seen.has(key))
80
+ continue;
81
+ seen.add(key);
82
+ options.push({
83
+ key,
84
+ provider,
85
+ providerName,
86
+ model: id,
87
+ name: text(m.name) ?? id,
88
+ description: text(m.description),
89
+ });
90
+ }
91
+ }
92
+ const failures = [];
93
+ const rawFailures = Array.isArray(source.failures) ? source.failures : [];
94
+ for (const failure of rawFailures) {
95
+ if (typeof failure !== 'object' || failure === null)
96
+ continue;
97
+ const f = failure;
98
+ const provider = text(f.id);
99
+ if (provider === null)
100
+ continue;
101
+ failures.push({
102
+ provider,
103
+ providerName: text(f.name) ?? provider,
104
+ message: text(f.message) ?? '列不出模型',
105
+ });
106
+ }
107
+ return {
108
+ options,
109
+ fallback: readModelRef(source.default),
110
+ failures,
111
+ source: 'host',
112
+ };
113
+ }
114
+ /** 只接受 `{ provider, model, reasoningEffort? }` 形状;其余一律 null。 */
115
+ export function readModelRef(value) {
116
+ if (typeof value !== 'object' || value === null)
117
+ return null;
118
+ const raw = value;
119
+ const provider = text(raw.provider);
120
+ const model = text(raw.model);
121
+ if (provider === null || model === null)
122
+ return null;
123
+ const effort = text(raw.reasoningEffort);
124
+ return effort === null ? { provider, model } : { provider, model, reasoningEffort: effort };
125
+ }
126
+ /** 这个路由在目录里存在吗(PUT /settings 的校验)。 */
127
+ export function catalogHas(catalog, ref) {
128
+ const key = modelKey(ref);
129
+ return catalog.options.some((option) => option.key === key);
130
+ }
131
+ function text(value) {
132
+ if (typeof value !== 'string')
133
+ return null;
134
+ const trimmed = value.trim();
135
+ return trimmed.length === 0 ? null : trimmed;
136
+ }
137
+ //# sourceMappingURL=models.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"models.js","sourceRoot":"","sources":["../../src/host/models.ts"],"names":[],"mappings":"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"}
@@ -12,7 +12,9 @@ import { allDiagrams, createProject, defaultProjectName, findDiagram, runnableDi
12
12
  import { projectDir, workspaceRoot } from './config.js';
13
13
  import { probeGit, syncToLatest } from './git.js';
14
14
  import { BadRequest, matchPath, methodOf, pathOf, readJsonObject, sendError, sendOk, } from './http.js';
15
+ import { catalogHas, readModelCatalog } from './models.js';
15
16
  import { RunnerBusy, RunnerUnavailable } from './runner.js';
17
+ import { readSettings, sanitizeModelRef, writeSettings } from './settings.js';
16
18
  import { confineToRoot, isDirectory } from './workspace.js';
17
19
  /** 生成项目 id。 */
18
20
  export function newProjectId(now = new Date()) {
@@ -52,6 +54,10 @@ async function route(state, req, res, rest) {
52
54
  const method = methodOf(req);
53
55
  if (rest === '/health' && method === 'GET')
54
56
  return handleHealth(state, res);
57
+ if (rest === '/settings' && method === 'GET')
58
+ return handleGetSettings(state, res);
59
+ if (rest === '/settings' && method === 'PUT')
60
+ return handlePutSettings(state, req, res);
55
61
  if (rest === '/projects' && method === 'GET')
56
62
  return handleList(state, res);
57
63
  if (rest === '/projects' && method === 'POST')
@@ -131,6 +137,43 @@ function handleHealth(state, res) {
131
137
  sendOk(res, value);
132
138
  });
133
139
  }
140
+ async function handleGetSettings(state, res) {
141
+ const value = await settingsValueOf(state);
142
+ sendOk(res, value);
143
+ }
144
+ /**
145
+ * 改设置(目前只有一个字段:分析用哪个模型)。
146
+ *
147
+ * 校验分两层:形状(`{provider, model}` 非空)与**可用性**(必须在宿主当前
148
+ * 模型目录里)。第二层只在宿主确实列得出模型时生效 —— 宿主没这个能力时,
149
+ * 不该因为「目录为空」就把用户手动写进来的路由判死。
150
+ */
151
+ async function handlePutSettings(state, req, res) {
152
+ const body = (await readJsonObject(req));
153
+ const clearing = body.model === undefined || body.model === null;
154
+ const requested = clearing ? null : sanitizeModelRef(body.model);
155
+ if (!clearing && requested === null) {
156
+ throw new BadRequest('model 必须是 { provider, model }', 'bad-request');
157
+ }
158
+ const catalog = await readModelCatalog(state.ctx);
159
+ if (requested !== null && catalog.options.length > 0 && !catalogHas(catalog, requested)) {
160
+ throw new BadRequest(`宿主的模型目录里没有 ${requested.provider}/${requested.model}`, 'bad-request');
161
+ }
162
+ const saved = await writeSettings(state.config, { model: requested });
163
+ const value = {
164
+ model: saved.model,
165
+ catalog,
166
+ subagentSessions: state.subagentSessions(),
167
+ };
168
+ sendOk(res, value);
169
+ }
170
+ async function settingsValueOf(state) {
171
+ const [settings, catalog] = await Promise.all([
172
+ readSettings(state.config),
173
+ readModelCatalog(state.ctx),
174
+ ]);
175
+ return { model: settings.model, catalog, subagentSessions: state.subagentSessions() };
176
+ }
134
177
  async function handleList(state, res) {
135
178
  const projects = await state.store.list();
136
179
  const value = {