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

feat(web): choose and remember terminal shells

Yichen Jiang 4 дней назад
Родитель
Сommit
33d89aee77
51 измененных файлов с 513 добавлено и 68 удалено
  1. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
  2. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
  3. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
  4. 5 0
      apps/web/tests/expected/sidebar-terminal/selection.expected.md
  5. 1 0
      apps/web/tests/fixtures/sidebar-terminal.patch.yml
  6. 34 0
      apps/web/tests/sidebar-terminal.e2e.ts
  7. 2 2
      docs/config-catalog.i18n.yaml
  8. 2 0
      docs/config-catalog.md
  9. 2 0
      docs/config-catalog.zh.md
  10. 2 2
      docs/subsystems/workspace.i18n.yaml
  11. 8 0
      docs/subsystems/workspace.md
  12. 8 0
      docs/subsystems/workspace.zh.md
  13. 2 2
      packages/api/terminal-controller/README.i18n.yaml
  14. 5 2
      packages/api/terminal-controller/README.md
  15. 5 2
      packages/api/terminal-controller/README.zh.md
  16. 1 0
      packages/api/terminal-controller/package.json
  17. 46 10
      packages/api/terminal-controller/src/client/model.ts
  18. 20 0
      packages/api/terminal-controller/src/client/shell-preference.ts
  19. 22 4
      packages/api/terminal-controller/src/index.ts
  20. 30 1
      packages/api/terminal-controller/src/shells.ts
  21. 2 0
      packages/api/terminal-controller/src/types.ts
  22. 37 2
      packages/api/terminal-controller/tests/controller.spec.ts
  23. 6 0
      packages/api/terminal-controller/tests/model.client.spec.ts
  24. 83 5
      packages/api/terminal-controller/tests/recovery.client.spec.ts
  25. 1 0
      packages/api/terminal-controller/tsconfig.client.json
  26. 2 2
      packages/client/ui-sidebar-terminal/README.i18n.yaml
  27. 3 3
      packages/client/ui-sidebar-terminal/README.md
  28. 2 2
      packages/client/ui-sidebar-terminal/README.zh.md
  29. 3 0
      packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css
  30. 10 1
      packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx
  31. 2 0
      packages/client/ui-sidebar-terminal/src/client/locales.ts
  32. 14 0
      packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx
  33. 7 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  34. 13 0
      packages/ssh/ssh/src/helper-processes.ts
  35. 16 2
      packages/ssh/ssh/src/helper.ts
  36. 3 1
      packages/ssh/ssh/src/schemas.ts
  37. 7 2
      packages/ssh/ssh/tests/helper-processes.spec.ts
  38. 3 0
      packages/ssh/ssh/tests/helper-runtime.spec.ts
  39. 1 1
      packages/ssh/ssh/tests/live.e2e.ts
  40. 2 2
      packages/ssh/ssh/tests/stream-capability.spec.ts
  41. 1 1
      packages/ssh/ssh/tests/stream-lifetime.spec.ts
  42. 2 2
      packages/ssh/subprocess-ssh/README.i18n.yaml
  43. 2 0
      packages/ssh/subprocess-ssh/README.md
  44. 2 0
      packages/ssh/subprocess-ssh/README.zh.md
  45. 22 6
      packages/ssh/subprocess-ssh/src/index.ts
  46. 1 1
      packages/ssh/subprocess-ssh/tests/lifecycle.spec.ts
  47. 58 0
      packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts
  48. 5 3
      packages/ssh/subprocess-ssh/tests/terminal-behavior.spec.ts
  49. 1 0
      scripts/gen-cordis-catalog.ts
  50. 1 0
      scripts/package-dependency-policy.ts
  51. 0 0
      snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
-2026-09-09-web-sidebar-terminal.md: d95968712e7f99b8e83d020273bb02818b4b2824
-2026-09-09-web-sidebar-terminal.zh.md: 2b8ed2fa1317dce510e01d840acd3b99f40eaccf
+2026-09-09-web-sidebar-terminal.md: f922e8c889fea0fef1fafcd729b1a5489a8b0c42
+2026-09-09-web-sidebar-terminal.zh.md: e53c71e3f6c307e1afe2fc9f1f9561599ba41829

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md

@@ -10,7 +10,7 @@ Web users need an interactive shell beside a Session to inspect the workspace an
 
 ## Decision
 
-`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal starts the execution environment's default shell immediately. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs only during creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions.
+`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal offers installed shells and waits for Start. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last successful shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions.
 
 Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup.
 
@@ -22,7 +22,7 @@ The latest attachment controls input and dimensions; other attachments remain re
 
 ## Alternatives considered
 
-**Add shell selection and terminal-specific creation controls.** Opening a terminal uses the execution environment's default shell, and the sidebar already owns tab creation and titles. Another picker, process list, header and add control would duplicate navigation around the terminal screen.
+**Persist complete shell profiles in the browser.** Only the selected path is a user preference. Arguments and availability belong to Host discovery; persisting them would allow stale profiles to bypass current execution configuration. The sidebar continues to own the terminal list and tab controls.
 
 **Keep the tab visible until process cleanup finishes.** A slow or failed termination would delay the user's close action. Saving the cleanup intent allows immediate removal while preserving failure reporting and retry.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md

@@ -10,7 +10,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
 
 ## 决定
 
-`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端立即启动执行环境的默认 shell。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
+`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端提供已安装 shell 的选择并等待用户启动。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次成功启动的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
 
 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。
 
@@ -22,7 +22,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
 
 ## 考虑过的替代方案
 
-**增加 shell 选择和终端专用创建控件。** 打开终端使用执行环境的默认 shell,侧栏已经负责标签页创建与标题。额外的选择器、进程列表、标题栏和添加控件会在终端屏幕周围重复导航功能
+**在浏览器保存完整 shell profile。** 用户偏好只包含选中的路径。参数和可用性由 Host 探测决定;持久化这些信息会让过期 profile 绕过当前执行配置。终端列表和标签页控件仍由侧栏负责
 
 **进程清理完成前保留标签页。** 缓慢或失败的终止会拖延用户关闭操作。保存清理意图后,可以立即移除标签页,同时保留错误反馈与重试。
 

+ 5 - 0
apps/web/tests/expected/sidebar-terminal/selection.expected.md

@@ -0,0 +1,5 @@
+- text: Shell
+- combobox "Shell":
+  - option "bash — /bin/bash" [selected]
+  - option "sh — /bin/sh"
+- button "Start terminal"

+ 1 - 0
apps/web/tests/fixtures/sidebar-terminal.patch.yml

@@ -1,6 +1,7 @@
 - id: terminal-controller
   config:
     maxTerminals: 2
+    shellCandidates: [/bin/bash, /bin/sh]
     shell:
       path: /bin/bash
       name: bash

+ 34 - 0
apps/web/tests/sidebar-terminal.e2e.ts

@@ -19,6 +19,7 @@ async function openTerminal(page: Page, waitForShell = true): Promise<void> {
   const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
   if (!await entry.isVisible()) await page.locator('[data-dockkit-add-tab]').click()
   await entry.click()
+  await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
   if (waitForShell) await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-')
 }
 
@@ -157,6 +158,39 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
     expect(tripwire.pageErrors).toEqual([])
   })
 
