dsh-ssh-workbench 1.6.4 → 1.7.8

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/lib/index.js CHANGED
@@ -1,35 +1,34 @@
1
1
  /**
2
2
  * dsh-ssh-workbench host half.
3
3
  *
4
- * Remote execution is deliberately not a second toolbox: on POSIX Hosts the
5
- * plugin gives DSH's own `bash` tool a remote execution world, and when a
6
- * Session has no active managed target every tool behaves exactly as shipped.
7
- * Windows Hosts ship no `bash` tool at all (DSH disables it by platform), so
8
- * there the plugin registers the standalone `remote_bash` tool instead. A
9
- * single `ssh_workbench_target` tool switches the active target, and a synchronous
10
- * `ctx.tools.guard()` blacklist is the fallback safety net.
4
+ * Local and remote execution are two separate worlds. The native shell tools
5
+ * always run on the local DSH Host; remote work always names its target on the
6
+ * tool call (`remote_bash`, `remote_pwsh`, `remote_exec`). There is no
7
+ * session-scoped target state and no native shell interception, so a follow-up
8
+ * without a new `@mention` can never move a command silently to a different
9
+ * machine. A synchronous `ctx.tools.guard()` blacklist is the fallback safety
10
+ * net for every explicit remote target.
11
11
  */
12
12
  import os from 'node:os'
13
13
  import path from 'node:path'
14
14
  import z from '@deepseek-ai/schemastery'
15
- import { defineTool } from '@deepseek-ai/dsh-tools'
16
15
 
17
16
  import { loadServers, saveServers } from './core/store.js'
18
- import { loadPrefs, savePrefs } from './core/prefs.js'
17
+ import { loadPrefs, savePrefs, resolveRemoteFileWrite, resolveSystemFileHierarchy } from './core/prefs.js'
19
18
  import { createTerminalHub } from './core/terminals.js'
20
19
  import { createLocalFs } from './core/localfs.js'
21
20
  import { createSecretStore } from './core/secrets.js'
22
21
  import { createKnownHosts } from './core/known-hosts.js'
23
22
  import { createSshRuntime } from './core/ssh.js'
24
23
  import { createActivityStore } from './core/activity.js'
25
- import { createTargetStore, TARGET_TOOL_NAME, LOCAL_TARGET } from './core/targets.js'
24
+ import { createTargetStore } from './core/mentions.js'
26
25
  import { createBlacklistGuard } from './core/guard.js'
27
- import { createRemoteBashRouter } from './core/remote-bash.js'
28
- import { createRemoteShellTool } from './core/remote-bash-tool.js'
29
- import { posixAdapter, win32Adapter } from './core/shell-adapters.js'
30
- import { effectiveDialect } from './core/dialect.js'
31
- import { hostPlatform } from './core/shell-adapters.js'
32
- import { installTargetContext } from './core/server-reference.js'
26
+ import { createRemoteShellTool } from './core/remote-shell-tool.js'
27
+ import { createRemoteExecTool } from './core/remote-exec-tool.js'
28
+ import { createRemoteFileTools } from './core/remote-file-tools.js'
29
+ import { createLiveExecutions } from './core/live-executions.js'
30
+ import { posixAdapter, win32Adapter, hostPlatform } from './core/shell-adapters.js'
31
+ import { installManagedContext } from './core/managed-context.js'
33
32
  import { createRoutes, registerRoutes } from './routes.js'
34
33
 
35
34
  export const name = 'dsh-ssh-workbench'
