dsh-remote 0.8.51 → 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/lib/index.js CHANGED
@@ -38,6 +38,9 @@ import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshC
38
38
  import { createRemoteFileReference, installFileReferenceOverlay, DEFAULT_EXCLUDED_DIRECTORIES } from './file-reference.js'
39
39
  import { getSecret, deleteSecret, platformBackend, persistPassword } from './credential.js'
40
40
  import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
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'
41
44
  import { searchRemote } from './search.js'
42
45
  import { installTerminalShell } from './remote-shell.js'
43
46
  import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
@@ -1006,6 +1009,26 @@ export async function apply(ctx, config) {
1006
1009
  appendFileSync(auditFile(), line, 'utf8')
1007
1010
  } catch {}
1008
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
+ }
1009
1032
  const readAudit = (limit) => {
1010
1033
  try {
1011
1034
  const text = readFileSync(auditFile(), 'utf8')
@@ -1124,6 +1147,43 @@ export async function apply(ctx, config) {
1124
1147
  autoPushWatchers.clear()
1125
1148
  }, 'dsh-remote.autopush')
1126
1149
 
1150
+ // ── remote instruction-file index (issue #68) ────────────────────────────
1151
+ // The core AGENTS.md loader only sees the LOCAL mirror, so a remote repo's
1152
+ // instruction files never reach the model's context. This index shallow-
1153
+ // scans a bound workspace (root + ≤2 levels, AGENTS.md / CLAUDE.md only),
1154
+ // mirrors the finds into the local mirror (where the core loader looks),
1155
+ // and lists them in the system-prompt section below. See lib/instructions.js.
1156
+ const instructionIndex = createInstructionIndex()
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
+
1127
1187
  // Helpers shared by session-bound tools and the machine-scoped web routes take
1128
1188
  // the pool explicitly (`p`), defaulting to the active-machine pool so route
1129
1189
  // callers — which are machine-scoped by design — stay unchanged.
@@ -1225,6 +1285,106 @@ export async function apply(ctx, config) {
1225
1285
  }
1226
1286
  return out
1227
1287
  }