+  it('offers installed shells, remembers the choice after reload, and restores without a picker', async () => {
+    onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shell-choice'))
+    await page.locator('[data-sidebar-right-expand]').click()
+    await page.locator('[data-sidebar-right-guide-entry="terminal"]').click()
+    const selector = page.getByRole('combobox', { name: 'Shell', exact: true })
+    await selector.waitFor()
+    expect(await selector.inputValue()).toBe('/bin/bash')
+    expect(handles).toHaveLength(0)
+    await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/selection.expected.md', import.meta.url)),
+      await page.locator('[data-sidebar-terminal]').ariaSnapshot(), webSnapshotMode())
+    await selector.selectOption('/bin/sh')
+    await page.screenshot({ path: `${shots}/shell-choice.png`, fullPage: true })
+    await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
+    await command(page, "printf 'CHOSEN_SHELL:%s\\n' \"$0\"")
+    const screen = page.locator('.xterm-rows:visible')
+    await expect.poll(() => screen.innerText()).toContain('CHOSEN_SHELL:/bin/sh')
+    expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh')
+    const retained = processIdentity(0)
+    await page.reload({ waitUntil: 'load' })
+    await page.locator('.xterm-helper-textarea:visible').waitFor()
+    expect(await selector.count()).toBe(0)
+    expect(alive(retained)).toBe(true)
+    await page.locator('[data-dockkit-add-tab]').click()
+    await page.locator('[data-sidebar-right-guide-entry="terminal"]').click()
+    await selector.waitFor()
+    expect(await selector.inputValue()).toBe('/bin/sh')
+    expect(handles).toHaveLength(1)
+    await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
+    await expect.poll(() => handles.length).toBe(2)
+    expect(scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).map(info => info.shell.path)).toEqual(['/bin/sh', '/bin/sh'])
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
   it('explains that exited terminals count toward the quota and permits creation after closing one', async () => {
     onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-quota'))
     const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id)

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 5c550cc5d36213da9ad5705d756f4a1ef163ee16
-config-catalog.zh.md: 06a98df3e00100025c668534d2b2f223efa11bdb
+config-catalog.md: a812da090af38b958b6bd1afffd97d0377c963ea
+config-catalog.zh.md: 0bf9e3b49a07782e9a647be08046440d34cd95ff

+ 2 - 0
docs/config-catalog.md

@@ -247,6 +247,8 @@ export interface Config {
     /** Arguments passed to the interactive shell. */
     args: string[]
   } | undefined
+  /** Executable names or paths checked for the new-terminal shell selector. */
+  readonly shellCandidates: string[]
   /** Maximum retained terminals and pending allocations per Session. */
   readonly maxTerminals: number
   /** Maximum terminal width in columns. */

+ 2 - 0
docs/config-catalog.zh.md

@@ -249,6 +249,8 @@ export interface Config {
     /** Arguments passed to the interactive shell. */
     args: string[]
   } | undefined
+  /** Executable names or paths checked for the new-terminal shell selector. */
+  readonly shellCandidates: string[]
   /** Maximum retained terminals and pending allocations per Session. */
   readonly maxTerminals: number
   /** Maximum terminal width in columns. */

+ 2 - 2
docs/subsystems/workspace.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/workspace.md
-workspace.md: ee5486800c2b0f01ab3cedde10e1b5ca30968950
-workspace.zh.md: 34d0e6f1cc2b00ee2ae8dd737b45d6a20061c107
+workspace.md: 77e5dd39afb6d2840751d6d56a7a50f9fea2adaa
+workspace.zh.md: ad226e34b4fb508e9ce68e90349848d28632d559

+ 8 - 0
docs/subsystems/workspace.md

@@ -198,6 +198,14 @@ Typed Remote control of transient Session-owned terminal processes.
  */
 @Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment
 
+/**
+ * Discover installed shells in the Session's execution environment.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param signal - request cancellation.
+ * @returns verified profiles, with the configured or system default first.
+ */
+@Remote shells(agent: Agent, signal: AbortSignal): Promise<TerminalShell[]>
+
 /**
  * List retained terminals without resolving or activating an Agent.
  * @param sessionId - displayed Session identity, including offline history.

+ 8 - 0
docs/subsystems/workspace.zh.md

@@ -198,6 +198,14 @@ Typed Remote control of transient Session-owned terminal processes.
  */
 @Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment
 
+/**
+ * Discover installed shells in the Session's execution environment.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param signal - request cancellation.
+ * @returns verified profiles, with the configured or system default first.
+ */
+@Remote shells(agent: Agent, signal: AbortSignal): Promise<TerminalShell[]>
+
 /**
  * List retained terminals without resolving or activating an Agent.
  * @param sessionId - displayed Session identity, including offline history.

+ 2 - 2
packages/api/terminal-controller/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md
-README.md: e6ffceff8cd3c16ad7a6d61697dec870bba0fe14
-README.zh.md: 609493cfc0fa127473538bc6ce79e6083f6d8703
+README.md: a24cb6e572782b59b83b836f0f477358abadf347
+README.zh.md: b979a7913e3b7fbcd1330ed7459eea9423348941

+ 5 - 2
packages/api/terminal-controller/README.md

@@ -25,13 +25,14 @@ Open the execution environment's default shell in a Session workspace from the W
 <a id="use-this-package"></a>
 ## Use this package
 
-The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error.
+The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error.
 
-New terminals use the execution environment's declared default shell. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The provider verifies the executable during creation; resolution failure is reported without trying another shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal.
+Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal.
 
 | Configuration | Default | Meaning |
 |---|---|---|
 | `shell` | omitted | Use the execution environment's default shell, or one explicit profile |
+| `shellCandidates` | `zsh`, `bash`, `fish`, `sh`, `ksh`, `tcsh`, `csh`, `pwsh`, `powershell`, `cmd`, `nu` | Additional executable names or paths offered when installed |
 | `maxTerminals` | `8` | Retained terminals and pending allocations per Session |
 | `maxCols`, `maxRows` | `500`, `200` | Maximum PTY dimensions |
 | `scrollback` | `1000` | Retained screen history rows |
@@ -51,6 +52,8 @@ The latest attachment owns input and resize. Detachment releases input control w
 
 Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes.
 
+New views wait for explicit shell selection and start. The last successful shell path is stored under `dsh.terminal.shell` in origin-scoped localStorage. A saved path is preselected only while Host discovery still lists it; otherwise the current default is selected. Storage failures do not prevent startup. Restoring existing terminals neither reads this preference nor discovers shells.
+
 Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup or a definitive `session/not-found` response; startup retries saved requests. Transport failures retain the request. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload.
 
 </details>

+ 5 - 2
packages/api/terminal-controller/README.zh.md

@@ -25,13 +25,14 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用此包
 
-Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。
+Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。
 
-新终端使用执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。provider 在创建时验证可执行文件;解析失败会直接报告,不尝试其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。
+Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。
 
 | 配置 | 默认值 | 含义 |
 |---|---|---|
 | `shell` | 省略 | 使用执行环境默认 shell,或指定一个 profile |
+| `shellCandidates` | `zsh`、`bash`、`fish`、`sh`、`ksh`、`tcsh`、`csh`、`pwsh`、`powershell`、`cmd`、`nu` | 已安装时供用户选择的额外可执行名称或路径 |
 | `maxTerminals` | `8` | 每个 Session 保留的终端和创建请求上限 |
 | `maxCols`、`maxRows` | `500`、`200` | PTY 最大尺寸 |
 | `scrollback` | `1000` | 屏幕历史行数 |
@@ -51,6 +52,8 @@ Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终
 
 Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。
 
+新视图等待用户选择 shell 并启动。上次成功启动的 shell 路径保存在当前站点 localStorage 的 `dsh.terminal.shell` 中。仅当 Host 探测仍列出该路径时才预选它,否则选择当前默认项。存储失败不影响启动。恢复已有终端既不读取这一偏好,也不探测 shell。
+
 关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功或收到明确的 `session/not-found` 响应后删除;启动时重试已保存的请求。传输失败时保留请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。
 
 </details>

+ 1 - 0
packages/api/terminal-controller/package.json

@@ -62,6 +62,7 @@
     "zod": "^4.4.3"
   },
   "peerDependencies": {
+    "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {

+ 46 - 10
packages/api/terminal-controller/src/client/model.ts

@@ -1,4 +1,5 @@
 /** React-free browser terminal state and reconnecting Remote-stream ownership. */
+import { preferredShell, rememberShell } from './shell-preference.ts'
 import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client'
@@ -6,7 +7,7 @@ import { RemoteError, remoteErrorOf, type RemoteResult } from '@deepseek-ai/dsh-
 import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {
-  TerminalAttachmentId, TerminalEnvironment, TerminalFrame,
+  TerminalShell, TerminalAttachmentId, TerminalEnvironment, TerminalFrame,
   WebTerminalId, WebTerminalInfo,
 } from '../types.ts'
 
@@ -35,7 +36,9 @@ export interface TerminalRenderFrame {
 
 /** Observable state of one sidebar occurrence. */
 export interface TerminalViewState {
-  readonly phase: 'idle' | 'loading' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed'
+  readonly phase: 'idle' | 'loading' | 'selecting' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed'
+  readonly shells?: readonly TerminalShell[] | undefined
+  readonly selectedShell?: string | undefined
   readonly environment?: TerminalEnvironment | undefined
   readonly title?: string | undefined
   readonly info?: WebTerminalInfo | undefined
@@ -78,7 +81,7 @@ export class TerminalView {
   ) {}
 
   /**
-   * Attach the DOM lifetime, starting the default shell or reconnecting the saved process.
+   * Attach the DOM lifetime, loading shell choices or reconnecting the saved process.
    * @returns a detach callback that leaves the terminal process alive.
    */
   mount(): () => void {
@@ -97,6 +100,7 @@ export class TerminalView {
    * @returns after environment lookup and creation or recovery settle.
    */
   refresh(): Promise<void> {
+    if (this.creation !== undefined) return this.creation
     if (this.loading !== undefined) return this.loading
     if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve()
     this.patch({ phase: 'loading', error: undefined, issue: undefined })
@@ -104,24 +108,56 @@ export class TerminalView {
       const [environment, available] = await Promise.all([
         this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId),
       ])
-      if (this.lifetime.signal.aborted || this.closing !== undefined) return
+      if (this.stopped()) return
       this.patch({ environment: valueOf(environment) })
       const info = valueOf(available).find(item => item.id === this.id)
       if (info !== undefined) this.adopt(info)
-      else if (this.createWhenMissing) await this.create(valueOf(environment))
+      else if (this.createWhenMissing) {
+        const shells = valueOf(await this.remote.shells(this.sessionId, this.lifetime.signal))
+        if (this.stopped()) return
+        const previous = this.state.getSnapshot().selectedShell ?? preferredShell()
+        const selectedShell = shells.find(shell => shell.path === previous)?.path ?? shells[0]?.path
+        this.patch({ phase: 'selecting', shells, selectedShell })
+      }
       else throw new TerminalViewError('missingTerminal')
     })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined })
     return this.loading
   }
 
-  private async create(environment: TerminalEnvironment): Promise<void> {
+  /**
+   * Select one verified shell before starting the terminal.
+   * @param path - executable path offered by Host discovery.
+   */
+  selectShell(path: string): void {
+    const state = this.state.getSnapshot()
+    if (state.phase === 'selecting' && state.shells?.some(shell => shell.path === path)) this.patch({ selectedShell: path })
+  }
+
+  /**
+   * Start the selected shell once; failed attempts retain their selection for retry.
+   * @returns after allocation settles; failures are exposed in observable state.
+   */
+  start(): Promise<void> {
+    if (this.creation !== undefined) return this.creation
+    const state = this.state.getSnapshot()
+    if (state.phase !== 'selecting' || state.environment === undefined || state.selectedShell === undefined
+      || this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve()
+    return this.create(state.environment, state.selectedShell)
+  }
+
+  private stopped(): boolean { return this.lifetime.signal.aborted || this.closing !== undefined }
+
+  private async create(environment: TerminalEnvironment, shellPath: string): Promise<void> {
     this.patch({ phase: 'creating', error: undefined, issue: undefined })
     this.creation = (async () => {
       const info = valueOf(await this.remote.create(this.sessionId, {
-        id: this.id, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows),
+        id: this.id, shellPath, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows),
       }, this.lifetime.signal))
-      if (!this.lifetime.signal.aborted) this.adopt(info)
-    })().finally(() => { this.creation = undefined })
+      if (!this.lifetime.signal.aborted) {
+        rememberShell(info.shell.path)
+        this.adopt(info)
+      }
+    })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.creation = undefined })
     await this.creation
   }
 
