@linxin666/dsh-ssh 0.3.24 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/icon.svg +3 -0
- package/lib/client.js +128 -62
- package/lib/client.js.map +1 -1
- package/lib/index.js +34 -1050
- package/lib/types/client/index.d.ts +0 -13
- package/lib/types/client/settings-binding.d.ts +61 -0
- package/lib/types/client/sidebar-entry-core.d.ts +0 -32
- package/lib/types/http.d.ts +18 -1
- package/lib/types/index.d.ts +46 -13
- package/package.json +22 -19
- package/src/client/index.ts +18 -22
- package/src/client/panel/panel.module.css +30 -11
- package/src/client/settings-binding.ts +165 -0
- package/src/client/sidebar-entry-core.ts +10 -94
- package/src/client/sidebar-entry.ts +2 -2
- package/src/http.ts +23 -1
- package/src/index.ts +88 -55
|
@@ -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
|
|
122
|
-
const
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
if (options.tooltip !== undefined)
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
20
|
-
const ICON = '<svg viewBox="0 0 16 16" width="
|
|
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,
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
40
|
+
/** Plugin config as a profile patch declares it, before schema resolution. */
|
|
37
41
|
export interface Config {
|
|
38
42
|
/**
|
|
39
|
-
* When true
|
|
40
|
-
*
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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?:
|
|
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 = ():
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
//
|
|
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 &&
|
|
144
|
+
if (enabled === false && profileConfig(ctx)?.enabled === undefined && store.list().length > 0) {
|
|
94
145
|
enabled = true
|
|
95
146
|
}
|
|
96
147
|
return {
|
|
97
|
-
announceToAgent:
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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
|
}
|