dsh-ssh-workbench 1.6.4 → 1.7.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.
@@ -1,337 +0,0 @@
1
- /**
2
- * Route the native `bash` tool to a managed server.
3
- *
4
- * `tools/execute` is DSH's around-dispatch waterfall. This handler returns a
5
- * replacement result for exactly one tool — the built-in `bash` — and only
6
- * while the session has an active managed target; every other call falls
7
- * through with `next()`. The replacement value is the native bash foreground
8
- * value, so the registry validates it against the built-in output schema and
9
- * renders it with the built-in terminal renderer: no shadow tool, no schema
10
- * copy, no custom rendering.
11
- */
12
- import { describeResultOutcome } from './activity.js'
13
- import { buildPosixBackgroundCommand, parseBackgroundStart, renderBackgroundStart } from './background.js'
14
- import { effectiveDialect } from './dialect.js'
15
- import { quotePosix } from './posix-quote.js'
16
- import { findTarget } from './targets.js'
17
-
18
- export const BASH_TOOL_NAME = 'bash'
19
-
20
- const MIN_TIMEOUT_MS = 1000
21
- const POSIX_PLATFORM = 'posix'
22
-
23
- export { quotePosix }
24
-
25
- /**
26
- * Wrap a remote command with the remote `timeout` utility.
27
- *
28
- * A client-side timeout only closes the SSH channel; a process that ignores
29
- * SIGHUP (nohup/background) keeps running on the server. Wrapping the command
30
- * gives the remote side the same deadline: coreutils `timeout` runs the
31
- * managed command in its own process group and signals the whole group, so
32
- * when the client gives up the remote work stops too. `kill` adds `-k <n>`
33
- * (KILL after TERM); `basic` sends TERM only; `none` (or an unknown
34
- * capability) leaves the command untouched.
35
- *
36
- * The wrapper re-enters the remote login shell (`<shell> -c`) so shell
37
- * semantics do not change. The shell path comes from the capability probe and
38
- * is referenced as a quoted literal, so this also works on fish/csh (no POSIX
39
- * `${SHELL:-...}` expansion). The command itself is single-quote escaped and
40
- * therefore expanded exactly once, inside that shell.
41
- *
42
- * @param {string} command
43
- * @param {number} timeoutMs
44
- * @param {'kill'|'basic'|'none'|{mode?: string, shell?: string}} [capability]
45
- * @param {{ killAfterMs?: number }} [options]
46
- */
47
- export function wrapRemoteTimeout(command, timeoutMs, capability = 'none', options = {}) {
48
- const info = typeof capability === 'string' ? { mode: capability } : (capability ?? {})
49
- const mode = info.mode === 'kill' || info.mode === 'basic' ? info.mode : 'none'
50
- if (mode === 'none') return String(command ?? '')
51
- const shell = typeof info.shell === 'string' && info.shell.startsWith('/') ? info.shell : '/bin/sh'
52
- const killAfterMs = Number.isFinite(options.killAfterMs) ? options.killAfterMs : 5000
53
- const killSeconds = Math.max(0, Math.ceil(killAfterMs / 1000))
54
- const seconds = Math.max(1, Number(timeoutMs) / 1000)
55
- const suffix = mode === 'kill' && killSeconds > 0 ? `-k ${killSeconds} ` : ''
56
- return `timeout ${suffix}${seconds} ${quotePosix(shell)} -c ${quotePosix(command)}`
57
- }
58
-
59
- /**
60
- * Turn the model-facing `workdir` into a shell prefix and the value's cwd label.
61
- *
62
- * The remote shell starts in the login home; absolute paths are used verbatim,
63
- * `~`/`~/x` and relative paths are anchored at `$HOME`.
64
- *
65
- * @returns {{ command: string, cwd: string } | { error: string }}
66
- */
67
- export function applyWorkdir(command, workdir) {
68
- const raw = typeof workdir === 'string' ? workdir.trim() : ''
69
- const body = String(command ?? '')
70
- if (!raw) return { command: body, cwd: '~' }
71
- if (raw.includes('\n') || raw.includes('\r')) return { error: 'workdir must not contain newlines' }
72
- if (raw.startsWith('~')) {
73
- const rest = raw === '~' ? '' : raw.slice(1)
74
- if (rest !== '' && !rest.startsWith('/')) return { error: 'workdir must be an absolute path, ~/path, or a relative path' }
75
- const target = rest === '' ? '"$HOME"' : `"$HOME"${quotePosix(rest)}`
76
- return { command: `cd -- ${target} && ${body}`, cwd: raw }
77
- }
78
- if (raw.startsWith('/')) {
79
- return { command: `cd -- ${quotePosix(raw)} && ${body}`, cwd: raw }
80
- }
81
- return { command: `cd -- "$HOME"/${quotePosix(raw)} && ${body}`, cwd: `~/${raw}` }
82
- }
83
-
84
- /** Effective timeout for one call, clamped to the configured ceiling. */
85
- export function resolveTimeoutMs(args, config) {
86
- const ceiling = Number.isInteger(config?.maxCommandTimeoutMs) ? config.maxCommandTimeoutMs : 30 * 60 * 1000
87
- const requested = Number.isInteger(args?.timeoutMs) ? args.timeoutMs : undefined
88
- const fallback = Number.isInteger(config?.commandTimeoutMs) ? config.commandTimeoutMs : 120000
89
- const value = requested ?? fallback
90
- return Math.max(MIN_TIMEOUT_MS, Math.min(value, ceiling))
91
- }
92
-
93
- /**
94
- * Map one runtime exec result onto the native bash foreground value.
95
- * Non-zero exits are normal results; only "the command never ran" becomes an
96
- * error result on the caller side.
97
- */
98
- export function toBashValue(result, { cwd, timeoutMs }) {
99
- const stdout = typeof result?.stdout === 'string' ? result.stdout : ''
100
- const stderr = typeof result?.stderr === 'string' ? result.stderr : ''
101
- const exitCode = Number.isInteger(result?.exitCode) ? result.exitCode : null
102
- const signal = typeof result?.signal === 'string' && result.signal.length > 0 ? result.signal : null
103
- return {
104
- kind: 'foreground',
105
- cwd,
106
- exitCode,
107
- signal,
108
- timedOut: result?.outcome === 'timeout',
109
- aborted: result?.outcome === 'cancelled',
110
- timeoutMs,
111
- stdout: { text: stdout, truncated: result?.truncated?.stdout === true },
112
- stderr: { text: stderr, truncated: result?.truncated?.stderr === true },
113
- }
114
- }
115
-
116
- /**
117
- * Whether a runtime result means the command never reached the remote shell.
118
- * Timeouts and aborts are real command outcomes (timedOut / aborted on the
119
- * native value), not transport failures.
120
- */
121
- export function isTransportFailure(result) {
122
- if (!result || typeof result !== 'object') return true
123
- if (result.exitCode !== null && result.exitCode !== undefined) return false
124
- if (typeof result.signal === 'string' && result.signal.length > 0) return false
125
- return result.outcome === 'failed'
126
- }
127
-
128
- /** Build the structured error result the pipeline accepts for a failed call. */
129
- export function createErrorResult(message, code = 'SSH_FAILED') {
130
- const text = String(message ?? 'remote bash failed')
131
- return {
132
- isError: true,
133
- content: [{ type: 'text', text: `Error: ${code}: ${text}` }],
134
- error: { message: text, info: { name: 'SshOpsError', code } },
135
- }
136
- }
137
-
138
- /**
139
- * @param {object} deps
140
- * @param {object} deps.runtime SSH runtime facade (`createSshRuntime`)
141
- * @param {object} deps.targets per-session target store
142
- * @param {object} deps.config resolved plugin config
143
- * @param {() => object[]} deps.loadInventory
144
- * @param {(entry: object) => void} [deps.recordActivity]
145
- * @returns {(exec: object, next: () => Promise<object>) => Promise<object>}
146
- */
147
- export function createRemoteBashRouter({ runtime, targets, config, loadInventory, recordActivity } = {}) {
148
- return async function remoteBashRouter(exec, next) {
149
- if (!exec || exec.name !== BASH_TOOL_NAME) return next()
150
- const session = exec.agent?.session
151
- const key = session?.id ?? exec.agent?.id
152
- const binding = typeof key === 'string' ? targets?.get(key) : undefined
153
- if (!binding || !binding.active) {
154
- // Several `@server` mentions clear the active target. Running bash locally
155
- // in that state would execute commands meant for a server on the DSH host,
156
- // so make the model choose a target first.
157
- if (binding && Array.isArray(binding.candidates) && binding.candidates.length > 1) {
158
- return createErrorResult(
159
- `several servers were mentioned in this session (${binding.candidates.join(', ')}); ` +
160
- 'call ssh_workbench_target({ server: "<name>" }) before running bash, or server: "local" to run locally',
161
- 'TARGET_NOT_SELECTED',
162
- )
163
- }
164
- return next()
165
- }
166
-
167
- const args = exec.arguments ?? {}
168
- const command = typeof args.command === 'string' ? args.command : ''
169
- if (!command.trim()) return next()
170
-
171
- let servers
172
- try {
173
- servers = loadInventory()
174
- } catch (error) {
175
- return createErrorResult(`cannot read the managed server inventory: ${describeError(error)}`, 'INVENTORY_UNAVAILABLE')
176
- }
177
- const server = findTarget(servers, binding.active)
178
- if (server === undefined) {
179
- return createErrorResult(
180
- `target "${binding.active}" is no longer in the managed inventory; call ${'ssh_workbench_target'} to pick another target or "local"`,
181
- 'TARGET_NOT_FOUND',
182
- )
183
- }
184
- if (server.enabled === false) {
185
- return createErrorResult(
186
- `target "${server.name}" is disabled; call ssh_workbench_target to pick another target or "local"`,
187
- 'TARGET_DISABLED',
188
- )
189
- }
190
- if (effectiveDialect(server) !== POSIX_PLATFORM) {
191
- return createErrorResult(
192
- `target "${server.name}" is a Windows server; the native bash tool only runs POSIX commands — use remote_pwsh for Windows targets`,
193
- 'TARGET_PLATFORM_UNSUPPORTED',
194
- )
195
- }
196
-
197
- const workdir = applyWorkdir(command, args.workdir)
198
- if (workdir.error !== undefined) return createErrorResult(workdir.error, 'INVALID_WORKDIR')
199
-
200
- // A detached command returns only remote facts that survive across
201
- // transports: pid + log file. It is not registered in DSH's local jobs.
202
- if (args.run_in_background === true) {
203
- const startTimeout = Math.min(resolveTimeoutMs(args, config), 30000)
204
- let start
205
- try {
206
- start = await runtime.exec(server, buildPosixBackgroundCommand(workdir.command), {
207
- timeoutMs: startTimeout,
208
- signal: exec.signal,
209
- readOnly: false,
210
- cap: Number.isInteger(config?.maxOutputBytes) ? config.maxOutputBytes : undefined,
211
- stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
212
- })
213
- } catch (error) {
214
- return createErrorResult(describeError(error), 'SSH_EXEC_FAILED')
215
- }
216
- try {
217
- recordActivity?.({ kind: 'exec', server: server.name, summary: `[background] ${command.slice(0, 190)}`, outcome: describeResultOutcome(start) })
218
- } catch {
219
- // Activity recording is best effort.
220
- }
221
- if (isTransportFailure(start)) {
222
- const detail = typeof start?.stderr === 'string' && start.stderr.trim() ? start.stderr.trim() : 'remote execution failed'
223
- return createErrorResult(detail, start?.errorCode ?? 'SSH_EXEC_FAILED')
224
- }
225
- const started = parseBackgroundStart(start.stdout)
226
- if (started.pid === undefined) {
227
- const detail = String(start?.stderr ?? '').trim() || String(start?.stdout ?? '').trim()
228
- return createErrorResult(`background start failed${detail ? `: ${detail}` : ''}`, 'REMOTE_BACKGROUND_FAILED')
229
- }
230
- const text = renderBackgroundStart({
231
- pid: started.pid,
232
- log: started.log,
233
- errLog: started.errLog,
234
- target: `${server.name} (${server.username}@${server.host}:${server.port})`,
235
- command,
236
- dialect: 'posix',
237
- })
238
- const synthetic = {
239
- outcome: 'success',
240
- exitCode: 0,
241
- signal: null,
242
- stdout: text,
243
- stderr: '',
244
- truncated: { stdout: false, stderr: false },
245
- }
246
- return { value: toBashValue(synthetic, { cwd: `${server.name}:${workdir.cwd}`, timeoutMs: startTimeout }) }
247
- }
248
-
249
- const timeoutMs = resolveTimeoutMs(args, config)
250
-
251
- // Detect (and cache) whether the server can enforce a remote deadline, so
252
- // a timed-out call also stops the remote work instead of leaving it behind.
253
- let capability = { mode: 'none', shell: '/bin/sh' }
254
- try {
255
- if (typeof runtime.timeoutGuard === 'function') {
256
- const probed = await runtime.timeoutGuard(server)
257
- capability = typeof probed === 'string' ? { mode: probed } : (probed ?? capability)
258
- }
259
- } catch {
260
- capability = { mode: 'none', shell: '/bin/sh' }
261
- }
262
- const remoteCommand = wrapRemoteTimeout(workdir.command, timeoutMs, capability, {
263
- killAfterMs: config?.timeoutKillAfterMs,
264
- })
265
-
266
- let result
267
- try {
268
- result = await runtime.exec(server, remoteCommand, {
269
- timeoutMs,
270
- signal: exec.signal,
271
- readOnly: false,
272
- cap: Number.isInteger(config?.maxOutputBytes) ? config.maxOutputBytes : undefined,
273
- stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
274
- })
275
- } catch (error) {
276
- return createErrorResult(describeError(error), 'SSH_EXEC_FAILED')
277
- }
278
-
279
- try {
280
- recordActivity?.({
281
- kind: 'exec',
282
- server: server.name,
283
- summary: command.slice(0, 200),
284
- outcome: describeResultOutcome(result),
285
- })
286
- } catch {
287
- // Activity recording is best effort.
288
- }
289
-
290
- if (isTransportFailure(result)) {
291
- const code = typeof result?.errorCode === 'string' && result.errorCode ? result.errorCode : 'SSH_EXEC_FAILED'
292
- const detail = typeof result?.stderr === 'string' && result.stderr.trim() ? result.stderr.trim() : 'remote execution failed'
293
- const suggestion = typeof result?.suggestedAction === 'string' && result.suggestedAction ? `\n${result.suggestedAction}` : ''
294
- return createErrorResult(`${detail}${suggestion}`, code)
295
- }
296
-
297
- // The cwd label is also what the native Bash card shows in its expanded
298
- // prompt; prefixing the target makes a card self-describing in multi-target
299
- // sessions without shadowing DSH's shipped Bash renderer.
300
- let value = toBashValue(result, { cwd: `${server.name}:${workdir.cwd}`, timeoutMs })
301
- const notices = []
302
- // One line after each target change: a card-level "where did this run" hint.
303
- if (binding.notifiedVersion !== binding.version && typeof targets?.markNotified === 'function') {
304
- notices.push(`[remote] target: ${server.name} (${server.username}@${server.host}:${server.port})`)
305
- targets.markNotified(key, binding.version)
306
- }
307
- if (result?.outcome === 'timeout') {
308
- notices.push(
309
- capability.mode === 'none'
310
- ? '[remote] client-side timeout only: this server has no usable `timeout` utility, so the remote process may still be running.'
311
- : `[remote] timeout: the remote command group was terminated (SIGTERM${capability.mode === 'kill' ? ', KILL after the remote deadline' : ''}).`,
312
- )
313
- }
314
- if (result?.outcome === 'cancelled') {
315
- notices.push(
316
- capability.mode === 'none'
317
- ? '[remote] cancelled: the SSH channel was closed, but this server has no usable `timeout` utility, so the remote process may still be running.'
318
- : '[remote] cancelled: the SSH channel was closed; the remote wrapper terminates the command at its deadline if it is still running.',
319
- )
320
- }
321
- if (args.sandbox_permissions !== undefined || args.justification !== undefined) {
322
- notices.push('[remote] sandbox_permissions does not apply to managed servers; the command ran over SSH.')
323
- }
324
- if (notices.length > 0) {
325
- const text = value.stderr.text
326
- value = {
327
- ...value,
328
- stderr: { ...value.stderr, text: `${text}${text ? '\n' : ''}${notices.join('\n')}` },
329
- }
330
- }
331
- return { value }
332
- }
333
- }
334
-
335
- function describeError(error) {
336
- return error instanceof Error ? error.message : String(error)
337
- }
@@ -1,299 +0,0 @@
1
- /**
2
- * Target context for @server mentions.
3
- *
4
- * The composer inserts an `@name` chip as plain text, so the model can read it
5
- * as a teammate mention. This module turns matches against the managed
6
- * inventory into (a) the session's active remote target and (b) one compact
7
- * user-role context message that tells the model where `bash` runs and how to
8
- * switch targets.
9
- *
10
- * The injected message enters the same step (`agent/pre-step` replaces the
11
- * messages that enter a step), which `agent.inject()` cannot promise because it
12
- * splices into the next step. The decision is spread so fields such as
13
- * `startsRequestSeries` survive.
14
- *
15
- * Scope: every agent mode; no preset gating.
16
- */
17
- import { randomUUID } from 'node:crypto'
18
-
19
- import { effectiveDialect } from './dialect.js'
20
- import { loadServers } from './store.js'
21
- import {
22
- LOCAL_TARGET,
23
- TARGET_CONTEXT_KIND,
24
- TARGET_TOOL_NAME,
25
- createTargetStore,
26
- extractServerMentions,
27
- extractUserText,
28
- lastHumanMessage,
29
- } from './targets.js'
30
-
31
- export { TARGET_CONTEXT_KIND, TARGET_TOOL_NAME }
32
-
33
- function describeError(error) {
34
- return error instanceof Error ? error.message : String(error)
35
- }
36
-
37
- function serverLine(server) {
38
- const target = `${server.username ?? 'root'}@${server.host}:${server.port ?? 22}`
39
- const state = server.enabled === false ? ' · disabled' : ''
40
- const dialect = effectiveDialect(server)
41
- return `- ${server.name} — ${target} · ${dialect}${state}${factsSummary(server)}`
42
- }
43
-
44
- /** Compact facts summary (hostname only: stable, not a stale capacity snapshot). */
45
- function factsSummary(server) {
46
- const facts = server?.facts
47
- if (!facts || typeof facts !== 'object') return ''
48
- const hostname = typeof facts.hostname === 'string' ? facts.hostname.trim() : ''
49
- return hostname ? ` · ${hostname}` : ''
50
- }
51
-
52
- /**
53
- * Resolve the copy dialect for one Host.
54
- *
55
- * `remoteToolName` is the tool that actually executes on the target: the native
56
- * `bash` tool on POSIX Hosts, the plugin-owned `remote_bash` tool on Windows
57
- * Hosts (where DSH ships no bash). The context must describe the tool the model
58
- * really has, never a tool name that cannot exist on this Host.
59
- */
60
- function contextOptions(options = {}) {
61
- const hostOs = options.hostPlatform === 'win32' ? 'win32' : 'posix'
62
- const remoteToolName =
63
- typeof options.remoteToolName === 'string' && options.remoteToolName.length > 0
64
- ? options.remoteToolName
65
- : hostOs === 'win32'
66
- ? 'remote_bash'
67
- : 'bash'
68
- const windowsToolName =
69
- typeof options.windowsToolName === 'string' && options.windowsToolName.length > 0 ? options.windowsToolName : 'remote_pwsh'
70
- return { hostOs, remoteToolName, windowsToolName }
71
- }
72
-
73
- /**
74
- * Render the model-facing target context.
75
- * @param {{ active: string|null, candidates: string[] }} state
76
- * @param {object[]} servers managed inventory
77
- * @param {{ hostPlatform?: 'win32'|'posix', remoteToolName?: string, windowsToolName?: string }} [options]
78
- */
79
- export function renderTargetContext(state, servers, options = {}) {
80
- const { hostOs, remoteToolName, windowsToolName } = contextOptions(options)
81
- const byName = new Map()
82
- for (const server of servers ?? []) byName.set(server.name, server)
83
- const lines = ['[ssh-workbench] Managed server context:']
84
- if (typeof state?.active === 'string' && state.active.length > 0) {
85
- const server = byName.get(state.active)
86
- lines.push(server ? serverLine(server) : `- ${state.active}`)
87
- const activeDialect = effectiveDialect(server)
88
- if (activeDialect === 'windows') {
89
- lines.push(
90
- `The ${windowsToolName} tool runs PowerShell commands on ${state.active} over SSH; pass ${windowsToolName}.workdir to change its working directory.`,
91
- )
92
- lines.push(`The native ${hostOs === 'win32' ? 'pwsh' : 'bash'} tool always runs on the local DSH host: never use it for commands meant for ${state.active}.`)
93
- lines.push(
94
- `read/write/edit/glob/grep always run on the local DSH host: never use write/edit for remote files — change them through ${windowsToolName} or the GUI SFTP panel.`,
95
- )
96
- lines.push(`Do not use ssh/scp inside ${windowsToolName} to reach other servers; switch targets with ${TARGET_TOOL_NAME}.`)
97
- lines.push('After a state change, verify the result before reporting; exit code 0 does not prove success.')
98
- lines.push(`Switch target: ${TARGET_TOOL_NAME}({ server: "<name>" }); detach: server: "${LOCAL_TARGET}".`)
99
- return lines.join('\n')
100
- }
101
- if (hostOs === 'win32') {
102
- lines.push(
103
- `The ${remoteToolName} tool runs POSIX shell commands on ${state.active} over SSH; pass ${remoteToolName}.workdir to change its working directory.`,
104
- )
105
- lines.push(`The native pwsh tool always runs on the local DSH host: never use it for commands meant for ${state.active}.`)
106
- lines.push(
107
- `read/write/edit/glob/grep always run on the local DSH host: never use write/edit for remote files — change them through ${remoteToolName} (heredoc, sed -i) or the GUI SFTP panel.`,
108
- )
109
- lines.push(`Do not use ssh/scp inside ${remoteToolName} to reach other servers; switch targets with ${TARGET_TOOL_NAME}.`)
110
- } else {
111
- lines.push(`The ${remoteToolName} tool runs on ${state.active}; pass ${remoteToolName}.workdir to change its working directory.`)
112
- lines.push(
113
- `read/write/edit/glob/grep always run on the local DSH host: never use write/edit for remote files — change them through ${remoteToolName} (heredoc, sed -i) or the GUI SFTP panel.`,
114
- )
115
- lines.push(`Do not use ssh/scp inside ${remoteToolName} to reach other servers; switch targets with ${TARGET_TOOL_NAME}.`)
116
- }
117
- lines.push('After a state change, verify the result before reporting; exit code 0 does not prove success.')
118
- lines.push(`Switch target: ${TARGET_TOOL_NAME}({ server: "<name>" }); detach: server: "${LOCAL_TARGET}".`)
119
- return lines.join('\n')
120
- }
121
- const candidates = Array.isArray(state?.candidates) ? state.candidates : []
122
- const listed = candidates.map((name) => byName.get(name)).filter(Boolean)
123
- for (const server of listed) lines.push(serverLine(server))
124
- if (listed.length === 0) lines.push(`- (unresolved: ${candidates.join(', ')})`)
125
- if (hostOs === 'win32') {
126
- lines.push(
127
- `The native pwsh tool runs on the local DSH host; ${remoteToolName} only runs after you choose a target with ${TARGET_TOOL_NAME}({ server: "<name>" }).`,
128
- )
129
- lines.push(
130
- `read/write/edit/glob/grep always run on the local DSH host: remote files are only reachable through ${remoteToolName}.`,
131
- )
132
- } else {
133
- lines.push(
134
- `${remoteToolName} still runs on the local DSH host until you choose a target with ${TARGET_TOOL_NAME}({ server: "<name>" }).`,
135
- )
136
- lines.push(`read/write/edit/glob/grep always run on the local DSH host: remote files are only reachable through ${remoteToolName}.`)
137
- }
138
- return lines.join('\n')
139
- }
140
-
141
- /**
142
- * Build one user-role context message, preferring the host's message factory.
143
- * `@` symbols are avoided so this message can never be re-matched as a mention.
144
- */
145
- export function buildTargetContextMessage(state, servers, createUserMessage, options = {}) {
146
- const text = renderTargetContext(state, servers, options)
147
- const source = {
148
- kind: TARGET_CONTEXT_KIND,
149
- form: 'notice',
150
- summary: state?.active ? `remote target: ${state.active}` : 'remote target not selected',
151
- }
152
- const input = { content: [{ type: 'text', text }], source }
153
- if (typeof createUserMessage === 'function') return createUserMessage(input)
154
- return { role: 'user', id: `ssh-workbench-target-${randomUUID()}`, ...structuredClone(input) }
155
- }
156
-
157
- /**
158
- * Install the target binding + context provider.
159
- *
160
- * @param {object} ctx plugin context
161
- * @param {{
162
- * enabled?: boolean,
163
- * remoteAvailable?: boolean,
164
- * hostPlatform?: 'win32'|'posix',
165
- * remoteToolName?: string,
166
- * windowsToolName?: string,
167
- * ensureDialect?: (server: object) => Promise<string|undefined>,
168
- * targets?: object,
169
- * loadInventory?: () => object[],
170
- * createUserMessage?: Function,
171
- * }} [options]
172
- */
173
- export function installTargetContext(ctx, options = {}) {
174
- const enabled = options.enabled !== false
175
- const remoteAvailable = options.remoteAvailable !== false
176
- const contextOptions = {
177
- hostPlatform: options.hostPlatform,
178
- remoteToolName: options.remoteToolName,
179
- windowsToolName: options.windowsToolName,
180
- }
181
- const loadInventory = options.loadInventory ?? loadServers
182
- const targets = options.targets ?? createTargetStore({ loadInventory })
183
- const warn = (message) => {
184
- try {
185
- ctx?.logger?.warn?.(`dsh-ssh-workbench: ${message}`)
186
- } catch {
187
- // Logging must never fail the plugin.
188
- }
189
- }
190
- if (!ctx || typeof ctx.on !== 'function') return { targets }
191
-
192
- let factory = options.createUserMessage
193
- const messageFactory = async () => {
194
- if (factory !== undefined) return factory
195
- try {
196
- const mod = await import('@deepseek-ai/dsh-llm')
197
- factory = typeof mod.createUserMessage === 'function' ? mod.createUserMessage : null
198
- } catch {
199
- factory = null
200
- }
201
- return factory
202
- }
203
-
204
- const handler = async (payload, next) => {
205
- const decision = await next()
206
- try {
207
- if (!decision || decision.kind === 'reject' || payload?.signal?.aborted) return decision
208
- if (enabled === false) return decision
209
- // Never describe a remote world the Host cannot execute in: without a
210
- // working remote tool the model must keep treating every tool as local.
211
- if (!remoteAvailable) return decision
212
- const agent = payload?.agent
213
- const session = agent?.session
214
- const key = session?.id ?? agent?.id
215
- if (typeof key !== 'string' || key.length === 0) return decision
216
- let servers = loadInventory()
217
- // Restore a resumed Session's target from its durable human messages once;
218
- // deriveMessages() is intentionally skipped on every later step.
219
- if (targets.get(key)?.seeded !== true) {
220
- try {
221
- const history = typeof session?.deriveMessages === 'function' ? session.deriveMessages() : []
222
- targets.ensureSeeded(key, history, servers)
223
- } catch {
224
- targets.ensureSeeded(key, [], servers)
225
- }
226
- }
227
- const human = lastHumanMessage(decision.messages)
228
- const mentions = human ? extractServerMentions(extractUserText(human), servers) : []
229
- const state = targets.bindMentions(key, mentions)
230
- if (!state) return decision
231
- // Describe the target in its real dialect. A fresh record may not have a
232
- // probe result yet; probing here (bounded) avoids telling the model to
233
- // use a POSIX shell against a Windows host for the whole first turn.
234
- const activeServer = state.active ? servers.find((entry) => entry.name === state.active) : undefined
235
- if (activeServer && typeof options.ensureDialect === 'function' && !activeServer.facts?.dialect) {
236
- try {
237
- await Promise.race([
238
- Promise.resolve().then(() => options.ensureDialect(activeServer)),
239
- new Promise((resolve) => {
240
- const timer = setTimeout(resolve, 2500)
241
- timer.unref?.()
242
- }),
243
- ])
244
- servers = loadInventory()
245
- } catch {
246
- // Keep the record hint when probing fails.
247
- }
248
- }
249
- const needsChoice = state.candidates.length > 1 && !state.active
250
- if (!state.active && !needsChoice) return decision
251
- const changed = state.version !== state.injectedVersion
252
- const firstStep = payload?.step === 1
253
- if (!changed) {
254
- if (!firstStep) return decision
255
- // One context message per turn is enough while it is still present in
256
- // the step messages (it is committed to the session like other runtime
257
- // context). Re-inject after compaction or when the target changed.
258
- const hasContext = decision.messages.some((message) => message?.source?.kind === TARGET_CONTEXT_KIND)
259
- if (hasContext) return decision
260
- }
261
- const message = buildTargetContextMessage(state, servers, await messageFactory(), contextOptions)
262
- targets.markInjected(key, state.version)
263
- return { ...decision, messages: [...decision.messages, message] }
264
- } catch (error) {
265
- warn(`target context skipped: ${describeError(error)}`)
266
- return decision
267
- }
268
- }
269
-
270
- const offPreStep = ctx.on('agent/pre-step', handler, { prepend: true })
271
- const offDisposed = ctx.on('agent/disposed', (disposed) => {
272
- try {
273
- const session = disposed?.agent?.session
274
- const key = session?.id ?? disposed?.agent?.id
275
- if (typeof key === 'string') targets.clear(key)
276
- } catch {
277
- // Best effort cleanup.
278
- }
279
- })
280
- if (typeof ctx.effect === 'function') {
281
- ctx.effect(
282
- () => () => {
283
- try {
284
- offPreStep?.()
285
- } catch {
286
- // Disposal is best effort.
287
- }
288
- try {
289
- offDisposed?.()
290
- } catch {
291
- // Disposal is best effort.
292
- }
293
- targets.clearAll?.()
294
- },
295
- 'dsh-ssh-workbench: target context',
296
- )
297
- }
298
- return { targets, offPreStep, offDisposed }
299
- }