@@ -221,7 +257,7 @@ export class TerminalView {
     this.detach()
     this.closing = (async () => {
       // Even a refused or lost creation response may leave an allocation to close.
-      await this.creation?.catch(() => {})
+      await this.creation
       valueOf(await this.remote.close(this.sessionId, this.id))
       this.detach()
       this.patch({ phase: 'closed', writable: false })

+ 20 - 0
packages/api/terminal-controller/src/client/shell-preference.ts

@@ -0,0 +1,20 @@
+/** Browser-local shell preference; Host discovery decides whether the saved path is usable. */
+const KEY = 'dsh.terminal.shell'
+
+/**
+ * Read the browser preference.
+ * @returns the last successfully started shell path, or null when storage is unavailable.
+ */
+export function preferredShell(): string | null {
+  try { return typeof localStorage === 'undefined' ? null : localStorage.getItem(KEY) }
+  catch (_storageUnavailable) { return null }
+}
+
+/**
+ * Remember a successful shell launch without making storage a startup dependency.
+ * @param path - verified executable path returned by the Host.
+ */
+export function rememberShell(path: string): void {
+  try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, path) }
+  catch (_storageUnavailable) { /* Private browsing or quota failure leaves this launch usable. */ }
+}

+ 22 - 4
packages/api/terminal-controller/src/index.ts

@@ -8,10 +8,10 @@ import type {} from '@deepseek-ai/dsh-sandbox-policy'
 import type {} from '@deepseek-ai/dsh-sandbox'
 import type {} from '@deepseek-ai/dsh-session-projection'
 import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
-import { resolveShell } from './shells.ts'
+import { discoverShells, resolveShell } from './shells.ts'
 import { BrowserTerminal } from './terminal.ts'
 import type {
-  TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame,
+  TerminalShell, TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame,
   WebTerminalId, WebTerminalInfo,
 } from './types.ts'
 
@@ -35,6 +35,8 @@ export interface Config {
     /** Arguments passed to the interactive shell. */
     args: string[]
   } | undefined
+  /** Executable names or paths checked for the new-terminal shell selector. */
+  readonly shellCandidates: string[]
   /** Maximum retained terminals and pending allocations per Session. */
   readonly maxTerminals: number
   /** Maximum terminal width in columns. */
@@ -67,6 +69,7 @@ export class TerminalController extends TypertRemoteService {
     shell: z.union([z.object({
       path: z.string().required(), name: z.string().required(), args: z.array(z.string()).default([]),
     }), z.const(undefined)]),
+    shellCandidates: z.array(z.string().min(1)).default(['zsh', 'bash', 'fish', 'sh', 'ksh', 'tcsh', 'csh', 'pwsh', 'powershell', 'cmd', 'nu']),
     maxTerminals: z.number().step(1).min(1).default(8),
     maxCols: z.number().step(1).min(2).default(500),
     maxRows: z.number().step(1).min(1).default(200),
@@ -117,6 +120,18 @@ export class TerminalController extends TypertRemoteService {
       maxRows: this.config.maxRows, scrollback: this.config.scrollback }
   }
 
+  /**
+   * Discover installed shells in the Session's execution environment.
+   * @param agent - Session owner supplied by the Gateway.
+   * @param signal - request cancellation.
+   * @returns verified profiles, with the configured or system default first.
+   */
+  @Remote
+  shells(agent: Agent, signal: AbortSignal): Promise<TerminalShell[]> {
+    signal.throwIfAborted()
+    return discoverShells(this.execution(agent).subprocess, this.config.shell, this.config.shellCandidates, signal)
+  }
+
   /**
    * List retained terminals without resolving or activating an Agent.
    * @param sessionId - displayed Session identity, including offline history.
@@ -301,13 +316,16 @@ export class TerminalController extends TypertRemoteService {
   private async spawn(agent: Agent, owner: OwnedSession, request: TerminalCreateRequest, signal: AbortSignal): Promise<BrowserTerminal> {
     const environment = this.environment(agent, signal)
     const { subprocess, sandboxPolicy } = this.execution(agent)
-    const shell = await resolveShell(subprocess, this.config.shell, signal)
+    const shell = request.shellPath === undefined
+      ? await resolveShell(subprocess, this.config.shell, signal)
+      : (await this.shells(agent, signal)).find(candidate => candidate.path === request.shellPath)
+    if (shell === undefined) throw new Error('Selected shell is not available in this execution environment')
     const policy = sandboxPolicy.resolve({ session: agent.session })
     let argv = [shell.path, ...shell.args]
     if (policy.mode !== 'danger-full-access') {
       const sandbox = agent.ctx.get('sandbox')
       if (sandbox === undefined) throw new Error('The Session sandbox mode requires an execution sandbox provider')
-      argv = sandbox.confine(argv, { ...policy, mode: policy.mode }).argv
+      argv = (await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)).argv
     }
     const handle = await subprocess.spawnTerminal({
       argv, cwd: environment.cwd, cols: request.cols, rows: request.rows,

+ 30 - 1
packages/api/terminal-controller/src/shells.ts

@@ -1,5 +1,5 @@
 /** Shell selection and executable verification use the target execution provider. */
-import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
+import { SubprocessExecutableNotFoundError, type SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
 import type { TerminalShell } from './types.ts'
 
 /**
@@ -26,3 +26,32 @@ function profile(path: string): TerminalShell {
   const kind = name.toLowerCase().replace(/\.exe$/u, '')
   return { path, name, args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] }
 }
+
+/**
+ * List verified candidates after the configured or environment-default shell.
+ * @param subprocess - target execution provider.
+ * @param configured - optional default profile.
+ * @param candidates - executable names or paths permitted for shell selection.
+ * @param signal - discovery cancellation.
+ * @returns unique installed shells, with the default first; transport failures reject.
+ */
+export async function discoverShells(
+  subprocess: SubprocessRuntime, configured: TerminalShell | undefined,
+  candidates: readonly string[], signal: AbortSignal,
+): Promise<TerminalShell[]> {
+  const preferred = await resolveShell(subprocess, configured, signal)
+  const found = await Promise.all(candidates.map(async (candidate) => {
+    try { return await resolveShell(subprocess, profile(candidate), signal) }
+    catch (error) {
+      if (error instanceof SubprocessExecutableNotFoundError) return undefined
+      throw error
+    }
+  }))
+  const shells = new Map<string, TerminalShell>()
+  for (const shell of [preferred, ...found]) {
+    if (shell === undefined) continue
+    const key = shell.path.includes('\\') ? shell.path.toLowerCase() : shell.path
+    if (!shells.has(key)) shells.set(key, shell)
+  }
+  return [...shells.values()]
+}

+ 2 - 0
packages/api/terminal-controller/src/types.ts

@@ -49,6 +49,8 @@ export interface WebTerminalInfo {
 
 /** Create is idempotent for an open identity; closed identities cannot be recreated. */
 export interface TerminalCreateRequest {
+  /** A path returned by shell discovery; absent selects the execution default. */
+  readonly shellPath?: string
   readonly id: WebTerminalId
   readonly cols: number
   readonly rows: number

+ 37 - 2
packages/api/terminal-controller/tests/controller.spec.ts

@@ -16,7 +16,7 @@ import type { TerminalAttachmentId, WebTerminalId } from '../src/types.ts'
 
 const roots: Context[] = []
 afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) })
-const config: Config = { shell: { path: '/bin/bash', name: 'bash', args: ['--noprofile', '--norc', '-i'] }, maxTerminals: 2, maxCols: 200, maxRows: 100, scrollback: 100, maxBufferedBytes: 100_000, maxInputBytes: 1000, disposeGraceMs: 100 }
+const config: Config = { shellCandidates: ['zsh', 'bash', 'sh'], shell: { path: '/bin/bash', name: 'bash', args: ['--noprofile', '--norc', '-i'] }, maxTerminals: 2, maxCols: 200, maxRows: 100, scrollback: 100, maxBufferedBytes: 100_000, maxInputBytes: 1000, disposeGraceMs: 100 }
 const id = 'test-terminal' as WebTerminalId
 const request = { id, cols: 80, rows: 24 }
 const signal = (): AbortSignal => new AbortController().signal
@@ -288,7 +288,7 @@ describe('TerminalController', () => {
     const confine = vi.fn((argv: readonly string[]) => ({ argv: ['sandbox-runner', ...argv] }))
     ctx.provide('sandbox', { confine } as never)
     await controller.create(agent, request, signal())
-    expect(confine).toHaveBeenCalledWith(['/bin/bash', '--noprofile', '--norc', '-i'], policy)
+    expect(confine).toHaveBeenCalledWith(['/bin/bash', '--noprofile', '--norc', '-i'], policy, expect.any(AbortSignal))
     expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['sandbox-runner', '/bin/bash', '--noprofile', '--norc', '-i'], env: { DSH_SESSION_ID: agent.id }, graceMs: 100 }))
   })
 
