diff --git a/README.md b/README.md index 75231e132..322d1a639 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,10 @@ settings are respected, not overwritten with defaults. `/login` offers ChatGPT OAuth, a device code or an API key (the last writes to Codex’s own credential store). Relay `/channel` connections take precedence; managed subscription tokens are only injected on first-party routes. dsh-TUI does -not write `~/.codex/config.toml` or log out your native Codex account. +not write `~/.codex/config.toml` or log out your native Codex account. A +provider `env_key` from your own config (e.g. `DEEPSEEK_API_KEY`) that the +shell did not export is injected from the DSH credential store when the ref +is stored there — keep the key in the store, no per-shell export needed. TPS includes hidden reasoning time and excludes tool execution time. Live text estimates are corrected when Codex reports output token usage. @@ -387,6 +390,10 @@ Everything, bilingual: [docs/README.md](docs/README.md). No sandbox of its own: dsh-TUI uses the active DSH profile's filesystem, shell, sandbox and approval policies. Permission presets come from the DSH `permissionPresets` registry. +**Permission persistence** (all three backends): the Claude and Codex backends keep their `/permission` picks in `~/.dsh-tui/backends//prefs.json`; the DSH pick is kept at `~/.dsh-tui/permission.json`. Every durable preset switch teaches it (picker, typed `/permission`, Shift+Tab static modes, or a switch the official command performed on its own), and a session that never customized its permission planes starts on the remembered preset — applied through the same official switch path. Plan-mode transients are excluded, an explicit `DSH_PERMISSION_MODE` deployment pin outranks the file, and an identity the mounted roster no longer offers is skipped. + +**Upstream auto-retry** (DSH): whenever a session binds (boot, `/model` switch, resume), the TUI seeds a retry policy — 5 attempts, transport-drop-aware failure codes including `STREAM_CLOSED` (a dropped upstream stream) — on **the provider route that session actually uses**, through the official `llm-pi-ai` settings section (the policy the kernel's `llm-retry` plugin executes). Only routes without an explicit `retryPolicy` are seeded — dormant channels are never written — and `upstreamRetry: false` in cordis.yml opts out. + Details: [Permissions and security boundary](docs/architecture.en.md#permissions-and-security-boundary). ## Acknowledgments diff --git a/README_ZH.md b/README_ZH.md index e53bacc37..84e3d3ff5 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -201,6 +201,8 @@ thread;这不是把历史导入 DSH 的 `migrate codex`。 权限档;Full Access 必须明确选择。已有 Codex 设置不会被默认值覆盖。`/login` 提供 ChatGPT OAuth、设备码或 API key(后者写入 Codex 自己的凭据存储)。`/channel` 连接 优先,托管订阅令牌只在第一方路由注入;不写 `~/.codex/config.toml`,不退出原生登录。 +自己 config.toml 里 provider 声明的 `env_key`(如 `DEEPSEEK_API_KEY`)若未在 shell +导出、但 DSH 凭据库存有该 ref,会自动注入子进程——key 存进凭据库即可,无需每次导出。 TPS 计入隐藏推理的生成时间,剔除工具执行时间;实时文本估算在 Codex 报告实际 输出 token 用量后校正。 @@ -336,6 +338,10 @@ pnpm smoke 不自带沙箱:用当前 DSH profile 的文件、Shell、sandbox 与 approval 策略。权限预设来自 DSH `permissionPresets` registry。 +**权限记忆**(三个内核都有):Claude 与 Codex 后端把 `/permission` 选择存在 `~/.dsh-tui/backends//prefs.json`;DSH 的选择存在 `~/.dsh-tui/permission.json`。任何一次持久的预设切换(选择器、手敲 `/permission`、Shift+Tab 静态模式、官方命令自行完成的切换)都会更新记忆;从未自定义过权限面的会话将以上次的选择启动——同样走官方切换路径。计划模式的临时切换不记入;显式的 `DSH_PERMISSION_MODE` 部署固定优先于该文件;名册已不提供的身份会被跳过。 + +**上游断链自动重试**(DSH):会话每次绑定(启动、`/model` 切换、resume)时,只为**当前实际使用的渠道**播种一份重试策略——最多重试 5 次、失败码覆盖传输类中断(含 `STREAM_CLOSED`,即上游流被掐断)——写入官方 `llm-pi-ai` settings 分节(内核 `llm-retry` 插件执行的就是这份策略)。只补未声明 `retryPolicy` 的渠道、从不写闲置渠道;已显式声明的不会被覆盖;cordis.yml 里 `upstreamRetry: false` 可整体关闭。 + 详见[权限边界](docs/architecture.md#权限与安全边界)。 ## 致谢 diff --git a/docs/configuration.en.md b/docs/configuration.en.md index 47afb05a4..5dc5c2e22 100644 --- a/docs/configuration.en.md +++ b/docs/configuration.en.md @@ -77,6 +77,7 @@ A complete common override looks like this: | `codeFrameStyle` | `light` | Frame of fenced code blocks in replies: `light` is a top label plus a left rail and costs no extra rows; `full` closes the box. Very narrow terminals always use a plain fence. Applies immediately | | `turnUsageRow` | `false` (boolean) | Show a right-aligned usage row at the end of each turn (tokens in/out, cache, duration, retries); `/tokens`, `/status` and the footer hover report the same numbers either way | | `modes` | built-in trio | Shift+Tab session-mode cycle (plan/sandbox/approval atom bundles); defaults to default → plan → full-access | +| `upstreamRetry` | `true` | Seed a retry policy (5 attempts, transport-drop-aware failure codes including `STREAM_CLOSED`) on the `llm-pi-ai` provider route the bound session actually uses, whenever it declares no `retryPolicy` — at every bind (boot, `/model` switch, resume), through the official `llm-pi-ai` settings section (the policy the kernel's `llm-retry` plugin executes). Dormant channels are never written; routes with an explicit `retryPolicy` (cordis.yml or hand-edited settings) are never overwritten; `false` opts out entirely | | `activity` | `true` | Show the live activity row | | `activityFrames` | `moon8` | Activity animation preset; `/activity` changes it at runtime. A legacy saved value of `claude` is read as `moon8`, and the picker no longer offers that legacy preset | | `contextBar` | `true` | Segmented context-usage bar below the input box; `false` hides the row. Both this and `/settings → statusBar.contextBar` (also on by default) must be on for it to render | diff --git a/docs/configuration.md b/docs/configuration.md index 946b5e539..195e09b41 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -74,6 +74,7 @@ Config;旧版仍使用 `~/.dsh/settings.yaml`。不要把旧文件路径当成 | `codeFrameStyle` | `light` | 回复里代码块的边框:`light` 只有顶部标签和左侧竖线,不多占行;`full` 是封闭的框。终端太窄时总是用纯文本 fence。立即生效 | | `turnUsageRow` | `false`(布尔) | 每回合末尾显示一行右对齐的用量(输入/输出、缓存、耗时、重试);关闭时 `/tokens`、`/status` 和底栏悬停照样能看到这些数字 | | `modes` | 内置三档 | Shift+Tab 会话模式循环(plan/sandbox/approval 原子组合);缺省为 默认 → 计划 → 完全访问 | +| `upstreamRetry` | `true` | 为当前会话实际使用的 `llm-pi-ai` 渠道播种重试策略(最多重试 5 次,失败码覆盖传输类中断、含 `STREAM_CLOSED` 上游断链):每次绑定(启动、`/model` 切换、resume)时检查该渠道,未声明 `retryPolicy` 才写入官方 `llm-pi-ai` settings 分节——内核 `llm-retry` 插件执行的就是这份策略。从不写闲置渠道;已显式声明 `retryPolicy` 的渠道(cordis.yml 或手改 settings)不会被覆盖;`false` 整体关闭 | | `activity` | `true` | 是否显示实时工作状态行 | | `activityFrames` | `moon8` | 工作状态动画预设;也可通过 `/activity` 修改。旧配置值 `claude` 读取时映射为 `moon8`,选择器不再显示该旧预设 | | `contextBar` | `true` | 输入框下方的分段上下文进度条;`false` 隐藏该行。与 `/settings → statusBar.contextBar`(默认开)同时为开才显示 | diff --git a/docs/interaction.en.md b/docs/interaction.en.md index 58e03a10c..13d733f9a 100644 --- a/docs/interaction.en.md +++ b/docs/interaction.en.md @@ -843,6 +843,13 @@ Additional forms: path (the same handler, real events) and confirms via event/readback. When neither is available, it fails loudly instead of sending the input to the model. - Exiting plan mode restores the pre-plan atoms first, then the durable preset you were on before plan mode (while the registry still offers it). +- The DSH `/permission` pick is persisted the same way at `~/.dsh-tui/permission.json`: every durable + preset switch teaches it (picker, typed command, Shift+Tab static mode, or a switch the official + command performed on its own), and a session that never customized its permission planes starts on + the remembered preset — applied through the same official switch path. Plan-mode transients are + excluded (entering plan keeps the pre-plan memory; the exit restore teaches it again), an explicit + `DSH_PERMISSION_MODE` deployment pin outranks the file, and an identity the mounted roster no + longer offers is skipped. - When the registry service is absent, TUI uses its legacy three-row compatibility roster; a mounted but broken service is unavailable and fails closed. - Non-DSH backends (Claude) that declare native permission modes answer `/permission` with that backend's own modes: `default` (ask before each risky diff --git a/docs/interaction.md b/docs/interaction.md index 40d27d07d..f7b7412d0 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -840,6 +840,11 @@ dsh-TUI 不预装通用技能;技能内容与发现规则由 DSH 及当前组 - registry 服务缺失时 TUI 使用三项 legacy 兼容名册。 - 服务已挂载但损坏、为空或不一致时标记 unavailable 并 fail closed。 - 退出计划模式先恢复进入前的 sandbox/approval,再还原进入前所在的权限预设(registry 仍提供时)。 + - DSH 的 `/permission` 选择以同样方式存进 `~/.dsh-tui/permission.json`:任何一次持久的预设切换 + (选择器、手敲命令、Shift+Tab 静态模式、官方命令自行完成的切换)都会更新它;从未自定义过 + 权限面的会话按记住的预设启动,走同一条官方切换路径。计划模式的临时切换不记入(进入计划保持 + 计划前的记忆、退出还原后再次记入);显式的 `DSH_PERMISSION_MODE` 部署固定优先于该文件; + 名册已不提供的身份会被跳过。 - 非 DSH 后端(如 Claude)声明原生权限模式时,`/permission` 列的是该后端的模式: `default`(逐个询问)、`acceptEdits`(自动接受编辑)、`plan`(只读规划)、 `bypassPermissions`(跳过全部权限确认),模型支持时还有 `auto`,每行带一句解释。 diff --git a/guide/dsh-tui-guide/configuration.en.md b/guide/dsh-tui-guide/configuration.en.md index 47afb05a4..5dc5c2e22 100644 --- a/guide/dsh-tui-guide/configuration.en.md +++ b/guide/dsh-tui-guide/configuration.en.md @@ -77,6 +77,7 @@ A complete common override looks like this: | `codeFrameStyle` | `light` | Frame of fenced code blocks in replies: `light` is a top label plus a left rail and costs no extra rows; `full` closes the box. Very narrow terminals always use a plain fence. Applies immediately | | `turnUsageRow` | `false` (boolean) | Show a right-aligned usage row at the end of each turn (tokens in/out, cache, duration, retries); `/tokens`, `/status` and the footer hover report the same numbers either way | | `modes` | built-in trio | Shift+Tab session-mode cycle (plan/sandbox/approval atom bundles); defaults to default → plan → full-access | +| `upstreamRetry` | `true` | Seed a retry policy (5 attempts, transport-drop-aware failure codes including `STREAM_CLOSED`) on the `llm-pi-ai` provider route the bound session actually uses, whenever it declares no `retryPolicy` — at every bind (boot, `/model` switch, resume), through the official `llm-pi-ai` settings section (the policy the kernel's `llm-retry` plugin executes). Dormant channels are never written; routes with an explicit `retryPolicy` (cordis.yml or hand-edited settings) are never overwritten; `false` opts out entirely | | `activity` | `true` | Show the live activity row | | `activityFrames` | `moon8` | Activity animation preset; `/activity` changes it at runtime. A legacy saved value of `claude` is read as `moon8`, and the picker no longer offers that legacy preset | | `contextBar` | `true` | Segmented context-usage bar below the input box; `false` hides the row. Both this and `/settings → statusBar.contextBar` (also on by default) must be on for it to render | diff --git a/guide/dsh-tui-guide/configuration.md b/guide/dsh-tui-guide/configuration.md index 946b5e539..195e09b41 100644 --- a/guide/dsh-tui-guide/configuration.md +++ b/guide/dsh-tui-guide/configuration.md @@ -74,6 +74,7 @@ Config;旧版仍使用 `~/.dsh/settings.yaml`。不要把旧文件路径当成 | `codeFrameStyle` | `light` | 回复里代码块的边框:`light` 只有顶部标签和左侧竖线,不多占行;`full` 是封闭的框。终端太窄时总是用纯文本 fence。立即生效 | | `turnUsageRow` | `false`(布尔) | 每回合末尾显示一行右对齐的用量(输入/输出、缓存、耗时、重试);关闭时 `/tokens`、`/status` 和底栏悬停照样能看到这些数字 | | `modes` | 内置三档 | Shift+Tab 会话模式循环(plan/sandbox/approval 原子组合);缺省为 默认 → 计划 → 完全访问 | +| `upstreamRetry` | `true` | 为当前会话实际使用的 `llm-pi-ai` 渠道播种重试策略(最多重试 5 次,失败码覆盖传输类中断、含 `STREAM_CLOSED` 上游断链):每次绑定(启动、`/model` 切换、resume)时检查该渠道,未声明 `retryPolicy` 才写入官方 `llm-pi-ai` settings 分节——内核 `llm-retry` 插件执行的就是这份策略。从不写闲置渠道;已显式声明 `retryPolicy` 的渠道(cordis.yml 或手改 settings)不会被覆盖;`false` 整体关闭 | | `activity` | `true` | 是否显示实时工作状态行 | | `activityFrames` | `moon8` | 工作状态动画预设;也可通过 `/activity` 修改。旧配置值 `claude` 读取时映射为 `moon8`,选择器不再显示该旧预设 | | `contextBar` | `true` | 输入框下方的分段上下文进度条;`false` 隐藏该行。与 `/settings → statusBar.contextBar`(默认开)同时为开才显示 | diff --git a/guide/dsh-tui-guide/interaction.en.md b/guide/dsh-tui-guide/interaction.en.md index 58e03a10c..13d733f9a 100644 --- a/guide/dsh-tui-guide/interaction.en.md +++ b/guide/dsh-tui-guide/interaction.en.md @@ -843,6 +843,13 @@ Additional forms: path (the same handler, real events) and confirms via event/readback. When neither is available, it fails loudly instead of sending the input to the model. - Exiting plan mode restores the pre-plan atoms first, then the durable preset you were on before plan mode (while the registry still offers it). +- The DSH `/permission` pick is persisted the same way at `~/.dsh-tui/permission.json`: every durable + preset switch teaches it (picker, typed command, Shift+Tab static mode, or a switch the official + command performed on its own), and a session that never customized its permission planes starts on + the remembered preset — applied through the same official switch path. Plan-mode transients are + excluded (entering plan keeps the pre-plan memory; the exit restore teaches it again), an explicit + `DSH_PERMISSION_MODE` deployment pin outranks the file, and an identity the mounted roster no + longer offers is skipped. - When the registry service is absent, TUI uses its legacy three-row compatibility roster; a mounted but broken service is unavailable and fails closed. - Non-DSH backends (Claude) that declare native permission modes answer `/permission` with that backend's own modes: `default` (ask before each risky diff --git a/guide/dsh-tui-guide/interaction.md b/guide/dsh-tui-guide/interaction.md index 40d27d07d..f7b7412d0 100644 --- a/guide/dsh-tui-guide/interaction.md +++ b/guide/dsh-tui-guide/interaction.md @@ -840,6 +840,11 @@ dsh-TUI 不预装通用技能;技能内容与发现规则由 DSH 及当前组 - registry 服务缺失时 TUI 使用三项 legacy 兼容名册。 - 服务已挂载但损坏、为空或不一致时标记 unavailable 并 fail closed。 - 退出计划模式先恢复进入前的 sandbox/approval,再还原进入前所在的权限预设(registry 仍提供时)。 + - DSH 的 `/permission` 选择以同样方式存进 `~/.dsh-tui/permission.json`:任何一次持久的预设切换 + (选择器、手敲命令、Shift+Tab 静态模式、官方命令自行完成的切换)都会更新它;从未自定义过 + 权限面的会话按记住的预设启动,走同一条官方切换路径。计划模式的临时切换不记入(进入计划保持 + 计划前的记忆、退出还原后再次记入);显式的 `DSH_PERMISSION_MODE` 部署固定优先于该文件; + 名册已不提供的身份会被跳过。 - 非 DSH 后端(如 Claude)声明原生权限模式时,`/permission` 列的是该后端的模式: `default`(逐个询问)、`acceptEdits`(自动接受编辑)、`plan`(只读规划)、 `bypassPermissions`(跳过全部权限确认),模型支持时还有 `auto`,每行带一句解释。 diff --git a/scripts/repro-effort.tsx b/scripts/repro-effort.tsx index 10fb7cec8..32249704d 100644 --- a/scripts/repro-effort.tsx +++ b/scripts/repro-effort.tsx @@ -256,7 +256,7 @@ function createRebindRig(options: { preferred: string; efforts: { id: string; na inputConvergence: { interruptSeq: 0, cancelInFlight: false }, selection: selection as never, modelActions: { selection: selection as never, applyPreferredEffort }, - modeActions: { refreshMode() {}, onSessionEvent() {} }, + modeActions: { refreshMode() {}, onSessionEvent() {}, async applyRememberedPermission() {} }, projector: { apply() {}, settleStreaming() {}, updateSpinnerMode() {} } as never, subagents: { onSessionEvent() { return false }, onStart() {}, onEnd() {} }, agentView: { schedule() {} }, diff --git a/scripts/verify-channel-router-lifecycle.ts b/scripts/verify-channel-router-lifecycle.ts index 9e1444d82..acff47405 100644 --- a/scripts/verify-channel-router-lifecycle.ts +++ b/scripts/verify-channel-router-lifecycle.ts @@ -69,7 +69,7 @@ const events = createBindingEvents(ctx as never, { activity: { start() {}, stop() {}, onAgentStatus() {}, onSessionEvent() {} }, inputConvergence: { interruptSeq: 0, cancelInFlight: false }, selection, modelActions: { selection, async applyPreferredEffort() {} }, - modeActions: { refreshMode() {}, onSessionEvent() {} }, + modeActions: { refreshMode() {}, onSessionEvent() {}, async applyRememberedPermission() {} }, projector: { apply() { projected += 1 }, settleStreaming() {}, updateSpinnerMode() {} } as never, subagents, agentView: { schedule() {} }, @@ -135,7 +135,7 @@ const failingEvents = createBindingEvents(ctx as never, { owner: failingOwner, binding: failingBinding, state: { ...state } as never, activity: { start() {}, stop() {}, onAgentStatus() {}, onSessionEvent() {} }, inputConvergence: { interruptSeq: 0, cancelInFlight: false }, selection: {}, - modelActions: { selection: {}, async applyPreferredEffort() {} }, modeActions: { refreshMode() {}, onSessionEvent() {} }, + modelActions: { selection: {}, async applyPreferredEffort() {} }, modeActions: { refreshMode() {}, onSessionEvent() {}, async applyRememberedPermission() {} }, projector: { settleStreaming() {}, updateSpinnerMode() {}, apply() {} } as never, subagents: { onSessionEvent() { return false }, onStart() {}, onEnd() {} }, agentView: { schedule() {} }, }) diff --git a/scripts/verify-permission-prefs.mjs b/scripts/verify-permission-prefs.mjs new file mode 100644 index 000000000..75c2bc211 --- /dev/null +++ b/scripts/verify-permission-prefs.mjs @@ -0,0 +1,374 @@ +/** + * Focused regression for the two upstream-facing behaviors added alongside + * verify-permission-modes.mjs: + * + * A. permissionPrefs.ts — the ~/.dsh-tui/permission.json round trip and + * its validation (safe command tokens only, junk reads as absent). + * B. channel/upstream-retry.ts — pure helpers (route selection, ops + * shape, policy freshness) and the ensureUpstreamRetry settings flow + * over a fake settings service (skip when absent/ns missing, one + * SETTINGS_CONFLICT retry, notify on success, quiet on refusal). + * C. the channel-level wiring — durable permission/preset events teach + * the preference (including ones the official /permission command + * appended on its own), in-plan switches do not, a fresh session is + * seeded with the remembered preset through the service write path, + * and the DSH_PERMISSION_MODE deployment pin outranks the file. + * + * Runs against the compiled channel (imports ../lib/types/…). Run after + * pnpm build: node scripts/verify-permission-prefs.mjs + */ +import { existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const isolatedHome = mkdtempSync(join(tmpdir(), 'dsh-tui-permission-prefs-')) +process.env.HOME = isolatedHome +process.env.USERPROFILE = isolatedHome +process.on('exit', () => rmSync(isolatedHome, { recursive: true, force: true })) + +const { readPermissionPref, writePermissionPref } = await import('../lib/types/permissionPrefs.js') +const { + ensureUpstreamRetry, + routesWithoutRetryPolicy, + upstreamRetryOps, + upstreamRetryPolicy, + UPSTREAM_RETRY_MAX_RETRIES, + UPSTREAM_RETRYABLE_CODES, +} = await import('../lib/types/dsh-adapter/channel/upstream-retry.js') +const { createChannel } = await import('../lib/types/dsh-adapter/channel.js') + +let failed = 0 +function check(name, ok, extra = '') { + console.log(`${ok ? 'PASS' : 'FAIL'}: ${name}${extra ? ` (${extra})` : ''}`) + if (!ok) failed += 1 +} + +const settle = async () => { + for (let index = 0; index < 6; index += 1) await Promise.resolve() + await new Promise(resolve => setTimeout(resolve, 90)) +} + +const prefFile = () => join(isolatedHome, '.dsh-tui', 'permission.json') +const clearPref = () => { try { unlinkSync(prefFile()) } catch { /* absent */ } } + +// ---- A. permissionPrefs round trip and validation ------------------------- +{ + const dir = mkdtempSync(join(tmpdir(), 'dsh-tui-prefs-unit-')) + try { + check('absent pref reads undefined', readPermissionPref(dir) === undefined) + check('write reports success', writePermissionPref('danger-full-access', dir) === true) + check('round trip keeps the identity', readPermissionPref(dir) === 'danger-full-access') + const raw = JSON.parse(readFileSync(join(dir, 'permission.json'), 'utf8')) + check('file shape is { permission }', raw.permission === 'danger-full-access') + writeFileSync(join(dir, 'permission.json'), '{not json', 'utf8') + check('corrupt file reads undefined', readPermissionPref(dir) === undefined) + writeFileSync(join(dir, 'permission.json'), JSON.stringify({ permission: 'not safe' }), 'utf8') + check('unsafe token reads undefined', readPermissionPref(dir) === undefined) + writeFileSync(join(dir, 'permission.json'), JSON.stringify({ permission: ' ' }), 'utf8') + check('blank identity reads undefined', readPermissionPref(dir) === undefined) + writeFileSync(join(dir, 'permission.json'), JSON.stringify(['danger-full-access']), 'utf8') + check('non-object document reads undefined', readPermissionPref(dir) === undefined) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +// ---- B. upstream-retry helpers and the settings ensure flow --------------- +{ + check('policy retries 5 times', upstreamRetryPolicy().maxRetries === UPSTREAM_RETRY_MAX_RETRIES && UPSTREAM_RETRY_MAX_RETRIES === 5) + check('policy covers the stock codes plus STREAM_CLOSED', + UPSTREAM_RETRYABLE_CODES.includes('STREAM_CLOSED') && UPSTREAM_RETRYABLE_CODES.includes('TRANSPORT') && UPSTREAM_RETRYABLE_CODES.includes('EMPTY_RESPONSE')) + const first = upstreamRetryPolicy() + const second = upstreamRetryPolicy() + check('policy objects are fresh per call', first !== second && JSON.stringify(first) === JSON.stringify(second)) + const section = { + providers: { + zhipu: { baseURL: 'https://example', models: [] }, + deepseek: { baseURL: 'https://example', retryPolicy: { mode: 'always' } }, + broken: null, + relay: 'scalar', + }, + } + check('routes without a retryPolicy are selected exactly', JSON.stringify(routesWithoutRetryPolicy(section)) === JSON.stringify(['zhipu', 'broken', 'relay'])) + check('non-object sections select nothing', routesWithoutRetryPolicy(undefined).length === 0 && routesWithoutRetryPolicy('nope').length === 0 && routesWithoutRetryPolicy({ providers: 7 }).length === 0) + const ops = upstreamRetryOps(['zhipu', 'relay']) + check('ops address the retryPolicy field', JSON.stringify(ops[0]?.path) === JSON.stringify(['providers', 'zhipu', 'retryPolicy']) && ops[0]?.op === 'set') + check('ops carry the widened policy', ops[1]?.value?.retryableCodes?.includes('STREAM_CLOSED') === true && ops[1]?.value?.maxRetries === 5) + check('wanted filtering keeps only requested routes', JSON.stringify(routesWithoutRetryPolicy(section, ['zhipu', 'deepseek'])) === JSON.stringify(['zhipu'])) + check('wanted filtering drops unknown routes', routesWithoutRetryPolicy(section, ['native-adapter-route']).length === 0) + + // A settings fake whose describe() reflects successful mutations, so + // idempotency (a seeded route drops out) is observable. + const makeSettings = (rows, behavior = {}) => { + const live = rows.map(row => ({ ...row, value: structuredClone(row.value) })) + const mutations = [] + const notifies = [] + const warnings = [] + const apply = (ns, opsToApply) => { + const row = live.find(candidate => candidate.ns === ns) + if (row === undefined) return + for (const op of opsToApply) { + let node = row.value + for (const key of op.path.slice(0, -1)) { + if (typeof node[key] !== 'object' || node[key] === null) node[key] = {} + node = node[key] + } + node[op.path[op.path.length - 1]] = structuredClone(op.value) + } + } + const settings = { + describe: () => live, + mutate: async (ns, opsToApply, revision) => { + mutations.push({ ns, opsToApply, revision, attempt: mutations.length + 1 }) + if (behavior.conflictOnce && mutations.length === 1) { const error = new Error('stale'); error.code = 'SETTINGS_CONFLICT'; throw error } + if (behavior.refuse) throw new Error('refused') + apply(ns, opsToApply) + }, + } + const ctx = { + on: () => () => {}, + get: name => (name === 'settings' ? settings : undefined), + logger: { warn: message => warnings.push(String(message)) }, + } + const notify = (text, options) => notifies.push({ text, options }) + return { settings, ctx, notify, mutations: () => mutations, notifies, warnings } + } + + { + const env = makeSettings([{ ns: 'llm-pi-ai', revision: 4, value: section }]) + await ensureUpstreamRetry(env.ctx, env.notify, ['zhipu']) + check('ensure mutates the llm-pi-ai namespace once', env.mutations().length === 1 && env.mutations()[0]?.ns === 'llm-pi-ai') + check('ensure seeds exactly the requested unclaimed route', env.mutations()[0]?.opsToApply?.length === 1 && env.mutations()[0]?.opsToApply?.[0]?.path?.[1] === 'zhipu', JSON.stringify(env.mutations()[0]?.opsToApply?.map(op => op.path?.[1]))) + check('ensure passes the observed revision', env.mutations()[0]?.revision === 4) + check('ensure notifies once on success, naming the route', env.notifies.length === 1 && env.notifies[0]?.options?.color === 'success' && env.notifies[0]?.text?.includes('zhipu') === true) + // The write landed in the resolved section: a second seeding for the + // same route (the next bind) is a read-only no-op. + await ensureUpstreamRetry(env.ctx, env.notify, ['zhipu']) + check('a seeded route drops out of later seedings', env.mutations().length === 1 && env.notifies.length === 1) + } + { + const env = makeSettings([{ ns: 'llm-pi-ai', revision: 2, value: section }], { conflictOnce: true }) + await ensureUpstreamRetry(env.ctx, env.notify, ['zhipu', 'relay']) + check('a stale revision is retried exactly once', env.mutations().length === 2 && env.mutations()[1]?.revision === 2 && env.mutations()[1]?.opsToApply?.length === 2) + } + { + const env = makeSettings([{ ns: 'llm-pi-ai', revision: 2, value: section }], { refuse: true }) + await ensureUpstreamRetry(env.ctx, env.notify, ['zhipu']) + check('a refused write warns and never throws', env.warnings.length === 1 && env.notifies.length === 0 && env.mutations().length === 1) + } + { + const env = makeSettings([{ ns: 'llm-pi-ai', revision: 2, value: { providers: { deepseek: { retryPolicy: { mode: 'always' } } } } }]) + await ensureUpstreamRetry(env.ctx, env.notify, ['deepseek']) + check('routes that already declare a policy are left alone', env.mutations().length === 0 && env.notifies.length === 0) + } + { + const env = makeSettings([{ ns: 'llm-pi-ai', revision: 2, value: section }]) + await ensureUpstreamRetry(env.ctx, env.notify, ['native-adapter-route']) + check('a route the section does not know writes nothing', env.mutations().length === 0 && env.notifies.length === 0) + } + { + const env = makeSettings([{ ns: 'other-ns', revision: 1, value: {} }]) + await ensureUpstreamRetry(env.ctx, env.notify, ['zhipu']) + check('a missing llm-pi-ai namespace writes nothing', env.mutations().length === 0) + } + { + const ctx = { on: () => () => {}, get: () => undefined, logger: { warn: () => {} } } + const notified = [] + await ensureUpstreamRetry(ctx, text => notified.push(text), ['zhipu']) + check('no settings service writes nothing', notified.length === 0) + } +} + +// ---- C. channel-level persistence, seeding, and the deployment pin -------- +const makeEnv = ({ history = [], withCommand = false, settings, options } = {}) => { + const commands = [] + const warnings = [] + const handlers = new Map() + const events = [...history] + const registry = { + names: ['read-only', 'workspace-write', 'auto', 'safe'], + entries: new Map([ + ['read-only', { value: 'read-only', name: 'Read only' }], + ['workspace-write', { value: 'workspace-write', name: 'Workspace write' }], + ['auto', { value: 'auto', name: 'Auto' }], + ['safe', { value: 'safe', name: 'Safe' }], + ]), + // Mirrors the real service derive(): folded knob state over the + // composition defaults, so a fresh session reads its effective preset + // (never undefined) exactly like dsh-permission-presets does. + current(subject) { + const bundles = { + 'read-only': ['read-only', 'ask'], + 'workspace-write': ['workspace-write', 'ask'], + auto: ['workspace-write', 'ask'], + safe: ['workspace-write', 'ask'], + } + let sandbox + let approval + let preset + for (const event of (Array.isArray(subject) ? subject : subject?.events ?? [])) { + if (event.type === 'sandbox/mode') sandbox = event.data?.mode + if (event.type === 'approval/policy') approval = event.data?.policy + if (event.type === 'permission/preset' && typeof event.data?.preset === 'string') preset = event.data.preset + } + sandbox ??= 'workspace-write' + approval ??= 'ask' + if (preset !== undefined && bundles[preset]?.[0] === sandbox && bundles[preset]?.[1] === approval) return preset + for (const [name, spec] of Object.entries(bundles)) { + if (spec[0] === sandbox && spec[1] === approval) return name + } + return 'custom' + }, + optionOf(name) { return this.entries.get(name) }, + resolve(name) { + const bundles = { + 'read-only': { sandbox: 'read-only', approval: 'ask' }, + 'workspace-write': { sandbox: 'workspace-write', approval: 'ask' }, + auto: { sandbox: 'workspace-write', approval: 'ask' }, + safe: { sandbox: 'workspace-write', approval: 'ask' }, + } + return { ...bundles[name] } + }, + set(session, name) { + if (!this.entries.has(name)) throw new Error(`unknown preset ${name}`) + session.append('permission/preset', { preset: name }) + }, + } + const services = { + planMode: { get: () => ({}) }, + commands: { + list: () => [], + find: (_agent, name) => (withCommand && name === 'permission' ? { name: 'permission' } : undefined), + execute: async () => undefined, + }, + approval: { setPolicy: (agent, policy) => agent.session.append('approval/policy', { policy }) }, + sandboxPolicy: { defaultMode: 'workspace-write' }, + permissionPresets: registry, + ...(settings === undefined ? {} : { settings }), + } + const ctx = { + on(event, handler) { handlers.set(event, handler); return () => handlers.delete(event) }, + get: name => services[name], + logger: { warn: message => warnings.push(String(message)) }, + } + const agent = { + id: 'a1', + status: 'idle', + ...(options === undefined ? {} : { options }), + session: { + id: 's1', + seq: 0, + events, + append(type, data) { + const event = { type, seq: events.length + 1, time: Date.now(), data } + events.push(event) + handlers.get('session/event')?.(agent.session, event) + }, + }, + ctx: { on: () => () => {} }, + } + return { ctx, agent, registry, commands, warnings, events } +} + +const baseOptions = { model: 'deepseek-chat', cwd: '/tmp', provider: 'deepseek', activity: false } +const AUTO_SEED = [ + { type: 'sandbox/mode', data: { mode: 'workspace-write' } }, + { type: 'approval/policy', data: { policy: 'ask' } }, + { type: 'permission/preset', data: { preset: 'auto' } }, +] + +{ + clearPref() + const env = makeEnv({ history: AUTO_SEED }) + createChannel(env.ctx, env.agent, baseOptions) + await settle() + check('observer: no switch leaves no preference file', !existsSync(prefFile())) + // The official /permission command appending on its own (typed input, + // another UI over the same registry) is the primary teaching path. + env.agent.session.append('permission/preset', { preset: 'safe' }) + await settle() + check('observer: durable identity switches teach the preference', readPermissionPref() === 'safe', readPermissionPref()) + // In-plan switches are transient: the exit restore re-seats the pre-plan + // preset, so the file must keep the last out-of-plan identity. + env.agent.session.append('plan/mode', { active: true }) + env.agent.session.append('permission/preset', { preset: 'read-only' }) + await settle() + check('observer: in-plan switches do not teach the preference', readPermissionPref() === 'safe', readPermissionPref()) + env.agent.session.append('plan/mode', { active: false }) + env.agent.session.append('permission/preset', { preset: 'safe' }) + await settle() + check('observer: post-plan restore teaches again', readPermissionPref() === 'safe') +} + +{ + clearPref() + writePermissionPref('auto') + const env = makeEnv() + const channel = createChannel(env.ctx, env.agent, baseOptions) + await settle() + const taught = env.events.some(event => event.type === 'permission/preset' && event.data?.preset === 'auto') + check('seed: a fresh session adopts the remembered preset', taught, JSON.stringify(env.events.map(event => event.type))) + check('seed: the derived mode follows the identity', channel.mode.id === 'permission:auto', channel.mode.id) +} + +{ + clearPref() + writePermissionPref('safe') + const env = makeEnv({ history: [{ type: 'sandbox/mode', data: { mode: 'read-only' } }] }) + createChannel(env.ctx, env.agent, baseOptions) + await settle() + const untouched = !env.events.some(event => event.type === 'permission/preset') + check('seed: a session with its own plane events is left alone', untouched) +} + +{ + clearPref() + writePermissionPref('auto') + process.env.DSH_PERMISSION_MODE = 'workspace-write' + const env = makeEnv() + createChannel(env.ctx, env.agent, baseOptions) + await settle() + check('seed: a DSH_PERMISSION_MODE pin outranks the file', !env.events.some(event => event.type === 'permission/preset')) + delete process.env.DSH_PERMISSION_MODE +} + +{ + clearPref() + writePermissionPref('gone-from-the-roster') + const env = makeEnv() + createChannel(env.ctx, env.agent, baseOptions) + await settle() + check('seed: an identity off the mounted roster is skipped', !env.events.some(event => event.type === 'permission/preset')) + clearPref() +} + +// ---- D. upstream retry follows the route the session actually uses -------- +{ + const mutations = [] + const section = { + providers: { + zhipu: { baseURL: 'https://zhipu.example', models: [] }, + deepseek: { baseURL: 'https://deepseek.example', models: [], retryPolicy: { mode: 'always' } }, + }, + } + const settings = { + describe: () => [{ ns: 'llm-pi-ai', revision: 7, value: section }], + mutate: async (ns, opsToApply, revision) => { mutations.push({ ns, opsToApply, revision }) }, + } + const env = makeEnv({ + settings, + options: { provider: 'zhipu', model: 'glm-5.3' }, + }) + createChannel(env.ctx, env.agent, baseOptions) + await settle() + const seeded = mutations[0]?.opsToApply ?? [] + check('retry: the bind seeds the route in use', mutations.length === 1 && seeded.length === 1 && seeded[0]?.path?.[1] === 'zhipu', JSON.stringify(seeded.map(op => op.path))) + check('retry: routes with their own policy and dormant channels stay untouched', !JSON.stringify(seeded).includes('deepseek')) +} + +if (failed > 0) { + console.error(`permission-prefs verification failed: ${failed}`) + process.exitCode = 1 +} else { + console.log('permission-prefs verification passed') +} diff --git a/src/dsh-adapter/channel/binding-events.ts b/src/dsh-adapter/channel/binding-events.ts index e846b1c3b..4307d3fe1 100644 --- a/src/dsh-adapter/channel/binding-events.ts +++ b/src/dsh-adapter/channel/binding-events.ts @@ -39,7 +39,13 @@ export function createBindingEvents(ctx: Context, deps: { inputConvergence: InputConvergence selection: ModelSelectionRef modelActions: { applyPreferredEffort(): Promise; selection: ModelSelectionRef } - modeActions: { refreshMode(): void; onSessionEvent(session: unknown, event: unknown): void } + modeActions: { + refreshMode(): void + onSessionEvent(session: unknown, event: unknown): void + /** Seed an untouched session with the persisted permission preference + * (permissionPrefs.ts); fire-and-forget on bind, never rejects. */ + applyRememberedPermission(): Promise + } projector: ChannelProjection subagents: { onSessionEvent(session: unknown, event: unknown): boolean @@ -50,6 +56,11 @@ export function createBindingEvents(ctx: Context, deps: { } agentView: { schedule(): void } messageObserver?: { publish(session: unknown, event: unknown): void } + /** Seed the upstream auto-retry policy (upstream-retry.ts) for the + * provider route this session actually uses; fire-and-forget on bind, + * never rejects, never blocks. Optional for direct/embed constructors + * that bring no settings seam. */ + seedUpstreamRetry?(provider: string | undefined): void /** Drop a pre-step attachment registered by this channel for one message id * (input-delivery's `retireAttachment`); see the discard hook below. * Optional for direct/embed constructors that never emit inbox discards; @@ -139,6 +150,13 @@ export function createBindingEvents(ctx: Context, deps: { } void deps.modelActions.applyPreferredEffort() deps.modeActions.refreshMode() + // Same pattern as the preferred effort above: the remembered + // permission preference seeds sessions that never chose their own. + void deps.modeActions.applyRememberedPermission() + // The upstream auto-retry policy follows the route in use: the + // agent's own options name it after a /model switch or a resume, + // and a fresh session falls back to the launch route. + deps.seedUpstreamRetry?.(deps.binding.agent.options?.provider ?? deps.state.provider) // Entering/resuming a session whose preset serves neither automatic // compaction nor tool-result pruning changes what the user can expect // from a long session; say it once, here, before the turn starts. diff --git a/src/dsh-adapter/channel/extensions.ts b/src/dsh-adapter/channel/extensions.ts index abb58bbc2..6ca0b4e6e 100644 --- a/src/dsh-adapter/channel/extensions.ts +++ b/src/dsh-adapter/channel/extensions.ts @@ -74,6 +74,7 @@ import { createSkillCatalog } from './skill-catalog.js' import { createSubagentProjection, type SubagentsServiceView } from './subagent-projection.js' import { readChildTranscriptPage } from './subagent-transcript.js' import { DSH_BACKEND_LABEL, type ChannelLaunchOptions } from './state.js' +import { ensureUpstreamRetry } from './upstream-retry.js' import { foldBack } from './transcript.js' import type { BackgroundResult, ResumeResult } from './types.js' import { createWorkspaceActions } from './workspace-actions.js' @@ -116,6 +117,20 @@ export function attachDshExtensions( if (native === undefined) throw new Error('dsh-tui: the bound session is not a DSH session') return native } + // Upstream auto-retry seeding (upstream-retry.ts), scoped to the route + // the bound session actually uses: the binding feed calls this on every + // bind (boot, /model switch, resume). The config gate defaults on, + // observational (shadow) compositions never write settings, and the + // fire-and-forget promise never blocks a bind. + const upstreamRetryAttempted = new Set() + const seedUpstreamRetry = (provider: string | undefined): void => { + if (options.upstreamRetry === false) return + if (adapterRuntime.mode === 'passive-shadow' || adapterRuntime.mode === 'replay-shadow') return + if (provider === undefined || provider === '' || upstreamRetryAttempted.has(provider)) return + upstreamRetryAttempted.add(provider) + void ensureUpstreamRetry(ctx, notify, [provider]) + } + // Detached work (/fork and agent-view dispatch) is owned until a caller // explicitly transfers the temporary handle to its destination ledger. const createDetachedHandle = createDetachedHandleFactory(owner) @@ -532,6 +547,7 @@ export function attachDshExtensions( subagents: subagentProjection, agentView, messageObserver, + seedUpstreamRetry, retireAttachment: core.input.retireAttachment, }) diff --git a/src/dsh-adapter/channel/mode-permission-actions.ts b/src/dsh-adapter/channel/mode-permission-actions.ts index 5a3bdc24c..ba6734d01 100644 --- a/src/dsh-adapter/channel/mode-permission-actions.ts +++ b/src/dsh-adapter/channel/mode-permission-actions.ts @@ -3,11 +3,13 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { assertShadowPolicy } from '../../adapter/kernel/runtime.js' import { t } from '../../i18n.js' +import { readPermissionPref, writePermissionPref } from '../../permissionPrefs.js' import { modeDisplayName, type SessionModeSpec } from '../../sessionModes.js' import { snapshotLiveSessionEvents } from '../compat/liveSession.js' import type { DshChannelBinding } from './binding.js' import { createModeActions, reportModeSwitchFailure } from './mode-actions.js' import { createPermissionIdentity, foldPermissionPreset, type PermissionIdentity } from './mode-permission.js' +import { PERMISSION_PRESET_CUSTOM } from './permissions.js' import type { PermissionModeRoster } from './mode-roster.js' import type { ChannelState } from './types.js' @@ -111,6 +113,7 @@ export function createPermissionModeActions( cycleMode(): Promise applyMode(spec: SessionModeSpec, capture?: ModeCapture): Promise onSessionEvent(session: unknown, event: unknown): void + applyRememberedPermission(): Promise runPermissionPreset(name: string): Promise permission: PermissionIdentity } { @@ -133,6 +136,25 @@ export function createPermissionModeActions( // An in-turn /plan off commits at pre-step, after the command has returned. const explicitPlanExits = new WeakSet() + /** True while this composition is walking a session INTO plan mode — from + * the pre-plan identity capture until the plan atoms have been applied. + * The canonical preset the plan entry switches to is a transient working + * state (restored on exit), so the preference must not learn it. */ + let planEntryInFlight = false + /** Sessions the remembered permission was already seeded into (or decided + * against): one attempt per session object, rebinds stay quiet. */ + const permissionPrefSeeded = new WeakSet() + + /** Remember one durable permission identity for the next session (see + * permissionPrefs.ts). Undefined and custom identities carry no preset + * to remember; a failed write is a debug line, never user-facing. */ + const persistPermissionPref = (value: string | undefined): void => { + if (value === undefined || value === PERMISSION_PRESET_CUSTOM) return + if (!writePermissionPref(value)) { + ctx.logger.debug(`dsh-tui: permission preference write failed for "${value}"`) + } + } + /** Re-derive the roster and the current mode. The roster rebuild rides on * every refresh: a registry mount/unmount and a re-bind both land here. */ const refreshMode = (): void => { @@ -176,6 +198,12 @@ export function createPermissionModeActions( // canonicalization can append replacement events. if (planChange && spec.plan === true && !planActive) { permission.rememberPrePlanIdentity(session) + // The canonicalization below reseats the durable identity on the + // plan atoms (read-only/ask) until the exit restores it. Keep the + // preference on the identity the user actually sat on, and let the + // event observer below ignore the replacement while it is in flight. + persistPermissionPref(foldPermissionPreset(snapshotLiveSessionEvents(session))) + planEntryInFlight = true } if (planChange && spec.plan === false) { // An explicit switch away from plan owns its target mode; the @@ -183,13 +211,22 @@ export function createPermissionModeActions( explicitPlanExits.add(session) permission.forgetPrePlanIdentity(session) } - if (spec.permission !== undefined) { - if (!(await permission.applyPermissionIdentity(spec.permission))) return - } else if (!(await permission.canonicalizeForMode(spec, session))) { - return + try { + if (spec.permission !== undefined) { + if (!(await permission.applyPermissionIdentity(spec.permission))) return + persistPermissionPref(spec.permission) + } else if (!(await permission.canonicalizeForMode(spec, session))) { + return + } else if (spec.plan !== true) { + // A static mode the user cycled to owns its canonical preset + // identity (the plan spec's canonical form is transient instead). + persistPermissionPref(permission.canonicalPermissionForMode(spec, session)) + } + if (!owner.current() || !binding.isCurrent(capture) || session !== agent.session) return + await base.applyMode(spec, capture) + } finally { + planEntryInFlight = false } - if (!owner.current() || !binding.isCurrent(capture) || session !== agent.session) return - await base.applyMode(spec, capture) if (!owner.current() || !binding.isCurrent(capture)) return const before = state.modeIndex refreshMode() @@ -215,7 +252,19 @@ export function createPermissionModeActions( const sessionEvent = event as SessionEvent base.onSessionEvent(subject, sessionEvent) const type = (sessionEvent as { type: string }).type - if (type === 'permission/preset') refreshMode() + if (type === 'permission/preset') { + refreshMode() + // Every durable identity change on the bound session teaches the + // preference — including switches the official /permission command + // performed on its own (typed input, other UIs over the same + // registry) — except the two plan-transition shapes: the canonical + // preset a plan entry reseats to (in flight below) and any identity + // switched to while plan is active (the exit restore re-seats the + // pre-plan one, and that restore event lands here again). + if (!planEntryInFlight && !foldPlanActive(snapshotLiveSessionEvents(subject))) { + persistPermissionPref(foldPermissionPreset(snapshotLiveSessionEvents(subject))) + } + } if (type !== 'plan/mode' || (sessionEvent.data as unknown as { active?: boolean }).active !== false) return const remembered = permission.prePlanPermissionIdentity(subject) const explicit = explicitPlanExits.delete(subject) @@ -238,11 +287,58 @@ export function createPermissionModeActions( }) } + /** Seed a session that never customized its permission planes with the + * persisted preference (permissionPrefs.ts): the last preset the user + * switched to, applied through the same official /permission path every + * manual switch takes. Idempotent by construction — once any + * permission/sandbox/approval event exists in the log, the session owns + * its planes and is left alone. Never rejects. + * + * Yields to a DSH_PERMISSION_MODE deployment pin (the composition's + * sandbox-policy/approval defaults came from it) and to shadow + * runtimes, which must not write durable session policy. */ + const applyRememberedPermission = async (): Promise => { + try { + if (deps.runtime.mode === 'passive-shadow' || deps.runtime.mode === 'replay-shadow') return + const pinned = process.env.DSH_PERMISSION_MODE + if (pinned !== undefined && pinned !== '') return + const pref = readPermissionPref() + if (pref === undefined) return + const agent = binding.agent + const session = agent.session + if (permissionPrefSeeded.has(session)) return + // A session that already holds any permission-plane event chose its + // own state (or was resumed with one); only untouched planes seed. + for (const event of snapshotLiveSessionEvents(session)) { + const known = (event as { type: string }).type + if (known === 'permission/preset' || known === 'sandbox/mode' || known === 'approval/policy') return + } + // Fail closed against the mounted roster: an identity this + // deployment does not offer must not be driven through /permission + // (it would surface as a boot-time switch failure). + const snapshot = roster.readSnapshot(agent) + if (snapshot === undefined || !snapshot.options.some(option => option.value === pref)) { + permissionPrefSeeded.add(session) + ctx.logger.debug(`dsh-tui: remembered permission preset "${pref}" is not on the mounted roster; skipped`) + return + } + permissionPrefSeeded.add(session) + const ok = await permission.applyPermissionIdentity(pref) + if (ok && owner.current() && session === binding.agent.session) { + refreshMode() + state.emit() + } + } catch (error) { + ctx.logger.warn(`dsh-tui: remembered permission could not be applied: ${error instanceof Error ? error.message : String(error)}`) + } + } + return { refreshMode, cycleMode, applyMode, onSessionEvent, + applyRememberedPermission, runPermissionPreset: name => permission.runPermissionPreset(name), permission, } diff --git a/src/dsh-adapter/channel/state.ts b/src/dsh-adapter/channel/state.ts index 7c795c8ad..a24fb2022 100644 --- a/src/dsh-adapter/channel/state.ts +++ b/src/dsh-adapter/channel/state.ts @@ -58,6 +58,11 @@ export interface ChannelLaunchOptions { * `'dsh-tui'` (direct `createChannel` embedders and fixtures). */ settingsNs?: string diffLayout?: 'auto' | 'split' | 'unified' + /** Seed the upstream auto-retry policy (upstream-retry.ts) on the + * llm-pi-ai route the bound session actually uses, when that route + * declares no retryPolicy. Absent means enabled (the Config schema's + * default); false opts out without touching settings. */ + upstreamRetry?: boolean thinkingFold?: 'preview' | 'full' jobGroupFold?: JobGroupFoldMode toolBackground?: ToolBackground diff --git a/src/dsh-adapter/channel/upstream-retry.ts b/src/dsh-adapter/channel/upstream-retry.ts new file mode 100644 index 000000000..6cde8c884 --- /dev/null +++ b/src/dsh-adapter/channel/upstream-retry.ts @@ -0,0 +1,161 @@ +/** + * Upstream auto-retry for DSH sessions: the llm-retry plugin (dsh-base) + * already retries transient model-request failures per each provider + * route's resolved retry policy, but the stock retryable-code set + * (EMPTY_RESPONSE / RATE_LIMIT / SERVER / TIMEOUT / TRANSPORT) does not + * include STREAM_CLOSED — the code dsh-llm-pi-ai throws when an upstream + * SSE stream simply ends with no terminal event, i.e. the bare upstream + * link drop. Those drops are exactly as transient as the classified + * transport errors, so this module widens the policy to cover them. + * + * The widening is delivered where the retry policy actually lives: the + * 'llm-pi-ai' settings section's 'providers..retryPolicy' field + * (dsh-llm-retry executes it; the TUI never retries anything itself). + * ensureUpstreamRetry seeds that field ONLY on the route(s) the bound + * session actually uses (the channel feeds it the live provider at every + * bind — boot, /model switch, resume), and only when that route declares + * no retryPolicy: an explicit value (from cordis.yml or a hand-edited + * settings document) is a deliberate choice and is never overwritten, and + * dormant channels are never touched. The settings user layer merges + * recursively over the composition base, so a route configured through + * the profile patch is reached with all its other fields intact. + * + * @module @deepseek-harness-tui/dsh-tui/channel/upstream-retry + */ + +import type { Context } from '@deepseek-ai/cordis' +import { t } from '../../i18n.js' +import { settingsValue } from '../compat/settings.js' + +/** Retry attempts after the first request, matching both the upstream + * default and the product ask (retry 5 times). */ +export const UPSTREAM_RETRY_MAX_RETRIES = 5 + +/** + * Failure codes the widened policy retries: the stock set dsh-llm's + * resolveRetryPolicy defaults to, plus STREAM_CLOSED (dsh-llm-pi-ai's + * signature for an upstream event stream that ended without a terminal + * event). Codes outside the set (AUTH, INVALID_REQUEST, QUOTA, …) stay + * non-retryable exactly as before. + */ +export const UPSTREAM_RETRYABLE_CODES: readonly string[] = [ + 'EMPTY_RESPONSE', + 'RATE_LIMIT', + 'SERVER', + 'TIMEOUT', + 'TRANSPORT', + 'STREAM_CLOSED', +] + +/** + * The retry policy value written under 'providers..retryPolicy' + * (RetryPolicySchema's normal mode). A fresh object per call: settings + * documents are plain JSON, never shared module state. + */ +export function upstreamRetryPolicy(): { + mode: 'normal' + maxRetries: number + retryableCodes: readonly string[] +} { + return { + mode: 'normal', + maxRetries: UPSTREAM_RETRY_MAX_RETRIES, + retryableCodes: [...UPSTREAM_RETRYABLE_CODES], + } +} + +/** The resolved 'llm-pi-ai' section narrowed to its provider dict. */ +function providersOf(section: unknown): Readonly> { + if (section === null || typeof section !== 'object' || Array.isArray(section)) return {} + const providers = (section as Record).providers + if (providers === null || typeof providers !== 'object' || Array.isArray(providers)) return {} + return providers as Record +} + +/** + * Provider routes whose resolved profile declares no retryPolicy — the ones + * ensureUpstreamRetry may seed. A present value (any shape) marks a + * deliberate choice and keeps the route out. With `wanted`, only those + * routes are considered — the caller names the route(s) its session + * actually uses, so dormant channels are never touched. + */ +export function routesWithoutRetryPolicy(section: unknown, wanted?: readonly string[]): readonly string[] { + const filter = wanted === undefined ? undefined : new Set(wanted) + return Object.entries(providersOf(section)) + .filter(([route, profile]) => filter === undefined || filter.has(route)) + .filter(([, profile]) => !(profile !== null && typeof profile === 'object' && 'retryPolicy' in profile)) + .map(([route]) => route) +} + +/** One settings mutation op per route (see ensureUpstreamRetry). */ +export function upstreamRetryOps(routes: readonly string[]): readonly { + op: 'set' + path: readonly string[] + value: unknown +}[] { + return routes.map(route => ({ + op: 'set' as const, + path: ['providers', route, 'retryPolicy'], + value: upstreamRetryPolicy(), + })) +} + +/** The structurally-typed settings service this module consumes. */ +interface SettingsService { + describe(): readonly { ns: string; revision: number; value?: unknown }[] + get?(ns: string): unknown + mutate( + ns: string, + ops: readonly { op: 'set'; path: readonly string[]; value: unknown }[], + expectedRevision?: number, + ): Promise +} + +/** + * Seed 'retryPolicy' (normal mode, 5 retries, transport-drop-aware codes) + * on the given llm-pi-ai routes — and only those — when their resolved + * profiles lack one, through the official settings mutation path (one + * stale-revision retry, same contract as the provider wizard). Callers + * name the route(s) the bound session actually uses, so channels nobody + * switched to are never written. Per-route once-per-process is the + * caller's guard; after a successful write the route carries the policy + * and drops out on its own. + * Best effort: no settings service, no llm-pi-ai namespace, a route the + * section does not know (a non-pi-ai adapter route), or a refused write + * all stay quiet (warn to the log on real errors). + * @param ctx - Context with the settings service (optional at runtime). + * @param notify - Channel notification for the one success line. + * @param routes - Provider routes the current session uses. + */ +export async function ensureUpstreamRetry( + ctx: Context, + notify: (text: string, options?: { color?: 'success' | 'warning' | 'error'; timeoutMs?: number }) => void, + routes: readonly string[], +): Promise { + if (routes.length === 0) return + let settings: SettingsService | undefined + try { + settings = ctx.get('settings') as SettingsService | undefined + } catch { + settings = undefined + } + if (settings === undefined || typeof settings.describe !== 'function') return + const revision = () => settings!.describe().find(row => row.ns === 'llm-pi-ai')?.revision + if (revision() === undefined) return + const missing = routesWithoutRetryPolicy(settingsValue(settings, 'llm-pi-ai'), routes) + if (missing.length === 0) return + try { + try { + await settings.mutate('llm-pi-ai', upstreamRetryOps(missing), revision()) + } catch (error) { + // One retry on a stale-revision conflict (a concurrent write landed + // between describe and mutate); anything else propagates. + if ((error as { code?: unknown })?.code !== 'SETTINGS_CONFLICT') throw error + await settings.mutate('llm-pi-ai', upstreamRetryOps(missing), revision()) + } + notify(t('upstream-retry-enabled', { routes: missing.join(', ') }), { color: 'success' }) + } catch (error) { + ctx.logger.warn(`dsh-tui: upstream retry policy could not be seeded for ${missing.join( +)} (${error instanceof Error ? error.message : String(error)})`) + } +} diff --git a/src/dsh-adapter/index.ts b/src/dsh-adapter/index.ts index aedc3dcdc..c40525d48 100644 --- a/src/dsh-adapter/index.ts +++ b/src/dsh-adapter/index.ts @@ -261,6 +261,14 @@ export interface Config { * `plan`/`sandbox`/`approval` atoms; absent → the built-in * default/plan/full cycle (see sessionModes.ts). */ modes?: SessionModeSpec[] + /** Upstream auto-retry for DSH sessions (default on): seed a retry + * policy (5 attempts, transport-drop-aware failure codes) on the + * llm-pi-ai provider route the bound session actually uses whenever + * it declares no retryPolicy, through the llm-pi-ai settings section + * — the policy the kernel's llm-retry plugin executes. Dormant + * channels are never written; routes with an explicit retryPolicy are + * never overwritten; setting this to false opts out entirely. */ + upstreamRetry?: boolean } /** The backend a configured value names: case-insensitive, trimmed, and @@ -419,6 +427,9 @@ export const Config: Schema> = editableConfig, // Edit/Write diff presentation (schema default 'auto'); the /settings // screen edits this key live through the dsh-tui namespace. diffLayout: config.diffLayout, + // Upstream auto-retry seeding (cordis.yml upstreamRetry, default on): + // only an explicit opt-out disables it. + upstreamRetry: config.upstreamRetry, thinkingFold: config.thinkingFold, jobGroupFold: config.jobGroupFold, toolBackground: config.toolBackground, diff --git a/src/i18n.ts b/src/i18n.ts index 21ad02a5a..1c48348fc 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1216,6 +1216,9 @@ const dict = { 'mode-permission-no-canonical': { zh: '模式「{{name}}」的 sandbox/approval 组合没有对应权限预设,无法安全切换', en: 'Mode "{{name}}" has no matching permission preset for its sandbox/approval combo; cannot switch safely' }, 'cmd-desc-permission': { zh: '切换权限预设(沙箱模式 + 审批策略)', en: 'Switch the permission preset (sandbox mode + approval policy)' }, + // ── channel/upstream-retry.ts — 上游断链自动重试 ────────────────────── + 'upstream-retry-enabled': { zh: '已为当前渠道 {{routes}} 启用上游断链自动重试(最多重试 5 次;cordis.yml upstreamRetry: false 可关闭)', en: 'Upstream auto-retry enabled for {{routes}} (the route in use) — up to 5 retries per request (opt out with upstreamRetry: false in cordis.yml)' }, + // ── components/LogoV2.tsx ─────────────────────────────────────────── 'logo-tagline': { zh: '探索未至之境!', en: 'Explore the uncharted!' }, // Star easter egg (bottom welcome line on ~1/20 of mounts, see diff --git a/src/permissionPrefs.ts b/src/permissionPrefs.ts new file mode 100644 index 000000000..803ef25f9 --- /dev/null +++ b/src/permissionPrefs.ts @@ -0,0 +1,61 @@ +/** + * Persisted DSH permission-preset preference (`~/.dsh-tui/permission.json`, + * `{ "permission": "danger-full-access" }`): the last permission preset the + * user switched to through `/permission` (picker or typed) or the Shift+Tab + * mode cycle, so the next session STARTS where the last one ended instead of + * the composition default. The Claude and Codex backends keep the same + * memory inside their own `~/.dsh-tui/backends//prefs.json`; this file + * is the DSH twin, consumed on the channel's first bind per session. + * + * Only the identity is stored — never sandbox/approval atoms — and it is + * re-applied exclusively through the official `/permission` switch path + * (mode-permission.ts), so the durable session log remains the source of + * truth. A value the running preset roster no longer offers is skipped, and + * an explicit `DSH_PERMISSION_MODE` deployment pin outranks the file. + * + * Best effort like the other ~/.dsh-tui preferences: a missing or corrupt + * file reads as no preference; a failed write is silent (debug log). + */ + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { isCommandCompletionToken } from './commands.js' +import { DATA_DIR } from './utils/paths.js' + +const PREFS_DIR = DATA_DIR + +/** + * The persisted permission-preset identity, or undefined when unset or + * invalid. Only safe command tokens survive (the value is re-driven through + * the official `/permission ` path, which requires one). + * @param dir - Prefs directory (injectable for tests). + * @returns The persisted preset id, if any. + */ +export function readPermissionPref(dir: string = PREFS_DIR): string | undefined { + try { + const parsed: unknown = JSON.parse(readFileSync(join(dir, 'permission.json'), 'utf8')) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined + const permission = (parsed as Record).permission + if (typeof permission !== 'string') return undefined + const id = permission.trim() + return id !== '' && isCommandCompletionToken(id) ? id : undefined + } catch { + return undefined + } +} + +/** + * Persist the chosen permission-preset identity (best effort). + * @param permission - Preset id to persist (already validated by the caller). + * @param dir - Prefs directory (injectable for tests). + * @returns True when the file was written, false on failure. + */ +export function writePermissionPref(permission: string, dir: string = PREFS_DIR): boolean { + try { + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'permission.json'), JSON.stringify({ permission }, null, 2)) + return true + } catch { + return false + } +}