dsh-bash-terminal-ts 0.7.1 → 0.7.2-compat.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.tsx CHANGED
@@ -1,35 +1,26 @@
1
- // dsh-bash-terminal-ts client plugin: a "Default terminal" preference row in the
2
- // Web UI General settings, mirroring the shipped EnterBehaviorRow grammar
3
- // (row layout, capsule selector with chevron, --dsw-* tokens).
1
+ // dsh-bash-terminal-ts client plugin (compat-legacy): a "Default terminal"
2
+ // preference row contributed to the family settings tab (dsh-family.tab,
3
+ // hosted by dsh-thinking-levels), with a shell.overlay floating-gear fallback
4
+ // for hosts without any settings surface (0.1.0/0.1.1).
4
5
  //
5
- // The row offers one entry per host shell id — powershell / gitbash / msys2 /
6
- // wsl — in the same order as SHELLS in ./index.ts.
7
- //
8
- // DSH >= 0.1.5: the browser module table (PLATFORM_MODULES) seeds react,
9
- // @deepseek-ai/cordis, @deepseek-ai/dsh-client-store, @deepseek-ai/dsh-client-ui-slots,
10
- // @deepseek-ai/dsh-client-ui-primitives and @deepseek-ai/dsh-client-ui-dockkit by
11
- // exact bare specifier. The old dsh-client-runtime name is gone from that table
12
- // (and so is its /client subpath), so this bundle requests dsh-client-store.
6
+ // compat-legacy rewiring vs upstream 0.7.1:
7
+ // - no @deepseek-ai/dsh-client-store / dsh-client-ui-primitives requires:
8
+ // 0.1.0/0.1.1 have no browser module table and 0.1.2 is unverified; the
9
+ // row reads/writes defaultShell through the host config bridge
10
+ // (GET/POST /api/bash-terminal/config) which works on every line.
11
+ // - ui-primitives Menu/Icon replaced by a native select (same capsule CSS).
12
+ // - every surface registration is independently guarded (one unknown slot
13
+ // name on one line must not take down the rest).
13
14
 
14
- import { useState } from "react";
15
- import { defineStore } from "@deepseek-ai/dsh-client-store";
16
- import { IconChevronDownOutlineMedium, Menu } from "@deepseek-ai/dsh-client-ui-primitives";
15
+ import { useEffect, useState } from "react";
17
16
 
18
17
  const SETTINGS_NS = "settings.bash-terminal";
19
- /** Entry id of this plugin in the active profile (cordis.patch.yml). */
20
- const ENTRY_ID = "tool-bash-terminal";
21
18
  const SHELLS = ["powershell", "gitbash", "msys2", "wsl"] as const;
22
19
 
23
20
  // Injected once when the browser loads the bundle (node tests guard on document).
24
21
  const ROW_CSS =