@@ -506,3 +506,38 @@ it.skipIf(process.platform === 'win32')('runs a real interactive shell with comp
     await rm(cwd, { recursive: true, force: true })
   }
 })
+
+it('discovers installed shells once per path, preserves default arguments, and refuses unlisted paths', async () => {
+  const h = fixture({ shellCandidates: ['bash', 'zsh', 'missing'] })
+  h.subprocess.resolveExecutable.mockImplementation(async (path) => {
+    if (path === 'missing') throw new SubprocessExecutableNotFoundError('absent')
+    return path.startsWith('/') ? path : `/bin/${path}`
+  })
+  const shells = await h.controller.shells(h.agent, signal())
+  expect(shells).toEqual([config.shell, { path: '/bin/zsh', name: 'zsh', args: ['-i'] }])
+  expect(h.subprocess.spawnTerminal).not.toHaveBeenCalled()
+  await expect(h.controller.create(h.agent, { ...request, shellPath: '/bin/unlisted' }, signal())).rejects.toThrow('Selected shell is not available')
+  expect(h.subprocess.spawnTerminal).not.toHaveBeenCalled()
+  const created = await h.controller.create(h.agent, { ...request, shellPath: '/bin/zsh' }, signal())
+  expect(created.shell.path).toBe('/bin/zsh')
+  expect(h.subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['/bin/zsh', '-i'] }))
+  h.subprocess.resolveExecutable.mockRejectedValue(new Error('SSH disconnected'))
+  await expect(h.controller.shells(h.agent, signal())).rejects.toThrow('SSH disconnected')
+  expect(await h.controller.create(h.agent, { ...request, shellPath: '/bin/bash' }, signal())).toBe(created)
+})
+
+it('propagates optional-shell discovery transport errors and cancellation', async () => {
+  const h = fixture({ shellCandidates: ['fish'] })
+  h.subprocess.resolveExecutable.mockImplementation(async (path) => {
+    if (path === 'fish') throw new Error('lookup transport failed')
+    return path
+  })
+  await expect(h.controller.shells(h.agent, signal())).rejects.toThrow('lookup transport failed')
+  const cancelled = AbortSignal.abort(new Error('cancelled discovery'))
+  expect(() => h.controller.shells(h.agent, cancelled)).toThrow('cancelled discovery')
+})
+
+it('deduplicates Windows executable paths regardless of letter case', async () => {
+  const h = fixture({ shell: { path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }, shellCandidates: ['c:\\windows\\cmd.exe'] })
+  expect(await h.controller.shells(h.agent, signal())).toEqual([{ path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }])
+})

+ 6 - 0
packages/api/terminal-controller/tests/model.client.spec.ts

@@ -31,6 +31,7 @@ function failure(message: string): RemoteResult<never> {
 
 function fixture(prepareStream?: <Item>(stream: RemoteStream<Item>) => void) {
   const remote: TerminalRemote = {
+    shells: vi.fn<TerminalRemote['shells']>(async () => success([info.shell])),
     environment: vi.fn<TerminalRemote['environment']>(async () => success(environment)), list: vi.fn<TerminalRemote['list']>(async () => success([])),
     create: vi.fn<TerminalRemote['create']>(async (_sessionId, request) => success({ ...info, id: request.id })),
     close: vi.fn<TerminalRemote['close']>(async () => success(undefined)), rename: vi.fn<TerminalRemote['rename']>(async () => success(undefined)),
@@ -61,6 +62,7 @@ function fixture(prepareStream?: <Item>(stream: RemoteStream<Item>) => void) {
 async function mount(model: TerminalView) {
   const detach = model.mount()
   await model.refresh()
+  await model.start()
   return detach
 }
 
@@ -434,6 +436,7 @@ it('classifies a discovery carrier failure as disconnected and supports explicit
   expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', error: 'offline' })
   expect(remote.create).not.toHaveBeenCalled()
   await model.refresh()
+  await model.start()
   expect(model.state.getSnapshot().info?.id).toBe(info.id)
   expect(remote.create).toHaveBeenCalledOnce()
 })
@@ -446,12 +449,14 @@ it('retains state when disposal overtakes successful allocation or a failed disc
     if (operation === 'creation') vi.mocked(remote.create).mockReturnValueOnce(creation.promise)
     else vi.mocked(remote.environment).mockReturnValueOnce(discovery.promise)
     const loading = model.refresh()
+    const starting = operation === 'creation' ? loading.then(() => model.start()) : undefined
     if (operation === 'creation') await expect.poll(() => remote.create).toHaveBeenCalledOnce()
     await model.dispose()
     const before = model.state.getSnapshot()
     if (operation === 'creation') creation.resolve(success(info))
     else discovery.reject(new Error('late discovery failure'))
     await loading
+    await starting
     expect(model.state.getSnapshot()).toBe(before)
     expect(remote.follow).not.toHaveBeenCalled()
   }
@@ -555,6 +560,7 @@ it('exposes a localized quota error and clears it after a successful retry', asy
   const { model, remote } = fixture()
   vi.mocked(remote.create).mockResolvedValueOnce({ ok: false, error: new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: 8 }) })
   await model.refresh()
+  await model.start()
   expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'terminalLimit' })
   await connected(model)
   expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined, error: undefined })

+ 83 - 5
packages/api/terminal-controller/tests/recovery.client.spec.ts