@@ -50,6 +49,22 @@ export const Config = z.object({
50
49
  // stderr is capped separately so one chatty failing command cannot flood the
51
50
  // model context (stdout keeps maxOutputBytes).
52
51
  maxStderrBytes: z.natural().default(16384),
52
+ // `remote_exec` fan-out: tasks per call, concurrent SSH channels, and the
53
+ // total stdout/stderr budget for one batch (shared across all tasks).
54
+ maxBatchTasks: z.natural().default(8),
55
+ batchConcurrency: z.natural().default(4),
56
+ maxBatchOutputBytes: z.natural().default(262144),
57
+ // Explicit remote file tools: read/glob/grep are always available; write and
58
+ // edit stay disabled until an operator opts in. All sizes are bytes.
59
+ remoteFileReadBytes: z.natural().default(262144),
60
+ remoteFileEditBytes: z.natural().default(2097152),
61
+ remoteFileWrite: z.boolean().default(false),
62
+ // Replaces the native right-sidebar files page with the hierarchical local
63
+ // browser when enabled. Off by default so DSH's own file page is untouched.
64
+ systemFileHierarchy: z.boolean().default(false),
65
+ remoteGlobMaxResults: z.natural().default(200),
66
+ remoteGlobMaxDepth: z.natural().default(4),
67
+ remoteGrepMaxResults: z.natural().default(200),
53
68
  // A stalled SFTP chunk (half-open connection) fails after this long instead
54
69
  // of hanging the file panel; the client can resume from its offset.
55
70
  transferChunkTimeoutMs: z.natural().default(30000),
@@ -60,8 +75,8 @@ export const Config = z.object({
60
75
  maxChannelsPerServer: z.natural().default(6),
61
76
  // Warm a server connection when the user selects it in the UI.
62
77
  prewarm: z.boolean().default(true),
63
- // Inject the `@server` target context into the step and bind the session's
64
- // remote target. Disable to keep every native tool strictly local.
78
+ // Inject the managed-resource context into every human turn. Disable only
79
+ // for a deliberately remote-blind session.
65
80
  targetContext: z.boolean().default(true),
66
81
  // Root of the workbench's local side (local terminal cwd + local file
67
82
  // panel). Empty = the OS user home directory, matching the local pane of
@@ -77,6 +92,16 @@ const APPLY_DEFAULTS = {
77
92
  timeoutKillAfterMs: 5000,
78
93
  maxOutputBytes: 102400,
79
94
  maxStderrBytes: 16384,
95
+ maxBatchTasks: 8,
96
+ batchConcurrency: 4,
97
+ maxBatchOutputBytes: 262144,
98
+ remoteFileReadBytes: 262144,
99
+ remoteFileEditBytes: 2097152,
100
+ remoteFileWrite: false,
101
+ systemFileHierarchy: false,
102
+ remoteGlobMaxResults: 200,
103
+ remoteGlobMaxDepth: 4,
104
+ remoteGrepMaxResults: 200,
80
105
  transferChunkTimeoutMs: 30000,
81
106
  hostKeyMode: 'strict',
82
107
  connectionPool: true,
@@ -96,7 +121,7 @@ const textOut = {
96
121
  export { APPLY_DEFAULTS }
97
122
 
98
123
  /** Public settings safe to show in the Client settings/state page. */
99
- export function publicConfig(config) {
124
+ export function publicConfig(config, prefs) {
100
125
  return {
101
126
  hostKeyMode: config.hostKeyMode,
102
127
  connectTimeoutMs: config.connectTimeoutMs,
@@ -105,6 +130,16 @@ export function publicConfig(config) {
105
130
  timeoutKillAfterMs: config.timeoutKillAfterMs,
106
131
  maxOutputBytes: config.maxOutputBytes,
107
132
  maxStderrBytes: config.maxStderrBytes,
133
+ maxBatchTasks: config.maxBatchTasks,
134
+ batchConcurrency: config.batchConcurrency,
135
+ maxBatchOutputBytes: config.maxBatchOutputBytes,
136
+ remoteFileReadBytes: config.remoteFileReadBytes,
137
+ remoteFileEditBytes: config.remoteFileEditBytes,
138
+ remoteFileWrite: resolveRemoteFileWrite(config, prefs),
139
+ systemFileHierarchy: resolveSystemFileHierarchy(config, prefs),
140
+ remoteGlobMaxResults: config.remoteGlobMaxResults,
141
+ remoteGlobMaxDepth: config.remoteGlobMaxDepth,
142
+ remoteGrepMaxResults: config.remoteGrepMaxResults,
108
143
  transferChunkTimeoutMs: config.transferChunkTimeoutMs,
109
144
  connectionPool: config.connectionPool !== false,
110
145
  poolIdleMs: config.poolIdleMs,
@@ -113,90 +148,8 @@ export function publicConfig(config) {
113
148
  }
114
149
  }
115
150
 
116
- /**
117
- * The only model-facing tool this plugin registers. It never executes a
118
- * command; it selects the managed server the native `bash` tool runs on.
119
- */
120
- export function createTargetTool({
121
- targets,
122
- loadInventory,
123
- onActivated,
124
- remoteToolName = 'bash',
125
- windowsToolName = 'remote_pwsh',
126
- hostPlatform: hostOs = 'posix',
127
- }) {
128
- const description =
129
- hostOs === 'win32'
130
- ? `Select the managed remote resource (server or VM) that this session's remote shell tools run on. Pass a managed resource name or id, or "local" to return to the local DSH host. Only one target is active at a time; the native pwsh tool and read/write/edit/glob/grep always run on the local DSH host. Remote POSIX targets run through ${remoteToolName}; Windows targets run through ${windowsToolName}.`
131
- : `Select the managed remote resource (server or VM) that this session's remote shell runs on. Pass a managed resource name or id, or "local" to return to the local DSH host. Only one target is active at a time; read/write/edit/glob/grep always run on the local DSH host. POSIX targets run through the native ${remoteToolName} tool; Windows targets run through ${windowsToolName}.`
132
- return defineTool({
133
- name: TARGET_TOOL_NAME,
134
- description,
135
- parameters: {
136
- server: { type: 'string', required: true, description: `Managed resource name or id, or "${LOCAL_TARGET}" to detach` },
137
- },
138
- output: textOut,
139
- async execute(args, exec) {
140
- const session = exec?.agent?.session
141
- const key = session?.id ?? exec?.agent?.id
142
- if (typeof key !== 'string' || key.length === 0) {
143
- throw new Error('ssh_workbench_target requires an agent session')
144
- }
145
- const servers = loadInventory()
146
- const outcome = targets.setActive(key, String(args.server ?? '').trim(), servers)
147
- if (!outcome.ok) {
148
- const available = servers
149
- .filter((server) => server.enabled !== false)
150
- .slice(0, 20)
151
- .map((server) => server.name)
152
- .join(', ')
153
- throw new Error(`${outcome.error}. Available targets: ${available || '(none)'}; or "${LOCAL_TARGET}".`)
154
- }
155
- const state = outcome.state
156
- if (!state.active) {
157
- return { text: `active target: ${LOCAL_TARGET} — ${localShellSentence(hostOs)}.` }
158
- }
159
- const server = servers.find((entry) => entry.name === state.active)
160
- const target = server ? `${server.username}@${server.host}:${server.port}` : state.active
161
- // Probing the dialect before answering makes the next injected context
162
- // name the right tool. It is bounded: a slow/unreachable host must not
163
- // stall target selection.
164
- let dialect = server ? effectiveDialect(server) : undefined
165
- if (server && typeof onActivated === 'function') {
166
- try {
167
- const facts = await Promise.race([
168
- Promise.resolve().then(() => onActivated(server)),
169
- new Promise((resolve) => {
170
- const timer = setTimeout(resolve, 4000)
171
- timer.unref?.()
172
- }),
173
- ])
174
- if (facts?.dialect) dialect = facts.dialect
175
- } catch {
176
- // Keep the record hint.
177
- }
178
- }
179
- if (dialect === 'windows') {
180
- return {
181
- text: `active target: ${state.active} (${target}); ${windowsToolName} runs PowerShell commands there; read/write/edit/glob/grep run on the local DSH host.`,
182
- }
183
- }
184
- return {
185
- text: `active target: ${state.active} (${target}); ${remoteToolName} runs remotely on this resource; read/write/edit/glob/grep run on the local DSH host.`,
186
- }
187
- },
188
- })
189
- }
190
-
191
- /** One sentence describing where the native shell tool runs on this Host. */
192
- function localShellSentence(hostOs) {
193
- return hostOs === 'win32'
194
- ? 'the native pwsh tool runs on the local DSH host; no remote target is selected'
195
- : 'bash runs on the local DSH host'
196
- }
197
-
198
151
  /** Expand a configured `~` prefix; relative/invalid values are left for the caller. */
199
- export function expandLocalRoot(value) {
152
+ function expandLocalRoot(value) {
200
153
  const raw = typeof value === 'string' ? value.trim() : ''
201
154
  if (!raw) return ''
202
155
  return raw === '~'
@@ -228,12 +181,11 @@ export function resolveLocalRoot(config = {}) {
228
181
  */
229
182
  export function apply(ctx, config, deps = {}) {
230
183
  config = { ...APPLY_DEFAULTS, ...(config ?? {}) }
231
- // Hosts ship different native shell tools: DSH disables `bash` on win32 and
232
- // `pwsh` elsewhere. The remote tool therefore depends on the Host, not on a
233
- // global assumption; tests can pin it through deps.hostPlatform.
184
+ // The native shell tool differs per Host, but remote tools are explicit:
185
+ // both dialect tools are registered on both Hosts.
234
186
  const hostOs = hostPlatform(deps.hostPlatform ?? process.platform)
235
187
  const isWindowsHost = hostOs === 'win32'
236
- const remoteToolName = isWindowsHost ? 'remote_bash' : 'bash'
188
+ const localShellName = isWindowsHost ? 'pwsh' : 'bash'
237
189
  let remoteAvailable = false
238
190
  if (config.hostKeyMode !== 'strict' && config.hostKeyMode !== 'accept-new') {
239
191
  ctx.logger?.warn?.('dsh-ssh-workbench: unknown hostKeyMode value, falling back to strict', { value: config.hostKeyMode })
@@ -272,6 +224,7 @@ export function apply(ctx, config, deps = {}) {
272
224
  }
273
225
 
274
226
  const targets = deps.targets ?? createTargetStore({ loadInventory })
227
+ const liveExecutions = deps.liveExecutions ?? createLiveExecutions()
275
228
 
276
229
  /**
277
230
  * Collect a host's facts once and persist them on the server record.
@@ -299,21 +252,8 @@ export function apply(ctx, config, deps = {}) {
299
252
  }
300
253
  }
301
254
 
302
- // ── model-facing tools: target selector + dialect-specific remote shells ───
255
+ // ── model-facing tools: explicit-target remote shells + fan-out ────────────
303
256
  if (typeof ctx.tools?.register === 'function') {
304
- const targetTool = createTargetTool({
305
- targets,
306
- loadInventory,
307
- onActivated: (server) => refreshServerFacts(server),
308
- remoteToolName,
309
- windowsToolName: 'remote_pwsh',
310
- hostPlatform: hostOs,
311
- })
312
- const offTool = ctx.tools.register(targetTool)
313
- if (typeof offTool === 'function' && typeof ctx.effect === 'function') {
314
- ctx.effect(() => offTool, 'dsh-ssh-workbench: target tool')
315
- }
316
-
317
257
  const registerRemote = (definition, label) => {
318
258
  try {
319
259
  const off = ctx.tools.register(definition)
@@ -329,42 +269,66 @@ export function apply(ctx, config, deps = {}) {
329
269
  }
330
270
  }
331
271
 
332
- // Windows targets are not covered by any native tool on either Host.
272
+ remoteAvailable =
273
+ registerRemote(
274
+ createRemoteExecTool({
275
+ runtime,
276
+ config,
277
+ loadInventory,
278
+ recordActivity,
279
+ }),
280
+ 'remote exec tool',
281
+ ) || remoteAvailable
282
+
283
+ remoteAvailable =
284
+ registerRemote(
285
+ createRemoteShellTool({
286
+ runtime,
287
+ config,
288
+ loadInventory,
289
+ recordActivity,
290
+ adapter: posixAdapter,
291
+ localShellName,
292
+ }),
293
+ 'remote bash tool',
294
+ ) || remoteAvailable
295
+
333
296
  remoteAvailable =
334
297
  registerRemote(
335
298
  createRemoteShellTool({
336
299
  runtime,
337
- targets,
338
300
  config,
339
301
  loadInventory,
340
302
  recordActivity,
341
303
  adapter: win32Adapter,
342
- localShellName: isWindowsHost ? 'pwsh' : 'bash',
304
+ localShellName,
343
305
  }),
344
306
  'remote pwsh tool',
345
307
  ) || remoteAvailable
346
- // Windows Hosts also have no native bash, so POSIX targets need the
347
- // plugin-owned tool there; POSIX Hosts keep the native bash router below.
348
- if (isWindowsHost) {
349
- remoteAvailable =
350
- registerRemote(
351
- createRemoteShellTool({
352
- runtime,
353
- targets,
354
- config,
355
- loadInventory,
356
- recordActivity,
357
- adapter: posixAdapter,
358
- localShellName: 'pwsh',
359
- }),
360
- 'remote bash tool',
361
- ) || remoteAvailable
308
+
309
+ for (const tool of createRemoteFileTools({ runtime, config, loadInventory, recordActivity })) {
310
+ remoteAvailable = registerRemote(tool, `remote file tool: ${tool.name}`) || remoteAvailable
311
+ }
312
+ }
313
+
314
+ // ── live execution indicator: display-only, never selects a target ─────────
315
+ if (remoteAvailable) {
316
+ const offLive = ctx.on?.('tools/execute', async (exec, next) => {
317
+ const token = liveExecutions.begin(exec)
318
+ try {
319
+ return await next()
320
+ } finally {
321
+ liveExecutions.end(token)
322
+ }
323
+ })
324
+ if (typeof offLive === 'function' && typeof ctx.effect === 'function') {
325
+ ctx.effect(() => offLive, 'dsh-ssh-workbench: live execution indicator')
362
326
  }
363
327
  }
364
328
 
365
329
  // ── fallback blacklist: deny-only, synchronous, monotonic ──────────────────
366
330
  if (typeof ctx.tools?.guard === 'function') {
367
- const offGuard = ctx.tools.guard(createBlacklistGuard({ targets, recordActivity }))
331
+ const offGuard = ctx.tools.guard(createBlacklistGuard({ recordActivity, loadInventory }))
368
332
  if (typeof offGuard === 'function' && typeof ctx.effect === 'function') {
369
333
  ctx.effect(() => offGuard, 'dsh-ssh-workbench: blacklist guard')
370
334
  }
@@ -372,30 +336,29 @@ export function apply(ctx, config, deps = {}) {
372
336
  ctx.logger?.warn?.('dsh-ssh-workbench: ctx.tools.guard() is unavailable; the destructive-command blacklist is disabled')
373
337
  }
374
338
 
375
- // ── route the native bash tool to the active managed target (POSIX Hosts) ──
376
- if (!isWindowsHost && typeof ctx.on === 'function') {
377
- const router = createRemoteBashRouter({ runtime, targets, config, loadInventory, recordActivity })
378
- const offExecute = ctx.on('tools/execute', router)
379
- if (typeof offExecute === 'function' && typeof ctx.effect === 'function') {
380
- ctx.effect(() => offExecute, 'dsh-ssh-workbench: remote bash router')
381
- }
382
- remoteAvailable = true
383
- }
384
-
385
- // ── @server → session target + one compact context message per turn ────────
339
+ // ── managed-resource context: one compact inventory + tool rules per turn ──
386
340
  // The context is only injected when this Host really has a remote execution
387
341
  // path; otherwise it would tell the model that a tool runs remotely when no
388
- // such tool exists (the Windows-without-remote_bash failure mode).
342
+ // such tool exists.
343
+ const loadLastExecuted = (sessionId) => {
344
+ try {
345
+ const entries = activity.list(200)
346
+ const last = entries.find((entry) => entry?.kind === 'exec' && entry.sessionId === sessionId)
347
+ return typeof last?.server === 'string' && last.server.length > 0 ? last.server : null
348
+ } catch {
349
+ return null
350
+ }
351
+ }
389
352
  if (config.targetContext !== false) {
390
- installTargetContext(ctx, {
353
+ installManagedContext(ctx, {
391
354
  targets,
392
355
  loadInventory,
356
+ loadLastExecuted,
393
357
  createUserMessage: deps.createUserMessage,
394
358
  remoteAvailable,
395
359
  hostPlatform: hostOs,
396
- remoteToolName,
397
- windowsToolName: 'remote_pwsh',
398
- ensureDialect: (server) => refreshServerFacts(server),
360
+ maxContextServers: 20,
361
+ loadRemoteFileWrite: () => resolveRemoteFileWrite(config, loadPrefs()),
399
362
  })
400
363
  }
401
364
 
@@ -436,12 +399,13 @@ export function apply(ctx, config, deps = {}) {
436
399
  secrets,
437
400
  knownHosts,
438
401
  targets,
402
+ liveExecutions,
439
403
  refreshFacts: (server) => refreshServerFacts(server),
440
404
  activity,
441
405
  prefs: { load: () => loadPrefs(), save: (patch) => savePrefs(patch) },
442
406
  terminals,
443
407
  localfs,
444
- publicConfig: () => publicConfig(config),
408
+ publicConfig: () => publicConfig(config, loadPrefs()),
445
409
  }),
446
410
  )
447
411
  if (typeof ctx.effect === 'function') {
package/lib/routes.js CHANGED
@@ -15,6 +15,7 @@ import { Readable } from 'node:stream'
15
15
  import { createLocalFileApi, LOCAL_FILE_REF } from './core/local-file-api.js'
16
16
  import { effectiveDialect } from './core/dialect.js'
17
17
  import { SshError } from './core/errors.js'
18
+ import { summarizeExecution } from './core/execution-stats.js'
18
19
 
19
20
  const PREFIX = '/dsh-ssh-workbench'
20
21
  const MAX_BODY = 8 * 1024 * 1024
@@ -147,7 +148,24 @@ function resolveRef(value) {
147
148
  * @param {() => object} deps.publicConfig
148
149
  * @param {{ append: Function, list: Function }} [deps.activity] bounded local activity store
149
150
  */
150
- export function createRoutes({ loadServers, saveServers, runtime, secrets, knownHosts, targets, refreshFacts, prefs, terminals, localfs, publicConfig, activity }) {
151
+ /** Read the bounded activity log without letting a store failure break /target. */
152
+ function targetActivityEntries(activity, limit = 200) {
153
+ try {
154
+ return typeof activity?.list === 'function' ? activity.list(limit) : []
155
+ } catch {
156
+ return []
157
+ }
158
+ }
159
+
160
+ /** Latest remote execution for one Session, derived from the activity ledger. */
161
+ function lastExecutedServer(entries, session) {
162
+ const entry = (Array.isArray(entries) ? entries : []).find(
163
+ (item) => item?.kind === 'exec' && item.sessionId === session && typeof item.server === 'string',
164
+ )
165
+ return entry?.server ?? null
166
+ }
167
+
168
+ export function createRoutes({ loadServers, saveServers, runtime, secrets, knownHosts, targets, liveExecutions, refreshFacts, prefs, terminals, localfs, publicConfig, activity }) {
151
169
  const prefsApi = prefs ?? {
152
170
  load: () => ({ sidebar: true }),
153
171
  save: () => ({ sidebar: true }),
@@ -308,11 +326,19 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
308
326
  if (req.method === 'GET' && rel === '/target') {
309
327
  const session = String(searchParams.get('session') ?? '')
310
328
  const state = session && typeof targets?.get === 'function' ? targets.get(session) : undefined
329
+ const entries = targetActivityEntries(activity)
311
330
  return send(res, 200, {
312
331
  ok: true,
313
- target: state?.active ?? null,
314
332
  candidates: state?.candidates ?? [],
333
+ lastExecuted: lastExecutedServer(entries, session),
334
+ running: typeof liveExecutions?.snapshot === 'function'
335
+ ? liveExecutions.snapshot(session)
336
+ : { count: 0, servers: [] },
315
337
  version: state?.version ?? 0,
338
+ // The dock is a read-only projection of this Session's executions:
339
+ // only servers that actually ran appear (zero-count candidates would
340
+ // make the total disagree with the rows).
341
+ stats: summarizeExecution(entries, { sessionId: session, candidates: [] }),
316
342
  })
317
343
  }
318
344
  if (req.method === 'GET' && rel === '/activity') {
@@ -361,10 +387,24 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
361
387
  }
362
388
 
363
389
  if (rel === '/prefs') {
364
- if (typeof body.sidebar !== 'boolean') {
365
- return send(res, 400, { ok: false, error: 'prefs.sidebar must be a boolean' })
390
+ const patch = {}
391
+ if (typeof body.sidebar === 'boolean') patch.sidebar = body.sidebar
392
+ // `null` clears the override and falls back to the plugin config.
393
+ if (typeof body.remoteFileWrite === 'boolean' || body.remoteFileWrite === null) {
394
+ patch.remoteFileWrite = body.remoteFileWrite
366
395
  }
367
- return send(res, 200, { ok: true, prefs: prefsApi.save({ sidebar: body.sidebar }) })
396
+ if (typeof body.systemFileHierarchy === 'boolean' || body.systemFileHierarchy === null) {
397
+ patch.systemFileHierarchy = body.systemFileHierarchy
398
+ }
399
+ if (Object.keys(patch).length === 0) {
400
+ return send(res, 400, { ok: false, error: 'prefs patch must contain sidebar, remoteFileWrite and/or systemFileHierarchy' })
401
+ }
402
+ const saved = prefsApi.save(patch)
403
+ return send(res, 200, {
404
+ ok: true,
405
+ prefs: saved,
406
+ config: typeof publicConfig === 'function' ? publicConfig() : undefined,
407
+ })
368
408
  }
369
409
 
370
410
  if (rel === '/terminal/open') {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-ssh-workbench",
3
- "version": "1.6.4",
4
- "description": "远程工作台:SSH 终端、本地与远程文件、@服务器 对话执行;Windows 宿主和目标都能用。DeepSeek Harness SSH workbench plugin.",
3
+ "version": "1.7.8",
4
+ "description": "远程工作台:SSH 终端、本地与远程文件、@服务器 对话执行;显式目标的远程 shell 与 read/glob/grep,Windows 宿主和目标都能用。DeepSeek Harness SSH workbench plugin.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -22,7 +22,9 @@
22
22
  "locale",
23
23
  "vendor",
24
24
  "docs/quickstart.md",
25
- "docs/architecture.md"
25
+ "docs/architecture.md",
26
+ "docs/execution-model.md",
27
+ "docs/images"
26
28
  ],
27
29
  "dsh": {
28
30
  "manifestVersion": 1,
@@ -36,7 +38,10 @@
36
38
  "platform": "web",
37
39
  "inject": [
38
40
  "@deepseek-ai/dsh-client-ui-renderer",
39
- "@deepseek-ai/dsh-client-locale"
41
+ "@deepseek-ai/dsh-client-locale",
42
+ "@deepseek-ai/dsh-client-ui-context-menu",
43
+ "@deepseek-ai/dsh-api-remotes",
44
+ "@deepseek-ai/dsh-api-session-controller"
40
45
  ]
41
46
  }
42
47
  },
@@ -46,6 +51,9 @@
46
51
  "@deepseek-ai/schemastery": "^3.18.5",
47
52
  "@deepseek-ai/dsh-client-ui-renderer": ">=0.2.1-rc.1 <0.3.0",
48
53
  "@deepseek-ai/dsh-client-locale": ">=0.2.1-rc.1 <0.3.0",
54
+ "@deepseek-ai/dsh-client-ui-context-menu": ">=0.2.1-rc.1 <0.3.0",
55
+ "@deepseek-ai/dsh-api-remotes": ">=0.2.1-rc.1 <0.3.0",
56
+ "@deepseek-ai/dsh-api-session-controller": ">=0.2.1-rc.1 <0.3.0",
49
57
  "react": "^18.3.1",
50
58
  "react-dom": "^18.3.1"
51
59
  },
@@ -55,6 +63,15 @@
55
63
  },
56
64
  "@deepseek-ai/dsh-client-locale": {
57
65
  "optional": true
66
+ },
67
+ "@deepseek-ai/dsh-client-ui-context-menu": {
68
+ "optional": true
69
+ },
70
+ "@deepseek-ai/dsh-api-remotes": {
71
+ "optional": true
72
+ },
73
+ "@deepseek-ai/dsh-api-session-controller": {
74
+ "optional": true
58
75
  }
59
76
  },
60
77
  "scripts": {
@@ -62,8 +79,7 @@
62
79
  "pack": "node -e \"require('node:fs').mkdirSync('dist',{recursive:true})\" && npm pack --ignore-scripts --pack-destination dist",
63
80
  "test:ssh": "node --import ./test/ssh.register.mjs test/ssh.integration.mjs",
64
81
  "test:ssh:sftp": "node --import ./test/ssh.register.mjs test/sftp.integration.mjs",
65
- "bench": "node --import ./test/ssh.register.mjs test/bench/latency.mjs",
66
- "test:live": "node --import ./test/register.mjs --import ./test/ssh.register.mjs test/regression/live-p0.mjs"
82
+ "bench": "node --import ./test/ssh.register.mjs test/bench/latency.mjs"
67
83
  },
68
84
  "license": "MIT",
69
85
  "keywords": [
@@ -81,7 +97,7 @@
81
97
  "author": "duobaobox",
82
98
  "repository": {
83
99
  "type": "git",
84
- "url": "https://github.com/duobaobox/dsh-ssh-workbench.git"
100
+ "url": "git+https://github.com/duobaobox/dsh-ssh-workbench.git"
85
101
  },
86
102
  "homepage": "https://github.com/duobaobox/dsh-ssh-workbench",
87
103
  "bugs": {