@rjfan/dsh-presence 0.19.0 → 0.20.1

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/src/tools.js CHANGED
@@ -3,11 +3,10 @@
3
3
  // and nothing is installed on the far side.
4
4
 
5
5
  import { defineTool } from '@deepseek-ai/dsh-tools';
6
- import { activeWorkspace } from './workspaces.js';
7
6
  import { parseMountPath, parsePresenceUrl } from './mounts.js';
8
7
  import { remoteGlobDetailed, remoteGrep, splitGlobPattern } from './remote-search.js';
9
8
  import { guardDestructive } from './destructive.js';
10
- import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
9
+ import { existsSync, globSync as nodeGlobSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
11
10
  import { homedir } from 'node:os';
12
11
  import { createHash } from 'node:crypto';
13
12
  import { connect as connectTcp } from 'node:net';
@@ -15,6 +14,7 @@ import { exportDirectory, join as joinPath, dirname as dirnamePath, resolve as r
15
14
  import { makeDirectory, movePath, copyPath, chmodPath } from './path-ops.js';
16
15
  import { diffLines as buildUnifiedDiff } from './diff.js';
17
16
  import { tailRemote } from './tail.js';
17
+ import { waitUntil } from './wait.js';
18
18
 
19
19
  const OUTPUT_CAP = 32 * 1024;
20
20
  const EXEC_OUTPUT_CAP = 16 * 1024;
@@ -42,6 +42,22 @@ const TAIL_MAX_TIMEOUT_MS = 60_000;
42
42
  const TAIL_MAX_LINES = 5000;
43
43
  const TAIL_JSON_TEXT_CAP = 48 * 1024;
44
44
  const TAIL_JSON_MAX = 64 * 1024;
45
+ // presence_wait: one condition, polled; a timeout is data (met:false), not an error.
46
+ const WAIT_DEFAULT_TIMEOUT_MS = 120_000;
47
+ const WAIT_MAX_TIMEOUT_MS = 3_600_000;
48
+ const WAIT_DEFAULT_POLL_MS = 1_000;
49
+ const WAIT_MIN_POLL_MS = 50;
50
+ const WAIT_MAX_POLL_MS = 60_000;
51
+ // One copyable example per condition, used in the tool's validation errors.
52
+ const WAIT_EXAMPLES = {
53
+ file: '{"file": {"path": "/srv/app/done.marker"}}',
54
+ grep: '{"grep": {"path": "/srv/app/run.log", "pattern": "ready", "count": 1}}',
55
+ process: '{"process": {"pattern": "python3 train.py", "alive": true}}',
56
+ port: '{"port": {"port": 8000, "state": "open"}}',
57
+ job: '{"job": {"jobId": "j_123"}}',
58
+ };
59
+ // Batch transfers: one call may move at most this many files.
60
+ const TRANSFER_MAX_FILES = 50;
45
61
 
46
62
  // ------------------------------------------------------- argument aliases
47
63
  //
@@ -887,9 +903,13 @@ function jobRecord(job) {
887
903
  exitCode: job.exitCode ?? null,
888
904
  exitSignal: job.exitSignal ?? null,
889
905
  pid: job.pid ?? null,
906
+ // The persisted registry may add these; older pools simply lack them.
907
+ pgid: job.pgid ?? null,
908
+ persisted: job.persisted === true,
890
909
  label: job.label ? stripPipefailPrelude(job.label) : null,
891
910
  startedAt: job.startedAt ?? null,
892
911
  logPath: job.logPath ?? null,
912
+ commandLogPath: job.commandLogPath ?? null,
893
913
  logSize: job.logSize ?? null,
894
914
  command: job.command ? stripPipefailPrelude(job.command) : null,
895
915
  cwd: job.cwd ?? null,
@@ -992,6 +1012,75 @@ function clampedNumber(value, fallback, min, max) {
992
1012
  return Math.min(max, Math.max(min, Math.floor(number)));
993
1013
  }
994
1014
 
1015
+ /** `presence_write` mode: 'append'/'overwrite', or null when the value is an octal file mode. */
1016
+ function writeModeOf(value) {
1017
+ if (typeof value !== 'string') return null;
1018
+ const lower = value.trim().toLowerCase();
1019
+ return lower === 'append' || lower === 'overwrite' ? lower : null;
1020
+ }
1021
+
1022
+ /** A short unpredictable suffix for private staging files (never model-visible). */
1023
+ function stagingSuffix() {
1024
+ return `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1025
+ }
1026
+
1027
+ /**
1028
+ * The files one batch transfer call names: `paths[]` (1-50) plus, for export,
1029
+ * an optional single `path`. Missing input is a self-explaining error.
1030
+ */
1031
+ function transferInputs(args, toolName, { single = null } = {}) {
1032
+ const listed = Array.isArray(args.paths) ? args.paths : [];
1033
+ const raw = single !== null && single !== undefined ? [single, ...listed] : listed;
1034
+ const inputs = [];
1035
+ for (const value of raw) {
1036
+ if (typeof value !== 'string' || value.trim() === '') {
1037
+ throw new Error(`${toolName}: every path must be a non-empty string`);
1038
+ }
1039
+ if (!inputs.includes(value.trim())) inputs.push(value.trim());
1040
+ }
1041
+ if (inputs.length > TRANSFER_MAX_FILES) {
1042
+ throw new Error(`${toolName} moves at most ${TRANSFER_MAX_FILES} files per call, got ${inputs.length}`);
1043
+ }
1044
+ return inputs;
1045
+ }
1046
+
1047
+ /** Local paths matching one glob, relative to `cwd`; the caller resolves them. */
1048
+ function localGlobPaths(pattern, cwd) {
1049
+ const source = String(pattern ?? '').trim();
1050
+ if (!source) return [];
1051
+ if (typeof nodeGlobSync !== 'function') {
1052
+ throw new Error('this Node build has no fs.globSync; pass explicit paths instead of a glob');
1053
+ }
1054
+ const matches = nodeGlobSync(source, { cwd });
1055
+ const rows = [];
1056
+ for (const entry of matches) {
1057
+ const value = typeof entry === 'string' ? entry : String(entry?.name ?? entry);
1058
+ if (value && !rows.includes(value)) rows.push(value);
1059
+ }
1060
+ return rows;
1061
+ }
1062
+
1063
+ /**
1064
+ * Default local destination for one export: the remote basename. An occupied
1065
+ * name (or a name already used by this batch) gets `-<sha8>` before the
1066
+ * extension, so nothing in the export folder is silently overwritten.
1067
+ */
1068
+ function exportLocalTarget(folder, remoteTarget, digest, used = new Set()) {
1069
+ const name = String(remoteTarget).split('/').filter(Boolean).pop() ?? 'export.bin';
1070
+ const absolute = (candidate) => joinPath(folder, candidate);
1071
+ const dot = name.lastIndexOf('.');
1072
+ const stem = dot > 0 ? name.slice(0, dot) : name;
1073
+ const extension = dot > 0 ? name.slice(dot) : '';
1074
+ let candidate = name;
1075
+ if (used.has(candidate) || existsSync(absolute(candidate))) {
1076
+ candidate = `${stem}-${digest.slice(0, 8)}${extension}`;
1077
+ let index = 2;
1078
+ while (used.has(candidate)) candidate = `${stem}-${digest.slice(0, 8)}-${index++}${extension}`;
1079
+ }
1080
+ used.add(candidate);
1081
+ return absolute(candidate);
1082
+ }
1083
+
995
1084
  /**
996
1085
  * JSON text that stays valid: oversized text fields are shortened (twice each
997
1086
  * round) instead of being cut mid-string, so callers can always JSON.parse it.
@@ -1047,10 +1136,13 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1047
1136
  };
1048
1137
 
1049
1138
  /**
1050
- * Where this call belongs. Inside a workspace the session's own directory
1051
- * decides the machine — every workspace in the sidebar just works, with no
1052
- * "make it current" step. Explicit host still wins; a session outside any
1053
- * remote workspace falls back to the last opened one.
1139
+ * Where this call belongs. The session's own directory decides the machine
1140
+ * when it is a mount path — every remote workspace in the sidebar just works,
1141
+ * with no "make it current" step. An explicit `host` still wins, and
1142
+ * `settings.defaultHost` (PRESENCE_HOST) is the documented opt-in for local
1143
+ * sessions. A genuinely local session with neither must fail loudly: the
1144
+ * globally last-activated remote workspace is another session's machine and
1145
+ * silently borrowing it can run commands in the wrong place.
1054
1146
  */
1055
1147
  const pickScope = async (args, exec) => {
1056
1148
  let cwd = null;
@@ -1060,15 +1152,24 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1060
1152
  cwd = null;
1061
1153
  }
1062
1154
  const mounted = cwd ? parseMountPath(cwd) : null;
1063
- if (mounted && !args.host) return { host: mounted.host, workspace: { host: mounted.host, path: mounted.path } };
1064
-
1065
- const active = activeWorkspace();
1066
- const host = args.host || active?.host;
1067
- if (!host) {
1068
- throw new Error('no host given and no remote workspace here; call presence_hosts then pass host');
1155
+ const workspace = mounted ? { host: mounted.host, path: mounted.path } : null;
1156
+ const explicitHost =
1157
+ typeof args?.host === 'string' && args.host.trim() !== '' ? args.host.trim() : null;
1158
+ if (explicitHost) {
1159
+ // A workspace-relative path only makes sense on the workspace's own host:
1160
+ // another explicit host gets no workspace context.
1161
+ return { host: explicitHost, workspace: workspace && workspace.host === explicitHost ? workspace : null };
1069
1162
  }
1070
- const workspace = active && active.host === host ? active : null;
1071
- return { host, workspace };
1163
+ if (workspace) return { host: workspace.host, workspace };
1164
+ const configured =
1165
+ typeof settings.defaultHost === 'string' && settings.defaultHost.trim() !== ''
1166
+ ? settings.defaultHost.trim()
1167
+ : null;
1168
+ if (configured) return { host: configured, workspace: null };
1169
+ throw new Error(
1170
+ `this session is local (cwd=${cwd ?? 'unknown'}); pass an explicit host. ` +
1171
+ 'Remote workspaces are not bound to this session; call presence_hosts first.',
1172
+ );
1072
1173
  };
1073
1174
 
1074
1175
  /** Every alias the ssh config knows, for the "known hosts" hint. */
@@ -1151,16 +1252,24 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1151
1252
  },
1152
1253
  async execute(args, exec) {
1153
1254
  const hosts = pool.hosts();
1154
- // The session's own workspace decides the working machine; a session
1155
- // that is not in a remote workspace falls back to the configured host.
1156
- let workspaceHost = null;
1255
+ // Scope is decided by this session only: a mount path means the session
1256
+ // runs in that remote workspace; otherwise settings.defaultHost
1257
+ // (PRESENCE_HOST) is the documented opt-in. The globally last-activated
1258
+ // workspace is never inherited — it may belong to another session.
1259
+ let workspace = null;
1157
1260
  try {
1158
1261
  const cwd = (await cwdOf?.(exec)) ?? null;
1159
- workspaceHost = cwd ? parseMountPath(cwd)?.host ?? null : null;
1262
+ workspace = cwd ? parseMountPath(cwd) : null;
1160
1263
  } catch {
1161
- workspaceHost = null;
1162
- }
1163
- const defaultHost = workspaceHost ?? settings.defaultHost ?? activeWorkspace()?.host ?? null;
1264
+ workspace = null;
1265
+ }
1266
+ const configured =
1267
+ typeof settings.defaultHost === 'string' && settings.defaultHost.trim() !== ''
1268
+ ? settings.defaultHost.trim()
1269
+ : null;
1270
+ const candidate = workspace?.host ?? configured;
1271
+ // A default that is not in the host list cannot be used by this session.
1272
+ const defaultHost = candidate && hosts.some((host) => host.alias === candidate) ? candidate : null;
1164
1273
  const stateOf = (host) => host.state ?? (host.connected ? 'connected' : 'idle');
1165
1274
  const stateText = (host) => {
1166
1275
  const state = stateOf(host);
@@ -1184,7 +1293,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1184
1293
  state: stateOf(host),
1185
1294
  connected: host.connected === true,
1186
1295
  default: host.alias === defaultHost,
1187
- workspace: host.alias === defaultHost,
1296
+ workspace: workspace?.host === host.alias,
1188
1297
  ...(host.lastError ? { lastError: host.lastError } : {}),
1189
1298
  })),
1190
1299
  );
@@ -1196,7 +1305,10 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1196
1305
  const marker = host.alias === defaultHost ? '*' : '';
1197
1306
  return `${marker}${host.alias} ${host.user ?? '?'}@${host.hostname}:${host.port} ${state} (${key})`;
1198
1307
  });
1199
- return `${hosts.length} host(s):\n${lines.join('\n')}`;
1308
+ const defaultLine = defaultHost
1309
+ ? `default: ${defaultHost} (${workspace?.host === defaultHost ? 'session workspace machine' : 'PRESENCE_HOST default'})`
1310
+ : null;
1311
+ return `${hosts.length} host(s):\n${lines.join('\n')}${defaultLine ? `\n${defaultLine}` : ''}`;
1200
1312
  },
1201
1313
  }),
1202
1314
 
@@ -1250,9 +1362,13 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1250
1362
  tool({
1251
1363
  name: 'presence_exec',
1252
1364
  description:
1253
- 'Run a shell command on a remote machine and return its exit code, stdout and stderr. Inside a workspace the machine and working directory come from the session itself, so no host is needed. The connection persists between calls. Commands run with `set -o pipefail` by default, so `cmd | tail` reports cmd\'s real failure; pass pipefail:false to disable. The text report starts with the absolute remote cwd; long output is shown as a head/tail excerpt that says how many characters were omitted and where the full stream was spilled. For servers or long jobs use background:true instead of `&`: it returns a job id, takes an optional label, and can wait until a port is listening with waitForPort.\n\nDESTRUCTIVE COMMANDS (`rm -r/-f`, `git clean -fdx`, `find -delete`, `shred`, `truncate`, `dd of=`) are dry-run first: the tool shows every resolved target and refuses workspace roots, ancestors, home, `/` and `.git` metadata. Re-run with confirm:true to execute the whole command, or confirm:"partial" to execute only the non-destructive segments (the result reports executedSegments/skippedSegments). Prefer presence_trash for recoverable deletes.',
1365
+ 'Run a shell command on a remote machine and return its exit code, stdout and stderr. In a remote-workspace session the machine and working directory come from the session itself, so no host is needed; a local session must pass host (or configure PRESENCE_HOST). The connection persists between calls. Commands run with `set -o pipefail` by default, so `cmd | tail` reports cmd\'s real failure; pass pipefail:false to disable. The text report starts with `host: <alias> remote cwd: <absolute>`; long output is shown as a head/tail excerpt that says how many characters were omitted and where the full stream was spilled. For servers or long jobs use background:true instead of `&`: it returns a job id, takes an optional label, and can wait until a port is listening with waitForPort.\n\nDESTRUCTIVE COMMANDS (`rm -r/-f`, `git clean -fdx`, `find -delete`, `shred`, `truncate`, `dd of=`) are dry-run first: the tool shows every resolved target and refuses workspace roots, ancestors, home, `/` and `.git` metadata. Re-run with confirm:true to execute the whole command, or confirm:"partial" to execute only the non-destructive segments (the result reports executedSegments/skippedSegments). Prefer presence_trash for recoverable deletes.',
1254
1366
  parameters: {
1255
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
1367
+ host: {
1368
+ type: 'string',
1369
+ description:
1370
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
1371
+ },
1256
1372
  command: { type: 'string', required: true, description: 'Command line, interpreted by the remote shell.' },
1257
1373
  cwd: { type: 'string', description: 'Directory to run in, absolute or workspace-relative. Defaults to the workspace root, else the remote login directory.' },
1258
1374
  timeoutMs: {
@@ -1315,12 +1431,14 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1315
1431
  args.timeoutMs === undefined || args.timeoutMs === null || args.timeoutMs === ''
1316
1432
  ? EXEC_DEFAULT_TIMEOUT_MS
1317
1433
  : Number(args.timeoutMs) || 0;
1318
- // The report always names the directory the command really runs in:
1319
- // the workspace/cwd when one applies, else the remote login directory.
1434
+ // The report always names the machine and the directory the command
1435
+ // really runs in: the workspace/cwd when one applies, else the remote
1436
+ // login directory. "remote cwd" is explicit so it is never mistaken for
1437
+ // a local path.
1320
1438
  const effectiveCwd =
1321
1439
  directory ??
1322
1440
  (typeof connection.homePath === 'function' ? await connection.homePath().catch(() => null) : null);
1323
- const cwdLine = `cwd: ${effectiveCwd ?? '(remote login directory)'}`;
1441
+ const scopeLine = `host: ${host} remote cwd: ${effectiveCwd ?? '(remote login directory)'}`;
1324
1442
  const backgroundHint = backgroundCommandHint(commandText);
1325
1443
  const hints = backgroundHint ? [backgroundHint] : [];
1326
1444
 
@@ -1440,7 +1558,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1440
1558
  const size =
1441
1559
  job.logSize === undefined || job.logSize === null ? null : ` (${formatBytes(job.logSize)})`;
1442
1560
  return [
1443
- cwdLine,
1561
+ scopeLine,
1444
1562
  `background job ${job.id ?? '(unknown)'} exited immediately on ${host} (it did not stay running)`,
1445
1563
  `exit: ${job.exitCode ?? 'unknown'}`,
1446
1564
  job.logPath ? `log: ${job.logPath}${size ?? ''}` : null,
@@ -1451,7 +1569,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1451
1569
  }
1452
1570
 
1453
1571
  const parts = [
1454
- cwdLine,
1572
+ scopeLine,
1455
1573
  `started in background on ${host}`,
1456
1574
  `job: ${job.id}`,
1457
1575
  `pid: ${job.pid}`,
@@ -1563,7 +1681,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1563
1681
  }
1564
1682
 
1565
1683
  const inlineCap = Math.max(1000, Math.min(Number(args.maxOutput) || EXEC_OUTPUT_CAP, 256 * 1024));
1566
- const parts = [cwdLine];
1684
+ const parts = [scopeLine];
1567
1685
  if (hints.length > 0) parts.push(`hint: ${hints[0]}`);
1568
1686
  if (partialInfo) {
1569
1687
  parts.push(
@@ -1619,12 +1737,227 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1619
1737
  },
1620
1738
  }),
1621
1739
 
1740
+ tool({
1741
+ name: 'presence_wait',
1742
+ description:
1743
+ 'Wait for one remote condition instead of a sleep+tail loop. Exactly one condition: file {path, exists?=true, minBytes?, mtimeAfter?}, grep {path, pattern, count?=1}, process {pid?|pattern, alive?=true}, port {port, state:"open"|"closed"}, or job {jobId}. Polls every pollMs (default 1000) until met or timeoutMs (default 120000, max 3600000) expires. A timeout is data — the tool returns met:false and never throws. Returns {met, timedOut, elapsedMs, observed, condition, polls}; format:"json" returns the same object as JSON.',
1744
+ parameters: {
1745
+ host: {
1746
+ type: 'string',
1747
+ description:
1748
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST). Ignored for a job condition, which finds the host from the job itself.',
1749
+ },
1750
+ timeoutMs: {
1751
+ type: 'number',
1752
+ description: 'How long to wait. Defaults to 120000 ms; capped at 3600000 ms (1 hour).',
1753
+ },
1754
+ pollMs: {
1755
+ type: 'number',
1756
+ description: 'Milliseconds between probes. Defaults to 1000 (min 50, max 60000).',
1757
+ },
1758
+ intervalMs: { type: 'number', description: 'Alias for pollMs.' },
1759
+ file: {
1760
+ type: 'object',
1761
+ additionalProperties: false,
1762
+ properties: {
1763
+ path: { type: 'string', required: true, description: 'Remote path to watch.' },
1764
+ exists: { type: 'boolean', description: 'Wait until the path exists (default true) or disappears (false).' },
1765
+ minBytes: { type: 'number', description: 'Wait until a regular file is at least this many bytes.' },
1766
+ mtimeAfter: {
1767
+ oneOf: [{ type: 'number' }, { type: 'string' }],
1768
+ description: 'Wait until the file mtime is newer than this epoch ms, epoch seconds or ISO timestamp.',
1769
+ },
1770
+ },
1771
+ description: 'Condition: {path, exists?: true, minBytes?, mtimeAfter?} — mtimeAfter accepts epoch ms, epoch seconds or an ISO timestamp.',
1772
+ },
1773
+ grep: {
1774
+ type: 'object',
1775
+ additionalProperties: false,
1776
+ properties: {
1777
+ path: { type: 'string', required: true, description: 'Remote file to search.' },
1778
+ pattern: { type: 'string', required: true, description: 'Extended regular expression to look for.' },
1779
+ count: { type: 'number', description: 'Wait until at least this many lines match. Defaults to 1.' },
1780
+ },
1781
+ description: 'Condition: {path, pattern, count?: 1} — met when at least `count` lines match the extended regex.',
1782
+ },
1783
+ process: {
1784
+ type: 'object',
1785
+ additionalProperties: false,
1786
+ properties: {
1787
+ pid: { type: 'number', description: 'Process id to watch.' },
1788
+ pattern: { type: 'string', description: 'pgrep -f pattern; exactly one of pid or pattern.' },
1789
+ alive: { type: 'boolean', description: 'Wait until the process exists (default true) or is gone (false).' },
1790
+ },
1791
+ description: 'Condition: {pid?|pattern, alive?: true} — exactly one of pid or pattern (pgrep -f).',
1792
+ },
1793
+ port: {
1794
+ type: 'object',
1795
+ additionalProperties: false,
1796
+ properties: {
1797
+ port: { type: 'number', required: true, description: 'TCP port on the remote loopback.' },
1798
+ state: { type: 'string', enum: ['open', 'closed'], description: 'Wait for the port to be open (default) or closed.' },
1799
+ },
1800
+ description: 'Condition: {port, state?: "open"|"closed"} — TCP probe on the remote loopback.',
1801
+ },
1802
+ job: {
1803
+ type: 'object',
1804
+ additionalProperties: false,
1805
+ properties: {
1806
+ jobId: { type: 'string', required: true, description: 'Background job id from presence_exec {background:true} or presence_jobs.' },
1807
+ },
1808
+ description: 'Condition: {jobId} — met when the background job has exited (state "exited").',
1809
+ },
1810
+ format: {
1811
+ type: 'string',
1812
+ enum: ['text', 'json'],
1813
+ description: 'Return one text block containing JSON: {met, timedOut, elapsedMs, observed, condition, polls}. Defaults to text.',
1814
+ },
1815
+ },
1816
+ async execute(args, exec) {
1817
+ const kinds = ['file', 'grep', 'process', 'port', 'job'].filter(
1818
+ (kind) => args[kind] !== undefined && args[kind] !== null,
1819
+ );
1820
+ if (kinds.length === 0) {
1821
+ throw new Error(
1822
+ 'presence_wait needs exactly one condition: file, grep, process, port or job — ' +
1823
+ 'e.g. {"file": {"path": "/srv/app/done.marker"}} or {"job": {"jobId": "j_123"}}',
1824
+ );
1825
+ }
1826
+ if (kinds.length > 1) {
1827
+ throw new Error(
1828
+ `presence_wait takes exactly one condition, got ${kinds.join(', ')}; make one call per condition ` +
1829
+ '(or run them in sequence)',
1830
+ );
1831
+ }
1832
+ const kind = kinds[0];
1833
+ const spec = args[kind];
1834
+ if (typeof spec !== 'object' || Array.isArray(spec)) {
1835
+ throw new Error(`presence_wait.${kind} must be an object, e.g. ${WAIT_EXAMPLES[kind]}`);
1836
+ }
1837
+ const timeoutMs = clampedNumber(args.timeoutMs, WAIT_DEFAULT_TIMEOUT_MS, 1, WAIT_MAX_TIMEOUT_MS);
1838
+ const pollMs = clampedNumber(
1839
+ pickArg(args, ['pollMs', 'poll_ms', 'intervalMs', 'interval_ms']),
1840
+ WAIT_DEFAULT_POLL_MS,
1841
+ WAIT_MIN_POLL_MS,
1842
+ WAIT_MAX_POLL_MS,
1843
+ );
1844
+
1845
+ // A job condition is host-independent (the registry knows where it
1846
+ // ran), so it never opens a connection; everything else needs one.
1847
+ let connection = null;
1848
+ let condition;
1849
+ if (kind === 'job') {
1850
+ if (typeof spec.jobId !== 'string' || spec.jobId.trim() === '') {
1851
+ throw new Error(`presence_wait.job needs a jobId, e.g. ${WAIT_EXAMPLES.job}`);
1852
+ }
1853
+ condition = { kind: 'job', jobId: spec.jobId.trim() };
1854
+ } else {
1855
+ const { host, workspace } = await pickScope(args, exec);
1856
+ connection = await pool.connection(host);
1857
+ if (kind === 'file' || kind === 'grep') {
1858
+ const rawPath = pickArg(spec, PATH_ALIASES);
1859
+ if (rawPath === undefined || rawPath === '') {
1860
+ throw new Error(`presence_wait.${kind} needs a path, e.g. ${WAIT_EXAMPLES[kind]}`);
1861
+ }
1862
+ const path = await remotePath(connection, workspace, rawPath);
1863
+ if (kind === 'file') {
1864
+ const minBytes =
1865
+ spec.minBytes === undefined || spec.minBytes === null ? null : Number(spec.minBytes);
1866
+ if (minBytes !== null && (!Number.isFinite(minBytes) || minBytes < 0)) {
1867
+ throw new Error('presence_wait.file.minBytes must be a non-negative number');
1868
+ }
1869
+ condition = {
1870
+ kind: 'file',
1871
+ path,
1872
+ exists: spec.exists !== false,
1873
+ ...(minBytes !== null ? { minBytes } : {}),
1874
+ ...(spec.mtimeAfter !== undefined && spec.mtimeAfter !== null && spec.mtimeAfter !== ''
1875
+ ? { mtimeAfter: spec.mtimeAfter }
1876
+ : {}),
1877
+ };
1878
+ } else {
1879
+ if (typeof spec.pattern !== 'string' || spec.pattern === '') {
1880
+ throw new Error(`presence_wait.grep needs a pattern, e.g. ${WAIT_EXAMPLES.grep}`);
1881
+ }
1882
+ condition = {
1883
+ kind: 'grep',
1884
+ path,
1885
+ pattern: spec.pattern,
1886
+ count: clampedNumber(spec.count, 1, 1, 1_000_000),
1887
+ };
1888
+ }
1889
+ } else if (kind === 'process') {
1890
+ const hasPid = spec.pid !== undefined && spec.pid !== null && spec.pid !== '';
1891
+ const hasPattern = typeof spec.pattern === 'string' && spec.pattern !== '';
1892
+ if (hasPid === hasPattern) {
1893
+ throw new Error(`presence_wait.process needs exactly one of pid or pattern, e.g. ${WAIT_EXAMPLES.process}`);
1894
+ }
1895
+ if (hasPid) {
1896
+ const pid = Number(spec.pid);
1897
+ if (!Number.isInteger(pid) || pid <= 0) {
1898
+ throw new Error(`presence_wait.process.pid must be a positive integer, got ${spec.pid}`);
1899
+ }
1900
+ condition = { kind: 'process', pid, alive: spec.alive !== false };
1901
+ } else {
1902
+ condition = { kind: 'process', pattern: spec.pattern, alive: spec.alive !== false };
1903
+ }
1904
+ } else {
1905
+ const port = Number(spec.port);
1906
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1907
+ throw new Error(`presence_wait.port.port must be a TCP port number, got ${spec.port}`);
1908
+ }
1909
+ condition = { kind: 'port', port, state: spec.state === 'closed' ? 'closed' : 'open' };
1910
+ }
1911
+ }
1912
+
1913
+ // waitUntil never throws on timeout: it reports met:false/timedOut:true.
1914
+ const result = await waitUntil({ pool, connection, condition, timeoutMs, pollMs, signal: exec?.signal });
1915
+ const payload = {
1916
+ met: result.met === true,
1917
+ timedOut: result.timedOut === true,
1918
+ elapsedMs: Number(result.elapsedMs) || 0,
1919
+ observed: result.observed ?? null,
1920
+ condition,
1921
+ polls: result.polls ?? null,
1922
+ };
1923
+ if (result.aborted === true) payload.aborted = true;
1924
+ if (args.format === 'json') return jsonText(payload);
1925
+ if (payload.aborted) {
1926
+ return clip(
1927
+ [
1928
+ `wait: aborted after ${payload.elapsedMs}ms (met:${payload.met})`,
1929
+ `condition: ${JSON.stringify(condition)}`,
1930
+ `observed: ${JSON.stringify(payload.observed)}`,
1931
+ ].join('\n'),
1932
+ );
1933
+ }
1934
+ const polls = payload.polls === null ? '' : `, ${payload.polls} poll(s)`;
1935
+ const headline = payload.met
1936
+ ? `wait: met after ${payload.elapsedMs}ms${polls}`
1937
+ : `wait: timed out after ${payload.elapsedMs}ms${polls} — condition not met (met:false)`;
1938
+ return clip(
1939
+ [
1940
+ headline,
1941
+ `condition: ${JSON.stringify(condition)}`,
1942
+ `observed: ${JSON.stringify(payload.observed)}`,
1943
+ payload.met ? '' : 'hint: the last observed value is above; follow it with presence_tail, presence_jobs or presence_port_probe.',
1944
+ ]
1945
+ .filter(Boolean)
1946
+ .join('\n'),
1947
+ );
1948
+ },
1949
+ }),
1950
+
1622
1951
  tool({
1623
1952
  name: 'presence_read',
1624
1953
  description:
1625
1954
  'Read a text file on a remote machine. Inside a workspace, relative paths resolve against the workspace root and the machine comes from the workspace. Returns line-numbered content with a line-count footer, like reading a local file. Use offset/limit for large files. Pass `raw:true` for the exact file content with no path header, no line numbers and no footer; pass `numbered:false` for unnumbered content that keeps the path header.',
1626
1955
  parameters: {
1627
- host: { type: 'string', description: 'ssh alias. Defaults to the active workspace\'s machine.' },
1956
+ host: {
1957
+ type: 'string',
1958
+ description:
1959
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
1960
+ },
1628
1961
  path: { type: 'string', required: true, description: 'Absolute path, ~/relative, or workspace-relative.' },
1629
1962
  offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
1630
1963
  limit: { type: 'number', description: 'Maximum lines to return. Defaults to 2000.' },
@@ -1666,7 +1999,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1666
1999
  required: true,
1667
2000
  description: 'Remote paths (absolute, ~/..., presence://host/path, or workspace-relative). 1-50 entries.',
1668
2001
  },
1669
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
2002
+ host: {
2003
+ type: 'string',
2004
+ description:
2005
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
2006
+ },
1670
2007
  raw: { type: 'boolean', description: 'Return verbatim content without line numbers. Defaults to false (numbered lines).' },
1671
2008
  limit: { type: 'number', description: 'Maximum lines per file. Defaults to 2000.' },
1672
2009
  },
@@ -1720,35 +2057,104 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1720
2057
  tool({
1721
2058
  name: 'presence_write',
1722
2059
  description:
1723
- 'Create or fully replace a file on a remote machine. Missing parent directories are created. Inside a workspace, relative paths resolve against the workspace root. The write is atomic (temp file + rename), so a reader never sees a half-written file. An existing file keeps its mode (e.g. 0755 stays executable); pass `mode` to set one explicitly. After writing, the remote file is stat-ed and the result reports its actual size, octal mode and full sha256. Pass `ifMatchSha256` (the hash the file must currently have) to refuse the overwrite when the remote content changed since you read it.',
2060
+ 'Create or replace a file on a remote machine. Missing parent directories are created; in a remote-workspace session relative paths resolve against the workspace root. mode:"overwrite" (default) writes atomically (temp file + rename), so a reader never sees a half-written file, and an existing file keeps its mode (e.g. 0755 stays executable). mode:"append" appends content at the end of the file (creating it when missing) and reports bytesAppended, total size and the resulting sha256. An octal mode string such as "755" (or "0644") still sets the file permissions for an overwrite. After writing, the remote file is stat-ed and the result reports its actual size, octal mode and full sha256. Pass ifMatchSha256 (the hash the file must currently have) to refuse an overwrite when the remote content changed since you read it; it is overwrite-only.',
1724
2061
  parameters: {
1725
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
2062
+ host: {
2063
+ type: 'string',
2064
+ description:
2065
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
2066
+ },
1726
2067
  path: { type: 'string', required: true, description: 'Absolute path, ~/relative, or workspace-relative.' },
1727
- content: { type: 'string', required: true, description: 'Complete new file content.' },
2068
+ content: { type: 'string', required: true, description: 'Content to write (overwrite) or append.' },
1728
2069
  mode: {
1729
- type: 'string',
1730
- description: 'Octal file mode to set, e.g. "755" or "644". Defaults to the existing file\'s mode, or 644 for a new file.',
2070
+ oneOf: [{ type: 'string' }, { type: 'number' }],
2071
+ description:
2072
+ 'Write mode "overwrite" (default) or "append"; or an octal file mode such as "755"/"0644" for an overwrite ("append" + an octal mode cannot be combined).',
1731
2073
  },
1732
2074
  ifMatchSha256: {
1733
2075
  type: 'string',
1734
- description: 'Optimistic lock: only write when the file currently has this sha256. A mismatch (or a missing/unreadable file) is an error that reports both hashes, and nothing is written.',
2076
+ description:
2077
+ 'Overwrite only: optimistic lock; only write when the file currently has this sha256. A mismatch (or a missing/unreadable file) is an error that reports both hashes, and nothing is written.',
1735
2078
  },
1736
2079
  format: {
1737
2080
  type: 'string',
1738
2081
  enum: ['text', 'json'],
1739
- description: 'Return one text block containing JSON: {path, sizeBytes, mode, sha256}. `mode` is the actual octal string such as "0644". Defaults to text.',
2082
+ description:
2083
+ 'Return one text block containing JSON: {path, mode, sizeBytes, sha256, writeMode}. `mode` is the actual octal string such as "0644". An append adds bytesAppended and keeps sizeBytes as the resulting total size. Defaults to text.',
1740
2084
  },
1741
2085
  },
1742
2086
  async execute(args, exec) {
1743
2087
  ensureWritable();
1744
- const pathInput = requirePath(args, 'presence_write', '{"path": "/srv/app/a.txt", "content": "hi"}');
2088
+ const example = '{"path": "/srv/app/a.txt", "content": "hi"}';
2089
+ const pathInput = requirePath(args, 'presence_write', example);
1745
2090
  if (args.content === undefined || args.content === null) {
1746
- throw missingArgument('presence_write', 'content', ['content'], args, '{"path": "/srv/app/a.txt", "content": "hi"}');
2091
+ throw missingArgument('presence_write', 'content', ['content'], args, example);
2092
+ }
2093
+ const writeMode = writeModeOf(args.mode) ?? 'overwrite';
2094
+ // 'append'/'overwrite' is a write mode; anything else stays an octal mode.
2095
+ const octalInput = writeModeOf(args.mode) ? undefined : args.mode;
2096
+ const expected = typeof args.ifMatchSha256 === 'string' ? args.ifMatchSha256.trim().toLowerCase() : '';
2097
+ if (writeMode === 'append' && expected) {
2098
+ throw new Error(
2099
+ 'ifMatchSha256 is only valid for an overwrite; drop it (or read the file and hash-check it yourself) before appending',
2100
+ );
1747
2101
  }
1748
2102
  const { host, workspace } = await pickScope(args, exec);
1749
2103
  const connection = await pool.connection(host);
1750
2104
  const target = await remotePath(connection, workspace, pathInput);
1751
- const expected = typeof args.ifMatchSha256 === 'string' ? args.ifMatchSha256.trim().toLowerCase() : '';
2105
+ const targetParent = target.includes('/') ? target.slice(0, target.lastIndexOf('/')) || '/' : '';
2106
+ const ensureParent = async () => {
2107
+ if (!targetParent) return;
2108
+ const made = await connection.exec(`mkdir -p ${shq(targetParent)}`, { timeoutMs: 30_000 });
2109
+ if (made.code !== 0) {
2110
+ throw new Error(`cannot create parent directory ${targetParent}: ${made.stderr.trim()}`);
2111
+ }
2112
+ };
2113
+ const payload = Buffer.from(args.content ?? '', 'utf8');
2114
+ const digest = createHash('sha256').update(payload).digest('hex');
2115
+
2116
+ if (writeMode === 'append') {
2117
+ const before = await connection.stat(target);
2118
+ if (before.exists && before.isDir) throw new Error(`${target} is a directory on ${host}; append needs a file`);
2119
+ await ensureParent();
2120
+ // Stage the appended bytes with the same atomic upload the overwrite
2121
+ // path uses, then append them with one remote `cat`: the SSH command
2122
+ // only carries paths, so a large payload never becomes a shell string
2123
+ // and the existing file is never rewritten or re-chmod-ed.
2124
+ const staging = `${targetParent === '/' ? '' : targetParent}/.presence-append-${stagingSuffix()}`;
2125
+ try {
2126
+ await connection.writeText(staging, payload);
2127
+ const appended = await connection.exec(`cat -- ${shq(staging)} >> ${shq(target)}`, {
2128
+ timeoutMs: 120_000,
2129
+ });
2130
+ if (appended.code !== 0) {
2131
+ const reason = String(appended.stderr ?? '').trim() || `exit ${appended.code}`;
2132
+ throw new Error(`append to ${target} on ${host} failed: ${reason}`);
2133
+ }
2134
+ } finally {
2135
+ await connection.exec(`rm -f -- ${shq(staging)}`, { timeoutMs: 15_000 }).catch(() => {});
2136
+ }
2137
+ const info = await connection.stat(target);
2138
+ const sizeBytes = info.exists ? info.size : (before.exists ? before.size : 0) + payload.length;
2139
+ const sha256 = await remoteFileSha256(connection, target);
2140
+ const mode = modeLabel(info.exists ? info.mode : null);
2141
+ await markObserved(exec, pathInput);
2142
+ if (args.format === 'json') {
2143
+ return jsonText({
2144
+ path: target,
2145
+ writeMode: 'append',
2146
+ bytesAppended: payload.length,
2147
+ sizeBytes,
2148
+ mode,
2149
+ sha256,
2150
+ });
2151
+ }
2152
+ return (
2153
+ `appended ${payload.length} bytes to ${target} on ${host} ` +
2154
+ `(total ${sizeBytes} bytes, mode ${mode ?? 'unknown'}, sha256 ${sha256 ?? 'unknown'})`
2155
+ );
2156
+ }
2157
+
1752
2158
  if (expected) {
1753
2159
  const current = await remoteFileSha256(connection, target);
1754
2160
  if (!current) {
@@ -1764,21 +2170,15 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1764
2170
  );
1765
2171
  }
1766
2172
  }
1767
- const parent = target.includes('/') ? target.slice(0, target.lastIndexOf('/')) : '';
1768
- if (parent) {
1769
- const made = await connection.exec(`mkdir -p ${shq(parent)}`, { timeoutMs: 30_000 });
1770
- if (made.code !== 0) throw new Error(`cannot create parent directory ${parent}: ${made.stderr.trim()}`);
1771
- }
1772
- const payload = Buffer.from(args.content ?? '', 'utf8');
1773
- const digest = createHash('sha256').update(payload).digest('hex');
1774
- const result = await connection.writeText(target, payload, { mode: parseMode(args.mode) });
2173
+ await ensureParent();
2174
+ const result = await connection.writeText(target, payload, { mode: parseMode(octalInput) });
1775
2175
  const info = await connection.stat(target);
1776
2176
  const sizeBytes = info.exists ? info.size : result.bytes;
1777
2177
  const mode = info.exists ? info.mode : result.mode;
1778
2178
  const octal = modeLabel(mode);
1779
2179
  await markObserved(exec, pathInput);
1780
2180
  if (args.format === 'json') {
1781
- return jsonText({ path: target, sizeBytes, mode: octal, sha256: digest });
2181
+ return jsonText({ path: target, writeMode: 'overwrite', sizeBytes, mode: octal, sha256: digest });
1782
2182
  }
1783
2183
  return `wrote ${sizeBytes} bytes to ${target} on ${host} mode ${octal ?? 'unknown'} sha256 ${digest}`;
1784
2184
  },
@@ -1787,144 +2187,371 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1787
2187
  tool({
1788
2188
  name: 'presence_export',
1789
2189
  description:
1790
- 'Copy a remote file to a real local file so desktop tools can open it. The workspace path is a virtual mount: Explorer, Office, Python and the `present` tool cannot see remote files directly. Export first, then present or open the returned local path. Files are written to the local export folder (content-addressed, deduplicated); `to` may only point inside that folder or the local workspace. Limit: 64 MiB.',
2190
+ 'Copy one or more remote files to real local files so desktop tools can open them. The workspace path is a virtual mount: Explorer, Office, Python and the `present` tool cannot see remote files directly. Export first, then present or open the returned local path. Single: `path` (or file_path) plus optional `to`. Batch: `paths` (1-50) and/or `glob` (remote-side, relative to the workspace root); every match is exported into the local export folder. The default local name is the remote basename; a name that already exists gets -<sha8> before the extension, so nothing is silently overwritten. format:\'json\' returns {files:[{remotePath, localPath, bytes, sha256}], exported, failed, errors}. Limit: 64 MiB per file.',
1791
2191
  parameters: {
1792
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
1793
- path: { type: 'string', required: true, description: 'Remote file: absolute, workspace-relative, presence URL, or mount path.' },
1794
- to: { type: 'string', description: 'Optional local destination inside the export folder (defaults to a content-addressed name).' },
2192
+ host: {
2193
+ type: 'string',
2194
+ description:
2195
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
2196
+ },
2197
+ path: { type: 'string', description: 'Single remote file: absolute, workspace-relative, presence URL, or mount path.' },
2198
+ paths: {
2199
+ type: 'array',
2200
+ items: { type: 'string' },
2201
+ description: 'Batch: 1-50 remote files (same spellings as path). Mutually exclusive with path.',
2202
+ },
2203
+ glob: {
2204
+ type: 'string',
2205
+ description:
2206
+ 'Batch: remote glob relative to the workspace root (or absolute with its own root), e.g. "out/*.png" or "/srv/app/logs/*.log". May be combined with paths.',
2207
+ },
2208
+ to: {
2209
+ type: 'string',
2210
+ description:
2211
+ 'Single path only: explicit local destination inside the export folder (or the local workspace). Defaults to the export folder + the remote basename.',
2212
+ },
2213
+ format: {
2214
+ type: 'string',
2215
+ enum: ['text', 'json'],
2216
+ description:
2217
+ 'Return one text block containing JSON: {files:[{remotePath, localPath, bytes, sha256}], exported, failed, errors}. Defaults to text.',
2218
+ },
1795
2219
  },
1796
2220
  async execute(args, exec) {
1797
- const pathInput = requirePath(args, 'presence_export', '{"path": "/srv/app/report.pdf"}');
2221
+ const single = pickArg(args, PATH_ALIASES);
2222
+ const singlePath = typeof single === 'string' && single.trim() !== '' ? single.trim() : null;
2223
+ const listed = Array.isArray(args.paths) ? args.paths : [];
2224
+ const globInput = typeof args.glob === 'string' && args.glob.trim() !== '' ? args.glob.trim() : null;
2225
+ if (!singlePath && listed.length === 0 && !globInput) {
2226
+ throw missingArgument(
2227
+ 'presence_export',
2228
+ 'path',
2229
+ ['path', 'file_path', 'paths', 'glob'],
2230
+ args,
2231
+ '{"paths": ["/srv/app/a.txt", "/srv/app/b.txt"]}',
2232
+ );
2233
+ }
2234
+ if (singlePath && listed.length > 0) throw new Error('pass either path or paths, not both');
2235
+ if (singlePath && globInput) throw new Error('pass either path or glob, not both');
2236
+ if (!singlePath && args.to !== undefined && args.to !== null) {
2237
+ throw new Error('to is only valid with a single path; a batch always lands in the export folder');
2238
+ }
1798
2239
  const { host, workspace } = await pickScope(args, exec);
1799
2240
  const connection = await pool.connection(host);
1800
- const target = await remotePath(connection, workspace, pathInput);
1801
- const info = await connection.stat(target);
1802
- if (!info.exists) throw new Error(`${target} does not exist on ${host}`);
1803
- if (info.isDir) throw new Error(`${target} is a directory; export files one by one, or use presence_exec with tar/scp`);
1804
- if (info.size > EXPORT_CAP) {
1805
- throw new Error(`${target} is ${info.size} bytes; export is capped at ${EXPORT_CAP} bytes — use presence_exec with scp/rsync for larger files`);
1806
- }
1807
- const { buffer } = await connection.readBytes(target, { offset: 0, length: info.size });
1808
- const name = target.split('/').filter(Boolean).pop() ?? 'export.bin';
1809
2241
  const folder = exportDirectory();
1810
2242
  mkdirSync(folder, { recursive: true });
1811
- const checksum = createHash('sha256').update(buffer).digest('hex');
1812
- const destination = args.to ?? joinPath(folder, `${checksum.slice(0, 12)}-${name}`);
1813
- const absolute = resolvePath(destination);
1814
-
1815
2243
  // Writes are confined to the export folder (or the local workspace, for
1816
2244
  // local sessions): an agent must not be able to drop files anywhere.
1817
2245
  const allowedRoots = [resolvePath(folder)];
1818
2246
  if (workspace && !parseMountPath(workspace.path)) allowedRoots.push(resolvePath(workspace.path));
1819
- const contained = allowedRoots.some((root) => absolute === root || absolute.startsWith(`${root}${sep}`));
1820
- if (!contained) {
1821
- throw new Error(`refusing to write "${destination}": export targets are limited to ${folder}`);
2247
+
2248
+ const targets = [];
2249
+ const addTarget = (value) => {
2250
+ if (!targets.includes(value)) targets.push(value);
2251
+ };
2252
+ if (singlePath) addTarget(await remotePath(connection, workspace, singlePath));
2253
+ if (listed.length > 0) {
2254
+ for (const input of transferInputs(args, 'presence_export')) {
2255
+ addTarget(await remotePath(connection, workspace, input));
2256
+ }
2257
+ }
2258
+ if (globInput) {
2259
+ // The same remote-glob semantics as presence_glob: an absolute
2260
+ // pattern carries its own root, a relative one resolves against the
2261
+ // workspace (or the login directory).
2262
+ const absolutePattern = splitGlobPattern(globInput);
2263
+ const pattern = absolutePattern ? absolutePattern.pattern : globInput;
2264
+ const base = absolutePattern
2265
+ ? absolutePattern.root
2266
+ : workspace?.path ?? (await connection.homePath());
2267
+ const baseInfo = await connection.stat(base);
2268
+ if (!baseInfo.exists) throw new Error(`${base} does not exist on ${host}`);
2269
+ if (!baseInfo.isDir) throw new Error(`${base} on ${host} is not a directory`);
2270
+ const detailed = await remoteGlobDetailed(connection, {
2271
+ root: base,
2272
+ pattern,
2273
+ limit: TRANSFER_MAX_FILES + 1,
2274
+ });
2275
+ const prefix = base.replace(/\/+$/, '');
2276
+ for (const entry of detailed.entries) addTarget(`${prefix}/${entry.path}`);
2277
+ }
2278
+ if (targets.length === 0) throw new Error(`no files matched ${globInput ?? ''} on ${host}`.replace(' ', ' '));
2279
+ if (targets.length > TRANSFER_MAX_FILES) {
2280
+ throw new Error(`presence_export moves at most ${TRANSFER_MAX_FILES} files per call, got ${targets.length}`);
1822
2281
  }
1823
2282
 
1824
- mkdirSync(dirnamePath(absolute), { recursive: true });
1825
- writeFileSync(absolute, buffer);
2283
+ const usedNames = new Set();
2284
+ const files = [];
2285
+ const errors = [];
2286
+ for (const target of targets) {
2287
+ try {
2288
+ const info = await connection.stat(target);
2289
+ if (!info.exists) throw new Error(`${target} does not exist on ${host}`);
2290
+ if (info.isDir) {
2291
+ throw new Error(`${target} is a directory; export files, or use presence_exec with tar/scp`);
2292
+ }
2293
+ if (info.size > EXPORT_CAP) {
2294
+ throw new Error(
2295
+ `${target} is ${info.size} bytes; export is capped at ${EXPORT_CAP} bytes — use presence_exec with scp/rsync for larger files`,
2296
+ );
2297
+ }
2298
+ const { buffer } = await connection.readBytes(target, { offset: 0, length: info.size });
2299
+ const digest = createHash('sha256').update(buffer).digest('hex');
2300
+ const destination = singlePath
2301
+ ? args.to ?? exportLocalTarget(folder, target, digest, usedNames)
2302
+ : exportLocalTarget(folder, target, digest, usedNames);
2303
+ const absolute = resolvePath(destination);
2304
+ const contained = allowedRoots.some((root) => absolute === root || absolute.startsWith(`${root}${sep}`));
2305
+ if (!contained) {
2306
+ throw new Error(`refusing to write "${destination}": export targets are limited to ${folder}`);
2307
+ }
2308
+ mkdirSync(dirnamePath(absolute), { recursive: true });
2309
+ writeFileSync(absolute, buffer);
2310
+ files.push({ remotePath: target, localPath: absolute, bytes: buffer.length, sha256: digest });
2311
+ } catch (error) {
2312
+ // A single export keeps its all-or-nothing error; a batch reports
2313
+ // each failure and still returns the files that did land.
2314
+ if (singlePath) throw error;
2315
+ errors.push({ remotePath: target, error: error?.message ?? String(error) });
2316
+ }
2317
+ }
1826
2318
  pruneExports(folder);
1827
- const digest = createHash('sha256').update(buffer).digest('hex');
1828
- return (
1829
- `exported ${info.size} bytes from ${target} on ${host}\n` +
1830
- `sha256: ${digest}\n` +
1831
- `local file: ${absolute}\n\n` +
1832
- 'That path is a real local file: present it, open it in Explorer, or read it with local tools.'
1833
- );
2319
+ if (args.format === 'json') {
2320
+ return jsonText({ files, exported: files.length, failed: errors.length, errors });
2321
+ }
2322
+ if (files.length === 0) {
2323
+ return clip(
2324
+ [`exported 0 of ${targets.length} file(s) from ${host}`, ...errors.map((row) => `${row.remotePath} error: ${row.error}`)].join('\n'),
2325
+ );
2326
+ }
2327
+ if (singlePath && files.length === 1) {
2328
+ const row = files[0];
2329
+ return (
2330
+ `exported ${row.bytes} bytes from ${row.remotePath} on ${host}\n` +
2331
+ `sha256: ${row.sha256}\n` +
2332
+ `local file: ${row.localPath}\n\n` +
2333
+ 'That path is a real local file: present it, open it in Explorer, or read it with local tools.'
2334
+ );
2335
+ }
2336
+ const lines = files.map((row) => `${row.remotePath} -> ${row.localPath} (${formatBytes(row.bytes)})`);
2337
+ const failedLines = errors.map((row) => `FAILED ${row.remotePath}: ${row.error}`);
2338
+ const header = `exported ${files.length}/${targets.length} file(s) from ${host} into ${folder}:`;
2339
+ return clip([header, ...lines, ...failedLines].join('\n'));
1834
2340
  },
1835
2341
  }),
1836
2342
 
1837
2343
  tool({
1838
2344
  name: 'presence_import',
1839
2345
  description:
1840
- 'Upload a LOCAL file to the remote machine — the missing local→remote direction of presence_export, and the only way to create binary files remotely (images, PDFs, build artifacts, attachments under ~/.dsh/attachments). Writes atomically, creates parent directories, can set a mode, and returns the sha256 so both sides can be checked. format:\'json\' returns {remotePath, localPath, sizeBytes, sha256, mode} with the full digest. Limit: 64 MiB; for larger files use presence_exec with scp/rsync.',
2346
+ 'Upload LOCAL files to a remote machine — the missing local→remote direction of presence_export, and the only way to create binary files remotely (images, PDFs, build artifacts, attachments under ~/.dsh/attachments). Writes atomically, creates parent directories, can set a mode, and returns the sha256 so both sides can be checked. Single: `source` + `to` (exact remote destination). Batch: `paths` (1-50 local paths, relative to the process cwd or absolute) and/or `glob` (local glob relative to the cwd) plus `to` as a remote directory; every file keeps its basename. format:\'json\' returns {remotePath, localPath, sizeBytes, sha256, mode} for one file, or {files:[...], imported, failed, errors} for a batch. Limit: 64 MiB per file; for larger files use presence_exec with scp/rsync.',
1841
2347
  parameters: {
1842
2348
  source: {
1843
2349
  type: 'string',
1844
- required: true,
1845
- description: 'Absolute local path (Windows drive paths welcome), e.g. C:\\Users\\...\\attachments\\v1\\objects\\ab\\ab12....png',
2350
+ description:
2351
+ 'Single local file: absolute (Windows drive paths welcome) or ~/relative, e.g. C:\\Users\\...\\attachments\\v1\\objects\\ab\\ab12....png',
2352
+ },
2353
+ paths: {
2354
+ type: 'array',
2355
+ items: { type: 'string' },
2356
+ description: 'Batch: 1-50 local files, relative to the process cwd or absolute. Mutually exclusive with source.',
2357
+ },
2358
+ glob: {
2359
+ type: 'string',
2360
+ description:
2361
+ 'Batch: local glob relative to the process cwd, e.g. "shots/*.png" or "out/**/*.pdf". May be combined with paths.',
1846
2362
  },
1847
2363
  to: {
1848
2364
  type: 'string',
1849
- required: true,
1850
- description: 'Remote destination: absolute, workspace-relative, presence URL, or local mount path.',
2365
+ description:
2366
+ 'Single: exact remote destination (absolute, workspace-relative, presence URL, or local mount path). Batch: remote directory that receives each basename.',
2367
+ },
2368
+ host: {
2369
+ type: 'string',
2370
+ description:
2371
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
2372
+ },
2373
+ mode: {
2374
+ type: 'string',
2375
+ description: 'Octal mode for the destination, e.g. "755". Defaults to 644 for a new file, or the existing file\'s mode.',
2376
+ },
2377
+ overwrite: {
2378
+ type: 'boolean',
2379
+ description: 'Allow replacing an existing remote file. Defaults to true; pass false to fail instead.',
1851
2380
  },
1852
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
1853
- mode: { type: 'string', description: 'Octal mode for the destination, e.g. "755". Defaults to 644 for a new file, or the existing file\'s mode.' },
1854
- overwrite: { type: 'boolean', description: 'Allow replacing an existing remote file. Defaults to true; pass false to fail instead.' },
1855
2381
  format: {
1856
2382
  type: 'string',
1857
2383
  enum: ['text', 'json'],
1858
- description: 'Return one text block containing JSON: {remotePath, localPath, sizeBytes, sha256, mode}. The digest is complete, not abbreviated. Defaults to text.',
2384
+ description:
2385
+ 'Return one text block containing JSON: {remotePath, localPath, sizeBytes, sha256, mode} for one file, or {files:[...], imported, failed, errors} for a batch. The digest is complete, not abbreviated. Defaults to text.',
1859
2386
  },
1860
2387
  },
1861
2388
  async execute(args, exec) {
1862
2389
  ensureWritable();
2390
+ const example = '{"source": "C:\\\\Temp\\\\report.pdf", "to": "/srv/app/report.pdf"}';
1863
2391
  const toInput = pickArg(args, DESTINATION_ALIASES);
1864
2392
  if (toInput === undefined || toInput === '') {
1865
- throw missingArgument(
1866
- 'presence_import',
1867
- 'to',
1868
- ['to', 'to_path', 'file_path'],
1869
- args,
1870
- '{"source": "C:\\\\Temp\\\\report.pdf", "to": "/srv/app/report.pdf"}',
1871
- );
2393
+ throw missingArgument('presence_import', 'to', ['to', 'to_path', 'file_path'], args, example);
1872
2394
  }
1873
- const rawSource = String(args.source ?? '').trim();
1874
- if (!rawSource) {
2395
+ const rawSource = typeof args.source === 'string' ? args.source.trim() : '';
2396
+ const listed = Array.isArray(args.paths) ? args.paths : [];
2397
+ const globInput = typeof args.glob === 'string' && args.glob.trim() !== '' ? args.glob.trim() : null;
2398
+ if (rawSource && (listed.length > 0 || globInput)) {
2399
+ throw new Error('pass either source (one file) or paths/glob (a batch), not both');
2400
+ }
2401
+ if (!rawSource && listed.length === 0 && !globInput) {
1875
2402
  throw missingArgument(
1876
2403
  'presence_import',
1877
2404
  'source',
1878
- ['source'],
2405
+ ['source', 'paths', 'glob'],
1879
2406
  args,
1880
2407
  '{"source": "C:\\\\Temp\\\\report.pdf", "to": "/srv/app/report.pdf"}',
1881
2408
  );
1882
2409
  }
1883
- const localSource = resolvePath(rawSource.startsWith('~') ? joinPath(homedir(), rawSource.slice(1)) : rawSource);
1884
- let localStat;
1885
- try {
1886
- localStat = statSync(localSource);
1887
- } catch {
1888
- throw new Error(`local file not found: ${localSource}`);
2410
+ // A batch names local files relative to the process cwd; `source` keeps
2411
+ // resolving exactly as before (absolute, or relative to the cwd).
2412
+ const cwd = process.cwd();
2413
+ const resolveLocal = (input) => {
2414
+ const value = String(input);
2415
+ const expanded = value.startsWith('~') ? joinPath(homedir(), value.slice(1)) : value;
2416
+ return resolvePath(expanded);
2417
+ };
2418
+ const localCandidates = [];
2419
+ const addLocal = (value) => {
2420
+ const absolute = resolveLocal(value);
2421
+ if (!localCandidates.includes(absolute)) localCandidates.push(absolute);
2422
+ };
2423
+ if (rawSource) addLocal(rawSource);
2424
+ for (const value of listed) {
2425
+ if (typeof value !== 'string' || value.trim() === '') {
2426
+ throw new Error('presence_import: every path in paths must be a non-empty string');
2427
+ }
2428
+ addLocal(value.trim());
1889
2429
  }
1890
- if (!localStat.isFile()) throw new Error(`${localSource} is not a regular file`);
1891
- if (localStat.size > EXPORT_CAP) {
1892
- throw new Error(`${localSource} is ${localStat.size} bytes; import is capped at ${EXPORT_CAP} bytes — use presence_exec with scp/rsync for larger files`);
2430
+ if (globInput) {
2431
+ const matches = localGlobPaths(globInput, cwd);
2432
+ if (matches.length === 0) {
2433
+ throw new Error(`no local files match ${JSON.stringify(globInput)} under ${cwd}`);
2434
+ }
2435
+ for (const match of matches) addLocal(match);
1893
2436
  }
1894
-
1895
- const buffer = readFileSync(localSource);
1896
- const digest = createHash('sha256').update(buffer).digest('hex');
2437
+ if (localCandidates.length > TRANSFER_MAX_FILES) {
2438
+ throw new Error(`presence_import moves at most ${TRANSFER_MAX_FILES} files per call, got ${localCandidates.length}`);
2439
+ }
2440
+ const batch = !rawSource;
1897
2441
 
1898
2442
  const { host, workspace } = await pickScope(args, exec);
1899
2443
  const connection = await pool.connection(host);
1900
- const target = await remotePath(connection, workspace, toInput);
1901
- const existing = await connection.stat(target);
1902
- if (existing.exists && existing.isDir) throw new Error(`${target} is a directory on ${host}`);
1903
- if (existing.exists && args.overwrite === false) {
1904
- throw new Error(`${target} already exists on ${host}; pass overwrite:true to replace it`);
1905
- }
1906
- const parent = target.includes('/') ? target.slice(0, target.lastIndexOf('/')) : '';
1907
- if (parent) {
1908
- const made = await connection.exec(`mkdir -p ${shq(parent)}`, { timeoutMs: 30_000 });
1909
- if (made.code !== 0) throw new Error(`cannot create parent directory ${parent}: ${made.stderr.trim()}`);
1910
- }
1911
- const mode = parseMode(args.mode) ?? (existing.exists ? undefined : 0o644);
1912
- const result = await connection.writeText(target, buffer, { mode });
1913
- const after = await connection.stat(target);
1914
- const modeText = modeLabel(after.exists ? after.mode : result.mode);
2444
+ const destinationInput = await remotePath(connection, workspace, toInput);
2445
+ let destinationDirectory = null;
2446
+ if (batch) {
2447
+ destinationDirectory = destinationInput.replace(/\/+$/, '') || '/';
2448
+ const existingDir = await connection.stat(destinationDirectory);
2449
+ if (existingDir.exists && !existingDir.isDir) {
2450
+ throw new Error(`${destinationDirectory} exists on ${host} but is not a directory; batch import needs a directory in "to"`);
2451
+ }
2452
+ }
2453
+ const usedTargets = new Set();
2454
+ const batchTarget = (localSource, digest) => {
2455
+ const name = localSource.split(/[\\/]/).filter(Boolean).pop() ?? 'upload.bin';
2456
+ let candidate = `${destinationDirectory}/${name}`;
2457
+ if (usedTargets.has(candidate)) {
2458
+ const dot = name.lastIndexOf('.');
2459
+ const stem = dot > 0 ? name.slice(0, dot) : name;
2460
+ const extension = dot > 0 ? name.slice(dot) : '';
2461
+ candidate = `${destinationDirectory}/${stem}-${digest.slice(0, 8)}${extension}`;
2462
+ }
2463
+ usedTargets.add(candidate);
2464
+ return candidate;
2465
+ };
2466
+
2467
+ const files = [];
2468
+ const errors = [];
2469
+ for (const localSource of localCandidates) {
2470
+ try {
2471
+ let localStat;
2472
+ try {
2473
+ localStat = statSync(localSource);
2474
+ } catch {
2475
+ throw new Error(`local file not found: ${localSource}`);
2476
+ }
2477
+ if (!localStat.isFile()) throw new Error(`${localSource} is not a regular file`);
2478
+ if (localStat.size > EXPORT_CAP) {
2479
+ throw new Error(
2480
+ `${localSource} is ${localStat.size} bytes; import is capped at ${EXPORT_CAP} bytes — use presence_exec with scp/rsync for larger files`,
2481
+ );
2482
+ }
2483
+ const buffer = readFileSync(localSource);
2484
+ const digest = createHash('sha256').update(buffer).digest('hex');
2485
+ const target = batch ? batchTarget(localSource, digest) : destinationInput;
2486
+ const existing = await connection.stat(target);
2487
+ if (existing.exists && existing.isDir) throw new Error(`${target} is a directory on ${host}`);
2488
+ if (existing.exists && args.overwrite === false) {
2489
+ throw new Error(`${target} already exists on ${host}; pass overwrite:true to replace it`);
2490
+ }
2491
+ const parent = target.includes('/') ? target.slice(0, target.lastIndexOf('/')) || '/' : '';
2492
+ if (parent) {
2493
+ const made = await connection.exec(`mkdir -p ${shq(parent)}`, { timeoutMs: 30_000 });
2494
+ if (made.code !== 0) throw new Error(`cannot create parent directory ${parent}: ${made.stderr.trim()}`);
2495
+ }
2496
+ const mode = parseMode(args.mode) ?? (existing.exists ? undefined : 0o644);
2497
+ const result = await connection.writeText(target, buffer, { mode });
2498
+ const after = await connection.stat(target);
2499
+ files.push({
2500
+ remotePath: target,
2501
+ localPath: localSource,
2502
+ sizeBytes: result.bytes ?? buffer.length,
2503
+ sha256: digest,
2504
+ mode: modeLabel(after.exists ? after.mode : result.mode),
2505
+ });
2506
+ } catch (error) {
2507
+ // A single import stays all-or-nothing; a batch reports per-file
2508
+ // failures and still uploads the files that can be.
2509
+ if (!batch) throw error;
2510
+ errors.push({ localPath: localSource, error: error?.message ?? String(error) });
2511
+ }
2512
+ }
2513
+
2514
+ if (!batch) {
2515
+ const row = files[0];
2516
+ if (!row) {
2517
+ throw new Error(`import of ${localCandidates[0]} failed: ${errors[0]?.error ?? 'unknown error'}`);
2518
+ }
2519
+ if (args.format === 'json') {
2520
+ return jsonText({
2521
+ remotePath: row.remotePath,
2522
+ localPath: row.localPath,
2523
+ sizeBytes: row.sizeBytes,
2524
+ sha256: row.sha256,
2525
+ mode: row.mode,
2526
+ });
2527
+ }
2528
+ return (
2529
+ `imported ${row.sizeBytes} bytes to ${row.remotePath} on ${host}${row.mode ? ` mode ${row.mode}` : ''}\n` +
2530
+ `sha256: ${row.sha256}\n` +
2531
+ `local source: ${row.localPath}`
2532
+ );
2533
+ }
1915
2534
  if (args.format === 'json') {
1916
- return jsonText({
1917
- remotePath: target,
1918
- localPath: localSource,
1919
- sizeBytes: result.bytes ?? buffer.length,
1920
- sha256: digest,
1921
- mode: modeText,
1922
- });
2535
+ return jsonText({ files, imported: files.length, failed: errors.length, errors });
2536
+ }
2537
+ if (files.length === 0) {
2538
+ return clip(
2539
+ [
2540
+ `imported 0 of ${localCandidates.length} file(s) to ${destinationDirectory} on ${host}`,
2541
+ ...errors.map((row) => `${row.localPath} error: ${row.error}`),
2542
+ ].join('\n'),
2543
+ );
1923
2544
  }
1924
- return (
1925
- `imported ${buffer.length} bytes to ${target} on ${host}${modeText ? ` mode ${modeText}` : ''}\n` +
1926
- `sha256: ${digest}\n` +
1927
- `local source: ${localSource}`
2545
+ const lines = files.map(
2546
+ (row) => `${row.localPath} -> ${row.remotePath}${row.mode ? ` mode ${row.mode}` : ''} (${formatBytes(row.sizeBytes)})`,
2547
+ );
2548
+ const failedLines = errors.map((row) => `FAILED ${row.localPath}: ${row.error}`);
2549
+ return clip(
2550
+ [
2551
+ `imported ${files.length}/${localCandidates.length} file(s) to ${destinationDirectory} on ${host}:`,
2552
+ ...lines,
2553
+ ...failedLines,
2554
+ ].join('\n'),
1928
2555
  );
1929
2556
  },
1930
2557
  }),
@@ -1935,7 +2562,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1935
2562
  'Delete a remote path RECOVERABLY: it is moved into `~/.dsh/presence-trash/<timestamp>/` on the same machine (no install, no extra service) and the restore path is returned. Prefer this over `rm -rf` for anything inside a workspace. Actions: trash (one path, or up to 50 paths in one call), list (recent trash containers, newest first; a single movedAtISO per entry), restore (move one back).',
1936
2563
  parameters: {
1937
2564
  action: { type: 'string', required: true, enum: ['trash', 'list', 'restore'], description: 'What to do.' },
1938
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
2565
+ host: {
2566
+ type: 'string',
2567
+ description:
2568
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
2569
+ },
1939
2570
  path: { type: 'string', description: 'For trash: the remote path to move into the trash. For list: substring filter on trashPath/name.' },
1940
2571
  paths: {
1941
2572
  type: 'array',
@@ -2100,7 +2731,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2100
2731
  description:
2101
2732
  'Replace exact text inside a file on a remote machine. Use this — or the built-in `edit` tool, which also reaches the remote workspace — instead of `sed -i`, `perl -pi`, or `python -c` for remote edits: it is atomic, refuses an anchor that occurs more than once unless replaceAll is set, and reports how many occurrences changed. Returns a compact diff of the changed lines (context +/-2, max 100 lines/8 KiB); dryRun:true validates the anchor and computes the same diff without writing. The file keeps its mode (an executable stays executable). Inside a workspace, relative paths resolve against the workspace root.',
2102
2733
  parameters: {
2103
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
2734
+ host: {
2735
+ type: 'string',
2736
+ description:
2737
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
2738
+ },
2104
2739
  path: { type: 'string', required: true, description: 'Absolute path, ~/relative, or workspace-relative.' },
2105
2740
  old: { type: 'string', description: 'Exact text to replace, including indentation.' },
2106
2741
  oldString: { type: 'string', description: 'Alias for `old` (same spelling as the built-in edit tool).' },
@@ -2200,7 +2835,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2200
2835
  description:
2201
2836
  'Search file contents on a remote machine with the tools that machine already has (ripgrep, falling back to grep). Smart-case: lowercase patterns match case-insensitively, any uppercase letter makes them exact — the same as the built-in grep. Inside a workspace the search root defaults to the workspace. `path` may be a single file or a directory; every match is reported as path:line:text. `include` accepts one glob (*.ts), a brace list (*.{js,jsx}) or a comma-separated list (*.txt,*.dat); spaces are trimmed. The result states the limit and whether it truncated; countOnly returns the exact number of matching lines instead. A bad regex or an unreadable/oversized tree is an error, never a silent empty result.',
2202
2837
  parameters: {
2203
- host: { type: 'string', description: 'ssh alias. Defaults to the active workspace\'s machine.' },
2838
+ host: {
2839
+ type: 'string',
2840
+ description:
2841
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
2842
+ },
2204
2843
  pattern: { type: 'string', required: true, description: 'Regular expression to search for (ripgrep syntax).' },
2205
2844
  path: { type: 'string', description: 'File or directory to search, absolute or workspace-relative. Defaults to the workspace root, else the remote home directory.' },
2206
2845
  include: { type: 'string', description: 'Only files matching this glob. Accepts *.ts, *.{js,jsx}, or a comma-separated list such as *.txt,*.dat (spaces are trimmed).' },
@@ -2281,7 +2920,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2281
2920
  'Find files on the remote machine by glob pattern. Use this instead of `glob`, which only scans the local disk and silently returns an empty result for a remote workspace. An absolute pattern such as /srv/app/*.py carries its own root and needs no path. All matches are sorted newest-first before `limit` is applied, and the result reports the total number of matches. Returns absolute paths with size and mtime when the host\'s find supports -printf; otherwise names only, and says so.',
2282
2921
  parameters: {
2283
2922
  pattern: { type: 'string', required: true, description: 'Glob relative to the search root, e.g. "src/**/*.ts", or absolute, e.g. "/srv/app/*.py".' },
2284
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
2923
+ host: {
2924
+ type: 'string',
2925
+ description:
2926
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
2927
+ },
2285
2928
  path: { type: 'string', description: 'Directory to search from. Ignored by an absolute pattern; defaults to the workspace root.' },
2286
2929
  limit: { type: 'number', description: 'Maximum files to return, applied after the newest-first sort. Defaults to 200.' },
2287
2930
  format: {
@@ -2362,7 +3005,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2362
3005
  required: true,
2363
3006
  description: 'Remote paths (absolute, ~/..., presence://host/path, or workspace-relative). 1-100 entries.',
2364
3007
  },
2365
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
3008
+ host: {
3009
+ type: 'string',
3010
+ description:
3011
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
3012
+ },
2366
3013
  hash: {
2367
3014
  type: 'boolean',
2368
3015
  description: 'Also compute sha256 for existing regular files. Defaults to false.',
@@ -2430,7 +3077,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2430
3077
  description:
2431
3078
  'Create a directory on a remote machine. Inside a workspace, relative paths resolve against the workspace root. `parents` defaults to true (mkdir -p): missing parents are created and an existing directory is a success. Pass parents:false to require the parent to exist. Prefer this over presence_exec for a plain mkdir.',
2432
3079
  parameters: {
2433
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
3080
+ host: {
3081
+ type: 'string',
3082
+ description:
3083
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
3084
+ },
2434
3085
  path: { type: 'string', required: true, description: 'Absolute path, ~/relative, or workspace-relative.' },
2435
3086
  parents: {
2436
3087
  type: 'boolean',
@@ -2456,7 +3107,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2456
3107
  description:
2457
3108
  'Rename or move a path on one remote machine (an atomic rename on the host). `to` is the exact new path, not a directory to move into. An existing destination file is refused unless overwrite:true; an existing destination directory is never merged into or replaced. Inside a workspace, relative paths resolve against the workspace root.',
2458
3109
  parameters: {
2459
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
3110
+ host: {
3111
+ type: 'string',
3112
+ description:
3113
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
3114
+ },
2460
3115
  from: { type: 'string', required: true, description: 'Remote path to move (absolute, ~/relative, or workspace-relative).' },
2461
3116
  to: { type: 'string', required: true, description: 'Exact new remote path.' },
2462
3117
  overwrite: { type: 'boolean', description: 'Replace an existing destination file. Defaults to false.' },
@@ -2489,7 +3144,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2489
3144
  description:
2490
3145
  'Copy a file or directory on one remote machine. `to` is the exact destination path. A directory requires recursive:true and is copied with remote `cp -a`; a file is streamed through SFTP up to 8 MiB and copied with remote `cp -p` above that, so nothing large goes through this process. An existing destination is refused unless overwrite:true. Inside a workspace, relative paths resolve against the workspace root.',
2491
3146
  parameters: {
2492
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
3147
+ host: {
3148
+ type: 'string',
3149
+ description:
3150
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
3151
+ },
2493
3152
  from: { type: 'string', required: true, description: 'Remote source path.' },
2494
3153
  to: { type: 'string', required: true, description: 'Exact remote destination path.' },
2495
3154
  recursive: { type: 'boolean', description: 'Copy a directory tree. Required when from is a directory. Defaults to false.' },
@@ -2529,7 +3188,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2529
3188
  description:
2530
3189
  'Change permissions on a remote path and report the applied octal mode. Accepts "755"/"0644"/"0o755" strings (preferred) or an octal mode number such as 0o755. With recursive:true a directory tree is changed with `chmod -R`. Inside a workspace, relative paths resolve against the workspace root.',
2531
3190
  parameters: {
2532
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
3191
+ host: {
3192
+ type: 'string',
3193
+ description:
3194
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
3195
+ },
2533
3196
  path: { type: 'string', required: true, description: 'Absolute path, ~/relative, or workspace-relative.' },
2534
3197
  mode: {
2535
3198
  oneOf: [{ type: 'string' }, { type: 'number' }],
@@ -2557,7 +3220,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2557
3220
  description:
2558
3221
  'Diff a text file on a remote machine against either another remote file (`b`) or a local host-process file (`localPath`); pass exactly one of b/localPath. Returns a unified diff body (hunk headers plus +/-/context rows, no ---/+++ headers): `context` controls context lines (default 3, max 20), the diff is capped by lines/bytes and reports truncation, and identical content returns "(no changes)". format:\'json\' returns {stats:{added,removed}, diff, truncated}. Inside a workspace, relative paths resolve against the workspace root; localPath is resolved against the host process cwd.',
2559
3222
  parameters: {
2560
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
3223
+ host: {
3224
+ type: 'string',
3225
+ description:
3226
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
3227
+ },
2561
3228
  a: { type: 'string', required: true, description: 'Left side: remote path (absolute, ~/relative, or workspace-relative).' },
2562
3229
  b: { type: 'string', description: 'Right side: another remote path. Mutually exclusive with localPath.' },
2563
3230
  localPath: { type: 'string', description: 'Right side: a local file on the host process. Mutually exclusive with b.' },
@@ -2624,7 +3291,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2624
3291
  description:
2625
3292
  'Read the last lines of a text file on a remote machine (default 200), optionally following it: follow:true keeps polling for new bytes for timeoutMs (default 8000, max 60000) and returns everything appended meanwhile. Only an incremental byte window is read, so a large log is never pulled whole; `truncated` is true when content before the returned window was dropped. format:\'json\' returns {path, lines, bytes, followedMs, truncated, text}. Inside a workspace, relative paths resolve against the workspace root.',
2626
3293
  parameters: {
2627
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
3294
+ host: {
3295
+ type: 'string',
3296
+ description:
3297
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
3298
+ },
2628
3299
  path: { type: 'string', required: true, description: 'Absolute path, ~/relative, or workspace-relative.' },
2629
3300
  lines: { type: 'number', description: 'How many trailing lines to return, 1-5000. Defaults to 200.' },
2630
3301
  follow: { type: 'boolean', description: 'Keep appending new bytes for timeoutMs before returning. Defaults to false.' },
@@ -2780,6 +3451,10 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2780
3451
  if (body.logPath) {
2781
3452
  lines.push(`log: ${body.logPath}${body.logSize !== null ? ` (${formatBytes(body.logSize)})` : ''}`);
2782
3453
  }
3454
+ if (body.commandLogPath && body.commandLogPath !== body.logPath) {
3455
+ lines.push(`command log: ${body.commandLogPath}`);
3456
+ }
3457
+ if (body.pgid !== null) lines.push(`pgid: ${body.pgid}${body.persisted ? ' (persisted job)' : ''}`);
2783
3458
  return clip(lines.join('\n'));
2784
3459
  }
2785
3460
 
@@ -2909,7 +3584,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2909
3584
  localPort: { type: 'number', description: 'For open: local port to bind. Defaults to an OS-assigned free port.' },
2910
3585
  remoteHost: { type: 'string', description: 'For open: remote bind host. Defaults to 127.0.0.1.' },
2911
3586
  forwardId: { type: 'string', description: 'For close: the id returned by open.' },
2912
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
3587
+ host: {
3588
+ type: 'string',
3589
+ description:
3590
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
3591
+ },
2913
3592
  format: {
2914
3593
  type: 'string',
2915
3594
  enum: ['text', 'json'],
@@ -2992,7 +3671,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2992
3671
  required: true,
2993
3672
  description: 'TCP ports to check (1-20 entries).',
2994
3673
  },
2995
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
3674
+ host: {
3675
+ type: 'string',
3676
+ description:
3677
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
3678
+ },
2996
3679
  expect: {
2997
3680
  type: 'string',
2998
3681
  enum: ['open', 'closed'],
@@ -3084,7 +3767,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
3084
3767
  type: 'boolean',
3085
3768
  description: 'Wait up to timeoutMs for the remote port to accept TCP before forwarding. Defaults to true.',
3086
3769
  },
3087
- host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
3770
+ host: {
3771
+ type: 'string',
3772
+ description:
3773
+ 'ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
3774
+ },
3088
3775
  format: {
3089
3776
  type: 'string',
3090
3777
  enum: ['text', 'json'],
@@ -3209,7 +3896,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
3209
3896
  enum: ['open', 'write', 'read', 'close', 'list'],
3210
3897
  description: 'What to do with the terminal.',
3211
3898
  },
3212
- host: { type: 'string', description: 'For open: ssh alias. Defaults to the active workspace\'s machine.' },
3899
+ host: {
3900
+ type: 'string',
3901
+ description:
3902
+ 'For open: ssh alias. In a remote-workspace session defaults to that machine; a local session must pass host (or configure PRESENCE_HOST).',
3903
+ },
3213
3904
  session: {
3214
3905
  type: 'string',
3215
3906
  description: