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 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。
@@ -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
- const res = await b.pool.exec(full, { timeoutMs: config.commandTimeoutMs, pty: !!args.pty, env: args.env })
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
- const parts = []
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.52",
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>",