@linxin666/dsh-ssh 0.3.24 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Settings reader of the dsh-ssh browser half.
3
+ *
4
+ * The plugin's browser half owns one preference: the xterm `fontFamily` the
5
+ * panel applies to its terminals. Under the 0.1.7 settings model that field
6
+ * belongs to the plugin's own profile entry (its Cordis Config schema), and
7
+ * the browser surface addresses settings as one form per profile entry id — so
8
+ * the entry has to be resolved before its section can be read:
9
+ *
10
+ * - the family binder (`ctx.get('webUiSettings')`, published while
11
+ * dsh-web-settings is loaded) resolves the dsh-ssh namespace to its entry id
12
+ * through the Host bridge and hands back the native shared form;
13
+ * - without that group the shared configuration forms serve the form directly,
14
+ * and the entry is recognised by the one field this plugin's own schema
15
+ * declares: the entry whose resolved section carries `terminalFontFamily`.
16
+ */
17
+
18
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
19
+ import type { ConfigForm, ConfigFormSnapshot, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client'
20
+
21
+ /** Domain-owned description of one settings namespace a family surface binds. */
22
+ export interface ConfigFormSpec<T> {
23
+ /** Settings namespace the surface edits. */
24
+ namespace: string
25
+ /**
26
+ * Narrow one wire section; undefined keeps the section the Host resolved.
27
+ * The shared form already validates against the namespace's serialized
28
+ * schema, so a decoder exists only to narrow beyond it.
29
+ */
30
+ decode?: (section: unknown) => T | undefined
31
+ }
32
+
33
+ declare module '@deepseek-ai/cordis' {
34
+ interface Context {
35
+ /**
36
+ * Optional family settings binder provided by dsh-web-settings; absent when
37
+ * that group plugin is not installed, so callers fall back to the shared
38
+ * configuration forms service (`ctx.configForms`).
39
+ */
40
+ webUiSettings?: { bind<S>(spec: ConfigFormSpec<S>): ConfigForm<S> }
41
+ }
42
+ }
43
+
44
+ /** The read face this plugin needs from its own settings entry. */
45
+ export interface SettingsReader<T> {
46
+ /** @returns the current snapshot (stable reference until the next change). */
47
+ getSnapshot(): ConfigFormSnapshot<T>
48
+ /** Observe snapshot replacements. */
49
+ subscribe(listener: () => void): () => void
50
+ }
51
+
52
+ /** A reader plus the release of every subscription it installed. */
53
+ export interface SettingsBinding<T> extends SettingsReader<T> {
54
+ /** Release the reader's subscriptions; later calls are no-ops. */
55
+ dispose(): void
56
+ }
57
+
58
+ /**
59
+ * The snapshot a reader reports before the Host has answered with this
60
+ * plugin's entry: nothing is readable yet, and no write is attempted.
61
+ */
62
+ const PENDING_SNAPSHOT: ConfigFormSnapshot<never> = {
63
+ status: 'loading',
64
+ value: undefined,
65
+ base: undefined,
66
+ user: undefined,
67
+ revision: undefined,
68
+ writable: false,
69
+ mode: 'host',
70
+ }
71
+
72
+ /** Whether one Host namespace section carries a field this plugin's schema declares. */
73
+ function carriesField(section: unknown, field: string): boolean {
74
+ return typeof section === 'object' && section !== null && Object.hasOwn(section, field)
75
+ }
76
+
77
+ /**
78
+ * Reader over the shared configuration forms service.
79
+ *
80
+ * The 0.1.7 surface carries no package identity, so this plugin recognises its
81
+ * own entry by the field its Config schema declares. The entry list arrives
82
+ * asynchronously with the shared describe mirror, and an id the Host has not
83
+ * answered with yet is not evidence of absence: the reader re-resolves on every
84
+ * mirror change and reports `loading` until it holds a form.
85
+ */
86
+ class SharedFormsReader<T> implements SettingsReader<T> {
87
+ private readonly listeners = new Set<() => void>()
88
+ private readonly unsubscribeMirror: () => void
89
+ private unsubscribeForm: (() => void) | undefined
90
+ private form: ConfigForm<T> | undefined
91
+ private snapshot: ConfigFormSnapshot<T> = PENDING_SNAPSHOT as ConfigFormSnapshot<T>
92
+
93
+ /**
94
+ * @param forms - the shared configuration forms service (`ctx.configForms`).
95
+ * @param field - the field this plugin's own schema declares, which is what identifies its entry.
96
+ */
97
+ constructor(private readonly forms: ConfigForms, private readonly field: string) {
98
+ this.unsubscribeMirror = forms.describe().subscribe(() => {
99
+ this.bind()
100
+ this.publish()
101
+ })
102
+ this.bind()
103
+ }
104
+
105
+ getSnapshot(): ConfigFormSnapshot<T> {
106
+ return this.snapshot
107
+ }
108
+
109
+ subscribe(listener: () => void): () => void {
110
+ this.listeners.add(listener)
111
+ return () => {
112
+ this.listeners.delete(listener)
113
+ }
114
+ }
115
+
116
+ /** Release the mirror and form subscriptions and drop every listener. */
117
+ dispose(): void {
118
+ this.unsubscribeMirror()
119
+ this.unsubscribeForm?.()
120
+ this.unsubscribeForm = undefined
121
+ this.listeners.clear()
122
+ }
123
+
124
+ /** Bind the entry form once the shared mirror names this plugin's entry. */
125
+ private bind(): void {
126
+ if (this.form !== undefined) return
127
+ const namespaces = this.forms.describe().getSnapshot().view?.namespaces
128
+ const entry = namespaces?.find(candidate => carriesField(candidate.value, this.field))
129
+ if (entry === undefined) return
130
+ const form = this.forms.get<T>(entry.ns)
131
+ // A service that answers no form for its own served entry leaves the reader
132
+ // unbound; it retries on the next mirror answer rather than failing a boot.
133
+ if (form === undefined) return
134
+ this.form = form
135
+ this.snapshot = form.getSnapshot()
136
+ this.unsubscribeForm = form.subscribe(() => {
137
+ this.snapshot = form.getSnapshot()
138
+ this.publish()
139
+ })
140
+ }
141
+
142
+ private publish(): void {
143
+ for (const listener of this.listeners) listener()
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Bind this plugin's settings reader.
149
+ * @param ctx - client context carrying the family binder and/or the shared forms service.
150
+ * @param namespace - the family settings namespace this plugin owns.
151
+ * @param field - the field this plugin's own schema declares, used to recognise its entry when the family binder is absent.
152
+ * @returns the reader and the release of its subscriptions.
153
+ */
154
+ export function bindSettingsReader<T>(ctx: ClientContext, namespace: string, field: string): SettingsBinding<T> {
155
+ const family = ctx.get('webUiSettings')
156
+ if (family !== undefined) {
157
+ const form = family.bind<T>({ namespace })
158
+ return {
159
+ getSnapshot: () => form.getSnapshot(),
160
+ subscribe: listener => form.subscribe(listener),
161
+ dispose: () => {},
162
+ }
163
+ }
164
+ return new SharedFormsReader<T>(ctx.configForms, field)
165
+ }
@@ -16,33 +16,6 @@
16
16
  */
17
17
  import { subscribeBodyInvalidations } from './body-mutations.ts'
18
18
 
19
- /**
20
- * One trailing action button seated at the entry row's right edge (e.g. a
21
- * refresh command or a collapse chevron). Rows with actions switch from the
22
- * classic single-button structure to a container with a main button plus the
23
- * action buttons (nested interactive elements are invalid HTML); rows without
24
- * actions keep the classic structure untouched.
25
- */
26
- export interface SidebarEntryAction {
27
- /** Stable id stamped as data-dsh-entry-action="<id>" (CSS hooks and tests). */
28
- id: string
29
- /** Inline icon markup while the row is active (always, when inactiveIcon is unset). */
30
- icon: string
31
- /**
32
- * Optional icon while the row is inactive. Setting it makes the action a
33
- * state mirror (a collapse chevron): the core swaps the icon and stamps
34
- * aria-expanded on every active-state change. Requires the active bridge.
35
- */
36
- inactiveIcon?: string
37
- /** Localized accessible label (aria-label + title); receives the active state. */
38
- label(active: boolean): string
39
- /**
40
- * Click handler; receives the button so the caller can drive transient
41
- * affordances (a refresh cooldown) without a re-render.
42
- */
43
- onClick(button: HTMLButtonElement): void
44
- }
45
-
46
19
  /** Per-package configuration for one sidebar entry row. */
47
20
  export interface SidebarEntryOptions {
48
21
  /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
@@ -74,12 +47,6 @@ export interface SidebarEntryOptions {
74
47
  refresh?: { subscribe(listener: () => void): () => void }
75
48
  /** Click action (open/toggle the owning panel). */
76
49
  onToggle(): void
77
- /**
78
- * Optional trailing action buttons at the row's right edge. Requires the
79
- * package CSS to seat entryMain / entryAction; empty keeps the classic
80
- * single-button row.
81
- */
82
- actions?: readonly SidebarEntryAction[]
83
50
  /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
84
51
  position: 'before' | 'after'
85
52
  /**
@@ -118,11 +85,9 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
118
85
  }
119
86
 
120
87
  /** Build the entry row (detached; inserted once the shell is up). */
121
- function createEntry(options: SidebarEntryOptions): { entry: HTMLElement; applyLabel: () => void; setOpen: (open: boolean) => void } {
122
- const actions = options.actions ?? []
123
- const composite = actions.length > 0
124
- const entry = document.createElement(composite ? 'div' : 'button') as HTMLElement
125
- if (!composite) (entry as HTMLButtonElement).type = 'button'
88
+ function createEntry(options: SidebarEntryOptions): { entry: HTMLElement; applyLabel: () => void } {
89
+ const entry = document.createElement('button')
90
+ entry.type = 'button'
126
91
  entry.setAttribute(options.rowAttribute, '')
127
92
  if (options.plugin !== undefined) {
128
93
  entry.setAttribute('data-dsh-plugin', options.plugin)
@@ -134,62 +99,15 @@ function createEntry(options: SidebarEntryOptions): { entry: HTMLElement; applyL
134
99
  const iconSpan = document.createElement('span')
135
100
  iconSpan.className = options.css['entryIcon'] ?? ''
136
101
  iconSpan.innerHTML = options.icon
137
- // The main hit area: the classic row IS the button; with trailing actions
138
- // the row becomes a container and the main area its own button, so the row
139
- // never nests interactive elements.
140
- const main = composite ? document.createElement('button') : (entry as HTMLButtonElement)
141
- if (composite) {
142
- main.type = 'button'
143
- main.className = options.css['entryMain'] ?? ''
144
- main.append(iconSpan, labelSpan)
145
- entry.append(main)
146
- } else {
147
- entry.append(iconSpan, labelSpan)
148
- }
149
- const actionButtons: { action: SidebarEntryAction; button: HTMLButtonElement }[] = []
150
- for (const action of actions) {
151
- const button = document.createElement('button')
152
- button.type = 'button'
153
- button.className = options.css['entryAction'] ?? ''
154
- button.setAttribute('data-dsh-entry-action', action.id)
155
- button.innerHTML = action.icon
156
- button.addEventListener('click', () => { action.onClick(button) })
157
- entry.append(button)
158
- actionButtons.push({ action, button })
159
- }
160
- let open = false
161
- const applyActions = (): void => {
162
- for (const { action, button } of actionButtons) {
163
- button.innerHTML = open || action.inactiveIcon === undefined ? action.icon : action.inactiveIcon
164
- const text = action.label(open)
165
- button.setAttribute('aria-label', text)
166
- button.setAttribute('title', text)
167
- if (action.inactiveIcon !== undefined) button.setAttribute('aria-expanded', String(open))
168
- }
169
- }
102
+ entry.append(iconSpan, labelSpan)
170
103
  const applyLabel = (): void => {
171
- main.setAttribute('aria-label', options.label())
172
- if (options.tooltip !== undefined) main.setAttribute('title', options.tooltip())
104
+ entry.setAttribute('aria-label', options.label())
105
+ if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
173
106
  labelSpan.textContent = options.label()
174
- applyActions()
175
107
  }
176
108
  applyLabel()
177
- main.addEventListener('click', options.onToggle)
178
- if (composite) {
179
- // The old row was clickable edge to edge; keep the padding between the
180
- // main area and the action buttons a toggle target as well.
181
- entry.addEventListener('click', (event) => {
182
- if (event.target === entry) options.onToggle()
183
- })
184
- }
185
- return {
186
- entry,
187
- applyLabel,
188
- setOpen: (next: boolean) => {
189
- open = next
190
- applyActions()
191
- },
192
- }
109
+ entry.addEventListener('click', options.onToggle)
110
+ return { entry, applyLabel }
193
111
  }
194
112
 
195
113
  /** Re-insert the entry after the New Session row (before the browser region). */
@@ -231,7 +149,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
231
149
  if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
232
150
  return () => {}
233
151
  }
234
- const { entry, applyLabel, setOpen } = createEntry(options)
152
+ const { entry, applyLabel } = createEntry(options)
235
153
  let root: HTMLElement | undefined
236
154
  let placed = false
237
155
  let unsubscribeRefresh: (() => void) | undefined
@@ -299,10 +217,8 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
299
217
  // row permanently highlighted — delete the attribute instead.
300
218
  const unsubscribeActive = options.active === undefined ? undefined : (() => {
301
219
  const syncActive = (): void => {
302
- const open = options.active!.isOpen()
303
- if (open) entry.dataset.active = 'true'
220
+ if (options.active!.isOpen()) entry.dataset.active = 'true'
304
221
  else delete entry.dataset.active
305
- setOpen(open)
306
222
  }
307
223
  const unsubscribe = options.active.subscribe(syncActive)
308
224
  syncActive()
@@ -16,8 +16,8 @@ import { mountSidebarEntry as mountSharedSidebarEntry } from './sidebar-entry-co
16
16
  /** Stable data attribute identifying the injected entry row. */
17
17
  export const ENTRY_SELECTOR = '[data-dsh-ssh-entry]'
18
18
 
19
- /** Inline terminal glyph sized to the shell's current sidebar navigation icons. */
20
- const ICON = '<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.25" width="12.5" height="11.5" rx="1.75"/><path d="M4.25 5.25l2.75 2.75-2.75 2.75"/><path d="M8.5 10.75h3.25"/></svg>'
19
+ /** Inline terminal glyph sized to the shell's panel-row navigation icons. */
20
+ const ICON = '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.25" width="12.5" height="11.5" rx="1.75"/><path d="M4.25 5.25l2.75 2.75-2.75 2.75"/><path d="M8.5 10.75h3.25"/></svg>'
21
21
 
22
22
  /** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
23
23
  export interface LocaleRefreshSource { subscribe(listener: () => void): () => void }
package/src/http.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  /**
3
3
  * Shared JSON body/response helpers for the host route families: one strict
4
4
  * bounded body reader, one lenient bounded body reader, one JSON object
5
- * narrow, and one JSON writer. Previously these were copy-pasted across the
5
+ * narrow, one JSON writer, and the outbound request-init helper that keeps a
6
+ * host fetch body decodable (withIdentityEncoding). Previously the readers
7
+ * were copy-pasted across the
6
8
  * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
7
9
  * family's route module) with drifting contracts: body caps ranging 4 KiB to
8
10
  * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
@@ -88,6 +90,26 @@ export function asJsonObject(value: unknown): Record<string, unknown> | undefine
88
90
  return isJsonObject(value) ? value : undefined
89
91
  }
90
92
 
93
+ /**
94
+ * Request init that asks a remote origin for an uncompressed body.
95
+ *
96
+ * The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
97
+ * the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
98
+ * Node's built-in `fetch()` reads. That cross-major wrapper drops the
99
+ * `content-encoding` header and automatic decompression, so a plain fetch
100
+ * resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
101
+ * consumer can read. Every host fetch that parses a remote body must request
102
+ * identity encoding. Caller headers survive; `accept-encoding` is forced to
103
+ * `identity` because a caller value has no decoder behind it in this host.
104
+ * @param init - the caller's request init (signal, headers, method, ...).
105
+ * @returns a copy with `accept-encoding: identity` merged in.
106
+ */
107
+ export function withIdentityEncoding(init: RequestInit = {}): RequestInit {
108
+ const headers = new Headers(init.headers)
109
+ headers.set('accept-encoding', 'identity')
110
+ return { ...init, headers }
111
+ }
112
+
91
113
  /**
92
114
  * Write one JSON response. Default headers are the family defaults
93
115
  * (content-type and referrer-policy); caller headers are appended or
package/src/index.ts CHANGED
@@ -8,9 +8,8 @@
8
8
  * no dsh source changes.
9
9
  */
10
10
 
11
- import type { Context } from '@deepseek-ai/cordis'
12
- import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
13
- import z from 'schemastery'
11
+ import type { Context, Fiber } from '@deepseek-ai/cordis'
12
+ import z from '@deepseek-ai/schemastery'
14
13
  import type {} from '@deepseek-ai/dsh-host-webserver'
15
14
  import type {} from '@deepseek-ai/dsh-system-prompt'
16
15
  import type {} from '@deepseek-ai/dsh-tools'
@@ -26,18 +25,23 @@ export const name = 'ssh'
26
25
  /** Services required before the SSH surfaces can mount. */
27
26
  export const inject = ['webServer', 'tools', 'systemPrompt']
28
27
 
29
- /**
30
- * Settings namespace of the SSH capability — the section the web settings
31
- * surface edits. Spelled here rather than imported: the browser half spells
32
- * the same value and must not depend on a Host package.
33
- */
34
- export const SSH_SETTINGS_NAMESPACE = 'dsh-ssh' as SettingsNamespace
28
+ declare module '@deepseek-ai/cordis' {
29
+ interface Events {
30
+ /**
31
+ * Volatile config values were committed into the running instance without a
32
+ * remount (cordis-plugin-loader); dispatched to the owning fiber only.
33
+ * @param paths - the changed config paths, as key arrays.
34
+ * @mode emit
35
+ */
36
+ 'loader/volatile-update'(paths: readonly (readonly string[])[]): void
37
+ }
38
+ }
35
39
 
36
- /** Plugin config, validated by the same-named schemastery schema. */
40
+ /** Plugin config as a profile patch declares it, before schema resolution. */
37
41
  export interface Config {
38
42
  /**
39
- * When true (default), a system-prompt section announces the SSH plugin to
40
- * every agent (tools + host store). Set false to keep it silent.
43
+ * When true, a system-prompt section announces the SSH plugin to every agent
44
+ * (tools + host store). Off by default to keep prompts clean.
41
45
  */
42
46
  announceToAgent?: boolean
43
47
  /** Master switch for the plugin (routes, tools, prompt section). */
@@ -51,14 +55,67 @@ export interface Config {
51
55
  terminalFontFamily?: string
52
56
  }
53
57
 
54
- export const Config: z<Config> = z.object({
55
- announceToAgent: z.boolean().default(false),
56
- enabled: z.boolean().default(true),
57
- terminalFontFamily: z.string().default(''),
58
+ /** The stable reference a `volatile()` config field resolves to; its owner updates it in place. */
59
+ interface ConfigRef<T> {
60
+ /** @returns the field's current value. */
61
+ get(): T
62
+ }
63
+
64
+ /** One resolved config field: a live reference, or a plain value from a hand-built context. */
65
+ type ConfigField<T> = ConfigRef<T> | T
66
+
67
+ /** The config the Host hands to {@link apply} — the runtime face of {@link Config}. */
68
+ export interface ResolvedConfig {
69
+ announceToAgent?: ConfigField<boolean>
70
+ enabled?: ConfigField<boolean>
71
+ terminalFontFamily?: ConfigField<string>
72
+ }
73
+
74
+ /**
75
+ * Plugin config schema. Under the 0.1.7 settings model this schema IS the
76
+ * entry's settings page: the Host derives one form per profile entry from it
77
+ * and serves it through the shared configuration forms. Every field is
78
+ * `volatile()`, which is what puts it on that page and what lets an edit reach
79
+ * a running instance without a remount: the loader commits the new value into
80
+ * the field's reference and announces `loader/volatile-update` on this fiber.
81
+ */
82
+ export const Config = z.object({
83
+ announceToAgent: z.boolean().default(false).volatile(),
84
+ enabled: z.boolean().default(true).volatile(),
85
+ terminalFontFamily: z.string().default('').volatile(),
58
86
  })
59
87
 
60
- /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
88
+ /** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
61
89
  const DEFAULT_ANNOUNCE = false
90
+ const DEFAULT_ENABLED = true
91
+
92
+ /** Read one resolved config field, following the live reference the schema produces. */
93
+ function readConfigField<T>(field: ConfigField<T> | undefined, fallback: T): T {
94
+ if (field === undefined) return fallback
95
+ if (typeof field === 'object' && field !== null && typeof (field as ConfigRef<T>).get === 'function') {
96
+ const value = (field as ConfigRef<T>).get()
97
+ return value === undefined ? fallback : value
98
+ }
99
+ return field as T
100
+ }
101
+
102
+ /**
103
+ * The loader's own record of the profile entry this instance was activated
104
+ * from, which carries the row's raw config — the user's own declaration, as
105
+ * opposed to the schema defaults and the inherited bundle layers. Absent on a
106
+ * host without the loader, which reads as "the profile declares nothing".
107
+ */
108
+ interface LoaderEntry {
109
+ options?: { config?: unknown }
110
+ }
111
+
112
+ /** Raw config the profile declares for this entry, when the loader exposes it. */
113
+ function profileConfig(ctx: Context): Record<string, unknown> | undefined {
114
+ const entry = (ctx.fiber as Fiber & { entry?: LoaderEntry }).entry
115
+ const config = entry?.options?.config
116
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) return undefined
117
+ return config as Record<string, unknown>
118
+ }
62
119
 
63
120
  /** Order of the announcement section within the tool-guidance band. */
64
121
  const SECTION_ORDER = 150
@@ -73,28 +130,22 @@ export const SSH_GUIDANCE = '本机已安装 dsh-ssh 插件(DSH 远程 SSH 运
73
130
  */
74
131
  export const apply = mountOnce('@linxin666/dsh-ssh', applyImpl)
75
132
 
76
- function applyImpl(ctx: Context, config?: Config): void {
77
- // The live source the surfaces read: the settings section once the web
78
- // settings surface is served, the composition entry otherwise.
79
- let current: () => Config = () => config ?? {}
80
- let isSettingsBound = false
81
-
133
+ function applyImpl(ctx: Context, config?: ResolvedConfig): void {
82
134
  const store = new HostStore()
83
135
  const engine = new SshEngine(store)
84
136
  ctx.effect(() => () => { engine.dispose() }, 'dsh-ssh: engine')
85
137
 
86
- const resolve = (): Config => {
87
- const value = current()
88
- let enabled = value.enabled ?? true
89
- // When dsh-ssh was seeded with enabled: false (e.g. from an aggregate profile line),
90
- // but the user has never explicitly configured settings (not bound yet)
91
- // AND already has active host records in dsh-ssh.json, keep the plugin enabled so
138
+ const resolve = (): { announceToAgent: boolean; enabled: boolean } => {
139
+ let enabled = readConfigField(config?.enabled, DEFAULT_ENABLED)
140
+ // When dsh-ssh was seeded with enabled: false (e.g. from an aggregate profile
141
+ // line), but the profile's own entry config never set the switch AND the user
142
+ // already has active host records in dsh-ssh.json, keep the plugin enabled so
92
143
  // existing users are not broken upon upgrade (#1250).
93
- if (enabled === false && !isSettingsBound && store.list().length > 0) {
144
+ if (enabled === false && profileConfig(ctx)?.enabled === undefined && store.list().length > 0) {
94
145
  enabled = true
95
146
  }
96
147
  return {
97
- announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
148
+ announceToAgent: readConfigField(config?.announceToAgent, DEFAULT_ANNOUNCE),
98
149
  enabled,
99
150
  }
100
151
  }
@@ -117,7 +168,7 @@ function applyImpl(ctx: Context, config?: Config): void {
117
168
  // System-prompt announcement.
118
169
  let disposeSection: (() => void) | undefined
119
170
 
120
- // Register (or drop) every surface to match the current source. Each group
171
+ // Register (or drop) every surface to match the resolved config. Each group
121
172
  // is kept under one disposer: re-registering first tears the old one down
122
173
  // so duplicate-name registrations never throw.
123
174
  const sync = (): void => {
@@ -162,29 +213,11 @@ function applyImpl(ctx: Context, config?: Config): void {
162
213
  )
163
214
  }
164
215
 
165
- ctx.inject(['settings'], (settingsCtx) => {
166
- try {
167
- if (typeof settingsCtx.settings?.installSection === 'function') {
168
- settingsCtx.settings.installSection(ctx, SSH_SETTINGS_NAMESPACE, Config, config ?? {}, {
169
- setSource: (source) => {
170
- isSettingsBound = true
171
- current = source
172
- sync()
173
- },
174
- onChange: sync,
175
- })
176
- } else if (typeof settingsCtx.settings?.register === 'function') {
177
- const scope = settingsCtx.settings.register(SSH_SETTINGS_NAMESPACE, Config, { base: config ?? {} })
178
- isSettingsBound = true
179
- current = () => scope?.get?.() ?? (config ?? {})
180
- scope?.watch?.(() => { sync() })
181
- }
182
- } catch {
183
- // Defensive fallback against settings registration differences
184
- }
185
- })
216
+ // A settings edit is committed into this instance's config references and
217
+ // announced on the owning fiber (the entry is not remounted), so the
218
+ // surfaces are re-derived from the new values here.
219
+ ctx.on('loader/volatile-update', () => { sync() })
186
220
 
187
- // Initial registration from the composition entry (covers deployments with
188
- // no settings service, whose installSection never fires its hooks).
221
+ // Initial registration from the config the Host activated this row with.
189
222
  sync()
190
223
  }