dsh-remote 0.8.10 → 0.8.12

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
@@ -39,6 +39,7 @@ import { importableEntries, sshConfigPath, readSshConfigText } from './sshconfig
39
39
  import { saveSecret, getSecret, deleteSecret, platformBackend } from './credential.js'
40
40
  import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
41
41
  import { searchTree } from './search.js'
42
+ import { resolveMirror, poolKey } from './binding.js'
42
43
  import { TaskManager } from './tasks.js'
43
44
  import { ForwardManager } from './forwards.js'
44
45
  import { selfDir, readVersion, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode } from './update.js'
@@ -859,6 +860,106 @@ export async function apply(ctx, config) {
859
860
  })
860
861
  }
861
862
 
863
+ // ── per-machine SSH pools (session-bound) ─────────────────────────────────
864
+ // The `pool` above stays the ACTIVE-machine pool: it backs the settings page,
865
+ // the workspace picker routes, and `rw_connect`, whose semantics are "switch
866
+ // the active machine". Session-bound tool calls must not go through it —
867
+ // `setTarget()` rewrites the shared `config` identity and closes the live
868
+ // connection, so two sessions on different machines would take the pool from
869
+ // each other and commands would land on the wrong host (issue #25).
870
+ //
871
+ // Instead each machine gets its own pool, keyed by its stable identity. A
872
+ // session resolves its machine from its own workspace mirror, so concurrent
873
+ // sessions on different hosts never share a connection. Sessions on the SAME
874
+ // machine still share one, preserving the single-connection + keepalive model.
875
+
876
+ /** Deployment-varying tunables shared by every pool, owned by the live plugin
877
+ * Config so a settings change reaches existing pools. Identity, credential,
878
+ * and per-machine policy fields (`hostKeyMode`, `shell`, `proxy`) are
879
+ * deliberately excluded — those are pinned per pool from the machine record,
880
+ * and re-copying them from the shared config is exactly the bug this
881
+ * registry fixes. */
882
+ const TUNABLE_KEYS = [
883
+ 'connectTimeoutMs', 'commandTimeoutMs', 'maxOutputChars', 'maxFileBytes', 'encoding',
884
+ ]
885
+ const syncTunables = (target) => {
886
+ for (const k of TUNABLE_KEYS) {
887
+ if (Object.prototype.hasOwnProperty.call(config, k)) target[k] = config[k]
888
+ }
889
+ return target
890
+ }
891
+
892
+ const machinePools = new Map()
893
+ ctx.effect(() => () => {
894
+ for (const p of machinePools.values()) {
895
+ try { p.close() } catch { /* a pool already torn down by its own error path */ }
896
+ }
897
+ machinePools.clear()
898
+ }, 'dsh-remote.machine-pools')
899
+
900
+ /** Registry record for a resolved mirror origin, so a session-bound pool gets
901
+ * the machine's real credentials (key path, passphrase, agent, proxy) rather
902
+ * than only the host/user/port the mirror meta records.
903
+ *
904
+ * An EPHEMERAL connection (rw_connect save:false) is not in the registry but
905
+ * is still a legitimate credential source for a pool the same process later
906
+ * resolves for that identity: without it a mirror picked on a temporary
907
+ * connection would resolve to an empty-credential pool and fail to connect. */
908
+ const machineRecordFor = ({ host, port, username }) =>
909
+ (ephemeral && ephemeral.host === host && Number(ephemeral.port) === Number(port) && (ephemeral.username || '') === (username || '')
910
+ ? ephemeral
911
+ : null)
912
+ || machines.find((m) => m.host === host && Number(m.port) === Number(port) && (m.username || '') === (username || ''))
913
+ || null
914
+
915
+ /**
916
+ * Get (or lazily create) the dedicated pool for one remote identity.
917
+ * @param {{host: string, port: number, username: string}} target - the mirror-recorded origin.
918
+ * @returns {SshPool} the pool pinned to that identity; never the active-machine pool.
919
+ */
920
+ const poolForMachine = (target) => {
921
+ const key = poolKey(target)
922
+ const rec = machineRecordFor(target)
923
+ const existing = machinePools.get(key)
924
+ if (existing) {
925
+ syncTunables(existing.config)
926
+ // Re-read credentials and per-machine policy from the registry: editing a
927
+ // machine in the settings page must reach a pool created earlier, and a
928
+ // pool holding a stale password would keep failing to reconnect.
929
+ if (rec) {
930
+ existing.config.password = rec.password || ''
931
+ existing.config.privateKeyPath = rec.privateKeyPath || ''
932
+ existing.config.passphrase = rec.passphrase || ''
933
+ existing.config.useAgent = !!rec.useAgent
934
+ existing.config.keyboardInteractive = !!rec.keyboardInteractive
935
+ existing.config.proxy = rec.proxy || undefined
936
+ existing.config.hostKeyMode = rec.hostKeyMode || config.hostKeyMode
937
+ }
938
+ return existing
939
+ }
940
+ const poolConfig = syncTunables({
941
+ ...config,
942
+ host: target.host,
943
+ port: Number(target.port) || 22,
944
+ username: target.username || (rec && rec.username) || 'root',
945
+ password: (rec && rec.password) || '',
946
+ privateKeyPath: (rec && rec.privateKeyPath) || '',
947
+ passphrase: (rec && rec.passphrase) || '',
948
+ useAgent: rec ? !!rec.useAgent : !!config.useAgent,
949
+ keyboardInteractive: rec ? !!rec.keyboardInteractive : !!config.keyboardInteractive,
950
+ proxy: (rec && rec.proxy) || undefined,
951
+ hostKeyMode: (rec && rec.hostKeyMode) || config.hostKeyMode,
952
+ // Identity-pinned pools never carry the shared active workspace: a
953
+ // session passes its own mirror-resolved remote path explicitly.
954
+ workspace: '',
955
+ })
956
+ const created = new SshPool(poolConfig)
957
+ // Keychain-backed passwords resolve against THIS pool's machine record.
958
+ created.passwordResolver = async () => machinePassword(rec || { id: '', password: poolConfig.password })
959
+ machinePools.set(key, created)
960
+ return created
961
+ }
962
+
862
963
  const setCurrent = async (id) => {
863
964
  if (!id) {
864
965
  // Explicit "active remote = none": saved machines stay in the registry
@@ -955,10 +1056,24 @@ export async function apply(ctx, config) {
955
1056
  }
956
1057
 
957
1058
  // ── audit log ─────────────────────────────────────────────────────────────
958
- const audit = (op, cmd, code) => {
1059
+ /**
1060
+ * Append one audit line. `target` names the machine the operation really ran
1061
+ * on and must be passed by every session-bound caller: the shared `config`
1062
+ * identity belongs to the ACTIVE machine, which can be a different host than
1063
+ * the session that issued the command, so defaulting to it would
1064
+ * misattribute the operation.
1065
+ * @param {string} op - operation name.
1066
+ * @param {string} cmd - command text or path detail.
1067
+ * @param {number|null} code - exit code, or null when not applicable.
1068
+ * @param {{host?: string, username?: string, port?: number}} [target] - machine acted on; defaults to the active machine.
1069
+ */
1070
+ const audit = (op, cmd, code, target) => {
959
1071
  if (!config.auditLog) return
1072
+ const host = (target && target.host) || config.host
1073
+ const user = (target && target.username) || config.username
1074
+ const port = (target && target.port) || config.port
960
1075
  try {
961
- const line = [new Date().toISOString(), `${config.username || '?'}@${config.host || '?'}:${config.port}`, op, code == null ? '-' : String(code), String(cmd || '').replace(/\s+/g, ' ').slice(0, 400)].join(' | ') + '\n'
1076
+ const line = [new Date().toISOString(), `${user || '?'}@${host || '?'}:${port}`, op, code == null ? '-' : String(code), String(cmd || '').replace(/\s+/g, ' ').slice(0, 400)].join(' | ') + '\n'
962
1077
  appendFileSync(auditFile(), line, 'utf8')
963
1078
  } catch {}
964
1079
  }
@@ -998,22 +1113,27 @@ export async function apply(ctx, config) {
998
1113
  if (!entry || !entry.pending.size) return
999
1114
  const rels = [...entry.pending]
1000
1115
  entry.pending.clear()
1001
- const ws = wsPath()
1002
- if (!ws || mirrorDirFor(ws, config.host, config.username, config.port) !== localDir) return
1116
+ // Resolve the destination from the WATCHED MIRROR, not from the active
1117
+ // machine: a watcher outlives any one session, so gating on the active
1118
+ // machine made it silently stop pushing (and, before per-machine pools,
1119
+ // risked pushing one host's mirror through another host's connection).
1120
+ const { remotePath: ws, machine } = resolveMirrorForLocal(localDir)
1121
+ if (!ws || !machine) return
1122
+ const target = poolForMachine(machine)
1003
1123
  let sftp
1004
- try { sftp = await pool.sftp() } catch { return }
1124
+ try { sftp = await target.sftp() } catch { return }
1005
1125
  const matcher = ignoreMatcher()
1006
1126
  const state = loadSyncState(localDir)
1007
1127
  const next = { ...state }
1008
1128
  if (rels.includes('*')) {
1009
1129
  const r = await pushTree(sftp, localDir, ws, { maxFiles: 500, maxFileBytes: config.maxFileBytes, isIgnored: matcher, state: next })
1010
1130
  Object.assign(next, r.nextState)
1011
- for (const c of r.stats.conflicts) audit('auto-push-conflict', `push ${c.path}`, 1)
1131
+ for (const c of r.stats.conflicts) audit('auto-push-conflict', `push ${c.path}`, 1, machine)
1012
1132
  } else {
1013
1133
  for (const rel of rels) {
1014
1134
  const r = await pushOneFile(sftp, localDir, ws, rel, { maxFileBytes: config.maxFileBytes, isIgnored: matcher, state: next })
1015
1135
  if (r.status === 'pushed' && r.state) Object.assign(next, r.state)
1016
- else if (r.status === 'conflict') audit('auto-push-conflict', `push ${rel}`, 1)
1136
+ else if (r.status === 'conflict') audit('auto-push-conflict', `push ${rel}`, 1, machine)
1017
1137
  }
1018
1138
  }
1019
1139
  saveSyncState(localDir, next)
@@ -1047,8 +1167,11 @@ export async function apply(ctx, config) {
1047
1167
  autoPushWatchers.clear()
1048
1168
  }, 'dsh-remote.autopush')
1049
1169
 
1050
- const run = async (cmd, opts = {}) => {
1051
- const res = await pool.exec(cmd, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {})
1170
+ // Helpers shared by session-bound tools and the machine-scoped web routes take
1171
+ // the pool explicitly (`p`), defaulting to the active-machine pool so route
1172
+ // callers — which are machine-scoped by design — stay unchanged.
1173
+ const run = async (cmd, opts = {}, p = pool) => {
1174
+ const res = await p.exec(cmd, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {})
1052
1175
  const parts = []
1053
1176
  if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
1054
1177
  if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
@@ -1061,11 +1184,11 @@ export async function apply(ctx, config) {
1061
1184
 
1062
1185
  /** Structured listing: name + type + size + mtime + mode (SFTP protocol-level,
1063
1186
  * works on any remote: POSIX / cmd.exe / PowerShell). */
1064
- const listDirStructured = async (p) => {
1187
+ const listDirStructured = async (p, sshPool = pool) => {
1065
1188
  const target = normalizeRemotePath(p || '/')
1066
1189
  let sftp
1067
1190
  try {
1068
- sftp = await pool.sftp()
1191
+ sftp = await sshPool.sftp()
1069
1192
  } catch (err) {
1070
1193
  throw new Error('browse failed: ' + ((err && err.message) || err))
1071
1194
  }
@@ -1141,10 +1264,10 @@ export async function apply(ctx, config) {
1141
1264
  return [...new Set(letters)].sort().map((l) => ({ name: l + ':\\', path: l + ':\\', type: 'dir', drive: true, size: 0, mtime: 0 }))
1142
1265
  }
1143
1266
 
1144
- const isRemoteDir = async (p) => {
1267
+ const isRemoteDir = async (p, sshPool = pool) => {
1145
1268
  const target = normalizeRemotePath(p)
1146
1269
  try {
1147
- const sftp = await pool.sftp()
1270
+ const sftp = await sshPool.sftp()
1148
1271
  const st = await sftp.stat(target)
1149
1272
  return !!(st && st.isDirectory && st.isDirectory())
1150
1273
  } catch {
@@ -1180,44 +1303,26 @@ export async function apply(ctx, config) {
1180
1303
  const wsPath = () => (config.workspace || '').trim()
1181
1304
  /** Map a LOCAL path to the remote path its mirror represents ('' when the
1182
1305
  * path is not inside any dsh-remote mirror). Shared by the resolve-mirror
1183
- * route, the session-aware system-prompt injection, and status(). */
1184
- const resolveMirrorForLocal = (local) => {
1185
- const root = remoteWorkspacesRoot()
1186
- const norm = (p) => path.resolve(String(p || '')).replace(/[\\/]+$/, '') || ''
1187
- let matched = ''
1188
- let matchedRemote = ''
1189
- if (local && existsSync(root)) {
1190
- const base = norm(local)
1191
- for (const hostDir of readdirSync(root)) {
1192
- const hostPath = path.join(root, hostDir)
1193
- if (!statSync(hostPath, { throwIfNoEntry: false })?.isDirectory?.()) continue
1194
- for (const mirrorDir of readdirSync(hostPath)) {
1195
- const metaPath = path.join(hostPath, mirrorDir, '.dsh-remote-meta.json')
1196
- if (!existsSync(metaPath)) continue
1197
- try {
1198
- const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
1199
- const mirrorAbs = norm(path.join(hostPath, mirrorDir))
1200
- if (mirrorAbs === base || base.startsWith(mirrorAbs + path.sep) || base.startsWith(mirrorAbs + '/')) {
1201
- if (mirrorAbs.length > matched.length) {
1202
- matched = mirrorAbs
1203
- matchedRemote = String(meta.remotePath || '')
1204
- }
1205
- }
1206
- } catch { /* skip unparsable meta */ }
1207
- }
1208
- }
1209
- }
1210
- return { mirrorDir: matched || null, remotePath: matchedRemote || '' }
1211
- }
1306
+ * route, the session-aware system-prompt injection, and status().
1307
+ *
1308
+ * `machine` carries the mirror's recorded origin (`host`/`port`/`username`
1309
+ * from `.dsh-remote-meta.json`), or null when the path is not inside a
1310
+ * mirror. That origin is what makes a session's remote target authoritative
1311
+ * without consulting the mutable active-machine state, so a concurrent
1312
+ * session switching machines cannot redirect this session's commands. */
1313
+ const resolveMirrorForLocal = (local) => resolveMirror(local, remoteWorkspacesRoot())
1212
1314
  if (config.autoPush && wsPath()) {
1213
1315
  startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
1214
1316
  }
1215
- /** The remote path the CURRENT agent session is really bound to, or '' when
1216
- * the session is a plain local one. Walks the mirror registry (host side
1217
- * copy of the resolve-mirror logic) so status / prompt / sidebar agree.
1218
- * `sessionId` narrows to one session; without it the first live session's
1219
- * cwd is used (best effort — status is informational, the per-session
1220
- * resolve-mirror endpoint is authoritative). */
1317
+ /** The remote path one agent session is bound to, or '' when that session is
1318
+ * a plain local one. Walks the mirror registry (host side copy of the
1319
+ * resolve-mirror logic) so status / prompt / sidebar agree.
1320
+ *
1321
+ * `sessionId` narrows to one session. WITHOUT it the first live session's cwd
1322
+ * is used, which is only meaningful for the machine-scoped settings view:
1323
+ * with several live sessions the "first" one is arbitrary and may belong to
1324
+ * another machine. Tool calls must never route through this — they resolve
1325
+ * their own session through `bindingFor()`, which is authoritative. */
1221
1326
  const sessionRemotePath = (sessionId) => {
1222
1327
  let cwd = ''
1223
1328
  try {
@@ -1233,6 +1338,111 @@ export async function apply(ctx, config) {
1233
1338
  if (!cwd) return ''
1234
1339
  return resolveMirrorForLocal(cwd).remotePath
1235
1340
  }
1341
+
1342
+ // ── session-bound remote binding (the authoritative tool path) ─────────────
1343
+
1344
+ /**
1345
+ * Resolve the remote binding a tool call must act on, from the calling
1346
+ * session's own workspace cwd.
1347
+ *
1348
+ * This is what keeps concurrent sessions independent: the machine and the
1349
+ * workspace root both come from the session's mirror `.dsh-remote-meta.json`,
1350
+ * never from the mutable active-machine state, so another session switching
1351
+ * machines cannot redirect this call. Sessions on the same machine resolve to
1352
+ * the same key and share one pool.
1353
+ *
1354
+ * @param {object} [exec] - the tool's `ToolRunContext`; `exec.agent.session.header.cwd` identifies the caller.
1355
+ * @returns {{pool: SshPool, ws: string, host: string, username: string, port: number, mirrorDir: string|null, bound: boolean, local: boolean}}
1356
+ * `bound` is true only for a session whose cwd resolves to a mirror. A
1357
+ * caller with a cwd that is NOT a mirror is a local session: `local` is
1358
+ * true and the active-machine values are returned for machine-scoped
1359
+ * reporting (`rw_info`) — session-scoped tools must refuse it instead,
1360
+ * which is what {@link requireBinding} enforces.
1361
+ */
1362
+ const bindingFor = (exec) => {
1363
+ const cwd = exec?.agent?.session?.header?.cwd
1364
+ if (cwd) {
1365
+ const { remotePath, machine, mirrorDir } = resolveMirrorForLocal(String(cwd))
1366
+ if (remotePath && machine) {
1367
+ return {
1368
+ pool: poolForMachine(machine),
1369
+ ws: remotePath,
1370
+ host: machine.host,
1371
+ username: machine.username,
1372
+ port: machine.port,
1373
+ mirrorDir,
1374
+ bound: true,
1375
+ local: false,
1376
+ }
1377
+ }
1378
+ }
1379
+ return {
1380
+ pool,
1381
+ ws: wsPath(),
1382
+ host: config.host,
1383
+ username: config.username,
1384
+ port: config.port,
1385
+ mirrorDir: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : null,
1386
+ bound: false,
1387
+ // A caller that identified itself with a cwd, yet resolved to no mirror,
1388
+ // is definitively a LOCAL session. Distinguish it from "no caller
1389
+ // context at all", where the active machine is still the right answer.
1390
+ local: !!cwd,
1391
+ }
1392
+ }
1393
+
1394
+ /**
1395
+ * Binding for a tool that requires a remote workspace, with a diagnostic
1396
+ * refusal when none is resolvable. Failing loud here is the point: silently
1397
+ * falling back to the active machine is how a command reached the wrong host.
1398
+ * @param {object} exec - the tool's `ToolRunContext`.
1399
+ * @param {string} tool - tool name, used in the error message.
1400
+ * @returns {ReturnType<typeof bindingFor>} a binding with `bound: true`.
1401
+ */
1402
+ const requireBinding = (exec, tool) => {
1403
+ const b = bindingFor(exec)
1404
+ // A LOCAL session must be refused even when an active machine happens to
1405
+ // have a workspace: inheriting it is precisely how a command reaches a host
1406
+ // the caller never asked for. Checking only `!b.ws` missed this, because
1407
+ // the active machine's workspace is usually non-empty.
1408
+ if (b.local) {
1409
+ throw new Error(
1410
+ `${tool}: this session is LOCAL — its workspace is not a remote mirror, `
1411
+ + 'so there is no remote host to act on. Refusing rather than falling back to the active machine '
1412
+ + `(${config.username || '?'}@${config.host || '?'}), which belongs to a different session. `
1413
+ + 'Use rw_connect + rw_pick_workspace to bind a remote workspace, then open a session in its local mirror.',
1414
+ )
1415
+ }
1416
+ if (!b.ws) {
1417
+ throw new Error(
1418
+ `${tool}: no remote workspace set — call rw_connect then rw_pick_workspace first.`,
1419
+ )
1420
+ }
1421
+ if (!b.host) throw new Error(`${tool}: no remote host resolved for this session — run rw_connect first.`)
1422
+ return b
1423
+ }
1424
+
1425
+ /**
1426
+ * Binding for a tool that takes an explicit absolute remote path: it needs a
1427
+ * resolvable MACHINE but not a workspace root. A local session is refused for
1428
+ * the same reason as {@link requireBinding} — the active machine belongs to
1429
+ * whoever set it, not to this caller.
1430
+ * @param {object} exec - the tool's `ToolRunContext`.
1431
+ * @param {string} tool - tool name, used in the error message.
1432
+ * @returns {ReturnType<typeof bindingFor>} a binding whose `host` is non-empty.
1433
+ */
1434
+ const requireMachine = (exec, tool) => {
1435
+ const b = bindingFor(exec)
1436
+ if (b.local) {
1437
+ throw new Error(
1438
+ `${tool}: this session is LOCAL — its workspace is not a remote mirror, `
1439
+ + 'so there is no remote host to act on. Refusing rather than falling back to the active machine '
1440
+ + `(${config.username || '?'}@${config.host || '?'}), which belongs to a different session.`,
1441
+ )
1442
+ }
1443
+ if (!b.host) throw new Error(`${tool}: no remote host for this session — run rw_connect first`)
1444
+ return b
1445
+ }
1236
1446
  const status = () => ({
1237
1447
  host: config.host,
1238
1448
  port: config.port,
@@ -1276,27 +1486,44 @@ export async function apply(ctx, config) {
1276
1486
  'Show the remote environment: host/user/port, connection health, current remote workspace path, active port forwards. Call this first to orient, or when an rw_* call fails to check connectivity. Note: remote context is session-scoped — a saved machine only becomes active when you explicitly rw_connect to it (issue #13).',
1277
1487
  parameters: {},
1278
1488
  output: textOut,
1279
- async execute() {
1489
+ async execute(_args, exec) {
1490
+ // Report THIS session's binding, not the globally active machine: the
1491
+ // two differ whenever another session is working on another host, and
1492
+ // reporting the active machine is what made this tool contradict itself.
1493
+ const b = bindingFor(exec)
1494
+ // rw_info is the orientation tool, so resolve the real platform rather
1495
+ // than reporting "detecting…" for a pool this session has not used yet.
1496
+ if (b.host) {
1497
+ try { await b.pool.detect() } catch { /* unreachable host → the ping below reports it */ }
1498
+ }
1280
1499
  const s = status()
1281
- const lines = [
1282
- `Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}${s.activeSource !== 'machine' ? ` (source: ${s.activeSource})` : ''}`,
1283
- `Current remote workspace: ${s.workspace || '(none — call rw_connect then rw_pick_workspace to set one)'}`,
1284
- `Local mirror: ${s.localMirror || '(none)'}`,
1285
- `Session remote context: ${s.sessionRemotePath ? s.sessionRemotePath : '(this session is LOCAL — no remote workspace bound)'}`,
1286
- `Connected: ${s.connected ? 'yes' : 'no'}`,
1287
- `Remote shell: ${s.platform === 'windows' ? (s.gitBash ? 'Git Bash (' + s.gitBash + ')' : 'Windows (Git Bash not found — set config.shell to a bash.exe path)') : (s.platform === 'posix' ? 'native POSIX' : 'detecting…')}`,
1288
- `Active forwards: ${s.forwards.filter((f) => f.active).length} / ${s.forwards.length}`,
1289
- `Host key: ${s.hostKeyKnown ? 'trusted' : 'not yet trusted'} (mode=${s.hostKeyMode})`,
1290
- '',
1291
- ]
1292
- if (s.host && s.workspace) {
1500
+ const lines = b.bound
1501
+ ? [
1502
+ `Remote host: ${b.username || '<user>'}@${b.host}:${b.port} (bound to this session's workspace)`,
1503
+ `Current remote workspace: ${toDisplayPath(b.ws, b.pool.platform)}`,
1504
+ `Local mirror: ${b.mirrorDir || '(none)'}`,
1505
+ `Connected: ${b.pool.client ? 'yes' : 'no'}`,
1506
+ `Remote shell: ${b.pool.platform === 'windows' ? (b.pool.gitBashPath ? 'Git Bash (' + b.pool.gitBashPath + ')' : 'Windows (Git Bash not found — set config.shell to a bash.exe path)') : (b.pool.platform === 'posix' ? 'native POSIX' : 'detecting…')}`,
1507
+ `Active forwards: ${s.forwards.filter((f) => f.active).length} / ${s.forwards.length} (forwards are machine-scoped)`,
1508
+ `Host key: ${isHostKeyKnown(b.host, b.port) ? 'trusted' : 'not yet trusted'} (mode=${s.hostKeyMode})`,
1509
+ '',
1510
+ ]
1511
+ : [
1512
+ `Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}${s.activeSource !== 'machine' ? ` (source: ${s.activeSource})` : ''}`,
1513
+ 'Session remote context: (this session is LOCAL — no remote workspace bound)',
1514
+ `Active machine workspace: ${s.workspace || '(none — call rw_connect then rw_pick_workspace to set one)'}`,
1515
+ `Connected: ${s.connected ? 'yes' : 'no'}`,
1516
+ `Host key: ${s.hostKeyKnown ? 'trusted' : 'not yet trusted'} (mode=${s.hostKeyMode})`,
1517
+ '',
1518
+ ]
1519
+ if (b.host && b.ws) {
1293
1520
  try {
1294
- const res = await pool.exec('echo ok', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
1521
+ const res = await b.pool.exec('echo ok', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
1295
1522
  if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
1296
1523
  else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
1297
1524
  else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
1298
1525
  } catch (err) {
1299
- lines.push('Ping: FAILED — ' + friendlyMessage(err, s))
1526
+ lines.push('Ping: FAILED — ' + friendlyMessage(err, { host: b.host, port: b.port }))
1300
1527
  }
1301
1528
  } else {
1302
1529
  lines.push('No host + workspace configured — call rw_connect with a host to get started.')
@@ -1376,17 +1603,28 @@ export async function apply(ctx, config) {
1376
1603
  path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project or C:\\Users\\dev\\project' },
1377
1604
  },
1378
1605
  output: textOut,
1379
- async execute(args) {
1606
+ async execute(args, exec) {
1380
1607
  const p = normalizeRemotePath(String(args.path || ''))
1381
1608
  if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
1382
- const ok = await isRemoteDir(p)
1383
- const shown = toDisplayPath(p, pool.platform)
1609
+ // Picking a workspace targets the machine this session is already bound
1610
+ // to when it has one. A LOCAL session deliberately falls back to the
1611
+ // active machine here: this is the setup flow that runs right after
1612
+ // rw_connect, before any mirror exists to bind to. It creates a mirror
1613
+ // rather than reading or writing remote data, so the refusal that
1614
+ // guards the other tools would make binding a first workspace
1615
+ // impossible.
1616
+ const b = bindingFor(exec)
1617
+ if (!b.host) throw new Error('rw_pick_workspace: no remote host — call rw_connect first')
1618
+ const ok = await isRemoteDir(p, b.pool)
1619
+ const shown = toDisplayPath(p, b.pool.platform)
1384
1620
  if (!ok) return { text: `not a directory (or missing) on ${shown}` }
1385
- persistWorkspace(p)
1386
- const local = ensureMirror(p, config.host, config.username, config.port)
1621
+ // A bound session's workspace lives in its own mirror meta, so the
1622
+ // shared active-machine workspace is only updated for the unbound flow.
1623
+ if (!b.bound) persistWorkspace(p)
1624
+ const local = ensureMirror(p, b.host, b.username, b.port)
1387
1625
  startAutoPush(local)
1388
1626
  return {
1389
- text: `Remote workspace set to ${shown} on ${config.username}@${config.host} (saved for this machine).\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
1627
+ text: `Remote workspace set to ${shown} on ${b.username}@${b.host}.\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
1390
1628
  }
1391
1629
  },
1392
1630
  }),
@@ -1403,10 +1641,10 @@ export async function apply(ctx, config) {
1403
1641
  async: { type: 'boolean', description: 'Run in the background and return a task id (default false)' },
1404
1642
  },
1405
1643
  output: textOut,
1406
- async execute(args) {
1407
- const p = wsPath()
1408
- if (!p) throw new Error('rw_sync: no remote workspace set — call rw_pick_workspace first')
1409
- const local = mirrorDirFor(p, config.host, config.username, config.port)
1644
+ async execute(args, exec) {
1645
+ const b = requireBinding(exec, 'rw_sync')
1646
+ const p = b.ws
1647
+ const local = b.mirrorDir || mirrorDirFor(p, b.host, b.username, b.port)
1410
1648
  mkdirSync(local, { recursive: true })
1411
1649
  const depth = Math.min(Math.max(Number(args.depth) || 5, 1), 8)
1412
1650
  const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
@@ -1415,7 +1653,7 @@ export async function apply(ctx, config) {
1415
1653
  const runSync = async () => {
1416
1654
  let sftp
1417
1655
  try {
1418
- sftp = await pool.sftp()
1656
+ sftp = await b.pool.sftp()
1419
1657
  } catch (err) {
1420
1658
  throw new Error('sftp unavailable: ' + ((err && err.message) || err))
1421
1659
  }
@@ -1453,10 +1691,10 @@ export async function apply(ctx, config) {
1453
1691
  async: { type: 'boolean', description: 'Run in the background and return a task id (default false)' },
1454
1692
  },
1455
1693
  output: textOut,
1456
- async execute(args) {
1457
- const p = wsPath()
1458
- if (!p) throw new Error('rw_push: no remote workspace set — call rw_pick_workspace first')
1459
- const local = mirrorDirFor(p, config.host, config.username, config.port)
1694
+ async execute(args, exec) {
1695
+ const b = requireBinding(exec, 'rw_push')
1696
+ const p = b.ws
1697
+ const local = b.mirrorDir || mirrorDirFor(p, b.host, b.username, b.port)
1460
1698
  if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`)
1461
1699
  const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
1462
1700
  const isIgnored = ignoreMatcher()
@@ -1464,7 +1702,7 @@ export async function apply(ctx, config) {
1464
1702
  const runPush = async () => {
1465
1703
  let sftp
1466
1704
  try {
1467
- sftp = await pool.sftp()
1705
+ sftp = await b.pool.sftp()
1468
1706
  } catch (err) {
1469
1707
  throw new Error('sftp unavailable: ' + ((err && err.message) || err))
1470
1708
  }
@@ -1499,12 +1737,13 @@ export async function apply(ctx, config) {
1499
1737
  path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
1500
1738
  },
1501
1739
  output: textOut,
1502
- async execute(args) {
1503
- const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
1740
+ async execute(args, exec) {
1741
+ const b = requireBinding(exec, 'rw_list_dir')
1742
+ const p = args.path ? normalizeRemotePath(String(args.path)) : b.ws
1504
1743
  if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
1505
1744
  let list
1506
1745
  try {
1507
- const sftp = await pool.sftp()
1746
+ const sftp = await b.pool.sftp()
1508
1747
  list = await sftp.readdir(p)
1509
1748
  } catch (err) {
1510
1749
  throw new Error('rw_list_dir: ' + ((err && err.message) || err))
@@ -1535,10 +1774,11 @@ export async function apply(ctx, config) {
1535
1774
  path: { type: 'string', required: true, description: 'Absolute remote path' },
1536
1775
  },
1537
1776
  output: textOut,
1538
- async execute(args) {
1777
+ async execute(args, exec) {
1778
+ const b = requireMachine(exec, 'rw_stat')
1539
1779
  const p = normalizeRemotePath(String(args.path || ''))
1540
1780
  if (!p) throw new Error('rw_stat: path is required')
1541
- const sftp = await pool.sftp()
1781
+ const sftp = await b.pool.sftp()
1542
1782
  let st
1543
1783
  try {
1544
1784
  st = await sftp.stat(p)
@@ -1569,14 +1809,15 @@ export async function apply(ctx, config) {
1569
1809
  encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
1570
1810
  },
1571
1811
  output: textOut,
1572
- async execute(args) {
1812
+ async execute(args, exec) {
1813
+ const b = requireMachine(exec, 'rw_read_file')
1573
1814
  const p = normalizeRemotePath(String(args.path || ''))
1574
1815
  if (!p) throw new Error('rw_read_file: path is required')
1575
1816
  const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
1576
1817
  let from = Math.max(Number(args.startLine) || 1, 1)
1577
1818
  let to = Number(args.endLine) || 0
1578
1819
  if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
1579
- const sftp = await pool.sftp()
1820
+ const sftp = await b.pool.sftp()
1580
1821
  let st
1581
1822
  try { st = await sftp.stat(p) } catch (err) { throw new Error('rw_read_file: ' + ((err && err.message) || err)) }
1582
1823
  if (config.maxFileBytes > 0 && st.size > config.maxFileBytes) {
@@ -1609,16 +1850,17 @@ export async function apply(ctx, config) {
1609
1850
  encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
1610
1851
  },
1611
1852
  output: okOut,
1612
- async execute(args) {
1853
+ async execute(args, exec) {
1854
+ const b = requireMachine(exec, 'rw_write_file')
1613
1855
  const p = normalizeRemotePath(String(args.path || ''))
1614
1856
  if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
1615
1857
  const content = String(args.content == null ? '' : args.content)
1616
- const sftp = await pool.sftp()
1858
+ const sftp = await b.pool.sftp()
1617
1859
  if (args.mkdir !== false) await mkdirRemoteDirs(sftp, remoteDirname(p))
1618
1860
  const buf = encodeText(content, args.encoding || config.encoding)
1619
1861
  await sftp.writeFile(p, buf)
1620
1862
  const bytes = buf.byteLength
1621
- audit('write_file', `write ${p} (${bytes}B)`, 0)
1863
+ audit('write_file', `write ${p} (${bytes}B)`, 0, b)
1622
1864
  return { ok: true, bytes, text: `wrote ${bytes} bytes to ${p}` }
1623
1865
  },
1624
1866
  }),
@@ -1635,13 +1877,14 @@ export async function apply(ctx, config) {
1635
1877
  encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
1636
1878
  },
1637
1879
  output: okOut,
1638
- async execute(args) {
1880
+ async execute(args, exec) {
1881
+ const b = requireMachine(exec, 'rw_edit')
1639
1882
  const p = normalizeRemotePath(String(args.path || ''))
1640
1883
  if (!p || p === '/') throw new Error('rw_edit: a file path is required')
1641
1884
  const oldS = String(args.old ?? '')
1642
1885
  const newS = String(args.new ?? '')
1643
1886
  if (oldS === '') throw new Error('rw_edit: old text must not be empty')
1644
- const sftp = await pool.sftp()
1887
+ const sftp = await b.pool.sftp()
1645
1888
  const st0 = await sftp.stat(p)
1646
1889
  const buf = await sftp.readFile(p)
1647
1890
  const content = decodeBuf(buf, args.encoding || config.encoding)
@@ -1665,7 +1908,7 @@ export async function apply(ctx, config) {
1665
1908
  throw new Error(`rw_edit: ${p} changed on the remote while editing (conflict) — re-read and retry`)
1666
1909
  }
1667
1910
  await sftp.writeFile(p, encodeText(out, args.encoding || config.encoding))
1668
- audit('edit', `edit ${p} (${n} occurrence(s))`, 0)
1911
+ audit('edit', `edit ${p} (${n} occurrence(s))`, 0, b)
1669
1912
  return { ok: true, bytes: Buffer.byteLength(out), text: `edited ${p}: replaced ${n} occurrence(s)` }
1670
1913
  },
1671
1914
  }),
@@ -1680,16 +1923,17 @@ export async function apply(ctx, config) {
1680
1923
  encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
1681
1924
  },
1682
1925
  output: okOut,
1683
- async execute(args) {
1926
+ async execute(args, exec) {
1927
+ const b = requireMachine(exec, 'rw_append')
1684
1928
  const p = normalizeRemotePath(String(args.path || ''))
1685
1929
  if (!p || p === '/') throw new Error('rw_append: a file path is required')
1686
- const sftp = await pool.sftp()
1930
+ const sftp = await b.pool.sftp()
1687
1931
  let existing = ''
1688
1932
  try { existing = decodeBuf(await sftp.readFile(p), args.encoding || config.encoding) } catch { /* new file */ }
1689
1933
  const content = existing + String(args.content ?? '')
1690
1934
  await sftp.writeFile(p, encodeText(content, args.encoding || config.encoding))
1691
1935
  const bytes = Buffer.byteLength(content)
1692
- audit('append', `append ${p}`, 0)
1936
+ audit('append', `append ${p}`, 0, b)
1693
1937
  return { ok: true, bytes, text: `appended to ${p} (now ${bytes} bytes)` }
1694
1938
  },
1695
1939
  }),
@@ -1702,12 +1946,13 @@ export async function apply(ctx, config) {
1702
1946
  path: { type: 'string', required: true, description: 'Absolute remote directory path' },
1703
1947
  },
1704
1948
  output: textOut,
1705
- async execute(args) {
1949
+ async execute(args, exec) {
1950
+ const b = requireMachine(exec, 'rw_mkdir')
1706
1951
  const p = normalizeRemotePath(String(args.path || ''))
1707
1952
  if (!p || p === '/') throw new Error('rw_mkdir: a directory path is required')
1708
- const sftp = await pool.sftp()
1953
+ const sftp = await b.pool.sftp()
1709
1954
  await mkdirRemoteDirs(sftp, p)
1710
- audit('mkdir', `mkdir ${p}`, 0)
1955
+ audit('mkdir', `mkdir ${p}`, 0, b)
1711
1956
  return { text: `created ${p}` }
1712
1957
  },
1713
1958
  }),
@@ -1721,20 +1966,21 @@ export async function apply(ctx, config) {
1721
1966
  recursive: { type: 'boolean', description: 'Recursively delete a directory (default false)' },
1722
1967
  },
1723
1968
  output: okOut,
1724
- async execute(args) {
1969
+ async execute(args, exec) {
1970
+ const b = requireMachine(exec, 'rw_remove')
1725
1971
  const p = normalizeRemotePath(String(args.path || ''))
1726
1972
  if (!p || p === '/') throw new Error('rw_remove: a path is required')
1727
- const sftp = await pool.sftp()
1973
+ const sftp = await b.pool.sftp()
1728
1974
  const st = await sftp.stat(p).catch(() => null)
1729
1975
  if (!st) return { ok: false, text: `not found: ${p}` }
1730
1976
  if (st.isDirectory && st.isDirectory()) {
1731
1977
  if (!args.recursive) throw new Error(`rw_remove: ${p} is a directory — pass recursive=true to delete its tree`)
1732
1978
  const removed = await removeRemoteTree(sftp, p)
1733
- audit('remove', `remove -r ${p}`, 0)
1979
+ audit('remove', `remove -r ${p}`, 0, b)
1734
1980
  return { ok: true, text: `removed ${removed} entries under ${p}` }
1735
1981
  }
1736
1982
  await sftp.unlink(p)
1737
- audit('remove', `remove ${p}`, 0)
1983
+ audit('remove', `remove ${p}`, 0, b)
1738
1984
  return { ok: true, text: `removed ${p}` }
1739
1985
  },
1740
1986
  }),
@@ -1748,14 +1994,15 @@ export async function apply(ctx, config) {
1748
1994
  dest: { type: 'string', required: true, description: 'Destination absolute remote path' },
1749
1995
  },
1750
1996
  output: textOut,
1751
- async execute(args) {
1997
+ async execute(args, exec) {
1998
+ const b = requireMachine(exec, 'rw_move')
1752
1999
  const p = normalizeRemotePath(String(args.path || ''))
1753
2000
  const d = normalizeRemotePath(String(args.dest || ''))
1754
2001
  if (!p || !d || p === '/') throw new Error('rw_move: both path and dest are required')
1755
- const sftp = await pool.sftp()
2002
+ const sftp = await b.pool.sftp()
1756
2003
  await mkdirRemoteDirs(sftp, remoteDirname(d))
1757
2004
  await sftp.rename(p, d)
1758
- audit('move', `move ${p} → ${d}`, 0)
2005
+ audit('move', `move ${p} → ${d}`, 0, b)
1759
2006
  return { text: `moved ${p} → ${d}` }
1760
2007
  },
1761
2008
  }),
@@ -1771,14 +2018,20 @@ export async function apply(ctx, config) {
1771
2018
  env: { type: 'object', additionalProperties: true, description: 'Extra environment variables (string values)' },
1772
2019
  },
1773
2020
  output: textOut,
1774
- async execute(args) {
2021
+ async execute(args, exec) {
2022
+ const b = requireBinding(exec, 'rw_exec')
1775
2023
  const cmd = String(args.command || '')
1776
2024
  if (!cmd) throw new Error('rw_exec: command is required')
1777
- const ws = wsPath()
2025
+ const ws = b.ws
1778
2026
  const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
1779
2027
  let full = cmd
1780
2028
  if (cwd) {
1781
- if (pool.platform === 'windows' && pool.gitBashPath) {
2029
+ // Detect before choosing the cwd form: a per-machine pool starts
2030
+ // undetected, and reading `platform` first would skip the Git Bash
2031
+ // mount-path rewrite on the first command sent to a Windows host.
2032
+ // detect() is cached and idempotent, so this costs one probe per pool.
2033
+ try { await b.pool.detect() } catch { /* probe failed → fall through to the POSIX form */ }
2034
+ if (b.pool.platform === 'windows' && b.pool.gitBashPath) {
1782
2035
  // Git Bash terminal: cd in the /c/Users/… mount form
1783
2036
  full = `cd ${shq(toShellPath(cwd))} && ${cmd}`
1784
2037
  } else if (!cwd.includes('\\')) {
@@ -1786,8 +2039,8 @@ export async function apply(ctx, config) {
1786
2039
  }
1787
2040
  }
1788
2041
  try {
1789
- const res = await pool.exec(full, { timeoutMs: config.commandTimeoutMs, pty: !!args.pty, env: args.env })
1790
- audit('exec', cmd, res.code)
2042
+ const res = await b.pool.exec(full, { timeoutMs: config.commandTimeoutMs, pty: !!args.pty, env: args.env })
2043
+ audit('exec', cmd, res.code, b)
1791
2044
  const parts = []
1792
2045
  if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
1793
2046
  if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
@@ -1797,7 +2050,7 @@ export async function apply(ctx, config) {
1797
2050
  else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
1798
2051
  return { text }
1799
2052
  } catch (err) {
1800
- throw new Error(friendlyMessage(err, { host: config.host, port: config.port }))
2053
+ throw new Error(friendlyMessage(err, { host: b.host, port: b.port }))
1801
2054
  }
1802
2055
  },
1803
2056
  }),
@@ -1815,10 +2068,11 @@ export async function apply(ctx, config) {
1815
2068
  maxMatches: { type: 'integer', description: 'Max matches to return (default 500)' },
1816
2069
  },
1817
2070
  output: textOut,
1818
- async execute(args) {
2071
+ async execute(args, exec) {
2072
+ const b = requireBinding(exec, 'rw_search')
1819
2073
  const pattern = String(args.pattern || '')
1820
2074
  if (!pattern) throw new Error('rw_search: pattern is required')
1821
- const ws = wsPath()
2075
+ const ws = b.ws
1822
2076
  const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
1823
2077
  if (!dir) throw new Error('rw_search: no path and no remote workspace set')
1824
2078
  let regex
@@ -1827,7 +2081,7 @@ export async function apply(ctx, config) {
1827
2081
  } catch (err) {
1828
2082
  throw new Error('rw_search: bad pattern: ' + ((err && err.message) || err))
1829
2083
  }
1830
- const sftp = await pool.sftp()
2084
+ const sftp = await b.pool.sftp()
1831
2085
  const maxMatches = Math.min(Math.max(Number(args.maxMatches) || 500, 1), 2000)
1832
2086
  const matcher = ignoreMatcher()
1833
2087
  const { matches, scanned, truncated } = await searchTree(sftp, dir, {
@@ -1854,17 +2108,18 @@ export async function apply(ctx, config) {
1854
2108
  localPath: { type: 'string', description: 'Local destination (default: the workspace mirror, preserving the relative path)' },
1855
2109
  },
1856
2110
  output: okOut,
1857
- async execute(args) {
2111
+ async execute(args, exec) {
2112
+ const b = requireMachine(exec, 'rw_download')
1858
2113
  const p = normalizeRemotePath(String(args.path || ''))
1859
2114
  if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
1860
- const sftp = await pool.sftp()
2115
+ const sftp = await b.pool.sftp()
1861
2116
  let local
1862
2117
  if (args.localPath) {
1863
2118
  local = path.resolve(String(args.localPath))
1864
2119
  } else {
1865
- const ws = wsPath()
2120
+ const ws = b.ws
1866
2121
  if (!ws) throw new Error('rw_download: no remote workspace set — pass localPath explicitly')
1867
- const base = mirrorDirFor(ws, config.host, config.username, config.port)
2122
+ const base = mirrorDirFor(ws, b.host, b.username, b.port)
1868
2123
  const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
1869
2124
  local = path.join(base, rel)
1870
2125
  }
@@ -1888,16 +2143,17 @@ export async function apply(ctx, config) {
1888
2143
  path: { type: 'string', required: true, description: 'Absolute remote destination path' },
1889
2144
  },
1890
2145
  output: okOut,
1891
- async execute(args) {
2146
+ async execute(args, exec) {
2147
+ const b = requireMachine(exec, 'rw_upload')
1892
2148
  const rp = normalizeRemotePath(String(args.path || ''))
1893
2149
  const lp = String(args.localPath || '')
1894
2150
  if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
1895
2151
  if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
1896
- const sftp = await pool.sftp()
2152
+ const sftp = await b.pool.sftp()
1897
2153
  await mkdirRemoteDirs(sftp, remoteDirname(rp))
1898
2154
  const st = statSync(lp)
1899
2155
  await sftp.fastPut(lp, rp)
1900
- audit('upload', `upload ${lp} → ${rp}`, 0)
2156
+ audit('upload', `upload ${lp} → ${rp}`, 0, b)
1901
2157
  return { ok: true, bytes: st.size, text: `uploaded ${st.size} bytes from ${lp} → ${rp}` }
1902
2158
  },
1903
2159
  }),
@@ -1944,12 +2200,17 @@ export async function apply(ctx, config) {
1944
2200
  defineTool({
1945
2201
  name: 'rw_disconnect',
1946
2202
  description:
1947
- 'Close the current SSH connection to the remote host, releasing the persistent pool (and stopping all active port forwards). Useful to rotate connections or after a long idle.',
2203
+ "Close this session's SSH connection to its remote host, releasing the persistent pool. Useful to rotate a connection or after a long idle. Sessions bound to other machines keep their own connections.",
1948
2204
  parameters: {},
1949
2205
  output: okOut,
1950
- async execute() {
1951
- pool.close()
1952
- return { ok: true, text: 'disconnected (forwards stopped)' }
2206
+ async execute(_args, exec) {
2207
+ // Close the connection THIS session actually uses: closing the
2208
+ // active-machine pool instead would look like a no-op to a bound
2209
+ // session while disconnecting whichever machine happened to be active.
2210
+ const b = bindingFor(exec)
2211
+ b.pool.close()
2212
+ if (!b.bound) return { ok: true, text: 'disconnected the active machine (forwards stopped)' }
2213
+ return { ok: true, text: `disconnected ${b.username}@${b.host}:${b.port} for this session` }
1953
2214
  },
1954
2215
  }),
1955
2216
  ]
@@ -1974,14 +2235,21 @@ export async function apply(ctx, config) {
1974
2235
  const session = agent && agent.session
1975
2236
  const cwd = session && session.header && session.header.cwd
1976
2237
  if (!cwd) return '' // no session cwd → nothing to map → stay quiet
1977
- const { remotePath } = resolveMirrorForLocal(cwd)
2238
+ const { remotePath, machine } = resolveMirrorForLocal(cwd)
1978
2239
  if (!remotePath) return '' // this session is NOT a remote session (issue #13)
2240
+ // Name the machine the MIRROR records, not the active one: pairing this
2241
+ // session's remote path with whichever machine happens to be active
2242
+ // states a host/path combination that may not exist (issue #25).
2243
+ const who = machine
2244
+ ? `${machine.username || 'user'}@${machine.host}`
2245
+ : `${config.username || 'user'}@${config.host}`
2246
+ const platform = machine ? poolForMachine(machine).platform : pool.platform
1979
2247
  const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
1980
2248
  let extra = ''
1981
2249
  if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
1982
2250
  return (
1983
2251
  '## Remote workspace\n' +
1984
- `Current remote workspace: ${config.username || 'user'}@${config.host}:${toDisplayPath(remotePath, pool.platform)}\n` +
2252
+ `Current remote workspace: ${who}:${toDisplayPath(remotePath, platform)}\n` +
1985
2253
  'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_edit / rw_exec / rw_search / rw_sync / rw_push) to inspect and act on files on the remote host. Treat this directory as the working root for this task.' +
1986
2254
  extra
1987
2255
  )