25
- ".btRow{border-bottom:1px solid var(--dsw-alias-border-l2);align-items:center;gap:8px;padding:16px 0;display:flex}" +
26
- ".btRowText{flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px;display:flex}" +
27
- ".btTitle{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}" +
28
- ".btDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}" +
29
- ".btSelector{background:var(--dsw-alias-bg-module-platform);height:36px;font:inherit;color:var(--dsw-alias-label-primary);cursor:pointer;border:none;border-radius:18px;align-items:center;gap:12px;padding:0 14px;font-size:14px;line-height:22px;display:inline-flex}" +
30
- ".btSelector:hover{background:var(--dsw-alias-interactive-bg-hover)}" +
31
- ".btChevron{flex:none}";
32
- if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"bash-terminal-row\"]") === null) {
22
+ ".btRow{border-bottom:1px solid var(--dsw-alias-border-l2);align-items:center;gap:8px;padding:16px 0;display:flex}.btRowText{flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px;display:flex}.btTitle{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}.btDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}.btSelector{background:var(--dsw-alias-bg-module-platform);height:36px;font:inherit;color:var(--dsw-alias-label-primary);cursor:pointer;border:none;border-radius:18px;align-items:center;gap:12px;padding:0 14px;font-size:14px;line-height:22px;display:inline-flex}.btSelector:hover{background:var(--dsw-alias-interactive-bg-hover)}.btGear{position:fixed;right:16px;bottom:110px;z-index:10000;width:40px;height:40px;border-radius:50%;border:1px solid rgba(127,127,127,.4);background:rgba(30,30,32,.85);color:inherit;cursor:pointer;font-size:17px;box-shadow:0 4px 16px rgba(0,0,0,.35)}.btOverlay{position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,.45)}.btPanel{position:fixed;top:6vh;left:50%;transform:translateX(-50%);width:min(760px,92vw);max-height:84vh;overflow-y:auto;z-index:10001;background:var(--dsw-alias-bg-module-platform,rgba(30,30,32,.92));border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));border-radius:14px;padding:20px;color:var(--dsw-alias-label-primary)}";
23
+ if (typeof document !== "undefined" && document.querySelector('style[data-plugin-css="bash-terminal-row"]') === null) {
33
24
  const tag = document.createElement("style");
34
25
  tag.dataset.plugin = "dsh-bash-terminal-ts";
35
26
  tag.dataset.pluginCss = "bash-terminal-row";
@@ -44,7 +35,7 @@ const zh: Record<string, string> = {
44
35
  "shell.gitbash": "Git Bash",
45
36
  "shell.msys2": "MSYS2",
46
37
  "shell.wsl": "WSL",
47
- "shell.unavailable": "设置面不可用:宿主未提供本插件的 configForms 条目(检查 cordis.patch.yml 的 entry id)"
38
+ "shell.unavailable": "设置面不可用:宿主配置桥未响应(检查宿主是否为 compat 版)"
48
39
  };
49
40
  const en: Record<string, string> = {
50
41
  "shell.title": "Default terminal",
@@ -53,7 +44,7 @@ const en: Record<string, string> = {
53
44
  "shell.gitbash": "Git Bash",
54
45
  "shell.msys2": "MSYS2",
55
46
  "shell.wsl": "WSL",
56
- "shell.unavailable": "Settings unavailable: the host exposes no configForms entry for this plugin (check the cordis.patch.yml entry id)"
47
+ "shell.unavailable": "Settings unavailable: the host config bridge did not respond (is the host a compat build?)"
57
48
  };
58
49
  const fr: Record<string, string> = {
59
50
  "shell.title": "Terminal par défaut",
@@ -62,73 +53,104 @@ const fr: Record<string, string> = {
62
53
  "shell.gitbash": "Git Bash",
63
54
  "shell.msys2": "MSYS2",
64
55
  "shell.wsl": "WSL",
65
- "shell.unavailable": "Paramètres indisponibles : l'hôte n'expose aucune entrée configForms pour ce plugin (vérifiez l'entry id du cordis.patch.yml)"
56
+ "shell.unavailable": "Paramètres indisponibles: le pont de configuration de l'hôte n'a pas répondu"
66
57
  };
67
58
  const de: Record<string, string> = {
68
- "shell.title": "Standard-Terminal",
69
- "shell.description": "Terminal, das vom Shell-Tool verwendet wird",
59
+ "shell.title": "Standardterminal",
60
+ "shell.description": "Terminal für den Shell-Befehl",
70
61
  "shell.powershell": "PowerShell",
71
62
  "shell.gitbash": "Git Bash",
72
63
  "shell.msys2": "MSYS2",
73
64
  "shell.wsl": "WSL",
74
- "shell.unavailable": "Einstellungen nicht verfügbar: Der Host stellt keinen configForms-Eintrag für dieses Plugin bereit (Entry-ID in cordis.patch.yml prüfen)"
65
+ "shell.unavailable": "Einstellungen nicht verfügbar: Die Konfigurationsbrücke des Hosts hat nicht geantwortet"
75
66
  };
76
67
  const it: Record<string, string> = {
77
68
  "shell.title": "Terminale predefinito",
78
- "shell.description": "Terminale usato dallo strumento shell",
69
+ "shell.description": "Terminale usato dal comando shell",
79
70
  "shell.powershell": "PowerShell",
80
71
  "shell.gitbash": "Git Bash",
81
72
  "shell.msys2": "MSYS2",
82
73
  "shell.wsl": "WSL",
83
- "shell.unavailable": "Impostazioni non disponibili: l'host non espone alcuna voce configForms per questo plugin (verifica l'entry id in cordis.patch.yml)"
74
+ "shell.unavailable": "Impostazioni non disponibili: il bridge di configurazione dell'host non ha risposto"
84
75
  };
85
76
  const ru: Record<string, string> = {
86
77
  "shell.title": "Терминал по умолчанию",
87
- "shell.description": "Терминал, используемый инструментом shell",
78
+ "shell.description": "Терминал для команд оболочки",
88
79
  "shell.powershell": "PowerShell",
89
80
  "shell.gitbash": "Git Bash",
90
81
  "shell.msys2": "MSYS2",
91
82
  "shell.wsl": "WSL",
92
- "shell.unavailable": "Настройки недоступны: хост не предоставляет запись configForms для этого плагина (проверьте entry id в cordis.patch.yml)"
83
+ "shell.unavailable": "Настройки недоступны: мост конфигурации хоста не ответил"
93
84
  };
94
85
  const es: Record<string, string> = {
95
- "shell.title": "Terminal predeterminado",
96
- "shell.description": "Terminal que usa la herramienta shell",
86
+ "shell.title": "Terminal predeterminada",
87
+ "shell.description": "Terminal usada por el comando shell",
97
88
  "shell.powershell": "PowerShell",
98
89
  "shell.gitbash": "Git Bash",
99
90
  "shell.msys2": "MSYS2",
100
91
  "shell.wsl": "WSL",
101
- "shell.unavailable": "Configuración no disponible: el host no expone ninguna entrada configForms para este plugin (comprueba el entry id de cordis.patch.yml)"
92
+ "shell.unavailable": "Configuración no disponible: el puente de configuración del host no respondió"
102
93
  };
103
94
 
104
- export const inject = ["slots", "locale", "configForms"];
95
+ const zhDictTitle = (): string => zh["shell.title"] ?? "Terminal";
105
96
 
106
- /** Row state snapshot served through the settings store. */
107
- interface RowState {
108
- shell: string;
109
- revision: number;
110
- writable: boolean;
111
- status: "loading" | "ready" | "unavailable";
97
+ async function bridgeConfig(): Promise<{ defaultShell?: string } | null> {
98
+ try {
99
+ const r = await fetch("/api/bash-terminal/config", {
100
+ method: "POST",
101
+ headers: { "content-type": "application/json" },
102
+ body: "{}",
103
+ });
104
+ const d = await r.json();
105
+ return d && d.ok ? (d.value as { defaultShell?: string }) : null;
106
+ } catch {
107
+ return null;
108
+ }
112
109
  }
113
110
 
114
- interface ShellPreferenceRowProps {
115
- t: (key: string) => string;
116
- useStore: <S>(selector: (state: RowState) => S) => S;
117
- setShell: (id: string) => void;
111
+ async function bridgeWrite(value: string): Promise<boolean> {
112
+ try {
113
+ const r = await fetch("/api/bash-terminal/config", {
114
+ method: "POST",
115
+ headers: { "content-type": "application/json" },
116
+ body: JSON.stringify({ defaultShell: value }),
117
+ });
118
+ const d = await r.json();
119
+ return !!(d && d.ok);
120
+ } catch {
121
+ return false;
122
+ }
118
123
  }
119
124
 
120
- function ShellPreferenceRow({ t, useStore, setShell }: ShellPreferenceRowProps) {
121
- const shell = useStore((s) => s.shell);
122
- const writable = useStore((s) => s.writable);
123
- const status = useStore((s) => s.status);
124
- const [open, setOpen] = useState(false);
125
- const items = SHELLS.map((id) => ({ id, label: t("shell." + id) }));
125
+ /** 默认终端选择行:配置读写全部走宿主 config bridge(六线统一)。 */
126
+ function ShellPreferenceRow() {
127
+ const [shell, setShell] = useState("powershell");
128
+ const [status, setStatus] = useState<"loading" | "ready" | "unavailable">("loading");
129
+ useEffect(() => {
130
+ let alive = true;
131
+ bridgeConfig().then((v) => {
132
+ if (!alive) return;
133
+ if (v && v.defaultShell) {
134
+ setShell(v.defaultShell);
135
+ setStatus("ready");
136
+ } else {
137
+ setStatus("unavailable");
138
+ }
139
+ });
140
+ return () => {
141
+ alive = false;
142
+ };
143
+ }, []);
144
+ const change = (value: string): void => {
145
+ setShell(value);
146
+ void bridgeWrite(value);
147
+ };
126
148
  if (status === "unavailable") {
127
149
  return (
128
150
  <div className="btRow">
129
151
  <div className="btRowText">
130
- <div className="btTitle">{t("shell.title")}</div>
131
- <div className="btDesc">{t("shell.unavailable")}</div>
152
+ <div className="btTitle">{zh["shell.title"]}</div>
153
+ <div className="btDesc">{zh["shell.unavailable"]}</div>
132
154
  </div>
133
155
  </div>
134
156
  );
@@ -136,114 +158,98 @@ function ShellPreferenceRow({ t, useStore, setShell }: ShellPreferenceRowProps)
136
158
  return (
137
159
  <div className="btRow">
138
160
  <div className="btRowText">
139
- <div className="btTitle">{t("shell.title")}</div>
140
- <div className="btDesc">{t("shell.description")}</div>
161
+ <div className="btTitle">{zh["shell.title"]}</div>
162
+ <div className="btDesc">{zh["shell.description"]}</div>
141
163
  </div>
142
- <Menu
143
- open={open}
144
- onClose={() => setOpen(false)}
145
- items={items}
146
- selectedId={shell}
147
- onSelect={(id) => {
148
- setOpen(false);
149
- setShell(id);
150
- }}
151
- align="end"
152
- portal
153
- anchor={
154
- <button
155
- type="button"
156
- className="btSelector"
157
- aria-haspopup="menu"
158
- aria-expanded={open}
159
- disabled={!writable}
160
- onClick={() => setOpen(!open)}
161
- style={!writable ? { opacity: 0.5, cursor: "not-allowed" } : undefined}
162
- >
163
- {t("shell." + shell)}
164
- <IconChevronDownOutlineMedium className="btChevron" />
165
- </button>
166
- }
167
- />
164
+ <select
165
+ className="btSelector"
166
+ value={shell}
167
+ onChange={(e) => change((e.target as HTMLSelectElement).value)}
168
+ >
169
+ {SHELLS.map((id) => (
170
+ <option key={id} value={id}>
171
+ {zh["shell." + id] ?? id}
172
+ </option>
173
+ ))}
174
+ </select>
168
175
  </div>
169
176
  );
170
177
  }
171
178
 
172
- interface SettingsSnapshot {
173
- status: "loading" | "ready" | "unavailable";
174
- value?: { defaultShell?: string };
175
- revision: number | undefined;
176
- writable: boolean;
177
- }
178
-
179
- interface ConfigFormBinding {
180
- getSnapshot(): SettingsSnapshot;
181
- subscribe(fn: () => void): () => void;
182
- set(field: string, value: string): Promise<boolean>;
183
- unset(field: string): Promise<boolean>;
184
- }
185
-
186
179
  interface ClientContext {
187
180
  effect(fn: () => unknown, name?: string): unknown;
188
181
  locale: { register(ns: string, dicts: Record<string, Record<string, string>>): unknown };
189
- configForms: { get(entryId: string): ConfigFormBinding };
190
182
  slots: {
191
183
  inject(slot: string, fn: () => unknown, name?: string): void;
192
184
  register(options: Record<string, unknown>, Component: unknown): Record<string, unknown>;
193
185
  };
194
186
  }
195
187
 
196
- interface RowStoreActions {
197
- sync(shell?: string, revision?: number, writable?: boolean, status?: RowState["status"]): void;
188
+ /** 无设置页宿主(0.1.0/0.1.1)兜底:浮窗齿轮 + 固定定位面板承载设置行。 */
189
+ function SettingsOverlayEntry() {
190
+ const [open, setOpen] = useState(false);
191
+ return (
192
+ <div style={{ position: "fixed", right: "16px", bottom: "110px", zIndex: 10000 }}>
193
+ {open ? <div className="btOverlay" onClick={() => setOpen(false)} /> : null}
194
+ {open ? (
195
+ <div className="btPanel">
196
+ <button
197
+ type="button"
198
+ className="btSelector"
199
+ style={{ float: "right", height: "30px", fontSize: "12px", marginBottom: "8px" }}
200
+ onClick={() => setOpen(false)}
201
+ >
202
+ ✕
203
+ </button>
204
+ <ShellPreferenceRow />
205
+ </div>
206
+ ) : null}
207
+ <button type="button" className="btGear" title={zhDictTitle()} onClick={() => setOpen(!open)}>
208
+ ⚙
209
+ </button>
210
+ </div>
211
+ );
198
212
  }
199
213
 
200
- /** 账本 label:从 zh/en 字典取「默认终端」标题(无宿主 locale 依赖)。 */
201
- const zhDictTitle = (): string => (zh as Record<string, string>)["shell.title"] ?? "终端";
202
-
203
214
  export function apply(ctx: ClientContext): void {
204
- // Single call for every locale: the registry rejects a duplicate
205
- // (namespace, locale) pair, so one bundle keeps the namespace atomic
206
- // (fr/de/it/ru/es ride the untyped dicts overload; only zh/en are built in).
207
- ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en, fr, de, it, ru, es }), "bash-terminal: settings dictionaries");
208
- const scope = ctx.configForms.get(ENTRY_ID);
209
- const store = defineStore<RowState, { sync: (draft: RowState, shell?: string, revision?: number, writable?: boolean, status?: RowState["status"]) => void }>({
210
- init: (): RowState => ({ shell: "powershell", revision: -1, writable: false, status: "loading" }),
211
- actions: {
212
- sync: (d, shell, revision, writable, status) => {
213
- if (revision !== undefined && revision <= d.revision) return;
214
- if (shell !== undefined) d.shell = shell;
215
- if (revision !== undefined) d.revision = revision;
216
- if (writable !== undefined) d.writable = writable;
217
- if (status !== undefined) d.status = status;
218
- }
215
+ const safe = (tag: string, fn: () => void): void => {
216
+ try {
217
+ fn();
218
+ } catch (e) {
219
+ console.warn(`[dsh-bash-terminal-ts] client surface '${tag}' failed:`, e);
219
220
  }
220
- });
221
- let bound: RowStoreActions | undefined;
222
- const push = (snap: SettingsSnapshot): void => {
223
- bound?.sync(snap.value?.defaultShell, snap.revision, snap.writable, snap.status);
224
221
  };
225
- // 插件族共用设置 tab(dsh-thinking-levels 顶级「起子插件设置」节声明该子席位)。
226
- // thinking-levels 缺席时本 inject 静默等待,不阻塞客户端半。
227
- ctx.slots.inject(
228
- "dsh-family.tab",
229
- () =>
230
- ctx.slots.register(
231
- {
232
- name: "dsh-family.tab",
233
- id: "bash-terminal-shell",
234
- order: 60,
235
- label: () => zhDictTitle(),
236
- store,
237
- locale: SETTINGS_NS,
238
- inject: (actions: unknown) => {
239
- bound = actions as RowStoreActions;
240
- push(scope.getSnapshot());
241
- return { setShell: (value: string) => void scope.set("defaultShell", value) };
242
- }
243
- },
244
- ShellPreferenceRow
245
- ),
246
- "bash-terminal: settings row"
247
- );
248
- ctx.effect(() => scope.subscribe(() => push(scope.getSnapshot())), "bash-terminal: settings watch");
222
+ safe("locale", () => {
223
+ ctx.effect(
224
+ () => ctx.locale.register(SETTINGS_NS, { zh, en, fr, de, it, ru, es }),
225
+ "bash-terminal: settings dictionaries",
226
+ );
227
+ });
228
+ safe("dsh-family.tab", () => {
229
+ ctx.slots.inject(
230
+ "dsh-family.tab",
231
+ () =>
232
+ ctx.slots.register(
233
+ {
234
+ name: "dsh-family.tab",
235
+ id: "bash-terminal-shell",
236
+ order: 60,
237
+ label: zhDictTitle,
238
+ locale: SETTINGS_NS,
239
+ inject: () => ({}),
240
+ },
241
+ () => <ShellPreferenceRow />,
242
+ ),
243
+ "bash-terminal: settings row",
244
+ );
245
+ });
246
+ safe("shell.overlay", () => {
247
+ ctx.slots.inject(
248
+ "shell.overlay",
249
+ () =>
250
+ ctx.slots.register({ name: "shell.overlay", id: "bash-terminal-settings" }, () => (
251
+ <SettingsOverlayEntry />
252
+ )),
253
+ );
254
+ });
249
255
  }
package/src/index.ts CHANGED
@@ -54,6 +54,8 @@ import { createTerminalRegistry, terminalTool } from "./terminal.js";
54
54
 
55
55
  /** Stable Cordis plugin name. */
56
56
  export const name = "bash-terminal";
57
+ /** Composition entry id (the `tool-bash-terminal` row the bundle patch declares). */
58
+ const ENTRY_ID = "tool-bash-terminal";
57
59
  /** Services required before the tool can register. */
58
60
  export const inject = ["tools", "systemPrompt", "shellEnv", "subprocess", "settings", "sandbox", "sandboxPolicy"];
59
61
 
@@ -677,7 +679,13 @@ export function apply(ctx: BashTerminalContext, config: Partial<ConfigValues> =
677
679
  // 0.1.7: `defaultShell` is a `.volatile()` config field — the loader hands
678
680
  // `apply` a live ref and the latest value is resolved on every read; there
679
681
  // is no settings registration call anymore (removed with the 0.1.7 host).
682
+ // compat-legacy: ≤0.1.6 的宿主没有配置热重载,bridge 写入走这个覆盖值
683
+ // (rc.1+ 上 volatile 与覆盖值同源同写,二者一致)。
684
+ let shellOverride: string | undefined;
680
685
  const currentShell = (): string => {
686
+ if (typeof shellOverride === "string" && (SHELLS as readonly string[]).includes(shellOverride)) {
687
+ return shellOverride;
688
+ }
681
689
  const v = config.defaultShell as string | VolatileRef<string> | undefined;
682
690
  if (typeof v === "string") return v;
683
691
  const got = typeof v?.get === "function" ? v.get() : undefined;
@@ -715,6 +723,103 @@ export function apply(ctx: BashTerminalContext, config: Partial<ConfigValues> =
715
723
  text: "Use the shell tool for terminal commands: it runs in the terminal the user chose in Settings -> General -> Default terminal (PowerShell, Git Bash, MSYS2, or WSL) and honors the DSH sandbox. Prefer it over the pwsh tool for everyday commands; keep the pwsh tool for cases that specifically need the sandboxed PowerShell surface."
716
724
  });
717
725
 
726
+ // compat-legacy: 六线统一配置通道(≤0.1.6 无 client-store 模块表,client 走本
727
+ // bridge 读写 defaultShell)。写路径 = settings.mutate;≤0.1.6 的 loader 不会自动
728
+ // 注册命名空间,复用 perm-gate 自门控(not registered → register → 重试);
729
+ // rc.1+ 命名空间由 loader 占用,该分支自然不命中。GET 带回当前值与可选集。
730
+ // 宿主 context 类型未收录 inject/局部服务形状,compat 桥按宽化面调用。
731
+ const compatCtx = ctx as unknown as { inject: (deps: string[], fn: (svcCtx: any) => void) => void };
732
+ compatCtx.inject(["webServer", "settings"], (svcCtx: any) => {
733
+ const svc = svcCtx.settings;
734
+ const writeShell = async (value: string): Promise<void> => {
735
+ if (typeof svc.mutate !== "function") {
736
+ // 显式降级:该线 settings 服务无 mutate(理论仅最老线),覆盖值仅本进程
737
+ // 生效;持久化请改 cordis.patch.yml 的 defaultShell。
738
+ throw new Error(
739
+ "settings service exposes no mutate on this host line - defaultShell applies for this process only; persist via cordis.patch.yml"
740
+ );
741
+ }
742
+ shellOverride = value;
743
+ try {
744
+ await svc.mutate(ENTRY_ID, [{ op: "set", path: ["defaultShell"], value }]);
745
+ } catch (e) {
746
+ const message = e instanceof Error ? e.message : String(e);
747
+ if (!/not registered/i.test(message) || typeof (svc as Record<string, unknown>).register !== "function") {
748
+ throw e;
749
+ }
750
+ console.warn(`[dsh-bash-terminal-ts] legacy line: registering settings namespace ${ENTRY_ID}`);
751
+ (svc as { register: (ns: string, schema: unknown, opts?: unknown) => unknown }).register(
752
+ ENTRY_ID,
753
+ Config,
754
+ { base: { defaultShell: value } }
755
+ );
756
+ await svc.mutate(ENTRY_ID, [{ op: "set", path: ["defaultShell"], value }]);
757
+ console.warn("[dsh-bash-terminal-ts] legacy settings namespace registered");
758
+ }
759
+ };
760
+ svcCtx.effect(
761
+ () => {
762
+ const json = (res: unknown, status: number, body: unknown): void => {
763
+ (res as { writeHead: (s: number, h: Record<string, string>) => void; end: (b: string) => void }).writeHead(status, { "content-type": "application/json; charset=utf-8" });
764
+ (res as { end: (b: string) => void }).end(JSON.stringify(body));
765
+ };
766
+ const handler = (req: any, res: any): void => {
767
+ const addr = req.socket?.remoteAddress ?? "";
768
+ if (addr !== "127.0.0.1" && addr !== "::1" && addr !== "::ffff:127.0.0.1") {
769
+ json(res, 403, { ok: false, code: "loopback-only" });
770
+ return;
771
+ }
772
+ if (req.method === "GET") {
773
+ // 缝内省:compat 验证用——当前线 ctx.subprocess 的真实能力面
774
+ const seam = ctx as unknown as { subprocess?: Record<string, unknown> };
775
+ const sp = seam.subprocess as { spawn?: unknown; spawnTerminal?: unknown } | undefined;
776
+ json(res, 200, {
777
+ ok: true,
778
+ value: {
779
+ defaultShell: currentShell(),
780
+ shells: SHELLS,
781
+ seam: { spawn: typeof sp?.spawn, spawnTerminal: typeof sp?.spawnTerminal },
782
+ },
783
+ });
784
+ return;
785
+ }
786
+ if (req.method !== "POST") {
787
+ json(res, 405, { ok: false, code: "method-not-allowed" });
788
+ return;
789
+ }
790
+ const chunks: Buffer[] = [];
791
+ let size = 0;
792
+ req.on("data", (c: Buffer) => {
793
+ size += c.length;
794
+ if (size <= 65536) chunks.push(c);
795
+ });
796
+ req.on("end", () => {
797
+ let body: { defaultShell?: unknown } = {};
798
+ try {
799
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
800
+ } catch {
801
+ json(res, 400, { ok: false, code: "bad-json" });
802
+ return;
803
+ }
804
+ const value = typeof body.defaultShell === "string" ? body.defaultShell : "";
805
+ if (!(SHELLS as readonly string[]).includes(value)) {
806
+ json(res, 400, { ok: false, code: "invalid-shell" });
807
+ return;
808
+ }
809
+ void writeShell(value)
810
+ .then(() => json(res, 200, { ok: true, value: { defaultShell: value } }))
811
+ .catch((e: unknown) => json(res, 500, { ok: false, code: "write-failed", message: e instanceof Error ? e.message : String(e) }));
812
+ });
813
+ };
814
+ const registered = [
815
+ svcCtx.webServer.register({ kind: "exact", path: "/api/bash-terminal/config", handler }),
816
+ ];
817
+ return () => {
818
+ for (const dispose of registered) dispose();
819
+ };
820
+ }, "bash-terminal: compat config bridge");
821
+ });
822
+
718
823
  const toolName = "shell";
719
824
  const terminalRegistry = createTerminalRegistry(ctx);
720
825
  ctx.tools.register(terminalTool(ctx, terminalRegistry, paths, () => currentShell()));