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,26 +1,18 @@
1
1
  /**
2
- * Per-session remote target binding.
2
+ * Per-session resource hints for the composer and the injected context.
3
3
  *
4
- * A session owns at most one *active* target: the managed server its native
5
- * `bash` tool runs on. `@name` mentions in a human message are the primary
6
- * input; the `ssh_workbench_target` tool switches the active target explicitly so a
7
- * conversation that mentions several servers stays unambiguous.
8
- *
9
- * State is process-local and keyed by Session id. It is a derived cache, not a
10
- * source of truth: the durable input is the human messages in the Session log,
11
- * which `replayTargets()` folds back in when a Session resumes.
4
+ * Every remote tool call carries its own `server`, so this store only
5
+ * remembers which managed resources the human mentioned with `@name` in the
6
+ * current conversation. It is a hint for the model and the UI, never an
7
+ * execution binding. The durable input is still the Session log, which
8
+ * `replayTargets()` folds back in after a resume.
12
9
  */
13
10
  import { loadServers } from './store.js'
11
+ import { findServerRef } from './server-ref.js'
14
12
 
15
- /** Tool name the model calls to switch targets. */
16
- export const TARGET_TOOL_NAME = 'ssh_workbench_target'
17
-
18
- /** Source kind recorded on the injected target-context message. */
13
+ /** Source kind recorded on the injected resource-context message. */
19
14
  export const TARGET_CONTEXT_KIND = 'ssh-workbench-target'
20
15
 
21
- /** The sentinel that detaches the session from any remote target. */
22
- export const LOCAL_TARGET = 'local'
23
-
24
16
  /** Concatenate the text blocks of one message. */
