@mzzsfy/dsh-shell-select 0.4.0 → 0.4.1

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/README.md CHANGED
@@ -28,9 +28,9 @@ Windows shell 链接管:禁用官方 `pwsh` 工具与执行器,替换为可配
28
28
  注册 keyed slot `tool.call.toolview`(key `shell`/`pwsh`/`bash` 三支,priority -1)替换默认通用卡,官方 `terminalCardModel` 同构派生(argsRaw + 结果文本尾部退出标记)。`pwsh`/`bash` 两支覆盖官方工具名:死态窗口 guard 代挂官方 tool-pwsh 时,其调用行同样获得增强卡;persistent 形(无 description)与后台 ack、isError、溢出预览一样回退简版行,不误渲染:
29
29
 
30
30
  - 折叠行:状态点(失败红/中断黄/其余工具图标)+ `Shell` 标题 + 描述首行摘要,失败摘要红显。
31
- - 展开卡:状态点 + cwd 末段目录 + 客户端名徽标(模型传了 `shell` 参数时)+ 失败 pill(退出码/信号)+ 复制命令/复制输出按钮 + 命令折行带行号 + 输出区(横向滚动,竖向限高)。
31
+ - 展开卡:状态点 + cwd 末段目录 + 客户端名徽标 + 失败 pill(退出码/信号)+ 复制命令/复制输出按钮 + 命令折行带行号 + 输出区(横向滚动,竖向限高)。徽章忠实于执行事实:每次调用结果尾附 `[shell: <id>]` 标记(模型可见,官方退出标记同族),卡片按标记回放实际生效的客户端——缺省调用在配置事后变更或跨宿主查看时不被查看时读数改写;无标记的历史块回退显式参数 → 当前 default 推导。`pwsh`/`bash` 官方工具的调用无标记无 shell 参数,徽章按工具名钉死(客户端即工具名),永不落 default 读数。
32
32
  - 非终端意图回退简版行:摘要 + 可展开原文 + 检查按钮。
33
- - 图标取官方 `dsh-client-ui-primitives`(StateDot/IconApi/IconInspect/Chevron),模块表缺席时降级自绘。
33
+ - 图标取官方 `dsh-client-ui-primitives`(StateDot/IconApi/IconInspect),模块表缺席时降级自绘。行首无展开箭头,与官方组件一致,可展开性由指针与整行点击承载。
34
34
 
35
35
  ## 配置(行条目 `shell-select`,settings 面同名 ns)
36
36
 
@@ -92,4 +92,17 @@ env 优先级(同键高右):内置覆盖集(NO_COLOR/PAGER/GIT_PAGER)< 条目 `e
92
92
  name: '@mzzsfy/dsh-shell-select/web'
93
93
  ```
94
94
 
95
+
96
+ ## dsh 版本兼容
97
+
98
+ 宿主要求 ≥0.1.7-rc.1:bundle patch 引用 0.1.7 才有的 @deepseek-ai/dsh-agent-preset 宿主包,更旧宿主安装即 boot 失败(2026-09-27 裁定不视为缺陷)。
99
+
100
+ - 0.2.0-rc.2:完全适配(设置面通过;16 格 LLM 工具调用格因模拟模型无 tool_calls 能力登记环境不可测,历史真机记录与单元测试覆盖)
101
+ - 0.1.7-rc.2:不崩溃底线通过(全家桶 crash-only)
102
+ - 0.1.5-rc.3:未承诺(本包宿主要求 ≥0.1.7-rc.1,该版本不安装;全家桶在该版本不启动,详见仓库 docs/兼容性测试/逐包测试进度.md)
103
+
104
+ 历史验收(Round 30,2026-09-27):0.1.7-rc.2 设置面 7 格过(自动探测/自定义路径/坏路径徽标/黑名单往返含行号拦截/默认热更落盘/preset 接管入册);运行时接管(Round 29)9291 keep 宿主实证 runtime-disable→fiber 激活+INV-6 残影稳定。
105
+
106
+ 更细的逐包结论见仓库 `docs/兼容性测试/逐包测试进度.md` 与本包 `兼容性测试.md`。
107
+
95
108
  测试:`node --test "test/*.test.mjs"`(包目录内)。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-shell-select",
3
3
  "description": "Windows shell 接管:替换默认 pwsh 工具为可配置多客户端的 shell 工具(git bash/WSL/cmd/pwsh,自定义路径,默认客户端,模型按名选择)",
4
- "version": "0.4.0",
4
+ "version": "0.4.1",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -41,9 +41,6 @@ window.__ModuleLoader__.load({
41
41
  opacity: .7, display: 'inline-block', flex: 'none',
42
42
  },
43
43
  })),
44
- IconChevron: createElementOf(['IconChevronDownOutlineRegular', 'IconChevronDownOutlineMedium', 'IconChevronDownOutline14'], ({ size }) => h('span', {
45
- style: { fontSize: (size ?? 14) - 3, lineHeight: 1, userSelect: 'none' },
46
- }, '▾')),
47
44
  IconInspect: createElementOf(['IconInspectOutlineRegular', 'IconInspectOutlineMedium', 'IconInspectOutline12'], () => h('span', {
48
45
  style: { fontSize: 10, lineHeight: 1, opacity: .8 },
49
46
  }, 'ⓘ')),
