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/CHANGELOG.md +133 -1
- package/CONTRIBUTING.md +5 -4
- package/README.md +47 -25
- package/SECURITY.md +2 -0
- package/docs/architecture.md +60 -38
- package/docs/execution-model.md +83 -0
- package/docs/images/chat-at-resource.png +0 -0
- package/docs/images/plugin.png +0 -0
- package/docs/images/remote-workbench.png +0 -0
- package/docs/images/workbench.png +0 -0
- package/docs/quickstart.md +24 -9
- package/lib/client.js +1249 -218
- package/lib/core/activity.js +5 -0
- package/lib/core/background.js +18 -4
- package/lib/core/broker.js +10 -7
- package/lib/core/dialect.js +1 -1
- package/lib/core/execution-stats.js +88 -0
- package/lib/core/gate-pwsh.js +1 -1
- package/lib/core/guard.js +86 -34
- package/lib/core/live-executions.js +101 -0
- package/lib/core/managed-context.js +287 -0
- package/lib/core/{targets.js → mentions.js} +49 -83
- package/lib/core/posix-shell.js +89 -0
- package/lib/core/prefs.js +36 -3
- package/lib/core/remote-exec-tool.js +441 -0
- package/lib/core/remote-file-tools.js +642 -0
- package/lib/core/{remote-bash-tool.js → remote-shell-tool.js} +58 -69
- package/lib/core/secrets.js +0 -1
- package/lib/core/server-ref.js +61 -0
- package/lib/core/shell-adapters.js +1 -1
- package/lib/core/ssh.js +22 -0
- package/lib/core/win32-adapter.js +1 -1
- package/lib/index.js +121 -157
- package/lib/routes.js +45 -5
- package/package.json +23 -7
- package/lib/core/remote-bash.js +0 -337
- package/lib/core/server-reference.js +0 -299
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-turn managed-resource context.
|
|
3
|
+
*
|
|
4
|
+
* The composer inserts an `@name` chip as plain text. This module treats it as a
|
|
5
|
+
* hint, not an execution binding: every remote tool call names its own server.
|
|
6
|
+
* This module injects one compact inventory + tool-rules message per human
|
|
7
|
+
* turn, so the model never has to read `servers.json` or switch a hidden
|
|
8
|
+
* session target before acting.
|
|
9
|
+
*
|
|
10
|
+
* The message enters the same step (`agent/pre-step` replaces the messages that
|
|
11
|
+
* enter a step), which `agent.inject()` cannot promise because it splices into
|
|
12
|
+
* the next step.
|
|
13
|
+
*
|
|
14
|
+
* Scope: every agent mode; no preset gating.
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
import { effectiveDialect } from './dialect.js'
|
|
19
|
+
import { loadServers } from './store.js'
|
|
20
|
+
import {
|
|
21
|
+
TARGET_CONTEXT_KIND,
|
|
22
|
+
createTargetStore,
|
|
23
|
+
extractServerMentions,
|
|
24
|
+
extractUserText,
|
|
25
|
+
lastHumanMessage,
|
|
26
|
+
} from './mentions.js'
|
|
27
|
+
|
|
28
|
+
export { TARGET_CONTEXT_KIND }
|
|
29
|
+
|
|
30
|
+
const DEFAULT_MAX_CONTEXT_SERVERS = 20
|
|
31
|
+
|
|
32
|
+
function describeError(error) {
|
|
33
|
+
return error instanceof Error ? error.message : String(error)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function serverLine(server) {
|
|
37
|
+
const target = `${server.username ?? 'root'}@${server.host}:${server.port ?? 22}`
|
|
38
|
+
const state = server.enabled === false ? ' · disabled' : ''
|
|
39
|
+
const dialect = effectiveDialect(server)
|
|
40
|
+
return `- ${server.name} — ${target} · ${dialect}${state}${factsSummary(server)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Compact facts summary (hostname only: stable, not a stale capacity snapshot). */
|
|
44
|
+
function factsSummary(server) {
|
|
45
|
+
const facts = server?.facts
|
|
46
|
+
if (!facts || typeof facts !== 'object') return ''
|
|
47
|
+
const hostname = typeof facts.hostname === 'string' ? facts.hostname.trim() : ''
|
|
48
|
+
return hostname ? ` · ${hostname}` : ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function localShellName(hostPlatform) {
|
|
52
|
+
return hostPlatform === 'win32' ? 'pwsh' : 'bash'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render the model-facing managed-resource context.
|
|
57
|
+
*
|
|
58
|
+
* @param {object[]} servers managed inventory
|
|
59
|
+
* @param {{
|
|
60
|
+
* candidates?: string[],
|
|
61
|
+
* lastExecuted?: string|null,
|
|
62
|
+
* hostPlatform?: 'win32'|'posix',
|
|
63
|
+
* maxServers?: number,
|
|
64
|
+
* }} [hints]
|
|
65
|
+
*/
|
|
66
|
+
export function renderManagedContext(servers, hints = {}) {
|
|
67
|
+
const inventory = Array.isArray(servers) ? servers.filter(Boolean) : []
|
|
68
|
+
if (inventory.length === 0) return ''
|
|
69
|
+
const maxServers = Number.isInteger(hints.maxServers) && hints.maxServers > 0
|
|
70
|
+
? hints.maxServers
|
|
71
|
+
: DEFAULT_MAX_CONTEXT_SERVERS
|
|
72
|
+
const listed = inventory.slice(0, maxServers)
|
|
73
|
+
const lines = [`[ssh-workbench] Managed remote resources (${inventory.length}):`]
|
|
74
|
+
for (const server of listed) lines.push(serverLine(server))
|
|
75
|
+
if (inventory.length > listed.length) {
|
|
76
|
+
lines.push(`- … and ${inventory.length - listed.length} more; call remote_exec with a resource name from the workbench UI.`)
|
|
77
|
+
}
|
|
78
|
+
lines.push('Remote execution rules:')
|
|
79
|
+
lines.push(
|
|
80
|
+
`- The native ${localShellName(hints.hostPlatform)} tool always runs on the local DSH host. Never use it for commands meant for a remote resource.`,
|
|
81
|
+
)
|
|
82
|
+
lines.push(
|
|
83
|
+
'- POSIX resources: remote_bash({ server: "<name>", command: "<command>", workdir?, timeoutMs?, run_in_background? }).',
|
|
84
|
+
)
|
|
85
|
+
lines.push(
|
|
86
|
+
'- Windows resources: remote_pwsh({ server: "<name>", command: "<command>", ... }).',
|
|
87
|
+
)
|
|
88
|
+
lines.push(
|
|
89
|
+
'- Several resources in one step: remote_exec({ tasks: [{ server: "<name>", command: "<command>" }], concurrency? }). Tasks run concurrently and never depend on a remembered target.',
|
|
90
|
+
)
|
|
91
|
+
lines.push(
|
|
92
|
+
'- Pass server explicitly on every call. The resource names above are authoritative; never read ~/.dsh/ssh-workbench/servers.json or other config files to find them.',
|
|
93
|
+
)
|
|
94
|
+
lines.push(
|
|
95
|
+
'- read/write/edit/glob/grep are the native local file tools; they never touch a remote resource.',
|
|
96
|
+
)
|
|
97
|
+
lines.push(
|
|
98
|
+
'- Remote files: remote_read({ server, path, maxBytes? }), remote_glob({ server, pattern, path?, maxResults?, maxDepth? }) and remote_grep({ server, pattern, path?, ignoreCase?, maxResults? }). Use absolute paths or paths under ~.',
|
|
99
|
+
)
|
|
100
|
+
if (hints.remoteFileWrite === true) {
|
|
101
|
+
lines.push(
|
|
102
|
+
'- Remote writes are enabled: remote_write({ server, path, content }) and remote_edit({ server, path, oldText, newText, replaceAll? }). remote_edit requires one exact match unless replaceAll=true.',
|
|
103
|
+
)
|
|
104
|
+
} else {
|
|
105
|
+
lines.push(
|
|
106
|
+
'- Remote writes are disabled: remote_write/remote_edit will refuse. Use the workbench SFTP panel for uploads, or ask the user to turn on remote writes in the plugin settings.',
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
const candidates = Array.isArray(hints.candidates) ? hints.candidates.filter(Boolean) : []
|
|
110
|
+
if (candidates.length > 0) {
|
|
111
|
+
lines.push(
|
|
112
|
+
`- The user mentioned: ${candidates.join(', ')}. Prefer these resources when the request is ambiguous, but still pass server explicitly.`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
const lastExecuted = typeof hints.lastExecuted === 'string' && hints.lastExecuted.length > 0
|
|
116
|
+
? hints.lastExecuted
|
|
117
|
+
: null
|
|
118
|
+
if (lastExecuted) lines.push(`- Last remote execution in this conversation: ${lastExecuted}.`)
|
|
119
|
+
lines.push(
|
|
120
|
+
'- If no resource is named and the request does not identify one, ask the user or use the resource named in the request; do not guess silently.',
|
|
121
|
+
)
|
|
122
|
+
return lines.join('\n')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build one user-role context message, preferring the host's message factory.
|
|
127
|
+
* `@` symbols are avoided so this message can never be re-matched as a mention.
|
|
128
|
+
*/
|
|
129
|
+
export function buildManagedContextMessage(servers, hints, createUserMessage) {
|
|
130
|
+
const text = renderManagedContext(servers, hints)
|
|
131
|
+
const source = {
|
|
132
|
+
kind: TARGET_CONTEXT_KIND,
|
|
133
|
+
form: 'notice',
|
|
134
|
+
summary: 'managed remote resources',
|
|
135
|
+
}
|
|
136
|
+
const input = { content: [{ type: 'text', text }], source }
|
|
137
|
+
if (typeof createUserMessage === 'function') return createUserMessage(input)
|
|
138
|
+
return { role: 'user', id: `ssh-workbench-context-${randomUUID()}`, ...structuredClone(input) }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Install the resource-context provider.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} ctx plugin context
|
|
145
|
+
* @param {{
|
|
146
|
+
* enabled?: boolean,
|
|
147
|
+
* remoteAvailable?: boolean,
|
|
148
|
+
* hostPlatform?: 'win32'|'posix',
|
|
149
|
+
* targets?: object,
|
|
150
|
+
* loadInventory?: () => object[],
|
|
151
|
+
* loadLastExecuted?: (sessionId: string) => string|null,
|
|
152
|
+
* remoteFileWrite?: boolean,
|
|
153
|
+
* loadRemoteFileWrite?: () => boolean,
|
|
154
|
+
* createUserMessage?: Function,
|
|
155
|
+
* }} [options]
|
|
156
|
+
*/
|
|
157
|
+
export function installManagedContext(ctx, options = {}) {
|
|
158
|
+
const enabled = options.enabled !== false
|
|
159
|
+
const remoteAvailable = options.remoteAvailable !== false
|
|
160
|
+
const loadInventory = options.loadInventory ?? loadServers
|
|
161
|
+
const targets = options.targets ?? createTargetStore({ loadInventory })
|
|
162
|
+
const maxContextServers = Number.isInteger(options.maxContextServers) && options.maxContextServers > 0
|
|
163
|
+
? options.maxContextServers
|
|
164
|
+
: DEFAULT_MAX_CONTEXT_SERVERS
|
|
165
|
+
const warn = (message) => {
|
|
166
|
+
try {
|
|
167
|
+
ctx?.logger?.warn?.(`dsh-ssh-workbench: ${message}`)
|
|
168
|
+
} catch {
|
|
169
|
+
// Logging must never fail the plugin.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!ctx || typeof ctx.on !== 'function') return { targets }
|
|
173
|
+
|
|
174
|
+
let factory = options.createUserMessage
|
|
175
|
+
const messageFactory = async () => {
|
|
176
|
+
if (factory !== undefined) return factory
|
|
177
|
+
try {
|
|
178
|
+
const mod = await import('@deepseek-ai/dsh-llm')
|
|
179
|
+
factory = typeof mod.createUserMessage === 'function' ? mod.createUserMessage : null
|
|
180
|
+
} catch {
|
|
181
|
+
factory = null
|
|
182
|
+
}
|
|
183
|
+
return factory
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const handler = async (payload, next) => {
|
|
187
|
+
const decision = await next()
|
|
188
|
+
try {
|
|
189
|
+
if (!decision || decision.kind === 'reject' || payload?.signal?.aborted) return decision
|
|
190
|
+
if (enabled === false) return decision
|
|
191
|
+
// Never describe a remote world the Host cannot execute in.
|
|
192
|
+
if (!remoteAvailable) return decision
|
|
193
|
+
const agent = payload?.agent
|
|
194
|
+
const session = agent?.session
|
|
195
|
+
const key = session?.id ?? agent?.id
|
|
196
|
+
if (typeof key !== 'string' || key.length === 0) return decision
|
|
197
|
+
let servers
|
|
198
|
+
try {
|
|
199
|
+
servers = loadInventory()
|
|
200
|
+
} catch (error) {
|
|
201
|
+
warn(`inventory unavailable: ${describeError(error)}`)
|
|
202
|
+
return decision
|
|
203
|
+
}
|
|
204
|
+
if (!Array.isArray(servers) || servers.length === 0) return decision
|
|
205
|
+
// Restore resumed-Session mention hints once; deriveMessages() is
|
|
206
|
+
// intentionally skipped on later steps.
|
|
207
|
+
if (targets.get(key)?.seeded !== true) {
|
|
208
|
+
try {
|
|
209
|
+
const history = typeof session?.deriveMessages === 'function' ? session.deriveMessages() : []
|
|
210
|
+
targets.ensureSeeded(key, history, servers)
|
|
211
|
+
} catch {
|
|
212
|
+
targets.ensureSeeded(key, [], servers)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const human = lastHumanMessage(decision.messages)
|
|
216
|
+
const mentions = human ? extractServerMentions(extractUserText(human), servers) : []
|
|
217
|
+
const state = targets.bindMentions(key, mentions, human?.id)
|
|
218
|
+
if (!state) return decision
|
|
219
|
+
const changed = state.version !== state.injectedVersion
|
|
220
|
+
const firstStep = payload?.step === 1
|
|
221
|
+
if (!changed) {
|
|
222
|
+
if (!firstStep) return decision
|
|
223
|
+
// One context message per turn is enough while it is still present in
|
|
224
|
+
// the step messages. Re-inject after compaction or a new human turn.
|
|
225
|
+
const hasContext = decision.messages.some((message) => message?.source?.kind === TARGET_CONTEXT_KIND)
|
|
226
|
+
if (hasContext) return decision
|
|
227
|
+
}
|
|
228
|
+
let lastExecuted = null
|
|
229
|
+
try {
|
|
230
|
+
lastExecuted = typeof options.loadLastExecuted === 'function' ? options.loadLastExecuted(key) : null
|
|
231
|
+
} catch {
|
|
232
|
+
lastExecuted = null
|
|
233
|
+
}
|
|
234
|
+
let remoteFileWrite = options.remoteFileWrite === true
|
|
235
|
+
if (typeof options.loadRemoteFileWrite === 'function') {
|
|
236
|
+
try {
|
|
237
|
+
remoteFileWrite = options.loadRemoteFileWrite() === true
|
|
238
|
+
} catch {
|
|
239
|
+
remoteFileWrite = false
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const hints = {
|
|
243
|
+
candidates: state.candidates,
|
|
244
|
+
lastExecuted: typeof lastExecuted === 'string' && lastExecuted.length > 0 ? lastExecuted : null,
|
|
245
|
+
hostPlatform: options.hostPlatform,
|
|
246
|
+
maxServers: maxContextServers,
|
|
247
|
+
remoteFileWrite,
|
|
248
|
+
}
|
|
249
|
+
const message = buildManagedContextMessage(servers, hints, await messageFactory())
|
|
250
|
+
targets.markInjected(key, state.version)
|
|
251
|
+
return { ...decision, messages: [...decision.messages, message] }
|
|
252
|
+
} catch (error) {
|
|
253
|
+
warn(`resource context skipped: ${describeError(error)}`)
|
|
254
|
+
return decision
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const offPreStep = ctx.on('agent/pre-step', handler, { prepend: true })
|
|
259
|
+
const offDisposed = ctx.on('agent/disposed', (disposed) => {
|
|
260
|
+
try {
|
|
261
|
+
const session = disposed?.agent?.session
|
|
262
|
+
const key = session?.id ?? disposed?.agent?.id
|
|
263
|
+
if (typeof key === 'string') targets.clear(key)
|
|
264
|
+
} catch {
|
|
265
|
+
// Best effort cleanup.
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
if (typeof ctx.effect === 'function') {
|
|
269
|
+
ctx.effect(
|
|
270
|
+
() => () => {
|
|
271
|
+
try {
|
|
272
|
+
offPreStep?.()
|
|
273
|
+
} catch {
|
|
274
|
+
// Disposal is best effort.
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
offDisposed?.()
|
|
278
|
+
} catch {
|
|
279
|
+
// Disposal is best effort.
|
|
280
|
+
}
|
|
281
|
+
targets.clearAll?.()
|
|
282
|
+
},
|
|
283
|
+
'dsh-ssh-workbench: resource context',
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
return { targets, offPreStep, offDisposed }
|
|
287
|
+
}
|
|
@@ -1,26 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-session
|
|
2
|
+
* Per-session resource hints for the composer and the injected context.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
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: [],
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
114
|
+
/** Resolve one resource reference against the inventory (name or id). */
|
|
126
115
|
export function findTarget(servers, ref) {
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
174
|
-
version: replayed.
|
|
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
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
|
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,
|
|
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
|
@@ -2,18 +2,25 @@
|
|
|
2
2
|
* Small user-visible UI preferences that are not part of the server inventory.
|
|
3
3
|
*
|
|
4
4
|
* Kept in the same private data directory as the inventory so a profile export
|
|
5
|
-
* carries them, and so the plugin manager's
|
|
5
|
+
* carries them, and so the plugin manager's settings form can change them
|
|
6
6
|
* without rewriting the profile's cordis.patch.yml.
|
|
7
|
+
*
|
|
8
|
+
* `remoteFileWrite` is deliberately tri-state: `null` means "follow the plugin
|
|
9
|
+
* config", a boolean means the user overrode it from the settings UI. Keeping
|
|
10
|
+
* the override separate from the config lets the plugin detail page show a
|
|
11
|
+
* switch without making the config file the only way in.
|
|
7
12
|
*/
|
|
8
13
|
import { prefsPath, readTextFile, writePrivateFile } from './paths.js'
|
|
9
14
|
|
|
10
|
-
export const DEFAULT_PREFS = Object.freeze({ sidebar: true })
|
|
15
|
+
export const DEFAULT_PREFS = Object.freeze({ sidebar: true, remoteFileWrite: null, systemFileHierarchy: null })
|
|
11
16
|
|
|
12
17
|
/** Coerce a stored document into the current preference shape. */
|
|
13
|
-
|
|
18
|
+
function normalizePrefs(raw) {
|
|
14
19
|
const prefs = { ...DEFAULT_PREFS }
|
|
15
20
|
if (!raw || typeof raw !== 'object') return prefs
|
|
16
21
|
if (typeof raw.sidebar === 'boolean') prefs.sidebar = raw.sidebar
|
|
22
|
+
if (typeof raw.remoteFileWrite === 'boolean') prefs.remoteFileWrite = raw.remoteFileWrite
|
|
23
|
+
if (typeof raw.systemFileHierarchy === 'boolean') prefs.systemFileHierarchy = raw.systemFileHierarchy
|
|
17
24
|
return prefs
|
|
18
25
|
}
|
|
19
26
|
|
|
@@ -36,3 +43,29 @@ export function savePrefs(patch) {
|
|
|
36
43
|
writePrivateFile(prefsPath(), `${JSON.stringify(next, null, 2)}\n`)
|
|
37
44
|
return next
|
|
38
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the write switch the tools and the model context should honour.
|
|
49
|
+
*
|
|
50
|
+
* A stored override wins; otherwise the plugin config decides. Anything that
|
|
51
|
+
* is neither `true` nor `false` falls back to the config, so a corrupt prefs
|
|
52
|
+
* file can never silently enable remote writes.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveRemoteFileWrite(config, prefs) {
|
|
55
|
+
const override = prefs?.remoteFileWrite
|
|
56
|
+
if (override === true || override === false) return override
|
|
57
|
+
return config?.remoteFileWrite === true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the hierarchical system file browser switch.
|
|
62
|
+
*
|
|
63
|
+
* Same tri-state contract as the write switch: a settings-page override wins,
|
|
64
|
+
* otherwise the plugin config decides. Defaults to off so the native files tab
|
|
65
|
+
* is untouched unless the user explicitly asks for the hierarchical browser.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveSystemFileHierarchy(config, prefs) {
|
|
68
|
+
const override = prefs?.systemFileHierarchy
|
|
69
|
+
if (override === true || override === false) return override
|
|
70
|
+
return config?.systemFileHierarchy === true
|
|
71
|
+
}
|