dsh-remote 0.8.52 → 0.8.53
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/README.en.md +4 -0
- package/README.md +22 -0
- package/lib/exec-job.js +221 -0
- package/lib/index.js +156 -11
- package/lib/instructions-dynamic.js +336 -0
- package/lib/pool.js +8 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -293,6 +293,10 @@ Two exceptions (0.8.52+): `.md`/`.markdown` links are **not** taken over and sta
|
|
|
293
293
|
|
|
294
294
|
**Remote Sessions could not see the workspace AGENTS.md** (issue #68) — fixed (0.8.52+). The core instruction loader only knows the local filesystem, and a remote Session's cwd is the local mirror, so the remote repo's AGENTS.md never reached the model's context. Now, when `rw_pick_workspace` succeeds, the remote workspace gets a shallow scan (root + at most two levels down, matching `AGENTS.md`/`CLAUDE.md` only); the found files are synced into the local mirror under a three-way guard (root-level copies thus enter the core's own injection path, exactly like a local Session), and an index of paths + headings is rendered into the system prompt, telling the model to read nested instruction files with `rw_read_file` before editing. Full-tree recursion and plugin-side full-text injection are deliberately **not** done (the former would scan through multi-project roots; the latter would run a second injection pipeline in parallel with the core's).
|
|
295
295
|
|
|
296
|
+
**Does reading a file in a remote subdirectory pull that subdirectory's AGENTS.md into context?** (issue #71) — yes (0.8.53+). #68 fixed only the static half (workspace shallow scan + index line); the dynamic half was still broken, because the core's incremental injection recognizes only `read`/`write`/`edit` with a `file_path` argument — `rw_read_file` matches on neither name nor argument — and it expands scopes using LOCAL paths, so a remote absolute path maps onto no mirror cwd. Any one of those three gaps was enough to stop the injection from ever happening. The plugin now hooks `rw_*` file touches itself on `tools/result`, takes the WHOLE ancestor chain from the touched directory up to the workspace root (not just the deepest level, matching local Sessions: reading `a/b/c.ts` brings in both `a/AGENTS.md` and `a/b/AGENTS.md`), stats each level, and injects the current contents as ONE user message. Boundaries that matter: the cwd's own level is not re-scanned (the core baseline covers it, so reading a file directly in the workspace root triggers no extra scan); delivery is deduplicated per session by content digest, so the same content is injected once; a remote edit is re-injected on the next touch; a previously injected file that disappears produces a "removed" notice; one batch has a 16 KB budget; and an offline machine degrades silently — it hangs neither the tool call nor the prompt assembly. The message carries the plugin's own `source.kind` (`dsh-remote-instructions`), while the core's state machine claims only its own `agent-instructions`, so the two injection paths cannot fight.
|
|
297
|
+
|
|
298
|
+
**Can `rw_exec` take a per-call timeout, be cancelled, and move to the background like the built-in shell?** (issue #70) — yes (0.8.53+). The transport (`pool.exec`) already supported `timeoutMs` and `AbortSignal`; what was missing was the wiring, and all three pieces are now in place: (1) a `timeoutMs` argument (clamped to 1000-600000, defaulting to `commandTimeoutMs`) whose timeout message prints the value ACTUALLY APPLIED rather than the config value; (2) `exec.signal` is forwarded, so pressing "stop" really SIGTERMs the remote process and returns at once instead of waiting out the timeout; (3) `run_in_background: true` returns a job id immediately (read with the existing `job_output`, stop with `job_kill`), and a command that outlives its timeout is PROMOTED to a background job instead of being killed (`promoteOnTimeout`, on by default). Background execution and promotion need the core `jobs` service; a composition without it (minimal embedding, test double) degrades to foreground-only execution — the 0.8.52 behaviour — rather than failing to load.
|
|
299
|
+
|
|
296
300
|
**The terminal in a remote Session is still the local one** (issue #55) — that is **by design, and a plugin cannot change it**, but there is an equivalent: DSH's sidebar terminal is core, its PTY comes from the **context-global** `subprocess` service, and its cwd is the Session's own workspace directory (the local mirror for a remote Session). A plugin can neither replace that service nor retarget the core terminal onto SSH. For a **genuine remote terminal** use the "Remote DSH Web UI" (above): it opens the **remote machine's own** DSH in this browser, and that interface's terminal runs on the remote host, with no extra configuration. To run a single remote command instead, use `rw_exec` (add `pty: true` for an interactive PTY).
|
|
297
301
|
|
|
298
302
|
**Host key changed** — `/remote forget-key` (or Settings → machine → trust again).
|
package/README.md
CHANGED
|
@@ -296,6 +296,28 @@ scripts/dev-run.sh --status # 是否在运行
|
|
|
296
296
|
system prompt,提示模型编辑前用 `rw_read_file` 阅读嵌套层的指令。全树递归扫描与插件侧全文注入都**刻意
|
|
297
297
|
不做**(前者会扫穿多项目根目录,后者会与核心的注入管线并行打架)。
|
|
298
298
|
|
|
299
|
+
**读了远程子目录的文件,子目录里的 AGENTS.md 会自动进上下文吗?**(issue #71)—— 会(0.8.53+)。
|
|
300
|
+
#68 只修了静态部分(工作区浅扫 + 索引行);动态部分当时还断着,原因是核心的增量注入只认
|
|
301
|
+
`read`/`write`/`edit` 且只认 `file_path` 参数,而 `rw_read_file` 名字和参数都对不上,且它做作用域
|
|
302
|
+
扩展时用的是**本地**路径——远程绝对路径映射不到镜像 cwd,三个断点任一都足以让注入永不发生。
|
|
303
|
+
现在插件自己在 `tools/result` 里接 `rw_*` 文件触碰,取「被访问目录 → 工作区根」的**整条目录链**
|
|
304
|
+
(不是最近一层,与本地会话一致:读 `a/b/c.ts` 会同时收 `a/AGENTS.md` 和 `a/b/AGENTS.md`),逐层
|
|
305
|
+
`stat` 后把当前内容合成**一条** user message 注入。几个关键边界:cwd 自己那层不重扫(核心 baseline
|
|
306
|
+
已覆盖,所以读工作区根下的文件不触发任何额外扫描);按内容摘要 per-session 去重,同内容只注入一次;
|
|
307
|
+
远端修改后下次触碰即重新注入;注入过的文件被删除会发一条「已移除」提示;单次注入有 16 KB 预算;
|
|
308
|
+
机器离线时整条链路静默降级,既不挂工具调用也不挂 prompt 组装。注入消息用插件自己的
|
|
309
|
+
`source.kind`(`dsh-remote-instructions`),核心的状态机只认领它自己的 `agent-instructions`,两条
|
|
310
|
+
注入路径不会互相打架。
|
|
311
|
+
|
|
312
|
+
**`rw_exec` 能像内置 shell 那样自定义超时/取消/转后台吗?**(issue #70)—— 能(0.8.53+)。
|
|
313
|
+
底层 `pool.exec` 本来就支持 `timeoutMs` 与 `AbortSignal`,缺的是接线,现在补齐三件事:
|
|
314
|
+
① `timeoutMs` 参数(clamp 到 1000–600000,缺省仍用 `commandTimeoutMs`,超时文案打印**实际生效**的值,
|
|
315
|
+
不会印错成配置值);② `exec.signal` 透传给 `pool.exec`——点「停止」会真的 SIGTERM 掉远端进程并立即
|
|
316
|
+
返回,不用再等满超时;③ `run_in_background: true` 立即返回 job id(用现成的 `job_output` 读、
|
|
317
|
+
`job_kill` 停),以及超时**转后台**而非直接杀(`promoteOnTimeout`,默认开)。后台/转后台依赖核心的
|
|
318
|
+
`jobs` 服务;没有该服务的组合(最小嵌入、测试替身)自动退化为只有前台执行——即 0.8.52 的行为,
|
|
319
|
+
不会因此加载失败。
|
|
320
|
+
|
|
299
321
|
**远程会话里的终端还是本机的**(issue #55)—— 这是**设计如此,插件改不了**,但有等效方案。
|
|
300
322
|
DSH 的侧栏终端是核心功能:它的 PTY 来自**全局唯一**的 `subprocess` 服务,cwd 取会话自己的
|
|
301
323
|
工作区目录(远程会话就是本地镜像),插件既不能替换那个服务、也不能把核心终端重定向到 SSH。
|
package/lib/exec-job.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// dsh-remote — remote command execution: per-call timeout, real cancellation,
|
|
2
|
+
// and background jobs (issue #70).
|
|
3
|
+
//
|
|
4
|
+
// The transport (`SshPool.exec`) already supported `opts.timeoutMs` and
|
|
5
|
+
// `opts.signal` — #70 was pure wiring on top of it:
|
|
6
|
+
//
|
|
7
|
+
// 1. `rw_exec` had no per-call `timeoutMs`, so a 10-minute build meant editing
|
|
8
|
+
// the machine-wide `commandTimeoutMs` config and editing it back.
|
|
9
|
+
// 2. `exec.signal` was never forwarded, so pressing "stop" left the tool
|
|
10
|
+
// running until the full timeout elapsed (rw_search already forwarded it).
|
|
11
|
+
// 3. A timeout always SIGTERM'd the command. There was no way to hand a long
|
|
12
|
+
// build to the background and collect it later, unlike the built-in shell.
|
|
13
|
+
//
|
|
14
|
+
// This module owns the pieces that are easy to get subtly wrong:
|
|
15
|
+
// • the timeout clamp and the "effective value" reported in the message (the
|
|
16
|
+
// old code printed the CONFIG value, which becomes a lie the moment a
|
|
17
|
+
// per-call override exists);
|
|
18
|
+
// • streaming taps, so a background job's output reaches its ring instead of
|
|
19
|
+
// only the return value;
|
|
20
|
+
// • the job lifecycle: register → wait → settle/remove, with cancellation
|
|
21
|
+
// flowing through the SAME AbortSignal the transport honors, so a kill
|
|
22
|
+
// actually stops the remote process rather than just abandoning the call.
|
|
23
|
+
//
|
|
24
|
+
// The core `jobs` service is optional. Without it (a minimal embedding, a test
|
|
25
|
+
// double) everything degrades to foreground-only execution — the tool still
|
|
26
|
+
// works, it just cannot be promoted.
|
|
27
|
+
|
|
28
|
+
/** Per-call timeout bounds, matching the built-in shell tool's shape. */
|
|
29
|
+
export const EXEC_TIMEOUT_MIN_MS = 1000
|
|
30
|
+
export const EXEC_TIMEOUT_MAX_MS = 600000
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the effective timeout for one `rw_exec` call.
|
|
34
|
+
*
|
|
35
|
+
* A supplied value is clamped into [MIN, MAX]; an absent/invalid one falls back
|
|
36
|
+
* to the machine-wide `commandTimeoutMs` (itself clamped, so a bad config can
|
|
37
|
+
* never produce a nonsensical deadline).
|
|
38
|
+
*
|
|
39
|
+
* @param {unknown} value - the call's `timeoutMs` argument.
|
|
40
|
+
* @param {number} fallback - `config.commandTimeoutMs`.
|
|
41
|
+
* @returns {number} milliseconds to actually apply.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveExecTimeout(value, fallback) {
|
|
44
|
+
const clamp = (n) => Math.min(Math.max(Math.round(n), EXEC_TIMEOUT_MIN_MS), EXEC_TIMEOUT_MAX_MS)
|
|
45
|
+
const n = Number(value)
|
|
46
|
+
if (Number.isFinite(n) && n > 0) return clamp(n)
|
|
47
|
+
const f = Number(fallback)
|
|
48
|
+
if (Number.isFinite(f) && f > 0) return clamp(f)
|
|
49
|
+
return clamp(20000)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Render a finished command into the tool's text form. Kept next to the timeout
|
|
54
|
+
* resolution so the "timed out after Nms" line can NEVER disagree with the
|
|
55
|
+
* deadline that was actually applied (issue #70 acceptance criterion 1).
|
|
56
|
+
*
|
|
57
|
+
* @param {{code: number|null, signal: string|null, stdout: string, stderr: string}} res
|
|
58
|
+
* @param {{timeoutMs: number, cancelled?: boolean}} opts
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function formatExecResult(res, { timeoutMs, cancelled = false } = {}) {
|
|
62
|
+
const parts = []
|
|
63
|
+
if (res.stdout) parts.push(String(res.stdout).replace(/\s+$/, ''))
|
|
64
|
+
if (res.stderr) parts.push('-- stderr --\n' + String(res.stderr).replace(/\s+$/, ''))
|
|
65
|
+
if (!parts.length) parts.push('(no output)')
|
|
66
|
+
let text = parts.join('\n')
|
|
67
|
+
if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${timeoutMs}ms]`
|
|
68
|
+
else if (cancelled || res.signal === 'ABORTED') text += '\n[cancelled — the remote command was stopped]'
|
|
69
|
+
else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
|
|
70
|
+
return text
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Register one remote command as a core background job.
|
|
75
|
+
*
|
|
76
|
+
* The producer owns execution: the command starts inside `run()` (so a
|
|
77
|
+
* preflight rejection leaves no SSH channel behind), its output is pushed into
|
|
78
|
+
* the job ring as it arrives, and `cancel` aborts the SAME AbortSignal the
|
|
79
|
+
* transport turns into a SIGTERM on the remote process. That last point is what
|
|
80
|
+
* makes `job_kill` real rather than cosmetic.
|
|
81
|
+
*
|
|
82
|
+
* Timeout ownership is subtle and got this wrong once: a JOB's deadline must
|
|
83
|
+
* not be enforced by the transport, because the whole point of a background
|
|
84
|
+
* job is to OUTLIVE the tool call that started it. Passing `timeoutMs` down
|
|
85
|
+
* would make the transport SIGTERM the command at the very moment the caller
|
|
86
|
+
* promotes it. So the transport gets a deadline only when this job is meant to
|
|
87
|
+
* be bounded on its own (`jobDeadlineMs`); the foreground/promotion decision is
|
|
88
|
+
* made by the CALLER's wait, not by the exec.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} registry - the core `jobs` service (`ctx.jobs`).
|
|
91
|
+
* @param {object} spec
|
|
92
|
+
* @param {object} spec.pool - the machine's SshPool.
|
|
93
|
+
* @param {string} spec.command - the full command line to run.
|
|
94
|
+
* @param {string} [spec.label] - one-line model-facing label (defaults to command).
|
|
95
|
+
* @param {any} [spec.owner] - owning session id, when the caller has one.
|
|
96
|
+
* @param {number} [spec.jobDeadlineMs] - optional deadline enforced ON THE
|
|
97
|
+
* COMMAND itself (omitted ⇒ the job runs until it finishes or is killed).
|
|
98
|
+
* @param {boolean} [spec.pty]
|
|
99
|
+
* @param {object} [spec.env]
|
|
100
|
+
* @returns {{id: string, cancel: Function}}
|
|
101
|
+
*/
|
|
102
|
+
export function startExecJob(registry, { pool, command, label, owner, jobDeadlineMs, pty, env }) {
|
|
103
|
+
let abort = null
|
|
104
|
+
let cancelled = null
|
|
105
|
+
const id = registry.start({
|
|
106
|
+
kind: 'rw-exec',
|
|
107
|
+
label: String(label || command || 'rw_exec'),
|
|
108
|
+
...(owner ? { owner } : {}),
|
|
109
|
+
run: (job) => {
|
|
110
|
+
const ac = new AbortController()
|
|
111
|
+
abort = ac
|
|
112
|
+
const done = (async () => {
|
|
113
|
+
let res
|
|
114
|
+
try {
|
|
115
|
+
res = await pool.exec(command, {
|
|
116
|
+
// 0/undefined ⇒ no transport deadline: the job outlives the call.
|
|
117
|
+
...(jobDeadlineMs ? { timeoutMs: jobDeadlineMs } : {}),
|
|
118
|
+
pty: !!pty,
|
|
119
|
+
env,
|
|
120
|
+
signal: ac.signal,
|
|
121
|
+
// Stream into the ring: a background job's value is its output, and
|
|
122
|
+
// the model reads it later through job_output, not from a return
|
|
123
|
+
// value it already lost.
|
|
124
|
+
onData: (channel, chunk) => {
|
|
125
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
|
|
126
|
+
if (!text) return
|
|
127
|
+
try { job.append(text, { channel }) } catch { /* settled: ring closed */ }
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return { status: 'failed', detail: String((err && err.message) || err) }
|
|
132
|
+
}
|
|
133
|
+
if (res.signal === 'ABORTED' || cancelled) return { status: 'killed', detail: cancelled || 'cancelled' }
|
|
134
|
+
if (res.signal === 'TIMEOUT') return { status: 'killed', detail: `timed out after ${jobDeadlineMs}ms` }
|
|
135
|
+
const code = res.code
|
|
136
|
+
return {
|
|
137
|
+
status: code === 0 ? 'completed' : 'failed',
|
|
138
|
+
detail: code === 0 ? 'exit code: 0' : `exit code: ${code}`,
|
|
139
|
+
}
|
|
140
|
+
})()
|
|
141
|
+
return {
|
|
142
|
+
done,
|
|
143
|
+
// Synchronous, idempotent, and it reaches the remote process: the
|
|
144
|
+
// transport turns this abort into `stream.signal('SIGTERM')`.
|
|
145
|
+
cancel: (reason) => {
|
|
146
|
+
if (cancelled === null) cancelled = String(reason || 'cancelled')
|
|
147
|
+
try { ac.abort() } catch { /* already aborted */ }
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
return {
|
|
153
|
+
id,
|
|
154
|
+
cancel: (reason) => {
|
|
155
|
+
if (cancelled === null) cancelled = String(reason || 'cancelled')
|
|
156
|
+
try { abort && abort.abort() } catch { /* not started yet */ }
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Run one remote command in the FOREGROUND, optionally promoting it to the
|
|
163
|
+
* background when it outlives `timeoutMs`.
|
|
164
|
+
*
|
|
165
|
+
* Without a job registry this is a plain bounded exec. With one, the command is
|
|
166
|
+
* registered first so the caller can be handed a live id the moment it stops
|
|
167
|
+
* waiting — the built-in shell's "promote on timeout" contract.
|
|
168
|
+
*
|
|
169
|
+
* @returns {Promise<{promoted: boolean, jobId?: string, text: string}>}
|
|
170
|
+
*/
|
|
171
|
+
export async function execWithOptionalPromotion({
|
|
172
|
+
registry, pool, command, label, owner, timeoutMs, pty, env, signal,
|
|
173
|
+
formatResult,
|
|
174
|
+
}) {
|
|
175
|
+
if (!registry) {
|
|
176
|
+
// No registry: no promotion is possible, so the timeout is the transport's
|
|
177
|
+
// job (bounded foreground execution, the pre-#70 behaviour).
|
|
178
|
+
const res = await pool.exec(command, { timeoutMs, pty: !!pty, env, signal })
|
|
179
|
+
return { promoted: false, text: formatResult(res, { timeoutMs }) }
|
|
180
|
+
}
|
|
181
|
+
// The transport gets NO deadline here on purpose: this command is registered
|
|
182
|
+
// as a job from the start, and the ONLY thing `timeoutMs` bounds is how long
|
|
183
|
+
// THIS CALL waits before handing it to the background. Giving the exec the
|
|
184
|
+
// same deadline would SIGTERM the command at the moment of promotion — the
|
|
185
|
+
// exact bug the issue #70 live E2E caught.
|
|
186
|
+
const job = startExecJob(registry, { pool, command, label, owner, pty, env })
|
|
187
|
+
let view
|
|
188
|
+
try {
|
|
189
|
+
view = await registry.wait(job.id, timeoutMs, owner, signal)
|
|
190
|
+
} catch {
|
|
191
|
+
// The WAIT was cancelled (the caller pressed stop), not necessarily the
|
|
192
|
+
// job: stop the job too, or the remote command keeps running behind a dead
|
|
193
|
+
// tool call.
|
|
194
|
+
try { registry.kill(job.id, owner, 'tool call aborted') } catch { /* already settled */ }
|
|
195
|
+
job.cancel('tool call aborted')
|
|
196
|
+
try { registry.remove(job.id, owner) } catch { /* settlement pending */ }
|
|
197
|
+
const aborted = { code: -1, signal: 'ABORTED', stdout: '', stderr: '' }
|
|
198
|
+
return { promoted: false, text: formatResult(aborted, { timeoutMs, cancelled: true }) }
|
|
199
|
+
}
|
|
200
|
+
if (view && (view.status === 'running' || view.status === 'stopping')) {
|
|
201
|
+
// Promotion: the command keeps running under the registry, the caller gets
|
|
202
|
+
// an id it can drain with job_output and stop with job_kill.
|
|
203
|
+
return {
|
|
204
|
+
promoted: true,
|
|
205
|
+
jobId: job.id,
|
|
206
|
+
text: `[still running after ${timeoutMs}ms; moved to background job ${job.id}]\n`
|
|
207
|
+
+ `Read its output with job_output (job_id="${job.id}") and stop it with job_kill.`,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Settled inside the deadline: collect the ring (the registry's own cursor)
|
|
211
|
+
// and drop the record — the model never saw the id, so it must not linger.
|
|
212
|
+
let text = ''
|
|
213
|
+
try {
|
|
214
|
+
const read = registry.read(job.id, owner)
|
|
215
|
+
text = (read.chunks || []).map((c) => c.text).join('')
|
|
216
|
+
if (read.result) text = text ? text + '\n' + read.result : read.result
|
|
217
|
+
} catch { /* fall through with an empty body */ }
|
|
218
|
+
try { registry.remove(job.id, owner) } catch { /* settlement pending */ }
|
|
219
|
+
const detail = (view && view.detail) || ''
|
|
220
|
+
return { promoted: false, text: formatResult({ code: 0, signal: null, stdout: text, stderr: '' }, { timeoutMs }) + (detail ? `\n[${detail}]` : '') }
|
|
221
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -39,6 +39,8 @@ import { createRemoteFileReference, installFileReferenceOverlay, DEFAULT_EXCLUDE
|
|
|
39
39
|
import { getSecret, deleteSecret, platformBackend, persistPassword } from './credential.js'
|
|
40
40
|
import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
|
|
41
41
|
import { createInstructionIndex, formatInstructionIndex } from './instructions.js'
|
|
42
|
+
import { resolveExecTimeout, formatExecResult, startExecJob, execWithOptionalPromotion } from './exec-job.js'
|
|
43
|
+
import { createInjectionTracker, refreshTouched, buildInstructionMessage } from './instructions-dynamic.js'
|
|
42
44
|
import { searchRemote } from './search.js'
|
|
43
45
|
import { installTerminalShell } from './remote-shell.js'
|
|
44
46
|
import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
|
|
@@ -1007,6 +1009,26 @@ export async function apply(ctx, config) {
|
|
|
1007
1009
|
appendFileSync(auditFile(), line, 'utf8')
|
|
1008
1010
|
} catch {}
|
|
1009
1011
|
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* The core background-job registry, or null when this composition has none.
|
|
1015
|
+
*
|
|
1016
|
+
* Issue #70: `ctx.jobs` is an OPTIONAL service — a minimal embedding, a test
|
|
1017
|
+
* double, or a plugin-only harness may mount no registry at all. Resolving it
|
|
1018
|
+
* lazily (rather than as an injected dependency) keeps the plugin loadable
|
|
1019
|
+
* there and degrades `rw_exec` to foreground-only, which is exactly the
|
|
1020
|
+
* pre-#70 behaviour instead of a hard failure at load time.
|
|
1021
|
+
*
|
|
1022
|
+
* @returns {object|null} the registry, or null.
|
|
1023
|
+
*/
|
|
1024
|
+
const jobRegistry = () => {
|
|
1025
|
+
try {
|
|
1026
|
+
const jobs = ctx.get('jobs')
|
|
1027
|
+
return jobs && typeof jobs.start === 'function' ? jobs : null
|
|
1028
|
+
} catch {
|
|
1029
|
+
return null
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1010
1032
|
const readAudit = (limit) => {
|
|
1011
1033
|
try {
|
|
1012
1034
|
const text = readFileSync(auditFile(), 'utf8')
|
|
@@ -1133,6 +1155,35 @@ export async function apply(ctx, config) {
|
|
|
1133
1155
|
// and lists them in the system-prompt section below. See lib/instructions.js.
|
|
1134
1156
|
const instructionIndex = createInstructionIndex()
|
|
1135
1157
|
|
|
1158
|
+
// Issue #71: per-session record of which remote instruction files were
|
|
1159
|
+
// already delivered, plus the sweep throttle. See the tools/result hook below.
|
|
1160
|
+
const instructionTracker = createInjectionTracker()
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* The core's user-message factory, when this composition exposes one.
|
|
1164
|
+
*
|
|
1165
|
+
* Issue #71's design leans on the message being a NORMAL user message with a
|
|
1166
|
+
* plugin-owned `source.kind` (consumers fall through unknown kinds; the
|
|
1167
|
+
* core's reconcile only claims `agent-instructions`). The factory lives in
|
|
1168
|
+
* `@deepseek-ai/dsh-llm`, which is not a dependency of this plugin — so it is
|
|
1169
|
+
* resolved opportunistically and a minimal object of the same shape is used
|
|
1170
|
+
* when it is unavailable. The two are interchangeable for every consumer
|
|
1171
|
+
* that matters here (the LLM request builder reads role + content).
|
|
1172
|
+
*
|
|
1173
|
+
* @returns {Function|null} the factory, or null to use the built-in shape.
|
|
1174
|
+
*/
|
|
1175
|
+
const userMessageFactory = () => {
|
|
1176
|
+
try {
|
|
1177
|
+
const llm = ctx.get('llm')
|
|
1178
|
+
const make = llm && typeof llm.createUserMessage === 'function'
|
|
1179
|
+
? llm.createUserMessage
|
|
1180
|
+
: (llm && llm.default && typeof llm.default.createUserMessage === 'function' ? llm.default.createUserMessage : null)
|
|
1181
|
+
return make ? (input) => make.call(llm, input) : null
|
|
1182
|
+
} catch {
|
|
1183
|
+
return null
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1136
1187
|
// Helpers shared by session-bound tools and the machine-scoped web routes take
|
|
1137
1188
|
// the pool explicitly (`p`), defaulting to the active-machine pool so route
|
|
1138
1189
|
// callers — which are machine-scoped by design — stay unchanged.
|
|
@@ -2406,12 +2457,15 @@ export async function apply(ctx, config) {
|
|
|
2406
2457
|
defineTool({
|
|
2407
2458
|
name: 'rw_exec',
|
|
2408
2459
|
description:
|
|
2409
|
-
'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped. Runs in the current remote workspace by default; pass cwd to run elsewhere. pty=true helps interactive commands (sudo prompts, REPLs); env sets environment variables.',
|
|
2460
|
+
'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped. Runs in the current remote workspace by default; pass cwd to run elsewhere. pty=true helps interactive commands (sudo prompts, REPLs); env sets environment variables. timeoutMs (1000-600000) overrides the machine-wide commandTimeoutMs for this call only. A command that outlives its timeout is MOVED TO A BACKGROUND JOB instead of being killed (pass promoteOnTimeout=false to kill it instead); run_in_background=true returns immediately with a job id. Background jobs are read with job_output and stopped with job_kill.',
|
|
2410
2461
|
parameters: {
|
|
2411
2462
|
command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
|
|
2412
2463
|
cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
|
|
2413
2464
|
pty: { type: 'boolean', description: 'Allocate a pseudo-terminal (interactive commands, default false)' },
|
|
2414
2465
|
env: { type: 'object', additionalProperties: true, description: 'Extra environment variables (string values)' },
|
|
2466
|
+
timeoutMs: { type: 'integer', description: 'Timeout for THIS call in milliseconds (clamped to 1000-600000; default: the commandTimeoutMs config)' },
|
|
2467
|
+
run_in_background: { type: 'boolean', description: 'Return a job id immediately and let the command run in the background (read it with job_output, stop it with job_kill)' },
|
|
2468
|
+
promoteOnTimeout: { type: 'boolean', description: 'When the command outlives its timeout, move it to a background job instead of killing it (default true; needs the core jobs service)' },
|
|
2415
2469
|
},
|
|
2416
2470
|
output: textOut,
|
|
2417
2471
|
async execute(args, exec) {
|
|
@@ -2434,18 +2488,40 @@ export async function apply(ctx, config) {
|
|
|
2434
2488
|
full = `cd ${shq(cwd)} && ${cmd}`
|
|
2435
2489
|
}
|
|
2436
2490
|
}
|
|
2491
|
+
// Issue #70: a per-call override, clamped, and the value we report is
|
|
2492
|
+
// the one we actually applied (never the config value behind an
|
|
2493
|
+
// override's back).
|
|
2494
|
+
const timeoutMs = resolveExecTimeout(args.timeoutMs, config.commandTimeoutMs)
|
|
2495
|
+
const registry = jobRegistry()
|
|
2496
|
+
const owner = exec && exec.agent ? exec.agent.id : undefined
|
|
2497
|
+
const promote = registry !== null && args.promoteOnTimeout !== false
|
|
2437
2498
|
try {
|
|
2438
|
-
|
|
2499
|
+
if (args.run_in_background) {
|
|
2500
|
+
if (!registry) {
|
|
2501
|
+
throw new Error('rw_exec: run_in_background needs the DSH jobs service, which this composition does not provide')
|
|
2502
|
+
}
|
|
2503
|
+
// A background job needs its OWN deadline, or nothing would ever
|
|
2504
|
+
// stop a runaway command. `timeoutMs` becomes that deadline here
|
|
2505
|
+
// (unlike the promote path, where it only bounds the caller's wait).
|
|
2506
|
+
const job = startExecJob(registry, { pool: b.pool, command: full, label: cmd, owner, jobDeadlineMs: timeoutMs, pty: args.pty, env: args.env })
|
|
2507
|
+
audit('exec-background', cmd, 0, b)
|
|
2508
|
+
return {
|
|
2509
|
+
text: `started in the background: jobId=${job.id}\n`
|
|
2510
|
+
+ `Read its output with job_output (job_id="${job.id}"); stop it with job_kill.\n`
|
|
2511
|
+
+ `(the job is killed if it outlives ${timeoutMs}ms)`,
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
if (promote) {
|
|
2515
|
+
const r = await execWithOptionalPromotion({
|
|
2516
|
+
registry, pool: b.pool, command: full, label: cmd, owner, timeoutMs,
|
|
2517
|
+
pty: args.pty, env: args.env, signal: exec && exec.signal, formatResult: formatExecResult,
|
|
2518
|
+
})
|
|
2519
|
+
audit('exec', cmd, r.promoted ? 0 : 0, b)
|
|
2520
|
+
return { text: r.text }
|
|
2521
|
+
}
|
|
2522
|
+
const res = await b.pool.exec(full, { timeoutMs, pty: !!args.pty, env: args.env, signal: exec && exec.signal })
|
|
2439
2523
|
audit('exec', cmd, res.code, b)
|
|
2440
|
-
|
|
2441
|
-
if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
|
|
2442
|
-
if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
|
|
2443
|
-
if (!parts.length) parts.push('(no output)')
|
|
2444
|
-
let text = parts.join('\n')
|
|
2445
|
-
if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${config.commandTimeoutMs}ms]`
|
|
2446
|
-
else if (res.signal === 'ABORTED') text += '\n[cancelled — the remote command was stopped]'
|
|
2447
|
-
else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
|
|
2448
|
-
return { text }
|
|
2524
|
+
return { text: formatExecResult(res, { timeoutMs }) }
|
|
2449
2525
|
} catch (err) {
|
|
2450
2526
|
throw new Error(friendlyMessage(err, { host: b.host, port: b.port }))
|
|
2451
2527
|
}
|
|
@@ -2787,6 +2863,75 @@ export async function apply(ctx, config) {
|
|
|
2787
2863
|
})
|
|
2788
2864
|
}
|
|
2789
2865
|
|
|
2866
|
+
// ── dynamic remote instruction injection (issue #71) ──────────────────────
|
|
2867
|
+
// #68 delivered the STATIC half (shallow scan + mirror + prompt index). The
|
|
2868
|
+
// dynamic half the core does for local sessions was still missing, because
|
|
2869
|
+
// the core's incremental path is keyed on (a) the tool names read/write/edit
|
|
2870
|
+
// and (b) a `file_path` argument, and it expands scopes using LOCAL paths.
|
|
2871
|
+
// `rw_read_file` is named differently, takes `path`, and its argument is a
|
|
2872
|
+
// REMOTE absolute path — none of the three can ever line up.
|
|
2873
|
+
//
|
|
2874
|
+
// So the plugin does it itself, without touching core: after any successful
|
|
2875
|
+
// rw_* file touch, take the touched path's whole ancestor chain below the
|
|
2876
|
+
// workspace root, stat each level's AGENTS.md / CLAUDE.md, and hand the
|
|
2877
|
+
// current contents to the model as ONE user message carrying our OWN source
|
|
2878
|
+
// kind. Consumers fall through unknown kinds, and the core's reconcile only
|
|
2879
|
+
// claims its own `agent-instructions` kind — which is exactly why the two
|
|
2880
|
+
// injection paths cannot fight over the same inbox slot.
|
|
2881
|
+
//
|
|
2882
|
+
// Everything here is best-effort: the hook never rejects, never blocks the
|
|
2883
|
+
// tool result, and treats an offline machine as "nothing to inject".
|
|
2884
|
+
if (typeof ctx.on === 'function') {
|
|
2885
|
+
ctx.on('tools/result', async (result, hookContext) => {
|
|
2886
|
+
try {
|
|
2887
|
+
const agent = hookContext && (hookContext.agent || (result && result.agent))
|
|
2888
|
+
const name = String((hookContext && hookContext.name) || (result && result.name) || '')
|
|
2889
|
+
// Only the tools that actually reach a remote file. `rw_exec` is
|
|
2890
|
+
// deliberately excluded: a shell command's argument is not a path we
|
|
2891
|
+
// can trust, and scanning arbitrary strings would be a guess.
|
|
2892
|
+
const TOUCH_TOOLS = new Set(['rw_read_file', 'rw_write_file', 'rw_edit', 'rw_list_dir'])
|
|
2893
|
+
if (!TOUCH_TOOLS.has(name)) return
|
|
2894
|
+
if (!agent || !agent.session || !agent.inbox) return
|
|
2895
|
+
const args = (hookContext && hookContext.arguments) || (result && result.arguments) || {}
|
|
2896
|
+
const rawPath = String(args.path || args.file_path || '')
|
|
2897
|
+
if (!rawPath) return
|
|
2898
|
+
const cwd = agent.session.header && agent.session.header.cwd
|
|
2899
|
+
if (!cwd) return
|
|
2900
|
+
const { remotePath, machine, mirrorDir } = resolveMirrorForLocal(String(cwd))
|
|
2901
|
+
if (!remotePath || !machine) return // a LOCAL session: the core owns it
|
|
2902
|
+
const touched = normalizeRemotePath(rawPath)
|
|
2903
|
+
// Reading the workspace root itself adds no chain (the core's baseline
|
|
2904
|
+
// already covers `<root>/AGENTS.md`), so this is the "reading a file
|
|
2905
|
+
// directly in the root triggers no extra scan" boundary from the issue.
|
|
2906
|
+
const sessionId = agent.id || agent.session.id || 'unscoped'
|
|
2907
|
+
if (instructionTracker.throttled(sessionId)) return
|
|
2908
|
+
instructionTracker.swept(sessionId)
|
|
2909
|
+
const pool = poolForMachine(machine)
|
|
2910
|
+
// Never let a slow/offline machine hold the tool result: the sweep runs
|
|
2911
|
+
// against a short-lived SFTP surface and any failure is swallowed.
|
|
2912
|
+
const sftp = await pool.sftp()
|
|
2913
|
+
const { items, removed } = await refreshTouched({
|
|
2914
|
+
sftp, remoteRoot: remotePath, touched, mirrorDir, tracker: instructionTracker, sessionId,
|
|
2915
|
+
})
|
|
2916
|
+
if (!items.length && !removed.length) return
|
|
2917
|
+
const platform = pool.platform
|
|
2918
|
+
const msg = buildInstructionMessage(items, removed, {
|
|
2919
|
+
// Items carry an ABSOLUTE `remote` path; removed entries carry a
|
|
2920
|
+
// workspace-RELATIVE rel. Prefix only the latter — always joining
|
|
2921
|
+
// would double the root prefix (caught by the issue #71 live E2E).
|
|
2922
|
+
display: (p) => {
|
|
2923
|
+
const s = String(p)
|
|
2924
|
+
const abs = s.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(s)
|
|
2925
|
+
return toDisplayPath(abs ? s : joinRemotePath(remotePath, s), platform)
|
|
2926
|
+
},
|
|
2927
|
+
createUserMessage: userMessageFactory(),
|
|
2928
|
+
})
|
|
2929
|
+
if (!msg) return
|
|
2930
|
+
agent.inbox.prepend('next-step', msg)
|
|
2931
|
+
} catch { /* best effort — an injection failure must never break the tool */ }
|
|
2932
|
+
})
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2790
2935
|
// ── slash commands ─────────────────────────────────────────────────────────
|
|
2791
2936
|
const commands = ctx.get('commands')
|
|
2792
2937
|
if (commands !== undefined) {
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// dsh-remote — dynamic remote instruction injection (issue #71).
|
|
2
|
+
//
|
|
3
|
+
// Issue #68 gave a remote workspace a STATIC instruction index: the workspace
|
|
4
|
+
// is shallow-scanned at pick/sync time, the files are mirrored locally, and the
|
|
5
|
+
// prompt lists them. What remained missing is the DYNAMIC half the core does
|
|
6
|
+
// for local sessions — three gaps, all plugin-side fixable:
|
|
7
|
+
//
|
|
8
|
+
// 1. The mirrored copies never refreshed, so a mid-session edit to a remote
|
|
9
|
+
// AGENTS.md was invisible until a manual rw_sync.
|
|
10
|
+
// 2. The core's incremental injection only recognizes read/write/edit with a
|
|
11
|
+
// `file_path` argument. `rw_read_file` is named differently AND takes
|
|
12
|
+
// `path`, so a remote file touch never registered.
|
|
13
|
+
// 3. The core expands scopes from cwd to the TOUCHED path — both local. A
|
|
14
|
+
// remote absolute path (`/root/proj/x.md`) can never map onto a local
|
|
15
|
+
// mirror cwd, so even a "touch" would expand nothing.
|
|
16
|
+
//
|
|
17
|
+
// This module closes all three without a single line of core change:
|
|
18
|
+
// • `noteTouchedPath` turns an rw_* file touch into the FULL ancestor chain
|
|
19
|
+
// below the workspace root (the same shape core's descendantDirsBetween
|
|
20
|
+
// produces — every level, not just the deepest);
|
|
21
|
+
// • `refreshTouched` stats each level's AGENTS.md/CLAUDE.md, pulls the ones
|
|
22
|
+
// that changed, and returns the chain's current contents;
|
|
23
|
+
// • `buildInstructionMessage` renders that into ONE user message whose source
|
|
24
|
+
// is our OWN kind, so the core's `agent-instructions` state machine never
|
|
25
|
+
// confuses it for its own (that is what keeps the two paths from fighting).
|
|
26
|
+
//
|
|
27
|
+
// Everything here is best-effort and synchronous-safe: an offline machine must
|
|
28
|
+
// never hang a tool call or a prompt assembly, so every remote step is guarded
|
|
29
|
+
// and every caller is expected to treat a rejection as "no injection".
|
|
30
|
+
|
|
31
|
+
import { statSync, mkdirSync, writeFileSync, utimesSync } from 'node:fs'
|
|
32
|
+
import { createHash } from 'node:crypto'
|
|
33
|
+
import path from 'node:path'
|
|
34
|
+
import { joinRemotePath, relPathUnder } from './paths.js'
|
|
35
|
+
import { INSTRUCTION_MAX_BYTES } from './instructions.js'
|
|
36
|
+
|
|
37
|
+
/** The message source kind this plugin owns. Deliberately NOT the core's
|
|
38
|
+
* `agent-instructions`: consumers fall through unknown kinds, and the core's
|
|
39
|
+
* reconcile only claims messages carrying its own kind. */
|
|
40
|
+
export const REMOTE_INSTRUCTIONS_KIND = 'dsh-remote-instructions'
|
|
41
|
+
|
|
42
|
+
/** Total byte budget for one injected batch (the core's own budget is similar). */
|
|
43
|
+
export const INSTRUCTION_MESSAGE_MAX_BYTES = 16 * 1024
|
|
44
|
+
|
|
45
|
+
/** Per-session throttle for the stat-and-refresh sweep, in milliseconds. */
|
|
46
|
+
export const REFRESH_THROTTLE_MS = 30 * 1000
|
|
47
|
+
|
|
48
|
+
const keyOf = (size, mtime) => `${size}:${Math.floor(mtime || 0)}`
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Split a remote path into the ancestor directories strictly BELOW `root`,
|
|
52
|
+
* root-excluded — the core's `ancestorChain(cwd, dir).slice(1)` shape.
|
|
53
|
+
*
|
|
54
|
+
* Reading `<root>/a/b/c.ts` yields `['a', 'a/b']`, so `a/AGENTS.md` and
|
|
55
|
+
* `a/b/AGENTS.md` are both in scope while `<root>/AGENTS.md` (the baseline
|
|
56
|
+
* layer) is not re-scanned. A path at or above the root yields `[]`.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} root - the workspace root (remote absolute path).
|
|
59
|
+
* @param {string} touched - the touched remote path.
|
|
60
|
+
* @returns {string[]} workspace-relative directory prefixes, shallow → deep.
|
|
61
|
+
*/
|
|
62
|
+
export function descendantDirs(root, touched) {
|
|
63
|
+
const rootRel = relPathUnder(String(root || ''), String(touched || ''))
|
|
64
|
+
if (!rootRel) return []
|
|
65
|
+
const parts = rootRel.split('/').filter(Boolean)
|
|
66
|
+
// The last part is the file itself; the chain stops at its directory.
|
|
67
|
+
parts.pop()
|
|
68
|
+
const out = []
|
|
69
|
+
let cur = ''
|
|
70
|
+
for (const part of parts) {
|
|
71
|
+
cur = cur ? `${cur}/${part}` : part
|
|
72
|
+
out.push(cur)
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Which instruction files a touch puts in scope, as workspace-relative paths:
|
|
79
|
+
* each directory in the chain contributes `AGENTS.md` and `CLAUDE.md`.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} root - workspace root.
|
|
82
|
+
* @param {string} touched - touched path.
|
|
83
|
+
* @param {number} depth - how many trailing chain levels to include (0 = none,
|
|
84
|
+
* which is what "touched a file directly in the root" means).
|
|
85
|
+
* @returns {string[]} candidate relative paths.
|
|
86
|
+
*/
|
|
87
|
+
export function instructionCandidates(root, touched, depth = Infinity) {
|
|
88
|
+
const dirs = descendantDirs(root, touched)
|
|
89
|
+
// `depth: 0` means "no chain level" (the touched file sits directly in the
|
|
90
|
+
// root): `slice(-0)` would return the WHOLE array, so this needs its own branch.
|
|
91
|
+
const chain = depth === 0 ? [] : (Number.isFinite(depth) ? dirs.slice(-depth) : dirs)
|
|
92
|
+
const out = []
|
|
93
|
+
for (const dir of chain) {
|
|
94
|
+
out.push(`${dir}/AGENTS.md`, `${dir}/CLAUDE.md`)
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A digest of an instruction's content, used to inject a given version once.
|
|
101
|
+
* Size + mtime is not enough: a same-size edit inside one second would slip
|
|
102
|
+
* through, and a spurious re-injection costs prompt budget.
|
|
103
|
+
*
|
|
104
|
+
* @param {Buffer|string} content
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
export function contentDigest(content) {
|
|
108
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content ?? ''), 'utf8')
|
|
109
|
+
// A 16-char prefix is plenty for "did this text change" within one session,
|
|
110
|
+
// and keeps the tracker's maps small.
|
|
111
|
+
return createHash('sha256').update(buf).digest('hex').slice(0, 16)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Per-session injection state: which (remote path → digest) pairs have already
|
|
116
|
+
* been delivered, plus the last sweep time for throttling.
|
|
117
|
+
*
|
|
118
|
+
* @returns {object} a tracker with `shouldInject`, `markInjected`, `removed`, and `throttled`.
|
|
119
|
+
*/
|
|
120
|
+
export function createInjectionTracker({ throttleMs = REFRESH_THROTTLE_MS } = {}) {
|
|
121
|
+
/** sessionId → { injected: Map<relPath, digest>, lastSweep: number } */
|
|
122
|
+
const sessions = new Map()
|
|
123
|
+
const stateFor = (sessionId) => {
|
|
124
|
+
const key = String(sessionId || 'unscoped')
|
|
125
|
+
let s = sessions.get(key)
|
|
126
|
+
if (!s) { s = { injected: new Map(), lastSweep: 0 }; sessions.set(key, s) }
|
|
127
|
+
return s
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
/** Whether a sweep may run now (the throttle gate). */
|
|
131
|
+
throttled(sessionId, now = Date.now()) {
|
|
132
|
+
const s = stateFor(sessionId)
|
|
133
|
+
return now - s.lastSweep < throttleMs
|
|
134
|
+
},
|
|
135
|
+
/** Record that a sweep happened now. */
|
|
136
|
+
swept(sessionId, now = Date.now()) {
|
|
137
|
+
stateFor(sessionId).lastSweep = now
|
|
138
|
+
},
|
|
139
|
+
/** Whether this exact content has NOT been delivered to this session yet. */
|
|
140
|
+
shouldInject(sessionId, rel, digest) {
|
|
141
|
+
return stateFor(sessionId).injected.get(rel) !== digest
|
|
142
|
+
},
|
|
143
|
+
/** Mark one file's content as delivered. */
|
|
144
|
+
markInjected(sessionId, rel, digest) {
|
|
145
|
+
stateFor(sessionId).injected.set(rel, digest)
|
|
146
|
+
},
|
|
147
|
+
/** Every rel path this session has been given, for scoped reconciliation. */
|
|
148
|
+
injectedRels(sessionId) {
|
|
149
|
+
return stateFor(sessionId).injected.keys()
|
|
150
|
+
},
|
|
151
|
+
/**
|
|
152
|
+
* Files injected before that are gone from a sweep LIMITED to `covers`.
|
|
153
|
+
*
|
|
154
|
+
* Scoping matters in both directions: a file outside the swept chain was
|
|
155
|
+
* never examined, so it must keep its record (dropping it would re-inject
|
|
156
|
+
* live rules later and, worse, could report a live rule as removed).
|
|
157
|
+
*
|
|
158
|
+
* @param {string} sessionId
|
|
159
|
+
* @param {Set<string>} present - rel paths the sweep actually found.
|
|
160
|
+
* @param {(rel: string) => boolean} covers - whether the sweep covered a rel.
|
|
161
|
+
* @returns {string[]} rel paths to report as removed.
|
|
162
|
+
*/
|
|
163
|
+
removedInScope(sessionId, present, covers) {
|
|
164
|
+
const s = stateFor(sessionId)
|
|
165
|
+
const out = []
|
|
166
|
+
for (const rel of [...s.injected.keys()]) {
|
|
167
|
+
if (typeof covers === 'function' && !covers(rel)) continue
|
|
168
|
+
if (present.has(rel)) continue
|
|
169
|
+
out.push(rel)
|
|
170
|
+
s.injected.delete(rel)
|
|
171
|
+
}
|
|
172
|
+
return out
|
|
173
|
+
},
|
|
174
|
+
/**
|
|
175
|
+
* Files that were injected before but no longer exist remotely — the model
|
|
176
|
+
* must be told, or it keeps following rules that were deleted.
|
|
177
|
+
* @returns {string[]} rel paths to report as removed.
|
|
178
|
+
*/
|
|
179
|
+
removed(sessionId, stillPresent) {
|
|
180
|
+
return this.removedInScope(sessionId, stillPresent, null)
|
|
181
|
+
},
|
|
182
|
+
/** Forget a session entirely (its cwd changed, or it went away). */
|
|
183
|
+
reset(sessionId) {
|
|
184
|
+
sessions.delete(String(sessionId || 'unscoped'))
|
|
185
|
+
},
|
|
186
|
+
/** Every session currently tracked (diagnostics/tests). */
|
|
187
|
+
sessions() {
|
|
188
|
+
return [...sessions.keys()]
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Fetch the CURRENT content of one instruction candidate from the remote and
|
|
195
|
+
* mirror it locally, three-way safe (a local edit is never clobbered).
|
|
196
|
+
*
|
|
197
|
+
* Returns null when the file does not exist remotely — that is a normal answer,
|
|
198
|
+
* not an error, since most directories carry no instruction file.
|
|
199
|
+
*
|
|
200
|
+
* @param {object} sftp - promisified sftp surface.
|
|
201
|
+
* @param {string} remoteRoot - workspace root.
|
|
202
|
+
* @param {string} rel - workspace-relative candidate path.
|
|
203
|
+
* @param {string} mirrorDir - local mirror root.
|
|
204
|
+
* @returns {Promise<{rel: string, remote: string, content: string, digest: string, mirrored: boolean}|null>}
|
|
205
|
+
*/
|
|
206
|
+
export async function fetchInstruction(sftp, remoteRoot, rel, mirrorDir) {
|
|
207
|
+
const remote = joinRemotePath(remoteRoot, rel)
|
|
208
|
+
let st
|
|
209
|
+
try { st = await sftp.stat(remote) } catch { return null }
|
|
210
|
+
if (!st || (typeof st.isDirectory === 'function' && st.isDirectory())) return null
|
|
211
|
+
const size = typeof st.size === 'number' ? st.size : 0
|
|
212
|
+
if (size > INSTRUCTION_MAX_BYTES) {
|
|
213
|
+
// Too large to inject whole: report the file, not its text.
|
|
214
|
+
return { rel, remote, content: '', digest: `oversize:${size}`, mirrored: false, oversizeBytes: size }
|
|
215
|
+
}
|
|
216
|
+
let buf
|
|
217
|
+
try { buf = await sftp.readFile(remote) } catch { return null }
|
|
218
|
+
const digest = contentDigest(buf)
|
|
219
|
+
let mirrored = false
|
|
220
|
+
if (mirrorDir) {
|
|
221
|
+
try {
|
|
222
|
+
const lp = path.join(mirrorDir, ...String(rel).split('/'))
|
|
223
|
+
let same = false
|
|
224
|
+
try {
|
|
225
|
+
const lst = statSync(lp)
|
|
226
|
+
same = lst.size === size && keyOf(lst.size, lst.mtimeMs / 1000) === keyOf(size, st.mtime)
|
|
227
|
+
} catch { same = false }
|
|
228
|
+
if (!same) {
|
|
229
|
+
mkdirSync(path.dirname(lp), { recursive: true })
|
|
230
|
+
writeFileSync(lp, buf)
|
|
231
|
+
try { utimesSync(lp, new Date((st.mtime || 0) * 1000), new Date((st.mtime || 0) * 1000)) } catch { /* cosmetic */ }
|
|
232
|
+
}
|
|
233
|
+
mirrored = true
|
|
234
|
+
} catch { /* a mirror failure must not lose the instruction itself */ }
|
|
235
|
+
}
|
|
236
|
+
return { rel, remote, content: buf.toString('utf8'), digest, mirrored }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Sweep the ancestor chain of a touched path and return the instructions that
|
|
241
|
+
* should enter the context now.
|
|
242
|
+
*
|
|
243
|
+
* @param {object} arg
|
|
244
|
+
* @param {object} arg.sftp
|
|
245
|
+
* @param {string} arg.remoteRoot - workspace root.
|
|
246
|
+
* @param {string} arg.touched - the touched remote path.
|
|
247
|
+
* @param {string} arg.mirrorDir - local mirror.
|
|
248
|
+
* @param {object} arg.tracker - from {@link createInjectionTracker}.
|
|
249
|
+
* @param {string} arg.sessionId
|
|
250
|
+
* @param {number} [arg.depth] - chain depth to consider (default: whole chain).
|
|
251
|
+
* @returns {Promise<{items: Array, removed: string[]}>} new/changed files and removals.
|
|
252
|
+
*/
|
|
253
|
+
export async function refreshTouched({ sftp, remoteRoot, touched, mirrorDir, tracker, sessionId, depth }) {
|
|
254
|
+
const candidates = instructionCandidates(remoteRoot, touched, depth)
|
|
255
|
+
if (!candidates.length) return { items: [], removed: [] }
|
|
256
|
+
const found = []
|
|
257
|
+
const present = new Set()
|
|
258
|
+
for (const rel of candidates) {
|
|
259
|
+
const got = await fetchInstruction(sftp, remoteRoot, rel, mirrorDir)
|
|
260
|
+
if (!got) continue
|
|
261
|
+
found.push(got)
|
|
262
|
+
present.add(got.rel)
|
|
263
|
+
}
|
|
264
|
+
const items = found.filter((f) => tracker.shouldInject(sessionId, f.rel, f.digest))
|
|
265
|
+
for (const f of items) tracker.markInjected(sessionId, f.rel, f.digest)
|
|
266
|
+
// Removal notices must be scoped to the directories THIS touch covers — both
|
|
267
|
+
// directions matter:
|
|
268
|
+
// • a file that no longer exists anywhere in the covered chain is gone;
|
|
269
|
+
// • a file outside the covered chain was simply never looked at, so its
|
|
270
|
+
// absence from `present` says nothing (and must not delete its record,
|
|
271
|
+
// or the model would be told a live rule vanished).
|
|
272
|
+
const dirs = descendantDirs(remoteRoot, touched)
|
|
273
|
+
if (!dirs.length) return { items, removed: [] }
|
|
274
|
+
const prefixes = dirs.map((d) => d + '/')
|
|
275
|
+
const covers = (rel) => prefixes.includes(rel.slice(0, rel.lastIndexOf('/') + 1))
|
|
276
|
+
// Only files INSIDE the covered chain can be judged by this sweep: a record
|
|
277
|
+
// for some other subtree was simply not examined, so its absence from
|
|
278
|
+
// `present` means nothing there.
|
|
279
|
+
const removed = tracker.removedInScope(sessionId, present, covers)
|
|
280
|
+
return { items, removed }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Render the injection batch into ONE user message.
|
|
285
|
+
*
|
|
286
|
+
* The shape mirrors what the core produces for its own incremental batches —
|
|
287
|
+
* a system-reminder-framed body with per-file sections — so the model reads the
|
|
288
|
+
* two identically while the SOURCE kind keeps them distinguishable.
|
|
289
|
+
*
|
|
290
|
+
* @param {Array} items - from {@link refreshTouched}.
|
|
291
|
+
* @param {string[]} removed - rel paths that no longer exist.
|
|
292
|
+
* @param {object} [opts]
|
|
293
|
+
* @param {number} [opts.maxBytes]
|
|
294
|
+
* @param {(p: string) => string} [opts.display] - path formatter for sections.
|
|
295
|
+
* @param {(text: string, source: object) => object} [opts.createUserMessage] -
|
|
296
|
+
* the core message factory when available.
|
|
297
|
+
* @returns {object|null} a user message, or null when there is nothing to say.
|
|
298
|
+
*/
|
|
299
|
+
export function buildInstructionMessage(items, removed, opts = {}) {
|
|
300
|
+
const maxBytes = Number(opts.maxBytes) || INSTRUCTION_MESSAGE_MAX_BYTES
|
|
301
|
+
const display = typeof opts.display === 'function' ? opts.display : (p) => p
|
|
302
|
+
const factory = typeof opts.createUserMessage === 'function' ? opts.createUserMessage : null
|
|
303
|
+
if ((!items || !items.length) && (!removed || !removed.length)) return null
|
|
304
|
+
const blocks = []
|
|
305
|
+
let used = 0
|
|
306
|
+
const sent = []
|
|
307
|
+
const truncated = []
|
|
308
|
+
for (const item of items || []) {
|
|
309
|
+
const head = item.oversizeBytes
|
|
310
|
+
? `### ${display(item.remote)}\n(too large to inject: ${item.oversizeBytes} bytes — read it with rw_read_file if you need its rules)`
|
|
311
|
+
: `### ${display(item.remote)}\n${item.content}`
|
|
312
|
+
const bytes = Buffer.byteLength(head, 'utf8')
|
|
313
|
+
if (used + bytes > maxBytes) { truncated.push(display(item.remote)); continue }
|
|
314
|
+
used += bytes
|
|
315
|
+
blocks.push(head)
|
|
316
|
+
sent.push(item.rel)
|
|
317
|
+
}
|
|
318
|
+
if (removed && removed.length) {
|
|
319
|
+
const line = `Removed on the remote host (no longer in effect): ${removed.map((r) => display(r)).join(', ')}`
|
|
320
|
+
if (used + Buffer.byteLength(line, 'utf8') <= maxBytes) blocks.push(line)
|
|
321
|
+
}
|
|
322
|
+
if (truncated.length) {
|
|
323
|
+
blocks.push(`Workspace instruction budget ${maxBytes} bytes: truncated ${truncated.join(', ')}`)
|
|
324
|
+
}
|
|
325
|
+
if (!blocks.length) return null
|
|
326
|
+
const text = [
|
|
327
|
+
'<system-reminder>',
|
|
328
|
+
'Remote workspace instructions for the directories you just touched. These are the CURRENT contents from the remote host, delivered because you accessed files under them.',
|
|
329
|
+
'',
|
|
330
|
+
blocks.join('\n\n'),
|
|
331
|
+
'</system-reminder>',
|
|
332
|
+
].join('\n')
|
|
333
|
+
const source = { kind: REMOTE_INSTRUCTIONS_KIND, form: 'instructions', changes: sent.map((rel) => ({ path: rel, change: 'changed' })) }
|
|
334
|
+
const content = [{ type: 'text', text }]
|
|
335
|
+
return factory ? factory({ content, source }) : { role: 'user', content, source }
|
|
336
|
+
}
|
package/lib/pool.js
CHANGED
|
@@ -520,6 +520,12 @@ export class SshPool {
|
|
|
520
520
|
_execRaw(command, opts, stdinWriter) {
|
|
521
521
|
const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
|
|
522
522
|
const signal = opts && opts.signal
|
|
523
|
+
// Streaming tap for background jobs (issue #70): a promoted command's value
|
|
524
|
+
// is its LIVE output, which the job ring must receive as it arrives rather
|
|
525
|
+
// than only as a return value the caller already abandoned. Called with
|
|
526
|
+
// ('stdout'|'stderr', chunk); failures inside the tap must never break the
|
|
527
|
+
// command itself, so every call site guards it.
|
|
528
|
+
const onData = opts && typeof opts.onData === 'function' ? opts.onData : null
|
|
523
529
|
return this.connect().then(
|
|
524
530
|
(client) =>
|
|
525
531
|
new Promise((resolve, reject) => {
|
|
@@ -614,9 +620,11 @@ export class SshPool {
|
|
|
614
620
|
})
|
|
615
621
|
stream.on('data', (d) => {
|
|
616
622
|
if (stdout.length < hardCap) stdout += d
|
|
623
|
+
if (onData) { try { onData('stdout', d) } catch { /* tap errors are not command errors */ } }
|
|
617
624
|
})
|
|
618
625
|
stream.stderr.on('data', (d) => {
|
|
619
626
|
if (stderr.length < hardCap) stderr += d
|
|
627
|
+
if (onData) { try { onData('stderr', d) } catch { /* tap errors are not command errors */ } }
|
|
620
628
|
})
|
|
621
629
|
stream.on('error', (e) => {
|
|
622
630
|
if (settled) return
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.53",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 22 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, one-click remote dsh deploy + web UI attach, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|