@@ -134,17 +131,13 @@ window.__ModuleLoader__.load({
134
131
  '.sls-tv__row { display:flex; align-items:center; gap:7px; padding:2px 0; cursor:default; }',
135
132
  '.sls-tv__row--exp { cursor:pointer; user-select:none; }',
136
133
  '.sls-tv__lead { display:flex; align-items:center; gap:4px; color:var(--dsw-alias-label-tertiary, inherit); }',
137
- // 官方 DisclosureRow 同构:icon 与 chevron 同 16px 槽,hover 交叉淡化;
134
+ // 官方 ToolRow 同构:行首仅 icon,无展开箭头(可展开性由指针与整行点击承载);
138
135
  // 错误/进行中状态点绝对定位覆盖 icon(红点掩盖 icon)
139
136
  '.sls-tv__leadStack { position:relative; width:16px; height:16px; display:inline-flex; align-items:center; justify-content:center; flex:none; }',
140
- '.sls-tv__iconIdle { display:inline-flex; opacity:1; transition:opacity 100ms ease; }',
137
+ '.sls-tv__iconIdle { display:inline-flex; }',
141
138
  '.sls-tv__stateCover { position:absolute; inset:0; margin:auto; display:inline-flex; align-items:center; justify-content:center; background:transparent; }',
142
139
  '.sls-tv__stateCover::before { content:""; position:absolute; inset:-2px; border-radius:50%; background:var(--dsw-alias-bg-primary, #fff); }',
143
140
  '.sls-tv__stateCover > span { position:relative; }',
144
- '.sls-tv__row:hover .sls-tv__iconIdle { opacity:0; }',
145
- '.sls-tv__chev { opacity:.45; transition:transform .15s ease, opacity .1s ease; }',
146
- '.sls-tv__row:hover .sls-tv__chev { opacity:1; }',
147
- '.sls-tv__row[data-open="1"] .sls-tv__chev { transform:rotate(-90deg); }',
148
141
  '.sls-tv__sr { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); }',
149
142
  '.sls-tv__title { font-weight:400; }',
150
143
  '.sls-tv__sep { width:2px; height:2px; border-radius:1px; background:var(--dsw-alias-label-caption, currentColor); opacity:.8; flex:none; }',
@@ -152,13 +145,14 @@ window.__ModuleLoader__.load({
152
145
  '.sls-tv__sum--err { color:var(--dsw-alias-state-error-primary, #d4553f); }',
153
146
  '.sls-tv__body { margin:6px 0 4px; border:1px solid rgba(128,128,128,.28); border-radius:8px; overflow:hidden; }',
154
147
  '.sls-tv__head { display:flex; align-items:center; gap:8px; padding:6px 10px; border-bottom:1px solid rgba(128,128,128,.18); background:rgba(128,128,128,.05); }',
155
- '.sls-tv__cwd { font-family:var(--sls-mono, monospace); font-size:12px; opacity:.7; }',
156
- '.sls-tv__badge { font-size:11px; padding:0 7px; border-radius:999px; border:1px solid rgba(128,128,128,.35); opacity:.85; flex:none; }',
157
- '.sls-tv__pill { font-size:12px; color:#d4553f; flex:none; }',
148
+ // 头部单行片段禁折行:cwd 行宽不足时收缩出省略号,徽章不收缩不折行
149
+ '.sls-tv__cwd { font-family:var(--sls-mono, monospace); font-size:12px; opacity:.7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
150
+ '.sls-tv__badge { font-size:11px; padding:0 7px; border-radius:999px; border:1px solid rgba(128,128,128,.35); opacity:.85; flex:none; white-space:nowrap; }',
151
+ '.sls-tv__pill { font-size:12px; color:#d4553f; flex:none; white-space:nowrap; }',
158
152
  '.sls-tv__pill--bg { color:var(--dsw-alias-label-tertiary, inherit); }',
159
- '.sls-tv__duration { font-family:var(--sls-mono, monospace); font-size:12px; color:var(--dsw-alias-label-tertiary, inherit); flex:none; font-variant-numeric:tabular-nums; }',
153
+ '.sls-tv__duration { font-family:var(--sls-mono, monospace); font-size:12px; color:var(--dsw-alias-label-tertiary, inherit); flex:none; white-space:nowrap; font-variant-numeric:tabular-nums; }',
160
154
  // 运行中脉冲文字:零信息窗口(参数未到)的活体反馈
161
- '.sls-tv__pulse { font-size:12px; color:var(--dsw-alias-label-tertiary, inherit); flex:none; animation:sls-pulse 1.5s ease-in-out infinite; }',
155
+ '.sls-tv__pulse { font-size:12px; color:var(--dsw-alias-label-tertiary, inherit); flex:none; white-space:nowrap; animation:sls-pulse 1.5s ease-in-out infinite; }',
162
156
  '@keyframes sls-pulse { 0%,100% { opacity:.35; } 50% { opacity:1; } }',
163
157
  '.sls-tv__sp { flex:1; }',
164
158
  '.sls-tv__copy { display:flex; align-items:center; gap:2px; flex:none; }',
@@ -616,11 +610,25 @@ window.__ModuleLoader__.load({
616
610
  }
617
611
  // LOGIC-END hasSpillNotice
618
612
 
613
+ // 执行事实标记(host renderResult 落,格式见 render.mjs):卡片徽章第一优先级,
614
+ // 配置事后变更/跨宿主查看不溯往改写历史调用
615
+ // LOGIC-BEGIN parseShellMark
616
+ function parseShellMark(text) {
617
+ return /\[shell: ([^\]\n]+)\]/.exec(text)?.[1]
618
+ }
619
+ // LOGIC-END parseShellMark
620
+
621
+ // LOGIC-BEGIN stripShellMark
622
+ function stripShellMark(text) {
623
+ return text.split('\n').filter((line) => !/^\[shell: [^\]]+\]$/.test(line)).join('\n')
624
+ }
625
+ // LOGIC-END stripShellMark
626
+
619
627
  // block → 卡片模型(官方 terminalCardModel 同构,数据源 argsRaw + content 文本)。
620
628
  // generic = 后台 ack / isError / 溢出预览 / persistent 形(无 description,
621
629
  // 官方 shellCall persistent→generic 同构),交回退行;terminal = 全量卡。
622
630
  // LOGIC-BEGIN shellCardModel
623
- function shellCardModel(block, sessionCwd) {
631
+ function shellCardModel(block, sessionCwd, toolKey) {
624
632
  const settled = 'kind' in block
625
633
  const call = settled ? block.call : block
626
634
  let args = null
@@ -632,37 +640,44 @@ window.__ModuleLoader__.load({
632
640
  const description = typeof args?.description === 'string' && args.description.trim() !== '' ? args.description : undefined
633
641
  const cwdFull = displayCwd(typeof args?.workdir === 'string' ? args.workdir : undefined, sessionCwd)
634
642
  const cwdDir = cwdFull !== undefined ? lastSegment(cwdFull) : undefined
635
- // shell 名 = 用户在配置页起的条目名;args.shell 缺省时按 default 客户端解析(配置当前读数)
636
- const shellName = clientDisplayName(typeof args?.shell === 'string' ? args.shell : undefined)
643
+ // 官方钉死客户端工具(bash/pwsh)徽章即工具名:其输出无标记、参数无 shell
644
+ // 字段,落默认读数会误标(官方 pwsh 调用显示当前默认客户端的事故根因)
645
+ const PINNED_CLIENT = { pwsh: 'pwsh', bash: 'bash' }
646
+ // shell 工具徽章优先级:结果标记(执行事实)→ 显式参数 → default 读数(旧块兜底,
647
+ // 配置即当前解析事实);读数推导仅兜无标记历史块,不覆盖事实
648
+ const requested = typeof args?.shell === 'string' ? args.shell : undefined
649
+ const shellNameOf = (mark) => PINNED_CLIENT[toolKey] ?? mark ?? clientDisplayName(requested)
637
650
  if (command === '') return { kind: 'generic', command: '', summary: undefined, output: null, running: !settled, alert: undefined }
638
651
 
639
652
  if (!settled) {
640
653
  const startedAt = typeof block.time === 'number' ? block.time : undefined
641
- // 运行中一律全量卡:阻塞时展开即可见命令与时长,不落无命令的 generic 回退行
642
- return { kind: 'terminal', status: 'running', command, description, cwdDir, cwdFull, shellName, output: undefined, exitCode: undefined, signal: undefined, code: undefined, startedAt }
654
+ // 运行中一律全量卡:阻塞时展开即可见命令与时长,不落无命令的 generic 回退行;
655
+ // 无结果文本,徽章按读数推导,落定后由标记校正
656
+ return { kind: 'terminal', status: 'running', command, description, cwdDir, cwdFull, shellName: shellNameOf(undefined), output: undefined, exitCode: undefined, signal: undefined, code: undefined, startedAt }
643
657
  }
644
658
 
645
659
  const contentText = (block.content ?? []).map((part) => (part.type === 'text' ? part.text : '')).filter((text) => text !== '').join('\n')
660
+ const shellName = shellNameOf(parseShellMark(contentText))
661
+ const outputText = stripShellMark(contentText)
646
662
  // 后台 ack:独立卡呈现命令全文与任务号徽标,ack 原文作输出(不再落 generic 简版行)
647
663
  if (args.run_in_background === true) {
648
664
  const jobId = /started background job (\S+)/.exec(contentText)?.[1]
649
- return { kind: 'background', status: 'background', command, description, cwdDir, cwdFull, shellName, output: contentText, jobId }
665
+ return { kind: 'background', status: 'background', command, description, cwdDir, cwdFull, shellName, output: outputText, jobId }
650
666
  }
651
667
  // 空结果落 generic:官方 singleResultText 无文本即回通用卡,避免空输出伪 done 终端卡;
652
668
  // isError/error 块带 alert(错误红点语义),其余(空输出/无描述/截断)中性无状态点
653
- if (contentText === '' || block.isError === true || block.error !== undefined || description === undefined || hasSpillNotice(contentText)) {
669
+ if (outputText === '' || block.isError === true || block.error !== undefined || description === undefined || hasSpillNotice(contentText)) {
654
670
  const alert = block.isError === true || block.error !== undefined ? 'error' : undefined
655
- return { kind: 'generic', command, summary: description, output: contentText, running: false, alert }
671
+ return { kind: 'generic', command, summary: description, output: outputText, running: false, alert }
656
672
  }
657
- const tail = parseExitTail(contentText)
673
+ const tail = parseExitTail(outputText)
658
674
  const status = tail.signal !== undefined ? 'signaled' : tail.exitCode !== 0 ? 'failed' : 'done'
659
675
  return { kind: 'terminal', status, command, description, cwdDir, cwdFull, shellName, output: tail.output, exitCode: tail.exitCode, signal: tail.signal, code: undefined }
660
676
  }
661
677
  // LOGIC-END shellCardModel
662
678
 
663
- // 官方 DisclosureRow leading 同构:16px 槽内 icon 与 chevron 同位,
664
- // 收起时 chevron hover 才现(交叉淡化),展开时仅 chevron-up;错误态
665
- // 红点叠 icon 之上(掩盖 icon,官方状态点语义)
679
+ // 官方 ToolRow leading 同构:行首仅 icon;错误/进行中状态红点叠 icon 之上
680
+ // (掩盖 icon,官方状态点语义)
666
681
  function leadingStack({ status, icons }) {
667
682
  const failed = status === 'failed' || status === 'signaled' || status === 'generic-error'
668
683
  const dot = failed ? icons.StateDot({ state: 'error' }) : status === 'generic-warn' ? icons.StateDot({ state: 'warning' }) : status === 'running' ? icons.StateDot({ state: 'ongoing' }) : null
@@ -770,7 +785,6 @@ window.__ModuleLoader__.load({
770
785
  },
771
786
  h('span', { className: 'sls-tv__lead' },
772
787
  leadingStack({ status, icons: TOOLVIEW_ICONS }),
773
- expandable ? h('span', { className: 'sls-tv__chev', 'data-open': open ? '1' : '0', style: { display: 'inline-flex', transform: open ? 'rotate(-90deg)' : 'none' } }, TOOLVIEW_ICONS.IconChevron({ size: 14 })) : null,
774
788
  ),
775
789
  h('span', { className: 'sls-tv__title' }, 'Shell'),
776
790
  summary !== '' ? h('span', { className: 'sls-tv__sep', 'aria-hidden': true }) : null,
@@ -833,9 +847,9 @@ window.__ModuleLoader__.load({
833
847
  }
834
848
 
835
849
  function ShellToolRow(props) {
836
- const { block, cwd, inspect } = props
850
+ const { block, cwd, inspect, toolKey } = props
837
851
  const en = detectEnglish()
838
- const model = shellCardModel(block, cwd)
852
+ const model = shellCardModel(block, cwd, toolKey)
839
853
  // hooks 恒序:必须在任何提前 return 之前完整执行(真实 React 的 hooks
840
854
  // 链表按调用序对位,generic 行与 terminal 行的提前 return 分叉会让
841
855
  // 后续 hook 错位,行组件树被整棵卸载——卡片全消失事故根因)
@@ -847,7 +861,6 @@ window.__ModuleLoader__.load({
847
861
  return () => { disposed = true }
848
862
  }, [])
849
863
  if (model.kind === 'generic') return h(GenericShellRow, { model, inspect, en })
850
- const expandable = true
851
864
  const srStatus = statusTextOf(model.status, en)
852
865
  const summary = model.description !== undefined ? model.description.split('\n')[0] : (en ? '(no description)' : '(无描述)')
853
866
  const failed = model.status === 'failed' || model.status === 'signaled'
@@ -857,7 +870,6 @@ window.__ModuleLoader__.load({
857
870
  role: 'button',
858
871
  tabIndex: 0,
859
872
  'aria-expanded': open,
860
- 'data-open': open ? '1' : '0',
861
873
  onClick: () => setOpen((value) => !value),
862
874
  onKeyDown: (event) => {
863
875
  if (event.key === 'Enter' || event.key === ' ') {
@@ -868,7 +880,6 @@ window.__ModuleLoader__.load({
868
880
  },
869
881
  h('span', { className: 'sls-tv__lead' },
870
882
  leadingStack({ status: model.status, icons: TOOLVIEW_ICONS }),
871
- h('span', { className: 'sls-tv__chev', style: { display: 'inline-flex', transform: open ? 'rotate(-90deg)' : 'none' } }, TOOLVIEW_ICONS.IconChevron({ size: 14 })),
872
883
  ),
873
884
  srStatus !== null ? h('span', { className: 'sls-tv__sr' }, srStatus) : null,
874
885
  h('span', { className: 'sls-tv__title' }, 'Shell'),
@@ -901,15 +912,15 @@ window.__ModuleLoader__.load({
901
912
  ))
902
913
  // 替换 shell 族工具行渲染:keyed hit 优先于 GenericToolCard 兜底;
903
914
  // priority -1 阴影官方同 key 注册(低值先渲染)。pwsh/bash 为官方工具
904
- // 名(死态窗口 guard 代挂官方 tool-pwsh 时,其调用行同样获得增强卡;
905
- // 卡片模型按官方 terminalCardModel 同构自 argsRaw+结果文本派生,数据
906
- // 面对两类工具一致,无需分支)。
915
+ // 名(死态窗口 guard 代挂官方 tool-pwsh 时,其调用行同样获得增强卡);
916
+ // key 以 toolKey 注入卡片,官方钉死客户端工具的徽章按工具名钉死,
917
+ // 不落默认客户端读数。
907
918
  const TOOLVIEW_KEYS = ['shell', 'pwsh', 'bash']
908
919
  for (const toolKey of TOOLVIEW_KEYS) {
909
920
  ctx.slots.inject('tool.call.toolview', () =>
910
921
  ctx.slots.register(
911
922
  { name: 'tool.call.toolview', key: toolKey, priority: -1 },
912
- (props) => React.createElement(ShellToolRow, props),
923
+ (props) => React.createElement(ShellToolRow, { ...props, toolKey }),
913
924
  ))
914
925
  }
915
926
  },
package/src/config.mjs CHANGED
@@ -83,6 +83,72 @@ export function resolveConfig(entry) {
83
83
  return unwrapConfig(Config(structuredClone(sanitized)))
84
84
  }
85
85
 
86
+ /** 执行面严格解析:输入键被类型对账丢弃时抛。执行面(挂载/refresh)输入是
87
+ * 宿主刚 resolve 的产物,形态非法 = 宿主契约变化或调用方未解箱,必须炸在
88
+ * 挂载点(接管序列有回滚告警)而非静默回落出厂默认——静默曾让行 config
89
+ * 长期失效(0.2.0-rc.2 真机实证)。持久化读回路径(落盘写坏降级、行不死
90
+ * 可保存修复)仍用 resolveConfig 的宽容语义。 */
91
+ export function resolveConfigStrict(entry) {
92
+ const input = entry ?? {}
93
+ const sanitized = sanitizeConfigEntry(input)
94
+ const dropped = Object.keys(FIELD_TYPES)
95
+ .filter((key) => input[key] !== undefined && !(key in sanitized))
96
+ if (dropped.length > 0) {
97
+ throw new Error(`shell-select: config 字段形态非法(执行面输入须先解箱为普通对象): ${dropped.join(', ')}`)
98
+ }
99
+ return unwrapConfig(Config(structuredClone(sanitized)))
100
+ }
101
+
102
+ /** 用户声明值挂载前 coercing:类型清洗(sanitize)+ 语义兜底,任意用户输入
103
+ * 产出可服务配置——插件本体不因配置死(行活、工具活,坏字段告警后降默认)。
104
+ * 语义兜底:shells 清洗后为空(含条目缺 id/kind 或 kind 非法被剔除)补出厂
105
+ * 默认;default 不在 shells id 集归 shells[0];预算数字非正数或超 graceMs
106
+ * 上限降 schema 默认;deny 剔除非字符串与不可编译条目。 */
107
+ export function coerceServiceable(entry) {
108
+ const warnings = []
109
+ const cleaned = sanitizeConfigEntry(entry ?? {})
110
+ const rawShells = Array.isArray(cleaned.shells) ? cleaned.shells : []
111
+ const usable = rawShells.filter((item) => item !== null && typeof item === 'object'
112
+ && typeof item.id === 'string' && item.id !== ''
113
+ && typeof item.kind === 'string' && KINDS.includes(item.kind))
114
+ if (usable.length !== rawShells.length) warnings.push(`shells 剔除 ${rawShells.length - usable.length} 个缺 id/kind 或 kind 非法的条目`)
115
+ const shells = usable.length > 0 ? usable : defaultConfig().shells
116
+ if (usable.length === 0) warnings.push('shells 为空或全部非法,回落出厂默认客户端清单')
117
+ let defaultId = cleaned.default
118
+ if (typeof defaultId !== 'string' || !shells.some((item) => item.id === defaultId)) {
119
+ warnings.push(`default ${JSON.stringify(defaultId)} 不在 shells 清单,归 shells[0]`)
120
+ defaultId = shells[0].id
121
+ }
122
+ const rawDeny = Array.isArray(cleaned.deny) ? cleaned.deny : undefined
123
+ const deny = rawDeny?.filter((pattern) => {
124
+ if (typeof pattern !== 'string' || pattern === '') return false
125
+ try {
126
+ new RegExp(pattern, 'i')
127
+ return true
128
+ } catch {
129
+ return false
130
+ }
131
+ })
132
+ if (rawDeny !== undefined && deny.length !== rawDeny.length) warnings.push(`deny 剔除 ${rawDeny.length - deny.length} 个非字符串或不可编译正则的条目`)
133
+ const budgets = {}
134
+ for (const key of ['timeoutMs', 'maxTimeoutMs', 'maxOutputBytes', 'maxSpillBytes', 'graceMs']) {
135
+ const value = cleaned[key]
136
+ if (value === undefined) continue
137
+ const overCeiling = key === 'graceMs' && value > MAX_TIMER_DELAY_MS
138
+ if (!Number.isFinite(value) || value <= 0 || overCeiling) {
139
+ warnings.push(`${key} 必须为正有限数${key === 'graceMs' ? `且不大于 ${MAX_TIMER_DELAY_MS}` : ''},降 schema 默认`)
140
+ // 显式置空覆盖 cleaned 原值,交 schema 默认兜底
141
+ budgets[key] = undefined
142
+ continue
143
+ }
144
+ budgets[key] = value
145
+ }
146
+ return {
147
+ config: { ...cleaned, shells, default: defaultId, deny, ...budgets },
148
+ warnings,
149
+ }
150
+ }
151
+
86
152
  // 落盘行 config 的类型防御:设置写路径(原生页/mutate)可能把 volatile 字段
87
153
  // 以非预期形态(ref 序列化产物 {})持久化,冷启动 Config 直接抛 → 行死 →
88
154
  // settings describe 无此节 → 设置页保存 "no longer configurable" 死锁,
package/src/executor.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { ShellExecutor } from '@deepseek-ai/dsh-shell'
16
16
  import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
17
- import { Config, KINDS, buildArgv, requireEntry, resolveConfig, assertServiceableConfig, normalizeWin32Path } from './config.mjs'
17
+ import { Config, KINDS, buildArgv, requireEntry, resolveConfig, resolveConfigStrict, unwrapConfig, assertServiceableConfig, normalizeWin32Path } from './config.mjs'
18
18
  import { matchDeny } from './denylist.mjs'
19
19
  import { candidateExists, detectCandidates, resolveEntryPath } from './resolve.mjs'
20
20
  import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from './sandbox-classify.mjs'
@@ -137,8 +137,7 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
137
137
  void loadSandboxUnavailable(ctx).then((resolved) => {
138
138
  this.unavailableError = resolved
139
139
  })
140
- this.#entry = config ?? {}
141
- assertServiceableConfig(resolveConfig(this.#entry))
140
+ this.#setEntry(config)
142
141
  this.source = () => resolveConfig(this.#entry)
143
142
  // refresh 桥绑定随本 fiber 卸载自动解绑(实例退场后写路径请求静默丢弃)
144
143
  ctx.effect(() => bindShellRefreshBridge((next) => this.refresh(next)))
@@ -152,11 +151,19 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
152
151
  this.#mountPromptSection()
153
152
  }
154
153
 
154
+ /** 挂载/换源共用落定:入参经宿主按 static Config resolve,volatile 字段是
155
+ * boxed ref,存储前解箱成普通对象;落定校验走 strict——解箱后仍存在非法
156
+ * 形态字段即抛(宿主契约变化炸在挂载点,接管序列回滚告警),禁止静默回落
157
+ * 出厂默认(行 config 永不生效缺陷的机械化防线)。 */
158
+ #setEntry(config) {
159
+ this.#entry = unwrapConfig(config ?? {})
160
+ assertServiceableConfig(resolveConfigStrict(this.#entry))
161
+ }
162
+
155
163
  /** volatile-only 配置变更的活刷新:换配置源并重建工具注册(写路径经
156
164
  * requestShellRefresh 触达;行不重挂,本实例与进行中执行不受扰)。 */
157
165
  refresh(config) {
158
- this.#entry = config ?? {}
159
- assertServiceableConfig(resolveConfig(this.#entry))
166
+ this.#setEntry(config)
160
167
  this.#registerTool()
161
168
  }
162
169
 
package/src/index.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  runtimeDisableRows,
32
32
  takeoverDecision,
33
33
  } from './takeover.mjs'
34
+ import { coerceServiceable } from './config.mjs'
34
35
 
35
36
  export const name = 'shell-select'
36
37
 
@@ -114,7 +115,12 @@ function unwrapConfigSnapshot(config) {
114
115
  * 快照经 ctx.shell.refresh 换源并重建工具。
115
116
  */
116
117
  async function mountExecutor(ctx, config) {
117
- const fiber = await ctx.plugin(ShellSelectExecutor, unwrapConfigSnapshot(config))
118
+ // 用户声明值挂载前 coercing:任意配置(坏类型/空 shells/悬空 default/坏
119
+ // deny 正则)降级为可服务配置 + 逐条告警,行永不因配置死(官方链兜底之外
120
+ // 的第一道本体防线)
121
+ const { config: coerced, warnings } = coerceServiceable(unwrapConfigSnapshot(config))
122
+ for (const warning of warnings) ctx.logger?.warn?.(`shell-select: ${warning}`)
123
+ const fiber = await ctx.plugin(ShellSelectExecutor, coerced)
118
124
  // await 返回 ≠ 激活:executor inject 的宿主服务缺失时 fiber 静默 pending
119
125
  // (不 provide 不报错),ctx.shell 空缺会被官方复活行补位——挂载必须核实
120
126
  // 激活态,未激活按失败处理(触发回滚,官方接管)
package/src/render.mjs CHANGED
@@ -14,8 +14,11 @@ function streamText(output) {
14
14
  * 前台运行结果 → 模型可见文本。
15
15
  * @param {object} result ShellRunResult(含 sandbox 事实)
16
16
  * @param {string[]} escalationModes 本组合公示的升权目标;非空时拒绝标记后附升权提示
17
+ * @param {string|undefined} shellId 执行事实:实际生效的客户端条目 id;缺省不落标记
18
+ * (渲染面与官方逐字同构)。标记居 markers 首位,官方退出/信号锚保持末行,
19
+ * parseExitStatus 复原不受扰;客户端半区据此校正卡片徽章(配置事后变更不溯往)。
17
20
  */
18
- export function renderResult(result, escalationModes = []) {
21
+ export function renderResult(result, escalationModes = [], shellId) {
19
22
  const out = streamText(result.stdout)
20
23
  const err = streamText(result.stderr)
21
24
  let body = out
@@ -25,6 +28,7 @@ export function renderResult(result, escalationModes = []) {
25
28
  }
26
29
  if (body.length === 0) body = '(no output)'
27
30
  const markers = []
31
+ if (shellId !== undefined) markers.push(`[shell: ${shellId}]`)
28
32
  if (result.sandbox?.denied) {
29
33
  markers.push(sandboxDenialMarker(result.sandbox.mode))
30
34
  if (escalationModes.length > 0) markers.push(escalationHintMarker('command'))
package/src/tool.mjs CHANGED
@@ -56,8 +56,8 @@ function processOutcome(proc) {
56
56
  return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
57
57
  }
58
58
 
59
- /** 前台结果 DTO 去只读化(官方 canonical 同构)。 */
60
- function canonicalResult(result) {
59
+ /** 前台结果 DTO 去只读化(官方 canonical 同构);shellId 为执行事实,随块落存储供卡片徽章回放。 */
60
+ function canonicalResult(result, shellId) {
61
61
  const output = (stream) => ({
62
62
  text: stream.text,
63
63
  truncated: stream.truncated,
@@ -72,6 +72,7 @@ function canonicalResult(result) {
72
72
  timeoutMs: result.timeoutMs,
73
73
  stdout: output(result.stdout),
74
74
  stderr: output(result.stderr),
75
+ ...shellId !== undefined ? { shell: shellId } : {},
75
76
  ...result.sandbox !== undefined ? { sandbox: {
76
77
  mode: result.sandbox.mode,
77
78
  denied: result.sandbox.denied,
@@ -81,10 +82,11 @@ function canonicalResult(result) {
81
82
  }
82
83
  }
83
84
 
84
- /** 后台输出并集公共键(官方同构)。 */
85
+ /** 后台输出并集公共键(官方同构);shell 同前台,执行事实随 DTO 供渲染。 */
85
86
  const BACKGROUND_OUTPUT_PROPERTIES = {
86
87
  kind: { type: 'string', required: true, const: 'background' },
87
88
  jobId: { type: 'string', required: true },
89
+ shell: { type: 'string' },
88
90
  }
89
91
 
90
92
  /** 前台输出分支(官方同构,逐字段)。 */
@@ -108,6 +110,7 @@ function foregroundOutputProperties() {
108
110
  timeoutMs: { type: 'number', required: true },
109
111
  stdout: streamSchema,
110
112
  stderr: streamSchema,
113
+ shell: { type: 'string' },
111
114
  sandbox: {
112
115
  type: 'object',
113
116
  additionalProperties: false,
@@ -139,18 +142,9 @@ function shellDescription({ executor, backgroundEnabled, escalationModes }) {
139
142
  return base + ' Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Never escalate speculatively: ground the request in a real denial. If the session states approval prompts are disabled, a denial is final — do not set `sandbox_permissions`.'
140
143
  }
141
144
 
142
- /** pwsh 工具描述:兼容保留工具,开头一句禁止调用。 */
143
- function pwshDescription({ backgroundEnabled, escalationModes }) {
144
- const base = 'Do not call this tool. It is kept only for backward compatibility with old sessions — call the `shell` tool instead. '
145
- + 'It executes a PowerShell command and returns its stdout/stderr. '
146
- + 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. '
147
- + 'Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed environment variables (`DSH_*`); inspect them when needed. '
148
- + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
149
- + (backgroundEnabled
150
- ? 'Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.'
151
- : 'Background execution is not available; long-running commands must finish within the timeout.')
152
- if (escalationModes.length === 0) return base
153
- return base + ' Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Never escalate speculatively: ground the request in a real denial. If the session states approval prompts are disabled, a denial is final — do not set `sandbox_permissions`.'
145
+ /** pwsh 工具描述:兼容保留工具,只声明不要调用与去向,零执行语义。 */
146
+ function pwshDescription() {
147
+ return 'Do not call; use `shell`.'
154
148
  }
155
149
 
156
150
  /**
@@ -189,7 +183,7 @@ export function registerShellTool(ctx, { executor }) {
189
183
  name: toolName,
190
184
  description: pinned === undefined
191
185
  ? shellDescription({ executor: faces.executor, backgroundEnabled, escalationModes })
192
- : pwshDescription({ backgroundEnabled, escalationModes }),
186
+ : pwshDescription(),
193
187
  parameters: {
194
188
  command: {
195
189
  type: 'string',
@@ -243,7 +237,9 @@ export function registerShellTool(ctx, { executor }) {
243
237
  }] },
244
238
  render: (_args, value) => [{
245
239
  type: 'text',
246
- text: value.kind === 'background' ? `started background job ${value.jobId}` : renderResult(value, escalationModes),
240
+ text: value.kind === 'background'
241
+ ? `started background job ${value.jobId}${value.shell !== undefined ? `\n[shell: ${value.shell}]` : ''}`
242
+ : renderResult(value, escalationModes, value.shell),
247
243
  }],
248
244
  },
249
245
  async execute(args, exec) {
@@ -269,11 +265,12 @@ export function registerShellTool(ctx, { executor }) {
269
265
  try {
270
266
  faces.executor.startFor(entry, faces.executor.resolve(request))
271
267
  } catch (error) {
272
- if (isDenyError(error)) return { kind: 'foreground', blocked: true, blockedBy: error.pattern, exitCode: null, signal: null, timedOut: false, aborted: false, timeoutMs: 0, stdout: { text: '', truncated: false }, stderr: { text: blockedMarker(error), truncated: false } }
268
+ if (isDenyError(error)) return { kind: 'foreground', shell: entry.id, blocked: true, blockedBy: error.pattern, exitCode: null, signal: null, timedOut: false, aborted: false, timeoutMs: 0, stdout: { text: '', truncated: false }, stderr: { text: blockedMarker(error), truncated: false } }
273
269
  throw error
274
270
  }
275
271
  return {
276
272
  kind: 'background',
273
+ shell: entry.id,
277
274
  jobId: jobs.start({
278
275
  kind: 'shell',
279
276
  label: args.command,
@@ -296,11 +293,11 @@ export function registerShellTool(ctx, { executor }) {
296
293
  signal: exec.signal,
297
294
  }))
298
295
  } catch (error) {
299
- if (isDenyError(error)) return { kind: 'foreground', blocked: true, blockedBy: error.pattern, exitCode: null, signal: null, timedOut: false, aborted: false, timeoutMs: 0, stdout: { text: '', truncated: false }, stderr: { text: blockedMarker(error), truncated: false } }
296
+ if (isDenyError(error)) return { kind: 'foreground', shell: entry.id, blocked: true, blockedBy: error.pattern, exitCode: null, signal: null, timedOut: false, aborted: false, timeoutMs: 0, stdout: { text: '', truncated: false }, stderr: { text: blockedMarker(error), truncated: false } }
300
297
  throw error
301
298
  }
302
299
  if (result.aborted) throw await abortError('tool call aborted')
303
- return canonicalResult(result)
300
+ return canonicalResult(result, entry.id)
304
301
  },
305
302
  presentCall: (args) => {
306
303
  if (args.run_in_background === true) {
@@ -5,7 +5,7 @@
5
5
  // settings.describe,写经 settings.replace;探测/扫描用 resolve 纯函数。
6
6
  // HTTP 形态(具名 exact 路由 + 跨源守卫 + 业务异常归一 400)照 dsh-maintain。
7
7
 
8
- import { KINDS, resolveConfig, assertServiceableConfig, normalizeConfigPaths, normalizeWin32Path } from './config.mjs'
8
+ import { KINDS, resolveConfig, coerceServiceable, assertServiceableConfig, normalizeConfigPaths, normalizeWin32Path } from './config.mjs'
9
9
  import { candidateExists, detectCandidates, resolveEntryPath } from './resolve.mjs'
10
10
  import { requestShellRefresh } from './executor.mjs'
11
11
  import { mountRoutes } from './api.mjs'
@@ -48,7 +48,9 @@ function readSection(ctx, settings) {
48
48
  ctx.logger?.warn?.(`shell-select: settings 面无 "${SECTION_NS}" 节,设置页回落出厂默认`)
49
49
  return resolveConfig({})
50
50
  }
51
- return resolveConfig(found.value ?? {})
51
+ // 读出面同走 coercing:落盘坏值(悬空 default/空 shells)投影为可服务配置,
52
+ // 设置页显示与执行面一致,不再暴露不可服务的中间态
53
+ return resolveConfig(coerceServiceable(found.value ?? {}).config)
52
54
  }
53
55
 
54
56
  /**
@@ -26,6 +26,8 @@ function cardModel(catalog = null) {
26
26
  lastSegment,
27
27
  parseExitTail: extractLogic('parseExitTail'),
28
28
  hasSpillNotice: extractLogic('hasSpillNotice'),
29
+ parseShellMark: extractLogic('parseShellMark'),
30
+ stripShellMark: extractLogic('stripShellMark'),
29
31
  clientDisplayName: extractLogic('clientDisplayName', { clientCatalog: catalog }),
30
32
  })
31
33
  }
@@ -155,6 +157,76 @@ test('S17d 后台状态中性:不断言运行中,点为中性图标(渲染守卫
155
157
  assert.doesNotMatch(source, /case 'background': return en \? 'Background' : '后台运行'/)
156
158
  })
157
159
 
160
+ test('S18 头部单行:cwd 与客户端徽章强制不换行(渲染守卫)', () => {
161
+ // BDD:Given 头部行宽受限,When cwd 或客户端名过长,Then 二者均不内部折行(cwd 压缩出省略号,徽章保持完整)
162
+ assert.match(source, /\.sls-tv__cwd \{[^}]*white-space:nowrap/)
163
+ assert.match(source, /\.sls-tv__cwd \{[^}]*overflow:hidden/)
164
+ assert.match(source, /\.sls-tv__cwd \{[^}]*text-overflow:ellipsis/)
165
+ assert.match(source, /\.sls-tv__badge \{[^}]*white-space:nowrap/)
166
+ assert.match(source, /\.sls-tv__pill \{[^}]*white-space:nowrap/)
167
+ assert.match(source, /\.sls-tv__duration \{[^}]*white-space:nowrap/)
168
+ assert.match(source, /\.sls-tv__pulse \{[^}]*white-space:nowrap/)
169
+ })
170
+
171
+ test('S19 行首无展开箭头:官方组件无展开箭头,可展开性由指针与整行点击承载(渲染守卫)', () => {
172
+ // BDD:Given 官方 ToolRow 无展开箭头,When 行渲染,Then 不存在 chevron 节点及其样式与状态钩子
173
+ assert.doesNotMatch(source, /sls-tv__chev/)
174
+ assert.doesNotMatch(source, /IconChevron/)
175
+ assert.doesNotMatch(source, /data-open/)
176
+ })
177
+
178
+ test('S20 徽章执行事实:结果标记优先于查看时配置读数', () => {
179
+ // BDD:Given 缺省客户端调用时 default 为 pwsh,查看时配置 default 已改 git-bash,When 派生卡片模型,Then 徽章显示标记承载的 pwsh,输出不含标记行
180
+ const model = cardModel({ default: 'git-bash', byId: { 'git-bash': 'git-bash' } })(
181
+ settledBlock(ARGS, 'out\n[shell: pwsh]\n[exit code: 0]'), SESSION_CWD)
182
+ assert.equal(model.kind, 'terminal')
183
+ assert.equal(model.shellName, 'pwsh')
184
+ assert.equal(model.output, 'out')
185
+ })
186
+
187
+ test('S20b 徽章执行事实:标记在场不依赖配置面,catalog 缺失仍显示', () => {
188
+ // BDD:Given 配置读取失败(catalog null),When 块带执行事实标记,Then 徽章仍按标记显示(执行事实不依赖查看时读数)
189
+ const model = cardModel(null)(
190
+ settledBlock(ARGS, 'out\n[shell: git-bash]\n[exit code: 2]'), SESSION_CWD)
191
+ assert.equal(model.shellName, 'git-bash')
192
+ assert.equal(model.exitCode, 2)
193
+ })
194
+
195
+ test('S20c 旧块无标记:回退显式参数 → 查看时 default(S15 链不回归)', () => {
196
+ // BDD:Given 历史块无标记时代产物,When 派生模型,Then 沿用显式参数/查看时 default 推导
197
+ const catalog = { default: 'git-bash', byId: { 'git-bash': 'git-bash', pwsh: 'pwsh' } }
198
+ const fallback = cardModel(catalog)(settledBlock(ARGS, 'out\n[exit code: 0]'), SESSION_CWD)
199
+ assert.equal(fallback.shellName, 'git-bash')
200
+ const explicit = cardModel(catalog)(
201
+ settledBlock(JSON.stringify({ command: 'ls', description: 'list', shell: 'pwsh' }), 'out\n[exit code: 0]'), SESSION_CWD)
202
+ assert.equal(explicit.shellName, 'pwsh')
203
+ })
204
+
205
+ test('S20d 后台 ack:标记入徽章,输出剥离标记行', () => {
206
+ // BDD:Given 后台调用 ack 携带执行事实标记,When 派生模型,Then shellName 按标记,输出仅剩 ack 原文
207
+ const model = cardModel({ default: 'git-bash', byId: {} })(settledBlock(
208
+ JSON.stringify({ command: 'node server.js', description: 'Boot server', run_in_background: true }),
209
+ 'started background job shell-11\n[shell: pwsh]'), SESSION_CWD)
210
+ assert.equal(model.kind, 'background')
211
+ assert.equal(model.jobId, 'shell-11')
212
+ assert.equal(model.shellName, 'pwsh')
213
+ assert.equal(model.output, 'started background job shell-11')
214
+ })
215
+
216
+ test('S20e generic 输出剥离标记行(isError 路径)', () => {
217
+ // BDD:Given isError 块文本含标记,When 回退 generic,Then 输出不含标记行
218
+ const model = cardModel(null)(settledBlock(ARGS, 'boom\n[shell: pwsh]', { isError: true }), SESSION_CWD)
219
+ assert.equal(model.kind, 'generic')
220
+ assert.equal(model.output, 'boom')
221
+ })
222
+
223
+ test('S20f 运行中块:无结果文本,徽章按显式参数/查看时 default 推导(现状)', () => {
224
+ // BDD:Given 调用进行中无结果,When 派生模型,Then 徽章沿用配置读数(执行事实落定后由标记校正)
225
+ const catalog = { default: 'git-bash', byId: { 'git-bash': 'git-bash' } }
226
+ const model = cardModel(catalog)(runningBlock(ARGS), SESSION_CWD)
227
+ assert.equal(model.shellName, 'git-bash')
228
+ })
229
+
158
230
  test('S12c isError 与空结果仍走 generic(回归)', () => {
159
231
  const errored = cardModel()(settledBlock(ARGS, 'boom', { isError: true }), SESSION_CWD)
160
232
  assert.equal(errored.kind, 'generic')
@@ -167,6 +239,91 @@ test('S13 注册面:shell/pwsh/bash 三 key 且 priority -1(文本守卫)', () =
167
239
  assert.match(source, /key: toolKey, priority: -1/)
168
240
  })
169
241
 
242
+ // ── S21 六态徽章矩阵:前台/后台 × shell(git-bash/pwsh) × pwsh 官方 ──
243
+ // 背景:官方 pwsh 工具调用被增强卡接管,但其输出无 [shell: ...] 标记、参数无
244
+ // shell 字段,徽章兜底落查看时默认客户端读数 → 官方 pwsh 调用误标 git-bash。
245
+
246
+ const BG_ARGS = JSON.stringify({ command: 'node server.js', description: 'Boot server', run_in_background: true })
247
+
248
+ test('S21a shell 前台 git-bash:执行事实标记入徽章', () => {
249
+ // BDD:Given shell 工具经 git-bash 客户端前台执行,When 派生卡片模型,Then 徽章显示标记承载的 git-bash
250
+ const model = cardModel(null)(settledBlock(ARGS, 'out\n[shell: git-bash]\n[exit code: 0]'), SESSION_CWD, 'shell')
251
+ assert.equal(model.kind, 'terminal')
252
+ assert.equal(model.shellName, 'git-bash')
253
+ })
254
+
255
+ test('S21b shell 前台 pwsh:执行事实标记入徽章', () => {
256
+ // BDD:Given shell 工具经 pwsh 客户端前台执行失败,When 派生卡片模型,Then 徽章 pwsh 且退出码保留
257
+ const model = cardModel(null)(settledBlock(ARGS, 'out\n[shell: pwsh]\n[exit code: 2]'), SESSION_CWD, 'shell')
258
+ assert.equal(model.shellName, 'pwsh')
259
+ assert.equal(model.exitCode, 2)
260
+ })
261
+
262
+ test('S21c shell 后台 git-bash:ack 标记入徽章,jobId 解析', () => {
263
+ // BDD:Given shell 工具后台启动于 git-bash,When 派生 ack 卡,Then 徽章 git-bash 且 jobId 取自 ack
264
+ const model = cardModel(null)(settledBlock(BG_ARGS, 'started background job shell-4\n[shell: git-bash]'), SESSION_CWD, 'shell')
265
+ assert.equal(model.kind, 'background')
266
+ assert.equal(model.jobId, 'shell-4')
267
+ assert.equal(model.shellName, 'git-bash')
268
+ })
269
+
270
+ test('S21d shell 后台 pwsh:ack 标记入徽章', () => {
271
+ // BDD:Given shell 工具后台启动于 pwsh,When 派生 ack 卡,Then 徽章 pwsh
272
+ const model = cardModel(null)(settledBlock(BG_ARGS, 'started background job shell-5\n[shell: pwsh]'), SESSION_CWD, 'shell')
273
+ assert.equal(model.kind, 'background')
274
+ assert.equal(model.jobId, 'shell-5')
275
+ assert.equal(model.shellName, 'pwsh')
276
+ })
277
+
278
+ test('S21e pwsh 官方前台:徽章钉死 pwsh,不落默认客户端读数', () => {
279
+ // BDD:Given 官方 pwsh 工具调用(无标记无 shell 参数)且查看时默认客户端为 git-bash,
280
+ // When 派生卡片模型,Then 徽章 pwsh(工具名即执行事实,读数兜底只属 shell 工具历史块)
281
+ const model = cardModel({ default: 'git-bash', byId: { 'git-bash': 'git-bash' } })(
282
+ settledBlock(ARGS, 'On branch main\n[exit code: 0]'), SESSION_CWD, 'pwsh')
283
+ assert.equal(model.kind, 'terminal')
284
+ assert.equal(model.shellName, 'pwsh')
285
+ })
286
+
287
+ test('S21f pwsh 官方后台:徽章钉死 pwsh,官方 pwsh 前缀 jobId 解析', () => {
288
+ // BDD:Given 官方 pwsh 工具后台启动(kind pwsh → 任务号 pwsh-N),When 派生 ack 卡,Then 徽章 pwsh 且 jobId=pwsh-3
289
+ const model = cardModel({ default: 'git-bash', byId: { 'git-bash': 'git-bash' } })(
290
+ settledBlock(BG_ARGS, 'started background job pwsh-3'), SESSION_CWD, 'pwsh')
291
+ assert.equal(model.kind, 'background')
292
+ assert.equal(model.jobId, 'pwsh-3')
293
+ assert.equal(model.shellName, 'pwsh')
294
+ })
295
+
296
+ test('S21g pwsh 官方运行中:徽章钉死 pwsh(落定前不误标)', () => {
297
+ // BDD:Given 官方 pwsh 调用进行中无结果文本,When 派生 running 卡,Then 徽章 pwsh 而非默认客户端读数
298
+ const model = cardModel({ default: 'git-bash', byId: { 'git-bash': 'git-bash' } })(runningBlock(ARGS), SESSION_CWD, 'pwsh')
299
+ assert.equal(model.status, 'running')
300
+ assert.equal(model.shellName, 'pwsh')
301
+ })
302
+
303
+ test('S21h bash 官方 key 同规钉死(同类预防)', () => {
304
+ // BDD:Given 官方 bash 工具调用且默认客户端读数为 git-bash,When 派生卡片模型,Then 徽章 bash
305
+ const model = cardModel({ default: 'git-bash', byId: { 'git-bash': 'git-bash' } })(
306
+ settledBlock(ARGS, 'out\n[exit code: 0]'), SESSION_CWD, 'bash')
307
+ assert.equal(model.shellName, 'bash')
308
+ })
309
+
310
+ test('S21i toolKey 贯通:注册循环注入 + 行组件传入模型(渲染守卫)', () => {
311
+ // BDD:Given 卡片按 key 注册,When 渲染,Then key 以 toolKey 注入组件并传入模型派生(漏任何一环徽章退回读数兜底)
312
+ assert.match(source, /React\.createElement\(ShellToolRow, \{ \.\.\.props, toolKey \}\)/)
313
+ assert.match(source, /shellCardModel\(block, cwd, toolKey\)/)
314
+ })
315
+
316
+ test('S21j 钉死键集与注册键集同源:PINNED_CLIENT 恒等于 TOOLVIEW_KEYS 去 shell(渲染守卫)', () => {
317
+ // BDD:Given 官方钉死客户端事实在 client.js 双处字面编码(TOOLVIEW_KEYS 与 PINNED_CLIENT),
318
+ // When 派生两处键集,Then 钉死键集恒等于注册键集去掉 shell——注册面新增官方工具 key 而
319
+ // 漏配钉死映射时此处红,误标 bug 不静默复发
320
+ const toolviewKeys = /const TOOLVIEW_KEYS = \[([^\]]*)\]/.exec(source)[1]
321
+ .split(',').map((item) => item.trim().replace(/^'|'$/g, '')).filter((key) => key !== '' && key !== 'shell')
322
+ const pinnedKeys = /const PINNED_CLIENT = \{([^}]*)\}/.exec(source)[1]
323
+ .split(',').map((pair) => pair.trim().split(':')[0].trim().replace(/^'|'$/g, '')).filter((key) => key !== '')
324
+ assert.deepEqual(pinnedKeys.sort(), toolviewKeys.sort())
325
+ })
326
+
170
327
  test('K=V 往返:值含 = 与空格无损,空行忽略,重复键后行胜', () => {
171
328
  const parse = parseEnvText()
172
329
  assert.deepEqual(parse('A=1\nB=x=y z\n\nA=2'), { A: '2', B: 'x=y z' })
@@ -119,7 +119,6 @@ test('图标名守卫:0.1.7 primitives 字重后缀名在场,尺寸后缀旧名
119
119
  // 0.1.7 图标改名:尺寸从名字移除(IconApiOutline14 → IconApiOutlineRegular/Medium),
120
120
  // 旧名 miss 使 IconApi 落淡灰 fallback 被当成"空白 icon"(实测事故)
121
121
  assert.match(source, /'IconApiOutlineRegular'/)
122
- assert.match(source, /'IconChevronDownOutlineRegular'/)
123
122
  assert.match(source, /'IconInspectOutlineRegular'/)
124
123
  assert.doesNotMatch(source, /createElementOf\('IconApiOutline14'/)
125
124
  })
@@ -3,7 +3,8 @@
3
3
 
4
4
  import { test } from 'node:test'
5
5
  import assert from 'node:assert/strict'
6
- import { resolveConfig, defaultConfig, entryById, requireEntry, buildArgv, KINDS, RESOLVED_AUTO } from '../src/config.mjs'
6
+ import { resolveConfig, resolveConfigStrict, coerceServiceable, defaultConfig, entryById, requireEntry, buildArgv, KINDS, RESOLVED_AUTO } from '../src/config.mjs'
7
+ import { assertServiceableConfig } from '../src/config.mjs'
7
8
 
8
9
  test('schema 应用出厂默认:三客户端 + 默认 pwsh', () => {
9
10
  const applied = resolveConfig({})
@@ -38,6 +39,13 @@ test('落盘坏形态防御:volatile 字段被序列化成对象时降级默认,
38
39
  const valid = resolveConfig({ shells: [{ id: 'pwsh', kind: 'pwsh' }], default: 'pwsh', deny: ['rm -rf'], cwd: 'C:\\tmp', timeoutMs: 5000 })
39
40
  assert.equal(valid.shells.length, 1)
40
41
  assert.equal(valid.default, 'pwsh')
42
+
43
+ // 执行面 strict:同款坏形态必须炸(宿主 resolve 产物须先解箱;静默降级
44
+ // 曾让行 config 永不生效,0.2.0-rc.2 真机实证)。宽容路径(上方)保留。
45
+ assert.throws(() => resolveConfigStrict(poisoned), /形态非法.*shells/)
46
+ // 合法输入与 undefined 缺省:strict 与宽容同产出
47
+ assert.deepEqual(resolveConfigStrict(valid), valid)
48
+ assert.deepEqual(resolveConfigStrict(undefined), defaultConfig())
41
49
  assert.deepEqual(valid.deny, ['rm -rf'])
42
50
  assert.equal(valid.cwd, 'C:\\tmp')
43
51
  assert.equal(valid.timeoutMs, 5000)
@@ -47,8 +55,38 @@ test('落盘坏形态防御:volatile 字段被序列化成对象时降级默认,
47
55
  }
48
56
  })
49
57
 
50
- test('冻结输入防御:volatile ref 深冻结快照喂入不改写不抛(挂载路径)', () => {
51
- // 事故:cosmokit createVolatile 的 snapshot 对 volatile 值逐层 Object.freeze,
58
+ // --- coerceServiceable:任意用户输入产出可服务配置(插件不因配置死) ---
59
+
60
+ test('coerce:悬空 default 归 shells[0],空/全非法 shells 回落出厂默认', () => {
61
+ const { config, warnings } = coerceServiceable({ shells: [{ id: 'pwsh', kind: 'pwsh' }], default: 'nope' })
62
+ assert.equal(config.default, 'pwsh')
63
+ assert.ok(warnings.some((line) => line.includes('default')))
64
+ const emptied = coerceServiceable({ shells: [{ id: 'x', kind: 'fish' }], default: 'x' })
65
+ assert.deepEqual(emptied.config.shells.map((entry) => entry.id), ['pwsh', 'git-bash', 'cmd'])
66
+ assert.equal(emptied.config.default, 'pwsh')
67
+ assert.ok(emptied.warnings.length >= 2)
68
+ })
69
+
70
+ test('coerce:预算字段非正数降默认,deny 剔除不可编译条目,合法输入零告警原样', () => {
71
+ const { config, warnings } = coerceServiceable({
72
+ shells: [{ id: 'pwsh', kind: 'pwsh' }],
73
+ default: 'pwsh',
74
+ timeoutMs: -5,
75
+ graceMs: Number.MAX_SAFE_INTEGER,
76
+ deny: ['rm -rf', '(unclosed', '', 42],
77
+ })
78
+ assert.equal(config.timeoutMs, undefined)
79
+ assert.equal(config.graceMs, undefined)
80
+ assert.deepEqual(config.deny, ['rm -rf'])
81
+ assert.equal(warnings.length, 3)
82
+ const clean = coerceServiceable({ shells: [{ id: 'pwsh', kind: 'pwsh' }], default: 'pwsh' })
83
+ assert.equal(clean.warnings.length, 0)
84
+ // 产物必过可服务校验:任意输入不产出会炸挂载的配置
85
+ assertServiceableConfig(resolveConfig(clean.config))
86
+ assertServiceableConfig(resolveConfig(config))
87
+ })
88
+
89
+ test('冻结输入防御:volatile ref 深冻结快照喂入不改写不抛(挂载路径)', () => { // 事故:cosmokit createVolatile 的 snapshot 对 volatile 值逐层 Object.freeze,
52
90
  // 行 Config ref .get() 产物是深冻结对象;sanitizeConfigEntry 引用透传,
53
91
  // schemastery resolve 原地改写入参(填默认)撞冻结 env 抛
54
92
  // "Cannot assign to read only property 'MSYSTEM'" → 执行器挂载失败,
@@ -4,7 +4,7 @@
4
4
  import { test } from 'node:test'
5
5
  import assert from 'node:assert/strict'
6
6
  import ShellSelectExecutor from '../src/executor.mjs'
7
- import { resolveConfig, assertServiceableConfig } from '../src/config.mjs'
7
+ import { resolveConfig, assertServiceableConfig, defaultConfig } from '../src/config.mjs'
8
8
 
9
9
  // --- 桩具 ---
10
10
 
@@ -125,7 +125,8 @@ test('构造:注册 shell+pwsh 双工具与 systemPrompt 段,路由不再由主
125
125
  const shell = registered.tools.find((tool) => tool.name === 'shell')
126
126
  const pwsh = registered.tools.find((tool) => tool.name === 'pwsh')
127
127
  assert.match(shell.description, /git-bash/)
128
- assert.match(pwsh.description, /PowerShell/)
128
+ // 兼容工具描述一句话:不要调用,使用 shell
129
+ assert.equal(pwsh.description, 'Do not call; use `shell`.')
129
130
  assert.ok(!('shell' in pwsh.parameters.properties))
130
131
  assert.ok('shell' in shell.parameters.properties)
131
132
  assert.equal(registered.promptSections.length, 1)
@@ -407,3 +408,84 @@ test('refresh:换源后执行面即时收紧(已删 id 拒绝,新清单生效)',
407
408
  assert.equal(executor.entryFor(undefined).id, 'pwsh')
408
409
  assert.equal(registered.tools.length, toolsBefore)
409
410
  })
411
+
412
+ // 真机宿主形态:行 config 经宿主按 static Config resolve,volatile 字段是
413
+ // boxed ref({get}),非普通对象。构造/refresh 直存该形态时 resolveConfig 的
414
+ // 类型对账会把 ref 全部当非法输入丢弃,执行面静默回落出厂默认(行 config
415
+ // 永不生效,真机 0.2.0-rc.2 实证)。回归锁定:boxed 形态必须解箱生效。
416
+
417
+ // cordis resolve 产物的同构 boxed 形态:每字段值包 {get}
418
+ function boxFields(config) {
419
+ return Object.fromEntries(Object.entries(config).map(([key, value]) => [key, { get: () => value }]))
420
+ }
421
+
422
+ test('构造:boxed volatile 字段(宿主 resolve 产物)解箱生效,不回落出厂默认', () => {
423
+ const { ctx, registered } = stubCtx()
424
+ const userConfig = {
425
+ shells: baseConfig().shells.filter((entry) => entry.id !== 'cmd'),
426
+ default: 'git-bash',
427
+ }
428
+ const executor = new ShellSelectExecutor(ctx, boxFields(userConfig))
429
+ const listing = executor.listShells()
430
+ assert.equal(listing.default, 'git-bash')
431
+ assert.deepEqual(listing.shells.map((entry) => entry.id), ['pwsh', 'git-bash'])
432
+ assert.equal(executor.entryFor(undefined).id, 'git-bash')
433
+ const shell = registered.tools.find((tool) => tool.name === 'shell')
434
+ assert.match(shell.description, /git-bash \(bash\)$|default client is "git-bash"/)
435
+ assert.doesNotMatch(shell.description, /cmd \(cmd\)/)
436
+ })
437
+
438
+ test('refresh:boxed volatile 字段换源同样解箱生效', () => {
439
+ const { ctx } = stubCtx()
440
+ const executor = new ShellSelectExecutor(ctx, baseConfig())
441
+ executor.refresh(boxFields({
442
+ shells: baseConfig().shells,
443
+ default: 'git-bash',
444
+ }))
445
+ assert.equal(executor.entryFor(undefined).id, 'git-bash')
446
+ const listing = executor.listShells()
447
+ assert.equal(listing.default, 'git-bash')
448
+ assert.equal(listing.shells.length, 3)
449
+ })
450
+
451
+ test('构造:混合形态(部分字段 boxed 部分普通)逐字段解箱生效', () => {
452
+ const { ctx } = stubCtx()
453
+ const executor = new ShellSelectExecutor(ctx, {
454
+ shells: boxFields({ shells: baseConfig().shells }).shells,
455
+ default: 'git-bash',
456
+ timeoutMs: 30000,
457
+ })
458
+ assert.equal(executor.entryFor(undefined).id, 'git-bash')
459
+ assert.equal(executor.config.timeoutMs, 30000)
460
+ })
461
+
462
+ test('入参缺省:null/undefined 落出厂默认,不抛', () => {
463
+ const { ctx } = stubCtx()
464
+ // 出厂默认 path=RESOLVED_AUTO('') → entryFor 走真实磁盘探测,linux CI 无
465
+ // pwsh.exe 必抛 no executable(出厂清单其余条目同);node 自身全平台存在,
466
+ // 以显式 path 覆写探测面,断言目标(id 落定与缺省容错)不受影响。
467
+ // refresh(null) 会把 #entry 归零回出厂(auto 路径),其后 entryFor 同样
468
+ // 需要探测面覆写:refresh 收 bypass 配置,null/undefined 容错语义(归零
469
+ // 不抛)由 refresh 调用本身不抛保证
470
+ const autoBypass = (entryConfig) => ({
471
+ ...entryConfig,
472
+ shells: entryConfig.shells.map((item) => ({ ...item, path: NODE_EXE })),
473
+ })
474
+ const fromNull = new ShellSelectExecutor(ctx, autoBypass(defaultConfig()))
475
+ assert.equal(fromNull.entryFor(undefined).id, 'pwsh')
476
+ fromNull.refresh(autoBypass(defaultConfig()))
477
+ assert.equal(fromNull.entryFor(undefined).id, 'pwsh')
478
+ const fromUndefined = new ShellSelectExecutor(ctx, autoBypass(defaultConfig()))
479
+ assert.equal(fromUndefined.entryFor(undefined).id, 'pwsh')
480
+ })
481
+
482
+ test('fail-loud:解箱后仍非法的形态炸在挂载点,不静默回落出厂默认', () => {
483
+ const { ctx } = stubCtx()
484
+ // ref 容器但解箱值非法(模拟宿主 resolve 形态契约变化):strict 必须抛
485
+ assert.throws(
486
+ () => new ShellSelectExecutor(ctx, { shells: { get: () => 'not-array' }, default: 'pwsh' }),
487
+ /形态非法.*shells/,
488
+ )
489
+ // 同形态宽容路径(resolveConfig)不抛——降级语义仅保留给持久化读回
490
+ assert.doesNotThrow(() => new ShellSelectExecutor(ctx, baseConfig()))
491
+ })
@@ -16,6 +16,45 @@ test('干净退出:仅 stdout,无标记', () => {
16
16
  assert.equal(text, 'hello\n')
17
17
  })
18
18
 
19
+ test('S20h shell 客户端标记:首标记行落,官方退出锚保持末行', () => {
20
+ // BDD:Given 调用经 git-bash 客户端执行且非零退出,When 渲染模型可见文本,Then 首行标记 [shell: git-bash],[exit code] 仍居末(官方 parseExitStatus 锚不被破坏)
21
+ const text = renderResult({
22
+ stdout: { text: 'out\n', truncated: false },
23
+ stderr: { text: '', truncated: false },
24
+ exitCode: 2, signal: null, timedOut: false, sandbox: undefined,
25
+ }, NO_ESCALATION, 'git-bash')
26
+ assert.equal(text, 'out\n[shell: git-bash]\n[exit code: 2]')
27
+ })
28
+
29
+ test('S20h shell 标记:干净成功仅标记行,退出锚缺省形态不变', () => {
30
+ // BDD:Given exit 0 成功调用,When 渲染,Then 输出尾追 [shell: id] 单行,无退出标记
31
+ const text = renderResult({
32
+ stdout: { text: 'hello\n', truncated: false },
33
+ stderr: { text: '', truncated: false },
34
+ exitCode: 0, signal: null, timedOut: false, sandbox: undefined,
35
+ }, NO_ESCALATION, 'pwsh')
36
+ assert.equal(text, 'hello\n[shell: pwsh]')
37
+ })
38
+
39
+ test('S20h shell 标记:先于超时/信号标记,退出锚仍居末', () => {
40
+ const text = renderResult({
41
+ stdout: { text: '', truncated: false },
42
+ stderr: { text: '', truncated: false },
43
+ exitCode: null, signal: 'SIGTERM', timedOut: true, timeoutMs: 1000, sandbox: undefined,
44
+ }, NO_ESCALATION, 'pwsh')
45
+ assert.equal(text, '(no output)\n[shell: pwsh]\n[timed out after 1000ms]\n[killed by signal: SIGTERM]')
46
+ })
47
+
48
+ test('S20h shell 标记:未传 id 不加标记(渲染面与官方逐字同构)', () => {
49
+ // BDD:Given 调用方未携带客户端事实,When 渲染,Then 文本与官方 pwsh 工具逐字一致
50
+ const text = renderResult({
51
+ stdout: { text: 'hello\n', truncated: false },
52
+ stderr: { text: '', truncated: false },
53
+ exitCode: 0, signal: null, timedOut: false, sandbox: undefined,
54
+ }, NO_ESCALATION)
55
+ assert.equal(text, 'hello\n')
56
+ })
57
+
19
58
  test('stderr 段 + 非零退出标记次序', () => {
20
59
  const text = renderResult({
21
60
  stdout: { text: 'out\n', truncated: false },