@@ -28,6 +28,7 @@ function storage() {
 
 function fixture() {
   const remote: TerminalRemote = {
+    shells: vi.fn<TerminalRemote['shells']>(async () => success([info.shell])),
     environment: vi.fn<TerminalRemote['environment']>(async () => success(environment)), list: vi.fn<TerminalRemote['list']>(async () => success([])),
     create: vi.fn<TerminalRemote['create']>(async (_session, request) => success({ ...info, id: request.id })),
     close: vi.fn<TerminalRemote['close']>(async () => success(undefined)), rename: vi.fn<TerminalRemote['rename']>(async () => success(undefined)),
@@ -58,7 +59,7 @@ function fixture() {
   return { remote, view, service }
 }
 
-it('starts the default shell once across overlapping mounts and retries, with no shell choice in the request', async () => {
+it('waits for shell selection and deduplicates overlapping starts', async () => {
   const h = fixture()
   const model = h.view()
   const creation = Promise.withResolvers<RemoteResult<WebTerminalInfo>>()
@@ -66,10 +67,15 @@ it('starts the default shell once across overlapping mounts and retries, with no
   model.mount()
   const loading = model.refresh()
   expect(model.refresh()).toBe(loading)
-  await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1)
-  expect(vi.mocked(h.remote.create).mock.calls[0]?.[1]).toEqual({ id: info.id, cols: 80, rows: 24 })
-  creation.resolve(success(info))
   await loading
+  expect(h.remote.create).not.toHaveBeenCalled()
+  const starting = model.start()
+  const remounting = model.refresh()
+  void model.start()
+  expect(vi.mocked(h.remote.create).mock.calls[0]?.[1]).toEqual({ id: info.id, shellPath: info.shell.path, cols: 80, rows: 24 })
+  creation.resolve(success(info))
+  await starting
+  await remounting
   await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
   expect(h.remote.create).toHaveBeenCalledOnce()
 })
@@ -105,8 +111,10 @@ it('retries lost create acknowledgements with the saved id and closes after a re
   const model = h.view()
   vi.mocked(h.remote.create).mockResolvedValueOnce(failure('response lost'))
   await model.refresh()
+  await model.start()
   expect(model.state.getSnapshot().error).toBe('response lost')
   await model.refresh()
+  await model.start()
   expect(vi.mocked(h.remote.create).mock.calls.map(call => call[1].id)).toEqual([info.id, info.id])
   await model.close()
   expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id)
@@ -118,12 +126,15 @@ it('waits for an in-flight creation while close detaches immediately and prevent
   const creation = Promise.withResolvers<RemoteResult<WebTerminalInfo>>()
   vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise)
   const unmount = model.mount()
+  await model.refresh()
+  const starting = model.start()
   await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1)
   const closing = model.close()
   expect(model.close()).toBe(closing)
   unmount()
   expect(h.remote.close).not.toHaveBeenCalled()
   creation.resolve(failure('cancelled allocation'))
+  await starting
   await closing
   expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id)
   expect(h.remote.follow).not.toHaveBeenCalled()
@@ -323,7 +334,7 @@ it('saves close intents without persisting any active terminal or sidebar state'
   const pending = Promise.withResolvers<RemoteResult<void>>()
   vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
   service.close(sessionId, 'new-tab')
-  const request = { sessionId, id: model.id, title: info.title }
+  const request = { sessionId, id: model.id, title: 'new-tab' }
   expect([...data.entries()]).toEqual([[`dsh.terminal.close.v1.${model.id}`, JSON.stringify(request)]])
   pending.resolve(success(undefined))
   await expect.poll(() => data.size).toBe(0)
@@ -414,6 +425,8 @@ it('waits for both active and detached stream finalizers during plugin disposal
   })
   const model = service.view(sessionId, 'tab')
   model.mount()
+  await model.refresh()
+  await model.start()
   await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
   model.connect()
   await started[0]!.promise
@@ -431,3 +444,68 @@ it('waits for both active and detached stream finalizers during plugin disposal
   expect(disposed).toBe(true)
   expect(h.remote.close).not.toHaveBeenCalled()
 })
+
+it('remembers a successful shell choice for new views and falls back when that shell is absent', async () => {
+  const data = storage()
+  const h = fixture()
+  const alternate = { name: 'bash', path: '/bin/bash', args: ['-i'] }
+  vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell, alternate]))
+  vi.mocked(h.remote.create).mockImplementation(async (_sessionId, request) => success({ ...info, shell: alternate, id: request.id }))
+  const first = h.view()
+  await first.start()
+  first.selectShell(alternate.path)
+  await first.refresh()
+  expect(first.state.getSnapshot()).toMatchObject({ phase: 'selecting', selectedShell: info.shell.path })
+  first.selectShell('/not-listed')
+  expect(first.state.getSnapshot().selectedShell).toBe(info.shell.path)
+  first.selectShell(alternate.path)
+  expect(data.size).toBe(0)
+  await first.start()
+  expect(data.get('dsh.terminal.shell')).toBe(alternate.path)
+  expect(h.remote.create).toHaveBeenCalledWith(sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal))
+  const second = h.view()
+  await second.refresh()
+  expect(second.state.getSnapshot().selectedShell).toBe(alternate.path)
+  await second.dispose()
+  await second.start()
+  vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell]))
+  const third = h.view()
+  await third.refresh()
+  expect(third.state.getSnapshot().selectedShell).toBe(info.shell.path)
+  expect(h.remote.create).toHaveBeenCalledOnce()
+})
+
+it('retains a selection across a failed launch without remembering the failed shell', async () => {
+  const data = storage()
+  const h = fixture()
+  vi.mocked(h.remote.create).mockResolvedValueOnce(failure('shell disappeared'))
+  const model = h.view()
+  await model.refresh()
+  await model.start()
+  expect(data.size).toBe(0)
+  await model.refresh()
+  expect(model.state.getSnapshot().selectedShell).toBe(info.shell.path)
+  await model.close()
+  await model.start()
+  expect(h.remote.create).toHaveBeenCalledOnce()
+})
+
+it('keeps launch usable when browser storage is denied and stops late shell discovery after close', async () => {
+  vi.stubGlobal('localStorage', undefined)
+  Object.defineProperty(globalThis, 'localStorage', { configurable: true, get() { throw new Error('denied') } })
+  const h = fixture()
+  const model = h.view()
+  await model.refresh()
+  await model.start()
+  expect(model.state.getSnapshot().info).toBeDefined()
+  const delayed = h.view()
+  const shells = Promise.withResolvers<Awaited<ReturnType<TerminalRemote['shells']>>>()
+  vi.mocked(h.remote.shells).mockReturnValueOnce(shells.promise)
+  const loading = delayed.refresh()
+  await expect.poll(() => h.remote.shells).toHaveBeenCalledTimes(2)
+  await delayed.close()
+  shells.resolve(success([info.shell]))
+  await loading
+  expect(delayed.state.getSnapshot().phase).toBe('closed')
+  expect(h.remote.create).toHaveBeenCalledOnce()
+})

+ 1 - 0
packages/api/terminal-controller/tsconfig.client.json

@@ -8,6 +8,7 @@
   "files": [
     "src/client/index.ts",
     "src/client/model.ts",
+    "src/client/shell-preference.ts",
     "src/types.ts",
     "src/client/close-requests.ts"
   ],

+ 2 - 2
packages/client/ui-sidebar-terminal/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md
-README.md: 2dba3ea839f89f505286fa6b977aef3fb0e45ab0
-README.zh.md: 63d483a37fcb9b913c20e47251aaaba3e0e21ffd
+README.md: f20648687bce864604a93915ea52e26990f15db4
+README.zh.md: c9f3bc8f285ff4d7dfd38104337c7466f69bca5c

+ 3 - 3
packages/client/ui-sidebar-terminal/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Open the execution environment's default shell beside a conversation without leaving the Web app. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration.
+Choose an installed shell beside a conversation without leaving the Web app. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration.
 
 ## Table of Contents
 
@@ -25,7 +25,7 @@ Open the execution environment's default shell beside a conversation without lea
 <a id="use-this-package"></a>
 ## Use this package
 
-Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. The default shell starts immediately. Use the sidebar's **New tab** control to open more terminals.
+Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. Choose an installed **Shell** and select **Start terminal**. The last successfully started shell is preselected from localStorage; first use or an unavailable saved choice selects the current default. Use the sidebar's **New tab** control to open more terminals.
 
 Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A failed connection offers **Reconnect**. An exited shell remains visible with its exit code and never restarts automatically. Exited terminals count toward the Session limit; close unused tabs when the limit is reached.
 
@@ -65,7 +65,7 @@ None; terminal output travels only between the browser and Host.
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- The default shell or native PTY startup can fail. The tab reports the failure without launching a different shell.
+- Shell discovery or native PTY startup can fail. The tab reports the failure without launching a different shell.
 - Completion menus and inline suggestions depend on shell configuration. The Web UI adds no independent completion engine.
 - Terminal history is bounded. The feature does not send terminal output to the Agent, provide split terminal panes inside a tab, or restore processes after Host restart.
 

+ 2 - 2
packages/client/ui-sidebar-terminal/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-在对话旁打开执行环境的默认 shell,无需离开 Web 应用。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。
+在对话旁选择已安装的 shell,无需离开 Web 应用。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。
 
 ## 目录
 
@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用此包
 
-展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。默认 shell 会立即启动。通过侧栏的**新标签页**控件打开更多终端。
+展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。选择已安装的 **Shell**,再点击**启动终端**。localStorage 会记住上次成功启动的 shell 并预选;首次使用或已保存选项不可用时,预选当前默认项。通过侧栏的**新标签页**控件打开更多终端。
 
 双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 已退出的终端计入 Session 数量上限;达到上限时请关闭不用的标签页。
 

+ 3 - 0
packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css

@@ -7,3 +7,6 @@
 .root button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; }
 .root button:hover { background: var(--dsw-alias-bg-l2); }
 .error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); }
+.launch { display: flex; align-items: end; flex-wrap: wrap; gap: 8px; padding: 12px; }
+.shellLabel { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; }
+.shellLabel select { min-width: 0; width: 100%; padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); }

+ 10 - 1
packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx

@@ -13,7 +13,7 @@ import css from './TerminalBody.module.css'
 export type TerminalBodyProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'sidebarTerminal'> & InjectFace<TerminalInjected>
 
 /**
- * Mount an automatically started or restored terminal.
+ * Offer shell selection for a new terminal or mount a restored screen.
  * @param props - sidebar occurrence, model lookup and translated copy.
  * @returns the terminal screen and any pending or exceptional state.
  */