1288
+
1289
+ /**
1290
+ * Rebind every mirror recorded for one machine identity to a NEW identity
1291
+ * (issue #67): when a machine's IP/user/port changes, its workspaces must
1292
+ * follow instead of being recreated. The mirror's `.dsh-remote-meta.json` is
1293
+ * the authoritative binding — session routing (bindingFor), pool keying
1294
+ * (poolForMachine) and the sidebar badges all read identity FROM THE META,
1295
+ * not from the directory name — so rewriting the meta re-points every live
1296
+ * session at the new address while the local mirror content stays put.
1297
+ *
1298
+ * The host directory itself is deliberately NOT renamed: live sessions hold
1299
+ * cwd paths inside it, and a rename would dangle them into "session is
1300
+ * LOCAL" refusals (on Windows the rename usually fails outright while a
1301
+ * shell sits inside). The directory name is a display label only; a stale
1302
+ * label after a rebind is the documented cost of keeping sessions alive.
1303
+ *
1304
+ * Matching covers both machine kinds: an ALIAS machine's mirrors are found
1305
+ * through the recorded `alias` (their meta host is the resolved address at
1306
+ * creation time), a literal machine's through host+port+username.
1307
+ *
1308
+ * @param {object} prev - the machine record BEFORE the identity change.
1309
+ * @param {object} next - the machine record AFTER it.
1310
+ * @returns {number} how many mirrors were rebound.
1311
+ */
1312
+ const rebindMirrors = (prev, next) => {
1313
+ if (!prev || !next) return 0
1314
+ const prevAlias = aliasOf(prev)
1315
+ const prevId = identityOf(prev)
1316
+ const prevRaw = { host: String(prev.host || ''), port: Number(prev.port) || 22, username: String(prev.username || '') }
1317
+ const nextAlias = aliasOf(next)
1318
+ const nextEff = withSshConfig(next) || next
1319
+ const nextId = {
1320
+ host: String(nextEff.host || next.host || ''),
1321
+ port: Number(nextEff.port || next.port) || 22,
1322
+ username: String(nextEff.username || next.username || ''),
1323
+ }
1324
+ const nextRaw = { host: String(next.host || ''), port: Number(next.port) || 22, username: String(next.username || '') }
1325
+ const sameIdentity = (a, b) => a.host === b.host && a.port === b.port && a.username === b.username
1326
+ if (!prevAlias && sameIdentity(prevId, nextId) && sameIdentity(prevRaw, nextRaw)) return 0
1327
+ if (prevAlias && prevAlias === nextAlias) return 0
1328
+ const root = remoteWorkspacesRoot()
1329
+ let rebound = 0
1330
+ let hostDirs = []
1331
+ try { hostDirs = readdirSync(root) } catch { return 0 }
1332
+ for (const hostDir of hostDirs) {
1333
+ const hostPath = path.join(root, hostDir)
1334
+ if (!statSync(hostPath, { throwIfNoEntry: false })?.isDirectory?.()) continue
1335
+ let entries = []
1336
+ try { entries = readdirSync(hostPath) } catch { continue }
1337
+ for (const entry of entries) {
1338
+ const metaPath = path.join(hostPath, entry, '.dsh-remote-meta.json')
1339
+ let meta = null
1340
+ try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch { continue }
1341
+ if (!meta || !meta.host) continue
1342
+ const byAlias = prevAlias && String(meta.alias || '') === prevAlias
1343
+ // A meta records the RESOLVED identity at pick time, but that identity
1344
+ // can also have been written raw — match on either form of the old
1345
+ // identity so a rebind still finds mirrors recorded before a later
1346
+ // ssh-config resolution rewrote what "this machine" meant.
1347
+ const metaId = { host: String(meta.host), port: Number(meta.port) || 22, username: String(meta.username || '') }
1348
+ const byIdentity = sameIdentity(metaId, prevId) || sameIdentity(metaId, prevRaw)
1349
+ if (!byAlias && !byIdentity) continue
1350
+ const updated = { ...meta }
1351
+ if (nextAlias) {
1352
+ // Alias machine: the recorded alias is what future resolution keys
1353
+ // on; the resolved address refreshes only when it actually resolved
1354
+ // (an unmatched alias keeps the old literal values rather than
1355
+ // stamping the alias NAME into the address fields).
1356
+ updated.alias = nextAlias
1357
+ if (nextEff.sshConfigMatched || nextEff.sshAlias) {
1358
+ updated.host = nextId.host
1359
+ updated.port = nextId.port
1360
+ updated.username = nextId.username
1361
+ }
1362
+ } else {
1363
+ delete updated.alias
1364
+ updated.host = nextRaw.host
1365
+ updated.port = nextRaw.port
1366
+ updated.username = nextRaw.username
1367
+ }
1368
+ updated.reboundAt = new Date().toISOString()
1369
+ try {
1370
+ writeFileSync(metaPath, JSON.stringify(updated, null, 2))
1371
+ rebound++
1372
+ } catch { /* leave that mirror as-is */ }
1373
+ }
1374
+ }
1375
+ if (rebound) {
1376
+ // The old identity's pooled connection dials an address the user just
1377
+ // abandoned: drop it so nothing keeps a dead SSH session warm. The new
1378
+ // identity gets its pool lazily on the next tool call.
1379
+ const stale = machinePools.get(poolKey(prevId))
1380
+ if (stale) {
1381
+ machinePools.delete(poolKey(prevId))
1382
+ try { stale.close() } catch { /* already gone */ }
1383
+ }
1384
+ }
1385
+ return rebound
1386
+ }
1387
+
1228
1388
  if (config.autoPush && wsPath()) {
1229
1389
  startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
1230
1390
  }