25
17
  export function extractUserText(message) {
26
18
  if (!message || message.role !== 'user' || !Array.isArray(message.content)) return ''
@@ -85,52 +77,47 @@ function sameList(left, right) {
85
77
  return left.every((value, index) => value === right[index])
86
78
  }
87
79
 
80
+ function mentionNames(mentions) {
81
+ const names = []
82
+ for (const server of Array.isArray(mentions) ? mentions : []) {
83
+ const name = typeof server?.name === 'string' ? server.name : ''
84
+ if (name && !names.includes(name)) names.push(name)
85
+ }
86
+ return names
87
+ }
88
+
88
89
  function freshState() {
89
- return { candidates: [], active: null, version: 0, injectedVersion: -1, notifiedVersion: -1, seeded: false }
90
+ return { candidates: [], lastHumanId: null, version: 0, injectedVersion: -1, seeded: false }
90
91
  }
91
92
 
92
- /**
93
- * Fold the mention rules over one session state.
94
- *
95
- * - one mention → that server becomes the active target;
96
- * - several mentions → candidates are remembered, active target is cleared;
97
- * - no mention → state is unchanged (a follow-up keeps its target).
98
- */
93
+ /** Remember the latest @mentions. No target is selected or cleared. */
99
94
  export function applyMentions(state, mentions) {
100
- if (!Array.isArray(mentions) || mentions.length === 0) return state
101
- const names = mentions.map((server) => server.name)
102
- if (mentions.length === 1) {
103
- if (state.active === names[0] && sameList(state.candidates, names)) return state
104
- return { ...state, candidates: names, active: names[0], version: state.version + 1 }
105
- }
106
- // Several mentions never pick a target by guesswork; the model must call the
107
- // selector tool. The previous active target is dropped for the same reason.
108
- const active = null
109
- if (active === state.active && sameList(state.candidates, names)) return state
110
- return { ...state, candidates: names, active, version: state.version + 1 }
95
+ const names = mentionNames(mentions)
96
+ if (names.length === 0) return state
97
+ if (sameList(state.candidates, names)) return state
98
+ return { ...state, candidates: names, version: state.version + 1 }
111
99
  }
112
100
 
113
- /** Replay the mention rules over a Session's historical messages. */
101
+ /** Replay the mention hints over a Session's historical messages. */
114
102
  export function replayTargets(messages, servers) {
115
103
  let state = freshState()
116
104
  if (Array.isArray(messages)) {
117
105
  for (const message of messages) {
118
106
  if (message?.role !== 'user' || message.source?.kind !== 'user') continue
119
- state = applyMentions(state, extractServerMentions(extractUserText(message), servers))
107
+ const mentions = extractServerMentions(extractUserText(message), servers)
108
+ if (mentions.length > 0) state = applyMentions(state, mentions)
120
109
  }
121
110
  }
122
111
  return state
123
112
  }
124
113
 
125
- /** Resolve one target reference against the inventory (name or id). */
114
+ /** Resolve one resource reference against the inventory (name or id). */
126
115
  export function findTarget(servers, ref) {
127
- const value = String(ref ?? '').trim()
128
- if (!value) return undefined
129
- return servers.find((server) => server.id === value || server.name === value)
116
+ return findServerRef(servers, ref)
130
117
  }
131
118
 
132
119
  /**
133
- * Create the per-session target store.
120
+ * Create the per-session resource-hint store.
134
121
  * @param {{ loadInventory?: () => object[] }} [options]
135
122
  */
136
123
  export function createTargetStore(options = {}) {
@@ -167,52 +154,42 @@ export function createTargetStore(options = {}) {
167
154
  const state = ensure(key)
168
155
  if (state === undefined || state.seeded) return state
169
156
  const replayed = replayTargets(messages, servers)
157
+ const history = Array.isArray(messages) ? messages : []
158
+ const lastHuman = [...history].reverse().find(
159
+ (message) => message?.role === 'user' && message.source?.kind === 'user',
160
+ )
170
161
  const next = {
171
162
  ...state,
172
163
  candidates: replayed.candidates,
173
- active: replayed.active,
174
- version: replayed.version,
164
+ lastHumanId: typeof lastHuman?.id === 'string' ? lastHuman.id : state.lastHumanId,
165
+ version: replayed.candidates.length > 0 ? 1 : 0,
175
166
  seeded: true,
176
167
  }
177
168
  sessions.set(sessionKey(key), next)
178
169
  return next
179
170
  }
180
171
 
181
- function bindMentions(key, mentions) {
172
+ /**
173
+ * Fold one human message into the hint state.
174
+ * A new human message always bumps `version` so the per-turn resource
175
+ * context is injected even when the message contains no @mention.
176
+ */
177
+ function bindMentions(key, mentions, humanId) {
182
178
  const state = ensure(key)
183
179
  if (state === undefined) return undefined
184
- const next = { ...applyMentions(state, mentions), seeded: true }
185
- if (next !== state) sessions.set(sessionKey(key), next)
186
- return next
187
- }
188
-
189
- /** Switch the active target explicitly. `local` detaches. */
190
- function setActive(key, ref, servers = loadInventory()) {
191
- const state = ensure(key)
192
- if (state === undefined) return { ok: false, error: 'session is not addressable' }
193
- if (String(ref).trim().toLowerCase() === LOCAL_TARGET) {
194
- // Detaching also clears any pending @a @b ambiguity, so bash is plainly
195
- // local again instead of staying blocked on "choose a target".
196
- const next = { ...state, active: null, candidates: [], version: state.version + 1, seeded: true }
197
- sessions.set(sessionKey(key), next)
198
- return { ok: true, state: next }
199
- }
200
- const server = findTarget(servers, ref)
201
- if (server === undefined) {
202
- return { ok: false, error: `unknown server "${String(ref)}"`, state }
203
- }
204
- if (server.enabled === false) {
205
- return { ok: false, error: `server "${server.name}" is disabled`, state }
206
- }
180
+ const newHuman = typeof humanId === 'string' && humanId.length > 0 && humanId !== state.lastHumanId
181
+ const names = mentionNames(mentions)
182
+ const changedMentions = names.length > 0 && !sameList(state.candidates, names)
183
+ if (!newHuman && !changedMentions) return state
207
184
  const next = {
208
185
  ...state,
209
- active: server.name,
210
- candidates: state.candidates.includes(server.name) ? state.candidates : [server.name],
186
+ candidates: names.length > 0 ? names : state.candidates,
187
+ lastHumanId: typeof humanId === 'string' && humanId.length > 0 ? humanId : state.lastHumanId,
211
188
  version: state.version + 1,
212
189
  seeded: true,
213
190
  }
214
191
  sessions.set(sessionKey(key), next)
215
- return { ok: true, state: next, server }
192
+ return next
216
193
  }
217
194
 
218
195
  function markInjected(key, version) {
@@ -222,17 +199,6 @@ export function createTargetStore(options = {}) {
222
199
  sessions.set(sessionKey(key), { ...state, injectedVersion: version })
223
200
  }
224
201
 
225
- /**
226
- * Remember that the bash router already showed the target to the model/UI
227
- * (one stderr line after each target change), so it is not repeated per call.
228
- */
229
- function markNotified(key, version) {
230
- const state = get(key)
231
- if (state === undefined) return
232
- if (state.notifiedVersion === version) return
233
- sessions.set(sessionKey(key), { ...state, notifiedVersion: version })
234
- }
235
-
236
202
  function clear(key) {
237
203
  const sessionId = sessionKey(key)
238
204
  if (sessionId !== undefined) sessions.delete(sessionId)
@@ -246,5 +212,5 @@ export function createTargetStore(options = {}) {
246
212
  return sessions.size
247
213
  }
248
214
 
249
- return { get, ensure, ensureSeeded, bindMentions, setActive, markInjected, markNotified, clear, clearAll, size, loadInventory }
215
+ return { get, ensure, ensureSeeded, bindMentions, markInjected, clear, clearAll, size, loadInventory }
250
216
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Shared POSIX remote-shell helpers.
3
+ *
4
+ * Pure command construction used by `remote_bash` and `remote_exec`; the
5
+ * native local `bash` tool never reaches this module.
6
+ */
7
+ import { quotePosix } from './posix-quote.js'
8
+
9
+ const MIN_TIMEOUT_MS = 1000
10
+
11
+ /**
12
+ * Wrap a remote command with the remote `timeout` utility.
13
+ *
14
+ * A client-side timeout only closes the SSH channel; a process that ignores
15
+ * SIGHUP (nohup/background) keeps running on the server. Wrapping the command
16
+ * gives the remote side the same deadline: coreutils `timeout` runs the
17
+ * managed command in its own process group and signals the whole group, so
18
+ * when the client gives up the remote work stops too. `kill` adds `-k <n>`
19
+ * (KILL after TERM); `basic` sends TERM only; `none` (or an unknown
20
+ * capability) leaves the command untouched.
21
+ *
22
+ * The wrapper re-enters the remote login shell (`<shell> -c`) so shell
23
+ * semantics do not change. The shell path comes from the capability probe and
24
+ * is referenced as a quoted literal, so this also works on fish/csh (no POSIX
25
+ * `${SHELL:-...}` expansion). The command itself is single-quote escaped and
26
+ * therefore expanded exactly once, inside that shell.
27
+ *
28
+ * @param {string} command
29
+ * @param {number} timeoutMs
30
+ * @param {'kill'|'basic'|'none'|{mode?: string, shell?: string}} [capability]
31
+ * @param {{ killAfterMs?: number }} [options]
32
+ */
33
+ export function wrapRemoteTimeout(command, timeoutMs, capability = 'none', options = {}) {
34
+ const info = typeof capability === 'string' ? { mode: capability } : (capability ?? {})
35
+ const mode = info.mode === 'kill' || info.mode === 'basic' ? info.mode : 'none'
36
+ if (mode === 'none') return String(command ?? '')
37
+ const shell = typeof info.shell === 'string' && info.shell.startsWith('/') ? info.shell : '/bin/sh'
38
+ const killAfterMs = Number.isFinite(options.killAfterMs) ? options.killAfterMs : 5000
39
+ const killSeconds = Math.max(0, Math.ceil(killAfterMs / 1000))
40
+ const seconds = Math.max(1, Number(timeoutMs) / 1000)
41
+ const suffix = mode === 'kill' && killSeconds > 0 ? `-k ${killSeconds} ` : ''
42
+ return `timeout ${suffix}${seconds} ${quotePosix(shell)} -c ${quotePosix(command)}`
43
+ }
44
+
45
+ /**
46
+ * Turn the model-facing `workdir` into a shell prefix and the value's cwd label.
47
+ *
48
+ * The remote shell starts in the login home; absolute paths are used verbatim,
49
+ * `~`/`~/x` and relative paths are anchored at `$HOME`.
50
+ *
51
+ * @returns {{ command: string, cwd: string } | { error: string }}
52
+ */
53
+ export function applyWorkdir(command, workdir) {
54
+ const raw = typeof workdir === 'string' ? workdir.trim() : ''
55
+ const body = String(command ?? '')
56
+ if (!raw) return { command: body, cwd: '~' }
57
+ if (raw.includes('\n') || raw.includes('\r')) return { error: 'workdir must not contain newlines' }
58
+ if (raw.startsWith('~')) {
59
+ const rest = raw === '~' ? '' : raw.slice(1)
60
+ if (rest !== '' && !rest.startsWith('/')) return { error: 'workdir must be an absolute path, ~/path, or a relative path' }
61
+ const target = rest === '' ? '"$HOME"' : `"$HOME"${quotePosix(rest)}`
62
+ return { command: `cd -- ${target} && ${body}`, cwd: raw }
63
+ }
64
+ if (raw.startsWith('/')) {
65
+ return { command: `cd -- ${quotePosix(raw)} && ${body}`, cwd: raw }
66
+ }
67
+ return { command: `cd -- "$HOME"/${quotePosix(raw)} && ${body}`, cwd: `~/${raw}` }
68
+ }
69
+
70
+ /** Effective timeout for one call, clamped to the configured ceiling. */
71
+ export function resolveTimeoutMs(args, config) {
72
+ const ceiling = Number.isInteger(config?.maxCommandTimeoutMs) ? config.maxCommandTimeoutMs : 30 * 60 * 1000
73
+ const requested = Number.isInteger(args?.timeoutMs) ? args.timeoutMs : undefined
74
+ const fallback = Number.isInteger(config?.commandTimeoutMs) ? config.commandTimeoutMs : 120000
75
+ const value = requested ?? fallback
76
+ return Math.max(MIN_TIMEOUT_MS, Math.min(value, ceiling))
77
+ }
78
+
79
+ /**
80
+ * Whether a runtime result means the command never reached the remote shell.
81
+ * Timeouts and aborts are real command outcomes (timedOut / aborted on the
82
+ * native value), not transport failures.
83
+ */
84
+ export function isTransportFailure(result) {
85
+ if (!result || typeof result !== 'object') return true
86
+ if (result.exitCode !== null && result.exitCode !== undefined) return false
87
+ if (typeof result.signal === 'string' && result.signal.length > 0) return false
88
+ return result.outcome === 'failed'
89
+ }
package/lib/core/prefs.js CHANGED
@@ -10,7 +10,7 @@ import { prefsPath, readTextFile, writePrivateFile } from './paths.js'
10
10
  export const DEFAULT_PREFS = Object.freeze({ sidebar: true })
11
11
 
12
12
  /** Coerce a stored document into the current preference shape. */
13
- export function normalizePrefs(raw) {
13
+ function normalizePrefs(raw) {
14
14
  const prefs = { ...DEFAULT_PREFS }
15
15
  if (!raw || typeof raw !== 'object') return prefs
16
16
  if (typeof raw.sidebar === 'boolean') prefs.sidebar = raw.sidebar