@mzzsfy/dsh-shell-select 0.3.1 → 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.
@@ -0,0 +1,167 @@
1
+ // 官方 pwsh 链(tool-pwsh + pwsh-sandbox)entry 生命周期探测与运行时接管。
2
+ // 运行时接管(替代 bundle patch 静态禁行):官方两行的禁用由本包主行 apply
3
+ // 落定后经 entry.update(dsh-market 同款调用)执行——禁用官方的前提严格强于
4
+ // 本包可服务性,本包任意死法(模块加载崩/apply 中途崩/被禁/卸载)下官方行
5
+ // 保持启用,官方 pwsh 链自服务,系统不出现 shell 全灭窗口。
6
+ //
7
+ // 接管空间的判定与 gateway(llm-pi-ai 行无 disabled 声明,undefined=默认态)
8
+ // 不同:官方两行在 dsh-base 带平台表达式 `disabled: !!js process.platform !==
9
+ // 'win32'`,win32 上求值 false——宿主默认启用态与「用户显式启用」在行树上
10
+ // 不可区分。因此行级 false 不作为让位信号:凡非 true(启用中)即接管空间,
11
+ // 用户意图通道改为「禁用本包」(market 禁主行或 user patch 禁行,重启后官方
12
+ // 自然回归)。getter 求值抛错(宿主状态异常)仍让位安全向;非布残值同样保守
13
+ // 让位。链式语义:禁用只更新启用中的行(0.1.7 web 面常态:tool-pwsh 被宿主
14
+ // patch 禁用、pwsh-sandbox 平台默认启用,混合态下仅禁后者)。
15
+
16
+ // 官方受控行 id 与行 id 解析前缀(guard-state 同源共享)
17
+ import { OFFICIAL_ROW_IDS, ROW_ID_PREFIXES } from './guard-state.mjs'
18
+
19
+ // 退场轮询步长与轮数上限,乘积为等待上界(guard 同源常量)
20
+ export const EXIT_POLL_INTERVAL_MS = 20
21
+ export const EXIT_POLL_ROUNDS = 50
22
+
23
+ const ABSENT = Object.freeze({ present: false, disabled: false, running: false, entry: undefined, disabledUnreliable: false, rawDisabled: true })
24
+
25
+ // 宿主 Entry.disabled getter 语义:!!js 表达式求值 + 父链回溯 + 布尔宽化,
26
+ // 裸读 options.disabled 会漏掉表达式与非布尔真值两类禁用形态;求值抛错按
27
+ // 未禁用处理并置 disabledUnreliable(宿主状态异常,自动动作一律让位安全向)
28
+ function effectiveDisabled(entry) {
29
+ try {
30
+ return entry.disabled === true
31
+ } catch {
32
+ return undefined
33
+ }
34
+ }
35
+
36
+ /**
37
+ * 探测单个官方行生命周期状态。loader 缺失或行不存在按缺席处理(官方包未装
38
+ * 时照常接管,与「官方包缺失时本包独占」的既有降级语义一致)。disabled
39
+ * getter 求值抛错时 disabled=false 且 disabledUnreliable=true。rawDisabled
40
+ * 为 options.disabled 原始值(仅观测留档:真机形态是 !!js 表达式包装对象,
41
+ * 决策不消费它,启用中判定走 disabled 求值结果)。
42
+ * @param {object|undefined} loader 宿主 loader 服务(cordis Loader/EntryTree)
43
+ * @param {string} id 行 id
44
+ */
45
+ export function officialRowState(loader, id) {
46
+ if (loader?.resolve === undefined) return ABSENT
47
+ let entry
48
+ for (const prefix of ROW_ID_PREFIXES) {
49
+ try {
50
+ const candidate = loader.resolve(prefix + id)
51
+ if (candidate) {
52
+ entry = candidate
53
+ break
54
+ }
55
+ } catch {
56
+ // 该命名空间无此行,试下一前缀
57
+ }
58
+ }
59
+ if (!entry) return ABSENT
60
+ const disabled = effectiveDisabled(entry)
61
+ let rawDisabled
62
+ try {
63
+ rawDisabled = entry.options?.disabled
64
+ } catch {
65
+ rawDisabled = undefined
66
+ }
67
+ return {
68
+ present: true,
69
+ disabled: disabled === true,
70
+ running: entry.fiber?.uid != null,
71
+ entry,
72
+ disabledUnreliable: disabled === undefined,
73
+ rawDisabled,
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 官方双行状态快照(id → 行状态,键序与 OFFICIAL_ROW_IDS 一致)。
79
+ * @param {object|undefined} loader
80
+ */
81
+ export function officialChainState(loader) {
82
+ return Object.fromEntries(OFFICIAL_ROW_IDS.map((id) => [id, officialRowState(loader, id)]))
83
+ }
84
+
85
+ /**
86
+ * 接管决策(链为单位):任一行 getter 求值不可信 → 让位安全向;全部已禁停稳
87
+ * → 接管;禁用但在退场 → 等待;存在启用中的行(getter 求值 false:平台默认
88
+ * 与无声明同形,同为接管空间)→ runtime-disable,仅禁启用中的行。注意
89
+ * options.disabled 原始值可能是 !!js 表达式包装对象(真机实证,非求值布尔),
90
+ * 启用中判定必须走 entry.disabled getter 求值结果,不读原始值。行缺席视为
91
+ * 已禁(不参与 update),全缺席 = 官方包未装 = 直接接管。
92
+ * @param {Record<string, object>} states officialChainState 快照
93
+ * @returns {{ decision: 'takeover'|'await-exit'|'yield'|'runtime-disable', targets: object[] }}
94
+ * targets = runtime-disable 时需 update 的 entry 清单,其余态为空
95
+ */
96
+ export function takeoverDecision(states) {
97
+ const rows = OFFICIAL_ROW_IDS.map((id) => states[id] ?? ABSENT)
98
+ if (rows.some((row) => row.present && row.disabledUnreliable)) return { decision: 'yield', targets: [] }
99
+ const targets = rows.filter((row) => row.present && row.disabled !== true).map((row) => row.entry)
100
+ if (targets.length > 0) return { decision: 'runtime-disable', targets }
101
+ const exiting = rows.some((row) => row.present && row.running)
102
+ return { decision: exiting ? 'await-exit' : 'takeover', targets: [] }
103
+ }
104
+
105
+ /**
106
+ * 运行时禁用一批官方行:dsh-market toggle 同款调用(force update,穿透
107
+ * 「init 在途时 options 翻转但收尾 init 仍拉起 fiber」的空 diff 形态)。
108
+ * 逐行独立执行,单行失败不中止其余行;调用方据失败清单决定回滚。
109
+ * 纯内存态不持久:重启后官方行回归行树声明态,由下一轮 apply 收敛。
110
+ * @param {object[]} entries 官方 loader entry 清单
111
+ * @param {{quiet?: boolean, force?: boolean}} [options]
112
+ * @returns {Promise<{disabled: object[], failed: object[]}>}
113
+ */
114
+ export async function runtimeDisableRows(entries, { quiet = false, force = true } = {}) {
115
+ const disabled = []
116
+ const failed = []
117
+ for (const entry of entries) {
118
+ try {
119
+ await entry.update({ disabled: true }, quiet, force)
120
+ disabled.push(entry)
121
+ } catch {
122
+ failed.push(entry)
123
+ }
124
+ }
125
+ return { disabled, failed }
126
+ }
127
+
128
+ /**
129
+ * 回滚:重新启用已禁的官方行(update {disabled:false}),官方链复活。
130
+ * 仅在接管序列失败路径调用;quiet 减噪(失败路径已另行告警),逐行独立,
131
+ * 失败不抛(重启后行树声明态自然归位)。
132
+ * @param {object[]} entries
133
+ */
134
+ export async function rollbackDisabledRows(entries) {
135
+ for (const entry of entries) {
136
+ try {
137
+ await entry.update({ disabled: false }, true, true)
138
+ } catch {
139
+ // 回滚失败不抛:重启收敛
140
+ }
141
+ }
142
+ }
143
+
144
+ const defaultDelay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
145
+
146
+ /**
147
+ * 有界等待官方行退场(fiber 清)。dispose 为纯内存操作毫秒级完成,轮询上界
148
+ * 仅防御异常宿主状态。fiber 清空后官方注册的撤销还在卸载收尾(微任务批)中
149
+ * 完成,返回前补一轮间隔让收尾落定,避免挂载执行器时与官方未撤完的 ctx.shell
150
+ * 注册交错(cordis 重复 provide 抛错)。
151
+ * @param {object[]} entries 官方 loader entry 清单
152
+ * @param {{intervalMs?: number, rounds?: number, delay?: (ms: number) => Promise<void>}} [options]
153
+ * @returns {Promise<boolean>} true = 已全部退场;false = 轮询耗尽仍有在场
154
+ */
155
+ export async function awaitOfficialExit(entries, {
156
+ intervalMs = EXIT_POLL_INTERVAL_MS,
157
+ rounds = EXIT_POLL_ROUNDS,
158
+ delay = defaultDelay,
159
+ } = {}) {
160
+ const stillRunning = () => entries.some((entry) => entry.fiber?.uid != null)
161
+ for (let round = 0; round < rounds && stillRunning(); round += 1) {
162
+ await delay(intervalMs)
163
+ }
164
+ if (stillRunning()) return false
165
+ await delay(intervalMs)
166
+ return true
167
+ }
package/src/tool.mjs CHANGED
@@ -3,6 +3,10 @@
3
3
  // 2. 描述动态含可用客户端清单与方言提示(settings onChange 重注册);
4
4
  // 3. 执行经 executor.entryFor/runFor/startFor 按条目 argv 运行。
5
5
  // 输出 schema、后台任务语义、升权流程、terminal 卡呈现与官方逐字同构。
6
+ // 另注册 pwsh 名字工具(镜像官方定义,钉死 pwsh 客户端):旧会话 preset
7
+ // 快照里的 pwsh 工具语义是"PowerShell 专用",缺省落 config.default 会随
8
+ // 用户改默认客户端而炸方言;本注册给新会话提供同名工具保语义,模型点名
9
+ // pwsh 时永远 PowerShell。
6
10
 
7
11
  import { TOOL_ABORTED, defineTool } from '@deepseek-ai/dsh-tools'
8
12
  import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
@@ -52,8 +56,8 @@ function processOutcome(proc) {
52
56
  return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
53
57
  }
54
58
 
55
- /** 前台结果 DTO 去只读化(官方 canonical 同构)。 */
56
- function canonicalResult(result) {
59
+ /** 前台结果 DTO 去只读化(官方 canonical 同构);shellId 为执行事实,随块落存储供卡片徽章回放。 */
60
+ function canonicalResult(result, shellId) {
57
61
  const output = (stream) => ({
58
62
  text: stream.text,
59
63
  truncated: stream.truncated,
@@ -68,6 +72,7 @@ function canonicalResult(result) {
68
72
  timeoutMs: result.timeoutMs,
69
73
  stdout: output(result.stdout),
70
74
  stderr: output(result.stderr),
75
+ ...shellId !== undefined ? { shell: shellId } : {},
71
76
  ...result.sandbox !== undefined ? { sandbox: {
72
77
  mode: result.sandbox.mode,
73
78
  denied: result.sandbox.denied,
@@ -77,10 +82,11 @@ function canonicalResult(result) {
77
82
  }
78
83
  }
79
84
 
80
- /** 后台输出并集公共键(官方同构)。 */
85
+ /** 后台输出并集公共键(官方同构);shell 同前台,执行事实随 DTO 供渲染。 */
81
86
  const BACKGROUND_OUTPUT_PROPERTIES = {
82
87
  kind: { type: 'string', required: true, const: 'background' },
83
88
  jobId: { type: 'string', required: true },
89
+ shell: { type: 'string' },
84
90
  }
85
91
 
86
92
  /** 前台输出分支(官方同构,逐字段)。 */
@@ -104,6 +110,7 @@ function foregroundOutputProperties() {
104
110
  timeoutMs: { type: 'number', required: true },
105
111
  stdout: streamSchema,
106
112
  stderr: streamSchema,
113
+ shell: { type: 'string' },
107
114
  sandbox: {
108
115
  type: 'object',
109
116
  additionalProperties: false,
@@ -135,8 +142,14 @@ function shellDescription({ executor, backgroundEnabled, escalationModes }) {
135
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`.'
136
143
  }
137
144
 
145
+ /** pwsh 工具描述:兼容保留工具,只声明不要调用与去向,零执行语义。 */
146
+ function pwshDescription() {
147
+ return 'Do not call; use `shell`.'
148
+ }
149
+
138
150
  /**
139
- * 注册 shell 工具,返回卸载 disposer(重注册 = 先卸后挂)。
151
+ * 注册 shell + pwsh 双工具,返回卸载 disposer(重注册 = 先卸后挂)。
152
+ * pwsh 工具钉死 pwsh 客户端(config 删 pwsh 条目时 requireEntry 明确拒绝)。
140
153
  * @param {object} ctx cordis context(需 tools/shellEnv 服务在场)
141
154
  * @param {{executor: object}} faces 执行器实例
142
155
  */
@@ -166,24 +179,28 @@ export function registerShellTool(ctx, { executor }) {
166
179
  validateEscalationArgs(args.sandbox_permissions, args.justification)
167
180
  }
168
181
 
169
- const definition = (faces) => defineTool({
170
- name: 'shell',
171
- description: shellDescription({ executor: faces.executor, backgroundEnabled, escalationModes }),
182
+ const makeDefinition = (toolName, pinned) => (faces) => defineTool({
183
+ name: toolName,
184
+ description: pinned === undefined
185
+ ? shellDescription({ executor: faces.executor, backgroundEnabled, escalationModes })
186
+ : pwshDescription(),
172
187
  parameters: {
173
188
  command: {
174
189
  type: 'string',
175
190
  required: true,
176
- description: 'The command to execute, in the dialect of the selected shell client (default client when `shell` is omitted).',
191
+ description: pinned === undefined
192
+ ? 'The command to execute, in the dialect of the selected shell client (default client when `shell` is omitted).'
193
+ : 'The PowerShell command to execute.',
177
194
  },
178
195
  description: {
179
196
  type: 'string',
180
197
  required: true,
181
198
  description: 'Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI).',
182
199
  },
183
- shell: {
200
+ ...pinned === undefined ? { shell: {
184
201
  type: 'string',
185
202
  description: 'Shell client id from the tool description list (e.g. pwsh, git-bash, cmd). Omit to use the configured default client.',
186
- },
203
+ } } : {},
187
204
  timeoutMs: {
188
205
  type: 'number',
189
206
  description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.',
@@ -220,18 +237,20 @@ export function registerShellTool(ctx, { executor }) {
220
237
  }] },
221
238
  render: (_args, value) => [{
222
239
  type: 'text',
223
- 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),
224
243
  }],
225
244
  },
226
245
  async execute(args, exec) {
227
246
  validateArgs(args)
228
247
  const standingPolicy = resolveStandingPolicy(exec)
229
248
  const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
230
- ? await approveShellEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
249
+ ? await approveShellEscalation(toolName, args.sandbox_permissions, args.justification, exec, standingPolicy)
231
250
  : undefined
232
251
  const policy = approvedMode === undefined ? standingPolicy : { ...standingPolicy, mode: approvedMode }
233
252
  const workdir = resolveWorkdir(args.workdir, exec)
234
- const entry = faces.executor.entryFor(args.shell)
253
+ const entry = faces.executor.entryFor(pinned ?? args.shell)
235
254
  const request = {
236
255
  command: args.command,
237
256
  ...workdir !== undefined ? { workdir } : {},
@@ -246,11 +265,12 @@ export function registerShellTool(ctx, { executor }) {
246
265
  try {
247
266
  faces.executor.startFor(entry, faces.executor.resolve(request))
248
267
  } catch (error) {
249
- 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 } }
250
269
  throw error
251
270
  }
252
271
  return {
253
272
  kind: 'background',
273
+ shell: entry.id,
254
274
  jobId: jobs.start({
255
275
  kind: 'shell',
256
276
  label: args.command,
@@ -273,11 +293,11 @@ export function registerShellTool(ctx, { executor }) {
273
293
  signal: exec.signal,
274
294
  }))
275
295
  } catch (error) {
276
- 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 } }
277
297
  throw error
278
298
  }
279
299
  if (result.aborted) throw await abortError('tool call aborted')
280
- return canonicalResult(result)
300
+ return canonicalResult(result, entry.id)
281
301
  },
282
302
  presentCall: (args) => {
283
303
  if (args.run_in_background === true) {
@@ -323,7 +343,7 @@ export function registerShellTool(ctx, { executor }) {
323
343
  }
324
344
 
325
345
  /** 升权审批:先于执行,共享 fail-closed 序列交 approveEscalation(官方同构)。 */
326
- function approveShellEscalation(mode, justification, exec, standingPolicy) {
346
+ function approveShellEscalation(toolName, mode, justification, exec, standingPolicy) {
327
347
  if (escalationModes.length === 0) throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
328
348
  const approver = ctx.get('approval')
329
349
  return approveEscalation({
@@ -335,16 +355,19 @@ export function registerShellTool(ctx, { executor }) {
335
355
  approver,
336
356
  agent: exec.agent,
337
357
  callId: exec.callId,
338
- toolName: 'shell',
358
+ toolName,
339
359
  signal: exec.signal,
340
360
  })
341
361
  }
342
362
 
343
363
  const faces = { executor }
344
- const disposer = ctx.tools.register(definition(faces))
364
+ const disposers = [
365
+ ctx.tools.register(makeDefinition('shell', undefined)(faces)),
366
+ ctx.tools.register(makeDefinition('pwsh', 'pwsh')(faces)),
367
+ ]
345
368
  return () => {
346
369
  if (disposed) return
347
370
  disposed = true
348
- disposer()
371
+ for (const disposer of disposers) disposer()
349
372
  }
350
373
  }
@@ -0,0 +1,93 @@
1
+ // shell-select web 路由行:设置页数据通道(inject 声明式门控,webServer/settings
2
+ // 就绪才激活——主行一次性同步探测的时序竞态即 0.1.7 设置页 404 事故根因)。
3
+ // faces 不依赖执行器实例:配置事实源 = 行 Config(0.1.7 settings 面以 profile
4
+ // 条目 id 为 ns,行 Config 即设置存储,变更经 cordis 行重载活生效),读经
5
+ // settings.describe,写经 settings.replace;探测/扫描用 resolve 纯函数。
6
+ // HTTP 形态(具名 exact 路由 + 跨源守卫 + 业务异常归一 400)照 dsh-maintain。
7
+
8
+ import { KINDS, resolveConfig, coerceServiceable, assertServiceableConfig, normalizeConfigPaths, normalizeWin32Path } from './config.mjs'
9
+ import { candidateExists, detectCandidates, resolveEntryPath } from './resolve.mjs'
10
+ import { requestShellRefresh } from './executor.mjs'
11
+ import { mountRoutes } from './api.mjs'
12
+
13
+ export const name = 'shell-select/web'
14
+
15
+ export const inject = ['webServer', 'settings']
16
+
17
+ // 设置节命名空间 = 本包主行在组合树中的条目 id(cordis.patch.yml insert id)
18
+ export const SECTION_NS = 'shell-select'
19
+
20
+ /** 会话可见清单:条目 + 解析后的真实路径与可用性(与主行 listShells 同构)。 */
21
+ function listShellsOf(section) {
22
+ return {
23
+ default: section.default,
24
+ shells: section.shells.map((entry) => {
25
+ const resolved = normalizeWin32Path(resolveEntryPath(entry, candidateExists))
26
+ return {
27
+ id: entry.id,
28
+ kind: entry.kind,
29
+ args: entry.args,
30
+ path: resolved,
31
+ available: resolved !== undefined,
32
+ login: entry.login === true,
33
+ distro: entry.distro ?? '',
34
+ env: entry.env ?? {},
35
+ }
36
+ }),
37
+ }
38
+ }
39
+
40
+ /**
41
+ * 从 settings describe 投影当前节:目标 ns 缺失(主行未挂/旧宿主形态)时
42
+ * 回落出厂默认并告警,读路径不崩。
43
+ */
44
+ function readSection(ctx, settings) {
45
+ const found = (typeof settings.describe === 'function' ? settings.describe() : [])
46
+ .find((descriptor) => descriptor.ns === SECTION_NS)
47
+ if (found === undefined) {
48
+ ctx.logger?.warn?.(`shell-select: settings 面无 "${SECTION_NS}" 节,设置页回落出厂默认`)
49
+ return resolveConfig({})
50
+ }
51
+ // 读出面同走 coercing:落盘坏值(悬空 default/空 shells)投影为可服务配置,
52
+ // 设置页显示与执行面一致,不再暴露不可服务的中间态
53
+ return resolveConfig(coerceServiceable(found.value ?? {}).config)
54
+ }
55
+
56
+ /**
57
+ * 设置页 faces:HTTP 层(api.mjs)与配置事实源(settings 行条目)的适配。
58
+ * @param {{logger?: {warn?: Function}}} ctx 行上下文(告警通道)
59
+ * @param settings 宿主 settings 服务(describe/replace)
60
+ */
61
+ export function buildFaces(ctx, settings) {
62
+ const readCurrent = () => readSection(ctx, settings)
63
+ return {
64
+ readConfig: () => readCurrent(),
65
+ listShells: () => listShellsOf(readCurrent()),
66
+ // replace 落盘后经模块级桥刷新执行面:执行器 provide 的 ctx.shell 作用域
67
+ // 在主行子树,本行是兄弟行够不着(cordis 服务不横向查找,getter 直接
68
+ // throw);cordis 对 volatile-only diff 换 ref 不重挂行,执行器手里的
69
+ // 挂载快照就此陈旧——replace 成功即权威信号,桥调活跃实例换源并重建
70
+ // 工具注册(原生设置页对本行被 auto:false 抑制,本 API 是唯一写口)
71
+ async updateConfig(patch) {
72
+ const current = readCurrent()
73
+ const section = normalizeConfigPaths({
74
+ ...current,
75
+ shells: patch.shells ?? current.shells,
76
+ default: patch.default ?? current.default,
77
+ deny: Array.isArray(patch.deny) ? patch.deny : current.deny,
78
+ })
79
+ const validated = resolveConfig(section)
80
+ assertServiceableConfig(validated)
81
+ await settings.replace(SECTION_NS, section)
82
+ requestShellRefresh(validated)
83
+ return listShellsOf(validated)
84
+ },
85
+ detect: (kinds) => detectCandidates(kinds ?? [...KINDS], process.env, candidateExists),
86
+ probe: (candidatePath) => candidateExists(candidatePath),
87
+ }
88
+ }
89
+
90
+ /** 行入口:webServer/settings 已由 inject 门控就绪。 */
91
+ export function apply(ctx) {
92
+ mountRoutes(ctx, buildFaces(ctx, ctx.get('settings')))
93
+ }
@@ -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
  }
@@ -34,6 +36,16 @@ function parseEnvText() {
34
36
  return extractLogic('parseEnvText')
35
37
  }
36
38
 
39
+ test('运行时长格式:秒→m:ss,一小时以上→h:mm:ss,负值钳 0', () => {
40
+ const formatDuration = extractLogic('formatDuration')
41
+ assert.equal(formatDuration(0), '0:00')
42
+ assert.equal(formatDuration(42_000), '0:42')
43
+ assert.equal(formatDuration(61_000), '1:01')
44
+ assert.equal(formatDuration(3600_000), '1:00:00')
45
+ assert.equal(formatDuration(3661_000), '1:01:01')
46
+ assert.equal(formatDuration(-5), '0:00')
47
+ })
48
+
37
49
  function invalidEnvLines() {
38
50
  return extractLogic('invalidEnvLines')
39
51
  }
@@ -57,10 +69,11 @@ function settledBlock(argsRaw, text, options = {}) {
57
69
  const ARGS = JSON.stringify({ command: 'git status', description: 'Show working tree status', workdir: 'sub' })
58
70
  const SESSION_CWD = 'C:\\repo'
59
71
 
60
- test('S11 running 且无 description(persistent 形)回退 generic', () => {
72
+ test('S11 running 且无 description(persistent 形):全量卡不回退(阻塞时展开必有命令)', () => {
61
73
  const model = cardModel()(runningBlock(JSON.stringify({ command: 'interactive session' })), SESSION_CWD)
62
- assert.equal(model.kind, 'generic')
63
- assert.equal(model.running, true)
74
+ assert.equal(model.kind, 'terminal')
75
+ assert.equal(model.status, 'running')
76
+ assert.equal(model.description, undefined)
64
77
  })
65
78
 
66
79
  test('S11b settled 无 description(persistent 结束)回退 generic', () => {
@@ -144,6 +157,76 @@ test('S17d 后台状态中性:不断言运行中,点为中性图标(渲染守卫
144
157
  assert.doesNotMatch(source, /case 'background': return en \? 'Background' : '后台运行'/)
145
158
  })
146
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
+
147
230
  test('S12c isError 与空结果仍走 generic(回归)', () => {
148
231
  const errored = cardModel()(settledBlock(ARGS, 'boom', { isError: true }), SESSION_CWD)
149
232
  assert.equal(errored.kind, 'generic')
@@ -156,6 +239,91 @@ test('S13 注册面:shell/pwsh/bash 三 key 且 priority -1(文本守卫)', () =
156
239
  assert.match(source, /key: toolKey, priority: -1/)
157
240
  })
158
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
+
159
327
  test('K=V 往返:值含 = 与空格无损,空行忽略,重复键后行胜', () => {
160
328
  const parse = parseEnvText()
161
329
  assert.deepEqual(parse('A=1\nB=x=y z\n\nA=2'), { A: '2', B: 'x=y z' })