@@ -1614,10 +1774,10 @@ export async function apply(ctx, config) {
1614
1774
  defineTool({
1615
1775
  name: 'rw_connect',
1616
1776
  description:
1617
- 'Connect SSH to a remote host for remote workspace work. Two ways (issue #48): (1) pass machineId to connect with a machine ALREADY SAVED in the registry (list them with rw_machines) — every stored setting applies: password/keychain password, privateKeyPath + passphrase, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, and ~/.ssh/config aliases; or (2) pass host plus any of username/port/password/privateKeyPath/passphrase/useAgent/keyboardInteractive/hostKeyMode/backend/name. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in. With useSshConfig=true, `host` is an ALIAS from ~/.ssh/config and every value (HostName/user/port/IdentityFile/ProxyJump) is read from that file at connect time. For a host that only accepts GSSAPI/Kerberos (issue #51), pass backend="openssh" (with useSshConfig=true) so the local ssh — which holds your kinit ticket — performs the login. A failed connection is reported as an error message — it never takes the plugin down.',
1777
+ 'Connect SSH to a remote host for remote workspace work. Two ways (issue #48): (1) pass machineId to connect with a machine ALREADY SAVED in the registry (list them with rw_machines) — every stored setting applies: password/keychain password, privateKeyPath + passphrase, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, and ~/.ssh/config aliases; or (2) pass host plus any of username/port/password/privateKeyPath/passphrase/useAgent/keyboardInteractive/hostKeyMode/backend/name. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in. With useSshConfig=true, `host` is an ALIAS from ~/.ssh/config and every value (HostName/user/port/IdentityFile/ProxyJump) is read from that file at connect time. For a host that only accepts GSSAPI/Kerberos (issue #51), pass backend="openssh" (with useSshConfig=true) so the local ssh — which holds your kinit ticket — performs the login. A failed connection is reported as an error message — it never takes the plugin down. Rebinding (issue #67): pass machineId TOGETHER with a new host/username/port to update that saved machine in place — every workspace mirror bound to its old address is re-pointed (local content kept, no re-sync) instead of forcing new workspaces.',
1618
1778
  parameters: {
1619
1779
  host: { type: 'string', description: 'Remote host IP or hostname (or a ~/.ssh/config alias with useSshConfig=true). Required unless machineId is given.' },
1620
- machineId: { type: 'string', description: 'Connect with a saved machine from the registry (see rw_machines): all its stored settings apply and it becomes the current machine. host/other fields are not needed.' },
1780
+ machineId: { type: 'string', description: 'Connect with a saved machine from the registry (see rw_machines): all its stored settings apply and it becomes the current machine. host/other fields are not needed — EXCEPT for rebinding (issue #67): combine machineId with a new host/username/port to update the machine in place and re-point its existing workspace mirrors.' },
1621
1781
  username: { type: 'string', description: 'SSH user (default from config or root)' },
1622
1782
  port: { type: 'integer', description: 'SSH port (default 22)' },
1623
1783
  password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
@@ -1634,6 +1794,9 @@ export async function apply(ctx, config) {
1634
1794
  },
1635
1795
  output: textOut,
1636
1796
  async execute(args) {
1797
+ // Issue #67: set by the machineId+overrides path, prepended to the
1798
+ // success line so the rebind is visible in the tool result.
1799
+ let reboundNote = ''
1637
1800
  const mid = String(args.machineId || '').trim()
1638
1801
  if (mid) {
1639
1802
  // Connect with an ALREADY SAVED machine (issue #48): the registry
@@ -1641,6 +1804,25 @@ export async function apply(ctx, config) {
1641
1804
  // keychain-backed passwords, stored passphrases, proxies, aliases.
1642
1805
  const i = machineIndex(mid)
1643
1806
  if (i < 0) throw new Error(`rw_connect: no saved machine "${mid}" — list saved machines with rw_machines`)
1807
+ // Issue #67: passing host/username/port ALONGSIDE a machineId rebinds
1808
+ // the saved machine in place — the fix for "the IP changed, do not
1809
+ // make me recreate every workspace". The record keeps its id (and
1810
+ // every credential), and each mirror bound to the old identity is
1811
+ // re-pointed so live sessions keep working against the new address.
1812
+ const prev = machines[i]
1813
+ const overrides = {}
1814
+ if (args.host !== undefined && String(args.host).trim()) overrides.host = String(args.host).trim()
1815
+ if (args.username !== undefined && String(args.username).trim()) overrides.username = String(args.username).trim()
1816
+ if (args.port !== undefined && Number(args.port)) overrides.port = Number(args.port)
1817
+ let rebound = 0
1818
+ if (Object.keys(overrides).length) {
1819
+ const next = { ...prev, ...overrides }
1820
+ try { rebound = rebindMirrors(prev, next) } catch { /* best effort */ }
1821
+ machines[i] = next
1822
+ }
1823
+ if (rebound) {
1824
+ reboundNote = `Rebound ${rebound} workspace mirror(s) from ${prev.username}@${prev.host}:${prev.port} to the new address (local content kept — no re-sync needed).\n`
1825
+ }
1644
1826
  store.currentId = machines[i].id
1645
1827
  ephemeral = null
1646
1828
  saveMachines(machines, store.currentId)
@@ -1749,7 +1931,7 @@ export async function apply(ctx, config) {
1749
1931
  const via = active && active.sshAlias
1750
1932
  ? `\n(ssh-config alias "${active.sshAlias}" → ${effUser}@${effHost}:${effPort}` + (active.sshConfigWarnings && active.sshConfigWarnings.length ? `; ${active.sshConfigWarnings.join(' ')}` : '') + ')'
1751
1933
  : ''
1752
- return { text: `Connected to ${effHost} as ${effUser}.${via}\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
1934
+ return { text: `${reboundNote}Connected to ${effHost} as ${effUser}.${via}\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
1753
1935
  } catch (err) {
1754
1936
  throw new Error(friendlyMessage(err, { host: effHost, port: effPort }))
1755
1937
  }
@@ -1825,6 +2007,12 @@ export async function apply(ctx, config) {
1825
2007
  if (!b.bound) persistWorkspace(p)
1826
2008
  const local = ensureMirror(p, b.host, b.username, b.port, { alias: b.alias || "" })
1827
2009
  startAutoPush(local)
2010
+ // Issue #68: discover the workspace's AGENTS.md / CLAUDE.md NOW — the
2011
+ // first prompt assembly can come before any rw_sync, and the mirror
2012
+ // copy must already be in place for the core instruction loader to
2013
+ // pick it up. Awaited (the scan is a handful of shallow readdirs) but
2014
+ // fully best-effort: a scan failure never fails the pick.
2015
+ try { await instructionIndex.refresh({ pool: b.pool, ws: p, mirrorDir: local, force: true }) } catch { /* best effort */ }
1828
2016
  // The closing hint depends on where THIS session stands (issue #63):
1829
2017
  // in a LOCAL session "Run rw_sync" is a dead instruction — rw_sync
1830
2018
  // refuses a local session — so say the mirror must be OPENED first.
@@ -1878,11 +2066,21 @@ export async function apply(ctx, config) {
1878
2066
  }
1879
2067
  return { text }
1880
2068
  }
2069
+ // Issue #68: a real sync can pull in (or obsolete) instruction files —
2070
+ // refresh the prompt index afterwards. Kept inside the task body so a
2071
+ // background sync refreshes when IT finishes, not when it is queued.
2072
+ const runSyncAndIndex = async () => {
2073
+ const r = await runSync()
2074
+ if (!args.dryRun) {
2075
+ try { await instructionIndex.refresh({ pool: b.pool, ws: p, mirrorDir: local, force: true }) } catch { /* best effort */ }
2076
+ }
2077
+ return r
2078
+ }
1881
2079
  if (args.async) {
1882
- const t = tasks.start('sync', `sync ${p}`, runSync)
2080
+ const t = tasks.start('sync', `sync ${p}`, runSyncAndIndex)
1883
2081
  return { text: `sync started in background: taskId=${t.id} (GET /dsh-remote/task?id=${t.id} for progress)` }
1884
2082
  }
1885
- return runSync()
2083
+ return runSyncAndIndex()
1886
2084
  },
1887
2085
  }),
1888
2086
 
@@ -2137,7 +2335,26 @@ export async function apply(ctx, config) {
2137
2335
  if (st1.size !== st0.size || st1.mtime !== st0.mtime) {
2138
2336
  throw new Error(`rw_edit: ${p} changed on the remote while editing (conflict) — re-read and retry`)
2139
2337
  }
2140
- await sftp.writeFile(p, encodeText(out, args.encoding || config.encoding))
2338
+ // Atomic write: stage to a temp file and rename over the target so a
2339
+ // mid-write failure cannot truncate the original (issue #65). Same
2340
+ // directory keeps the rename on one filesystem.
2341
+ const tmpPath = `${p}.dsh-remote-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.tmp`
2342
+ try {
2343
+ await sftp.writeFile(tmpPath, encodeText(out, args.encoding || config.encoding))
2344
+ // renameOverwrite, not rename: the target EXISTS by definition here,
2345
+ // and plain SSH_FXP_RENAME refuses to replace it (issue #65).
2346
+ await sftp.renameOverwrite(tmpPath, p)
2347
+ // The rename swaps in a FRESH inode, so the original permission bits
2348
+ // (an executable script's +x, a 0600 secret) would silently reset to
2349
+ // the umask default. Restore them; a chmod failure must not fail the
2350
+ // edit itself — the content is already safely in place.
2351
+ if (st0 && typeof st0.mode === 'number') {
2352
+ try { await sftp.setstat(p, { mode: st0.mode & 0o7777 }) } catch { /* best effort */ }
2353
+ }
2354
+ } catch (err) {
2355
+ try { await sftp.unlink(tmpPath) } catch { /* best effort */ }
2356
+ throw err
2357
+ }
2141
2358
  audit('edit', `edit ${p} (${n} occurrence(s))`, 0, b)
2142
2359
  return { ok: true, bytes: Buffer.byteLength(out), text: `edited ${p}: replaced ${n} occurrence(s)` }
2143
2360
  },
@@ -2240,12 +2457,15 @@ export async function apply(ctx, config) {
2240
2457
  defineTool({
2241
2458
  name: 'rw_exec',
2242
2459
  description:
2243
- '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.',
2244
2461
  parameters: {
2245
2462
  command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
2246
2463
  cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
2247
2464
  pty: { type: 'boolean', description: 'Allocate a pseudo-terminal (interactive commands, default false)' },
2248
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)' },
2249
2469
  },
2250
2470
  output: textOut,
2251
2471
  async execute(args, exec) {
@@ -2268,18 +2488,40 @@ export async function apply(ctx, config) {
2268
2488
  full = `cd ${shq(cwd)} && ${cmd}`
2269
2489
  }
2270
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
2271
2498
  try {
2272
- 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 })
2273
2523
  audit('exec', cmd, res.code, b)
2274
- const parts = []
2275
- if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
2276
- if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
2277
- if (!parts.length) parts.push('(no output)')
2278
- let text = parts.join('\n')
2279
- if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${config.commandTimeoutMs}ms]`
2280
- else if (res.signal === 'ABORTED') text += '\n[cancelled — the remote command was stopped]'
2281
- else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
2282
- return { text }
2524
+ return { text: formatExecResult(res, { timeoutMs }) }
2283
2525
  } catch (err) {
2284
2526
  throw new Error(friendlyMessage(err, { host: b.host, port: b.port }))
2285
2527
  }
@@ -2549,7 +2791,7 @@ export async function apply(ctx, config) {
2549
2791
  const session = agent && agent.session
2550
2792
  const cwd = session && session.header && session.header.cwd
2551
2793
  if (!cwd) return '' // no session cwd → nothing to map → stay quiet
2552
- const { remotePath, machine } = resolveMirrorForLocal(cwd)
2794
+ const { remotePath, machine, mirrorDir } = resolveMirrorForLocal(cwd)
2553
2795
  if (!remotePath) return '' // this session is NOT a remote session (issue #13)
2554
2796
  // Name the machine the MIRROR records, not the active one: pairing this
2555
2797
  // session's remote path with whichever machine happens to be active
@@ -2562,6 +2804,17 @@ export async function apply(ctx, config) {
2562
2804
  const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
2563
2805
  let extra = ''
2564
2806
  if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
2807
+ // Issue #68: surface the workspace's AGENTS.md / CLAUDE.md. The scan is
2808
+ // lazy (fire-and-forget on first assembly; negative-cached on failure),
2809
+ // so the index appears from the NEXT assembly once known — rw_pick_workspace
2810
+ // scans eagerly, so a picked workspace normally has it from the start.
2811
+ if (machine && mirrorDir) {
2812
+ instructionIndex.note({ pool: poolForMachine(machine), ws: remotePath, mirrorDir })
2813
+ const instr = instructionIndex.get(mirrorDir)
2814
+ if (instr && instr.files && instr.files.length) {
2815
+ extra += `\n${formatInstructionIndex(instr.files, (p) => toDisplayPath(p, platform))}`
2816
+ }
2817
+ }
2565
2818
  // Issue #39: `@` mentions are resolved from the REMOTE tree by
2566
2819
  // lib/file-reference.js, so the model must know they are remote-relative
2567
2820
  // and that the built-in read tool sees only the local mirror.
@@ -2610,6 +2863,75 @@ export async function apply(ctx, config) {
2610
2863
  })
2611
2864
  }
2612
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
+
2613
2935
  // ── slash commands ─────────────────────────────────────────────────────────
2614
2936
  const commands = ctx.get('commands')
2615
2937
  if (commands !== undefined) {
@@ -3193,6 +3515,14 @@ export async function apply(ctx, config) {
3193
3515
  // alone, so a fresh registry stays "no choice yet" (not an
3194
3516
  // explicit none) and a previously-cleared registry stays cleared.
3195
3517
  saveMachines(machines, store.currentId, false)
3518
+ // Issue #67: an EDITED connection identity (host/user/port) must
3519
+ // carry the machine's existing workspaces over — without this the
3520
+ // mirrors stayed bound to the old address and the user had to
3521
+ // create (and re-sync) a brand-new workspace for the new IP.
3522
+ let reboundMirrors = 0
3523
+ if (i >= 0 && prev) {
3524
+ try { reboundMirrors = rebindMirrors(prev, rec) } catch { /* best effort */ }
3525
+ }
3196
3526
  // Issue #50: host/user/port/key have ONE home — ~/.ssh/config.
3197
3527
  // Saving a literal machine upserts a marker-tagged Host block,
3198
3528
  // so the file and the registry can never drift; the record's
@@ -3234,6 +3564,7 @@ export async function apply(ctx, config) {
3234
3564
  machine: sanitizeMachine(rec),
3235
3565
  machines: machinesForClient(),
3236
3566
  currentId: store.currentId,
3567
+ ...(reboundMirrors ? { reboundMirrors } : {}),
3237
3568
  ...(warning ? { warning, warningDetail } : {}),
3238
3569
  ...(sshConfigWarning ? { sshConfigWarning } : {}),
3239
3570
  })