@@ -34,6 +34,15 @@ export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyP
   const readOnly = state.phase === 'connected' && state.info?.state === 'running' && !state.writable
   return (
     <section className={css.root} data-sidebar-terminal>
+      {state.phase === 'selecting' && <form className={css.launch} onSubmit={(event) => { event.preventDefault(); void model.start() }}>
+        <label className={css.shellLabel}>
+          {t('shell')}
+          <select aria-label={t('shell')} value={state.selectedShell ?? ''} onChange={(event) => { model.selectShell(event.currentTarget.value) }}>
+            {state.shells?.map(shell => <option key={shell.path} value={shell.path}>{shell.name} — {shell.path}</option>)}
+          </select>
+        </label>
+        <button type="submit" disabled={state.selectedShell === undefined}>{t('start')}</button>
+      </form>}
       {(status !== undefined || retry || readOnly) && <div className={css.status} role="status">
         {status}
         {readOnly && <>{t('readonly')} <button type="button" onClick={() => { model.connect() }}>{t('control')}</button></>}

+ 2 - 0
packages/client/ui-sidebar-terminal/src/client/locales.ts

@@ -10,6 +10,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 /** Simplified Chinese terminal copy. */
 export const zh = {
   recoveryFailed: '恢复终端失败:{message}', retryRecovery: '重试恢复终端',
+  shell: 'Shell', start: '启动终端',
   title: '终端', new: '新建终端', loading: '正在读取终端环境…', creating: '正在启动…',
   connecting: '正在连接…', disconnected: '连接已断开。', reconnect: '重新连接',
   readonly: '此页面当前只读。', control: '接管输入',
@@ -26,6 +27,7 @@ export const zh = {
 /** English terminal copy. */
 export const en = {
   recoveryFailed: 'Terminal recovery failed: {message}', retryRecovery: 'Retry terminal recovery',
+  shell: 'Shell', start: 'Start terminal',
   title: 'Terminal', new: 'New terminal', loading: 'Reading terminal environment…', creating: 'Starting…',
   connecting: 'Connecting…', disconnected: 'Disconnected.', reconnect: 'Reconnect',
   readonly: 'This view is read-only.', control: 'Take control',

+ 14 - 0
packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx

@@ -65,6 +65,7 @@ function mount(initial: TerminalViewState | undefined = idle, dictionary = en) {
   let visible = true
   const detach = vi.fn()
   const model = {
+    start: vi.fn(async () => {}), selectShell: vi.fn(),
     mount: vi.fn(() => detach), refresh: vi.fn(async () => {}),
     rename: vi.fn(async () => {}), connect: vi.fn(), write: vi.fn(), resize: vi.fn(), acknowledge: vi.fn(),
   }
@@ -309,3 +310,16 @@ it.each([en, zh])('translates known terminal failures while retaining unknown Ho
   h.update({ ...idle, phase: 'failed', error: 'Host permission denied' })
   expect(h.view.getByRole('alert').textContent).toContain('Host permission denied')
 })
+
+it('lets the user select an installed shell and start it before rendering a screen', () => {
+  const other = { path: '/bin/zsh', name: 'zsh', args: ['-i'] }
+  const h = mount({ phase: 'selecting', writable: false, environment, shells: [info.shell, other], selectedShell: other.path })
+  expect(h.view.getByRole('combobox', { name: 'Shell' })).toHaveProperty('value', other.path)
+  expect(fake.terminals).toHaveLength(0)
+  fireEvent.change(h.view.getByRole('combobox'), { target: { value: info.shell.path } })
+  expect(h.model.selectShell).toHaveBeenCalledWith(info.shell.path)
+  fireEvent.click(h.view.getByRole('button', { name: 'Start terminal' }))
+  expect(h.model.start).toHaveBeenCalledOnce()
+  h.update({ phase: 'selecting', writable: false, environment })
+  expect(h.view.getByRole('button', { name: 'Start terminal' })).toHaveProperty('disabled', true)
+})

+ 7 - 1
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -2519,6 +2519,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'signal', description: 'request cancellation.' }],
         returns: 'the Session workspace directory and terminal limits.',
       },
+      {
+        signature: '@Remote shells(agent: Agent, signal: AbortSignal): Promise<TerminalShell[]>',
+        description: 'Discover installed shells in the Session\'s execution environment.',
+        parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'signal', description: 'request cancellation.' }],
+        returns: 'verified profiles, with the configured or system default first.',
+      },
       {
         signature: '@Remote list(sessionId: SessionId): WebTerminalInfo[]',
         description: 'List retained terminals without resolving or activating an Agent.',
@@ -6101,7 +6107,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'TerminalCreateRequest',
-    declaration: 'export interface TerminalCreateRequest {\n    readonly id: WebTerminalId;\n    readonly cols: number;\n    readonly rows: number;\n}',
+    declaration: 'export interface TerminalCreateRequest {\n    readonly shellPath?: string;\n    readonly id: WebTerminalId;\n    readonly cols: number;\n    readonly rows: number;\n}',
   },
   {
     name: 'TerminalEnvironment',

+ 13 - 0
packages/ssh/ssh/src/helper-processes.ts

@@ -331,6 +331,19 @@ export class RemoteProcesses {
     return terminal.signalForeground(z.enum(['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP']).parse(value))
   }
 
+  /**
+   * Resize an allocated terminal without replacing its process.
+   * @param id - terminal reservation.
+   * @param cols - positive terminal width.
+   * @param rows - positive terminal height.
+   * @returns after the local provider accepts the dimensions.
+   */
+  async resizeTerminal(id: SshProcessId, cols: number, rows: number): Promise<void> {
+    const terminal = this.record(id).terminal
+    if (terminal === undefined) throw new Error('SSH handle does not own a terminal')
+    await terminal.resize(cols, rows)
+  }
+
   /** Stop every owned process on lease expiry or disconnect. */
   async close(): Promise<void> {
     if (this.closing) return

+ 16 - 2
packages/ssh/ssh/src/helper.ts

@@ -6,13 +6,14 @@ import type { Readable, Writable } from 'node:stream'
 import { Context } from '@deepseek-ai/cordis'
 import { FsError, type FsTarget, type FsWriteIntent, type FsVersion } from '@deepseek-ai/dsh-fs'
 import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
+import { SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess'
 import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
 import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
 import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
 import { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
 import type { SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
 import { z } from 'zod'
-import { SshRpcPeer, SSH_MAX_PROCESS_HANDLES, SSH_MAX_TEXT_STREAMS, SSH_PROTOCOL_VERSION } from './protocol.ts'
+import { SshRpcPeer, RemoteOperationError, SSH_MAX_PROCESS_HANDLES, SSH_MAX_TEXT_STREAMS, SSH_PROTOCOL_VERSION } from './protocol.ts'
 import { RemoteProcesses } from './helper-processes.ts'
 import { editSchema, environmentSchema, intentSchema, policySchema, processIdSchema, remotePath, targetSchema, textStreamIdSchema } from './schemas.ts'
 import type { SshTextStreamId } from './schemas.ts'
@@ -114,6 +115,14 @@ export async function runSshHelper(transport: HelperTransport): Promise<void> {
     if (method === 'process.done') return processes.done(processIdRequest.parse(raw).id)
     if (method === 'process.wait') return processes.wait(processIdRequest.parse(raw).id, signal)
     if (method === 'process.terminate') { await processes.terminate(processIdRequest.parse(raw).id); return null }
+    if (method === 'terminal.environment') { object.parse(raw); return ctx.subprocess.terminalEnvironment(signal) }
+    if (method === 'terminal.resize') {
+      const input = z.object({
+        id: processIdSchema, cols: z.number().int().positive(), rows: z.number().int().positive(),
+      }).strict().parse(raw)
+      await processes.resizeTerminal(input.id, input.cols, input.rows)
+      return null
+    }
     if (method === 'terminal.write' || method === 'terminal.inspect' || method === 'terminal.signal') {
       const input = z.object({ id: processIdSchema, value: z.string().optional() }).strict().parse(raw)
       return processes.terminal(input.id, method === 'terminal.write' ? 'write' : method === 'terminal.inspect' ? 'inspect' : 'signal', input.value)
@@ -123,7 +132,12 @@ export async function runSshHelper(transport: HelperTransport): Promise<void> {
       const env = input.env === undefined ? undefined : Object.fromEntries(
         Object.entries(input.env).filter((entry): entry is [string, string] => entry[1] !== null),
       )
-      return ctx.subprocess.resolveExecutable(input.command, env, signal)
+      try {
+        return await ctx.subprocess.resolveExecutable(input.command, env, signal)
+      } catch (error) {
+        if (error instanceof SubprocessExecutableNotFoundError) throw new RemoteOperationError(error.message, 'SUBPROCESS_EXECUTABLE_NOT_FOUND')
+        throw error
+      }
     }
     if (method === 'sandbox') {
       const input = z.object({ argv: z.array(z.string()).min(1), policy: policySchema }).strict().parse(raw)

+ 3 - 1
packages/ssh/ssh/src/schemas.ts

@@ -48,7 +48,9 @@ export const spawnSchema = z.object({
     stderr: z.union([z.literal('pipe'), z.literal('inherit'), collection]),
     control: z.literal('pipe').optional(),
   }).strict().optional(),
-  terminal: z.object({ rows: z.number().int().positive(), cols: z.number().int().positive() }).strict().optional(),
+  terminal: z.object({
+    terminalType: z.string().min(1), rows: z.number().int().positive(), cols: z.number().int().positive(),
+  }).strict().optional(),
 }).strict().refine(value => (value.stdio === undefined) !== (value.terminal === undefined), 'select ordinary or terminal execution')
 /** Connection handshake binds sockets and workspace to one helper process. */
 export const helloSchema = z.object({ protocol: z.literal(1), hash: z.string().regex(/^[0-9a-f]{64}$/), platform: z.enum(['linux', 'darwin']), nodeVersion: z.string(), node: remotePath, root: remotePath, workspace: remotePath, bootstrapHash: z.string().regex(/^[0-9a-f]{64}$/).optional() }).strict()

+ 7 - 2
packages/ssh/ssh/tests/helper-processes.spec.ts

@@ -19,7 +19,7 @@ vi.mock('node:fs/promises', async (original) => {
 
 const outcome = { exitCode: 0, signal: null }
 const ordinaryRequest = { argv: ['target'], cwd: '/tmp', graceMs: 20, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } }
-const terminalRequest = { argv: ['target'], cwd: '/tmp', graceMs: 20, terminal: { rows: 24, cols: 80 } }
+const terminalRequest = { argv: ['target'], cwd: '/tmp', graceMs: 20, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } }
 
 async function harness() {
   const root = await mkdtemp('/tmp/dsh-ssh-owner-')
@@ -76,10 +76,12 @@ function terminal() {
   const inspectForeground = vi.fn<SubprocessTerminalHandle['inspectForeground']>(async () => undefined)
   const signalForeground = vi.fn(async () => 42)
   const terminate = vi.fn(async () => { output.end(); completion.resolve(outcome) })
+  const resize = vi.fn(async (_cols: number, _rows: number) => {})
   const handle: SubprocessTerminalHandle = {
+    resize,
     pid: 42, output, done: completion.promise, write, inspectForeground, signalForeground, terminate,
   }
-  return { handle, completion, output, write, inspectForeground, signalForeground, terminate }
+  return { handle, completion, output, write, resize, inspectForeground, signalForeground, terminate }
 }
 
 describe.skipIf(process.platform === 'win32')('SSH helper process settlement', () => {
@@ -99,6 +101,8 @@ describe.skipIf(process.platform === 'win32')('SSH helper process settlement', (
         await expect(test.owner.start(run.id)).rejects.toThrow('already requested')
         await test.owner.terminal(run.id, 'write', 'input')
         expect(child.write).toHaveBeenCalledWith('input')
+        await test.owner.resizeTerminal(run.id, 120, 40)
+        expect(child.resize).toHaveBeenCalledWith(120, 40)
         expect(await test.owner.terminal(run.id, 'inspect')).toBeNull()
         child.inspectForeground.mockResolvedValueOnce({ processGroupId: 42, inputWaiting: true })
         expect(await test.owner.terminal(run.id, 'inspect')).toEqual({ processGroupId: 42, inputWaiting: true })
@@ -144,6 +148,7 @@ describe.skipIf(process.platform === 'win32')('SSH helper process settlement', (
       const run = await test.prepare({ ...ordinaryRequest, env: { VALUE: 'kept', REMOVED: null }, stdio: { ...ordinaryRequest.stdio, stdin: 'pipe' } })
       await expect(test.owner.done(run.id)).rejects.toThrow('has not started')
       await expect(test.owner.wait(run.id)).rejects.toThrow('was not started')
+      await expect(test.owner.resizeTerminal(run.id, 80, 24)).rejects.toThrow('does not own a terminal')
       const bytes: Buffer[] = []
       child.stdin.on('data', (chunk: Buffer) => { bytes.push(Buffer.from(chunk)) })
       const ended = once(child.stdin, 'end')

+ 3 - 0
packages/ssh/ssh/tests/helper-runtime.spec.ts

@@ -93,6 +93,9 @@ describe.skipIf(process.platform === 'win32')('SSH helper runtime', () => {
       await expect(test.client.request('sandbox', { argv: ['true'], policy: { mode: 'danger-full-access', workspaceRoot: test.root } }, z.unknown())).rejects.toThrow('does not need')
       expect(await test.client.request('executable', { command: process.execPath, env: { REMOVED: null } }, z.string())).toBe(process.execPath)
       expect(await test.client.request('executable', { command: process.execPath }, z.string())).toBe(process.execPath)
+      expect(await test.client.request('terminal.environment', {}, z.unknown())).toMatchObject({ platform: 'posix' })
+      await expect(test.client.request('executable', { command: `${test.root}/absent` }, z.string())).rejects.toMatchObject({ code: 'SUBPROCESS_EXECUTABLE_NOT_FOUND' })
+      await expect(test.client.request('executable', { command: './relative' }, z.string())).rejects.toThrow('relative')
       const wrapped = await test.client.request('sandbox', { argv: ['true'], policy: policy(test.root) }, z.looseObject({ argv: z.array(z.string()), enforcement: z.enum(['full', 'partial']) }))
       expect(wrapped.argv).toEqual(['confined', 'true'])
       expect(confine).toHaveBeenCalledWith(['true'], policy(test.root), expect.any(AbortSignal))

+ 1 - 1
packages/ssh/ssh/tests/live.e2e.ts

@@ -191,7 +191,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
     const test = await setup()
     try {
       const terminal = await test.ctx.subprocess.spawnTerminal({
-        argv: ['/bin/bash', '--noprofile', '--norc', '-i'], cwd: test.root, rows: 24, cols: 80, graceMs: 500,
+        terminalType: 'dumb', argv: ['/bin/bash', '--noprofile', '--norc', '-i'], cwd: test.root, rows: 24, cols: 80, graceMs: 500,
       })
       const output: string[] = []
       const received = Promise.withResolvers<undefined>()

+ 2 - 2
packages/ssh/ssh/tests/stream-capability.spec.ts

@@ -21,7 +21,7 @@ describe.skipIf(process.platform === 'win32')('SSH stream capabilities', () => {
     const owner = new RemoteProcesses(new Context(), root, 1, 5000)
     let stream: Socket | undefined
     try {
-      const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { rows: 24, cols: 80 } })
+      const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } })
       const endpoint = prepared.streams.terminal!
       const raw = createConnection(endpoint.path)
       await once(raw, 'connect')
@@ -40,7 +40,7 @@ describe.skipIf(process.platform === 'win32')('SSH stream capabilities', () => {
     const owner = new RemoteProcesses(new Context(), root, 1, 5000)
     const sockets: Socket[] = []
     try {
-      const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { rows: 24, cols: 80 } })
+      const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } })
       const endpoint = prepared.streams.terminal!
       const raw = [createConnection(endpoint.path), createConnection(endpoint.path)]
       await Promise.all(raw.map(socket => once(socket, 'connect')))

+ 1 - 1
packages/ssh/ssh/tests/stream-lifetime.spec.ts

@@ -12,7 +12,7 @@ describe('SSH TLS cancellation ownership', () => {
     const root = await mkdtemp('/tmp/dsh-ssh-tls-life-')
     const owner = new RemoteProcesses(new Context(), root, 1, 5000)
     try {
-      const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { rows: 24, cols: 80 } })
+      const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } })
       const endpoint = prepared.streams.terminal!
       const raw = createConnection({ path: endpoint.path, allowHalfOpen: true })
       raw.on('error', () => {})

+ 2 - 2
packages/ssh/subprocess-ssh/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/ssh/subprocess-ssh/README.md
-README.md: ea647cd15b97ae281e145c5ac3afbf1e4cc4740c
-README.zh.md: 455da9cade52540eb69581ddc3ec858553c444a5
+README.md: 15afa74f041e9944036c451dacaaedb53ecd9ff9
+README.zh.md: 3417f184fbcb702edeadf3b510663b1a9a58192f

+ 2 - 0
packages/ssh/subprocess-ssh/README.md

@@ -33,6 +33,8 @@ A direct exit reported by `done` does not prove managed-range quiescence; `waitF
 
 -----
 
+Terminal shell facts and executable verification come from the remote helper. Lookup misses remain distinct from SSH failures. PTY creation forwards the caller's `terminalType`, and `resize()` updates the remote terminal without replacing its process.
+
 <a id="understand-the-implementation"></a>
 ## Understand the implementation
 

+ 2 - 0
packages/ssh/subprocess-ssh/README.zh.md

@@ -33,6 +33,8 @@ kind: "package-reference"
 
 -----
 
+终端 shell 信息与可执行文件验证来自远端 helper。查找未命中与 SSH 故障分别报告。PTY 创建传递调用方的 `terminalType`,`resize()` 更新远端终端尺寸,不替换进程。
+
 <a id="understand-the-implementation"></a>
 ## 理解实现
 

+ 22 - 6
packages/ssh/subprocess-ssh/src/index.ts

@@ -2,13 +2,13 @@
 import { Duplex, PassThrough, type Readable, type Writable } from 'node:stream'
 import type { Socket } from 'node:net'
 import { Context } from '@deepseek-ai/cordis'
-import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
-import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess'
+import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalEnvironment, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/output'
 import type { SshConnection } from '@deepseek-ai/dsh-ssh'
 import { doneSchema, foregroundSchema, outputSnapshotFrameLimit, outputSnapshotSchema, preparedSchema, remotePath, streamEndpointSchema } from '@deepseek-ai/dsh-ssh/schemas'
 import type { SshProcessId } from '@deepseek-ai/dsh-ssh/schemas'
-import { SshRpcPeer } from '@deepseek-ai/dsh-ssh/protocol'
+import { SshRpcPeer, RemoteOperationError } from '@deepseek-ai/dsh-ssh/protocol'
 import { z } from 'zod'
 
 function environment(env?: NodeJS.ProcessEnv): Record<string, string | null> | undefined {
@@ -252,8 +252,23 @@ export class SshSubprocessRuntime extends SubprocessRuntime {
     })
   }
 
-  override resolveExecutable(command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal): Promise<string> {
-    return this.ctx.ssh.request('executable', { command, env }, remotePath, signal)
+  override async resolveExecutable(command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal): Promise<string> {
+    try {
+      return await this.ctx.ssh.request('executable', { command, env }, remotePath, signal)
+    } catch (error) {
+      if (error instanceof RemoteOperationError && error.code === 'SUBPROCESS_EXECUTABLE_NOT_FOUND') {
+        throw new SubprocessExecutableNotFoundError(error.message, { cause: error })
+      }
+      throw error
+    }
+  }
+
+  override terminalEnvironment(signal?: AbortSignal): Promise<SubprocessTerminalEnvironment> {
+    return this.ctx.ssh.request('terminal.environment', {}, z.object({
+      platform: z.enum(['posix', 'windows']), defaultShell: z.string().optional(),
+    }).strict().transform(value => ({ platform: value.platform,
+      ...(value.defaultShell === undefined ? {} : { defaultShell: value.defaultShell }),
+    })), signal)
   }
 
   override spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
@@ -279,7 +294,7 @@ export class SshSubprocessRuntime extends SubprocessRuntime {
     const ssh = this.ctx.ssh
     const prepared = await ssh.request('process.prepare', {
       argv: spec.argv, cwd: spec.cwd, env: environment(spec.env), graceMs: spec.graceMs,
-      terminal: { rows: spec.rows, cols: spec.cols },
+      terminal: { rows: spec.rows, cols: spec.cols, terminalType: spec.terminalType },
     }, preparedSchema, signal)
     const id = prepared.id
     let socket: Socket | undefined
@@ -298,6 +313,7 @@ export class SshSubprocessRuntime extends SubprocessRuntime {
       const abort = (): void => { void handle.terminate().catch(() => { void ssh.dispose().catch(() => {}) }) }
       const handle: SubprocessTerminalHandle = {
         pid: started.pid, output, done,
+        resize: async (cols, rows) => { await ssh.request('terminal.resize', { id, cols, rows }, z.null()) },
         write: async (data) => { await ssh.request('terminal.write', { id, value: data }, z.null()) },
         inspectForeground: async () => await ssh.request('terminal.inspect', { id }, foregroundSchema) ?? undefined,
         signalForeground: (signal: SubprocessTerminalSignal) => ssh.request('terminal.signal', { id, value: signal }, z.number().int().positive()),

+ 1 - 1
packages/ssh/subprocess-ssh/tests/lifecycle.spec.ts

@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
 import { SshSubprocessRuntime } from '../src/index.ts'
 
 const id = 'a7b17d9c-5ebf-40d8-9e82-3d1ddae8517b'
-const spec = { argv: ['bash'], cwd: '/workspace', rows: 24, cols: 80, graceMs: 100 }
+const spec = { terminalType: 'dumb', argv: ['bash'], cwd: '/workspace', rows: 24, cols: 80, graceMs: 100 }
 
 describe('SSH terminal allocation lifecycle', () => {
   it.each(['prepare', 'connect', 'start'])('joins a pending %s before completing provider disposal', async (stage) => {

+ 58 - 0
packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts

@@ -0,0 +1,58 @@
+/** Shell facts and lookup failures retain remote execution semantics. */
+import { Context } from '@deepseek-ai/cordis'
+import { SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess'
+import { RemoteOperationError } from '@deepseek-ai/dsh-ssh/protocol'
+import { expect, it, vi } from 'vitest'
+import { z } from 'zod'
+import { SshSubprocessRuntime } from '../src/index.ts'
+
+it('validates remote shell facts and distinguishes a lookup miss from transport failure', async () => {
+  const ctx = new Context()
+  let facts: unknown = { platform: 'posix', defaultShell: '/remote/bin/zsh' }
+  let failure: Error | undefined
+  const request = vi.fn(async <T>(method: string, _params: unknown, schema: z.ZodType<T>): Promise<T> => {
+    if (failure !== undefined) throw failure
+    return schema.parse(method === 'terminal.environment' ? facts : '/remote/bin/bash')
+  })
+  ctx.provide('ssh', { request } as never)
+  const runtime = new SshSubprocessRuntime(ctx)
+  const signal = new AbortController().signal
+  try {
+    expect(await runtime.terminalEnvironment(signal)).toEqual(facts)
+    expect(request).toHaveBeenCalledWith('terminal.environment', {}, expect.anything(), signal)
+    facts = { platform: 'posix' }
+    expect(await runtime.terminalEnvironment()).toEqual(facts)
+    facts = { platform: 'untrusted' }
+    await expect(runtime.terminalEnvironment()).rejects.toBeInstanceOf(z.ZodError)
+    expect(await runtime.resolveExecutable('bash', undefined, signal)).toBe('/remote/bin/bash')
+    failure = new RemoteOperationError('not installed', 'SUBPROCESS_EXECUTABLE_NOT_FOUND')
+    await expect(runtime.resolveExecutable('absent')).rejects.toBeInstanceOf(SubprocessExecutableNotFoundError)
+    failure = new RemoteOperationError('permission', 'EACCES')
+    await expect(runtime.resolveExecutable('bash')).rejects.toBe(failure)
+    failure = new Error('SSH disconnected')
+    await expect(runtime.resolveExecutable('bash')).rejects.toBe(failure)
+  } finally { await ctx.fiber.dispose() }
+})
+
+it.skipIf(process.platform === 'win32')('queries the real helper and resizes its PTY without replacing the shell', async () => {
+  const { createHelperHarness } = await import('../../ssh/tests/fixtures/helper.ts')
+  const helper = await createHelperHarness()
+  const ctx = new Context()
+  ctx.provide('ssh', helper.connection as never)
+  const runtime = new SshSubprocessRuntime(ctx)
+  try {
+    expect(await runtime.terminalEnvironment()).toMatchObject({ platform: 'posix' })
+    await expect(runtime.resolveExecutable(`${helper.root}/absent`)).rejects.toBeInstanceOf(SubprocessExecutableNotFoundError)
+    const terminal = await runtime.spawnTerminal({ argv: ['/bin/sh', '-i'], cwd: helper.root, cols: 80, rows: 24, terminalType: 'xterm-256color', graceMs: 100 })
+    let output = ''
+    terminal.output.on('data', (chunk: Buffer) => { output += chunk.toString() })
+    await terminal.resize(120, 40)
+    await terminal.write('printf "REMOTE_TERM:%s\\n" "$TERM"; stty size\n')
+    await expect.poll(() => output).toContain('REMOTE_TERM:xterm-256color')
+    await expect.poll(() => output).toMatch(/40\s+120/u)
+    await terminal.terminate()
+    await terminal.done
+  } finally {
+    try { await ctx.fiber.dispose() } finally { await helper.close() }
+  }
+})

+ 5 - 3
packages/ssh/subprocess-ssh/tests/terminal-behavior.spec.ts

@@ -8,7 +8,7 @@ import { z } from 'zod'
 import { SshSubprocessRuntime } from '../src/index.ts'
 
 const id = 'aa910b47-e7d7-467b-8421-3331569dd02b'
-const spec: SubprocessTerminalSpawnSpec = { argv: ['bash'], cwd: '/remote/workspace', rows: 24, cols: 80, graceMs: 100 }
+const spec: SubprocessTerminalSpawnSpec = { argv: ['bash'], cwd: '/remote/workspace', terminalType: 'dumb', rows: 24, cols: 80, graceMs: 100 }
 const completion = { outcome: { exitCode: 0, signal: null }, spills: {}, collected: {} }
 
 async function setup(options: {
@@ -42,7 +42,7 @@ async function setup(options: {
         if (options.terminateFailure !== undefined) throw options.terminateFailure
         finished.resolve(completion)
         value = null
-      } else if (method === 'terminal.write') value = null
+      } else if (method === 'terminal.write' || method === 'terminal.resize') value = null
       else if (method === 'terminal.inspect') value = foreground
       else if (method === 'terminal.signal') value = 321
       else throw new Error(`Unexpected terminal request ${method}`)
@@ -89,13 +89,15 @@ describe('SSH terminal behavior', () => {
     test.remote.write('terminal output')
     expect(String((await data)[0])).toBe('terminal output')
     await handle.write('input\n')
+    await handle.resize(120, 40)
+    expect(test.calls.find(call => call.method === 'terminal.resize')?.params).toEqual({ id, cols: 120, rows: 40 })
     expect(await handle.inspectForeground()).toBeUndefined()
     test.setForeground({ processGroupId: 321, inputWaiting: true })
     expect(await handle.inspectForeground()).toEqual({ processGroupId: 321, inputWaiting: true })
     expect(await handle.signalForeground('SIGINT')).toBe(321)
     expect(test.calls.find(call => call.method === 'process.prepare')?.params).toEqual({
       argv: ['bash'], cwd: spec.cwd, env: { KEEP: 'value' }, graceMs: 100,
-      terminal: { rows: 24, cols: 80 },
+      terminal: { terminalType: 'dumb', rows: 24, cols: 80 },
     })
     expect(test.calls.find(call => call.method === 'terminal.write')?.params).toEqual({ id, value: 'input\n' })
     expect(test.calls.find(call => call.method === 'terminal.signal')?.params).toEqual({ id, value: 'SIGINT' })

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -787,6 +787,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
   WorkspaceFileRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
   WorkspaceFileStat: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
   WorkspaceFileText: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
+  TerminalShell: 'Browser terminal shell profiles are owned by packages/api/terminal-controller/README.md',
   TerminalEnvironment: 'Browser terminal environment fields are owned by packages/api/terminal-controller/README.md',
   WebTerminalInfo: 'Browser terminal metadata is owned by packages/api/terminal-controller/README.md',
   TerminalCreateRequest: 'Browser terminal allocation fields are owned by packages/api/terminal-controller/README.md',

+ 1 - 0
scripts/package-dependency-policy.ts

@@ -54,6 +54,7 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = {
 
 /** Runtime exports that require every consumer to resolve the provider's shared peer instance. */
 const PEER_REQUIRED_HOST_EXPORTS = {
+  '@deepseek-ai/dsh-subprocess': ['SubprocessExecutableNotFoundError'],
   '@deepseek-ai/dsh-scope': ['carrierKeyOf', 'scopeOf', 'scopeTarget'],
   '@deepseek-ai/dsh-session': ['SESSION_FORMAT_VERSION'],
   '@deepseek-ai/dsh-session-persistence': ['SessionPersistenceNotFoundError'],

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl


Некоторые файлы не были показаны из-за большого количества измененных файлов