@rjfan/dsh-presence 0.18.0 → 0.19.0

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
@@ -1,5 +1,5 @@
1
1
  // The model-facing surface. Every tool targets one machine by its ssh alias and
2
- // reuses that machine's persistent connection 鈥?there is no per-call handshake
2
+ // reuses that machine's persistent connection — there is no per-call handshake
3
3
  // and nothing is installed on the far side.
4
4
 
5
5
  import { defineTool } from '@deepseek-ai/dsh-tools';
@@ -18,6 +18,11 @@ import { tailRemote } from './tail.js';
18
18
 
19
19
  const OUTPUT_CAP = 32 * 1024;
20
20
  const EXEC_OUTPUT_CAP = 16 * 1024;
21
+ const EXEC_DEFAULT_TIMEOUT_MS = 300_000;
22
+ const EXEC_MAX_TAIL_LINES = 5000;
23
+ // The JSON twin of an exec/jobs report must stay one valid JSON document; this
24
+ // is the same budget the text report uses.
25
+ const EXEC_JSON_CAP = 32 * 1024;
21
26
  const READ_CAP = 32 * 1024 * 1024;
22
27
  const EXPORT_CAP = 64 * 1024 * 1024;
23
28
  const WAIT_PORT_TIMEOUT_MS = 15_000;
@@ -38,6 +43,70 @@ const TAIL_MAX_LINES = 5000;
38
43
  const TAIL_JSON_TEXT_CAP = 48 * 1024;
39
44
  const TAIL_JSON_MAX = 64 * 1024;
40
45
 
46
+ // ------------------------------------------------------- argument aliases
47
+ //
48
+ // Models habitually spell the same argument several ways (`file_path`,
49
+ // `oldString`, `workdir`, ...). The JSON schema keeps one canonical name per
50
+ // parameter; these lists are applied at execute time, and the canonical
51
+ // spelling always wins when a caller sends more than one.
52
+
53
+ const PATH_ALIASES = ['path', 'file_path', 'filePath'];
54
+ const EDIT_OLD_ALIASES = ['old', 'oldString', 'old_string'];
55
+ const EDIT_NEW_ALIASES = ['new', 'newString', 'new_string'];
56
+ const EDIT_REPLACE_ALL_ALIASES = ['replaceAll', 'replace_all'];
57
+ const EDIT_DRY_RUN_ALIASES = ['dryRun', 'dry_run'];
58
+ const EXEC_CWD_ALIASES = ['cwd', 'workdir', 'dir'];
59
+ const FROM_ALIASES = ['from', 'from_path', 'fromPath'];
60
+ const TO_ALIASES = ['to', 'to_path', 'toPath'];
61
+ // Two-path tools also accept a plain path spelling on their source/destination
62
+ // side, so `{from, path}`-style calls resolve instead of failing.
63
+ const SOURCE_ALIASES = [...FROM_ALIASES, ...PATH_ALIASES];
64
+ const DESTINATION_ALIASES = [...TO_ALIASES, ...PATH_ALIASES];
65
+ const DIFF_A_ALIASES = ['a', 'a_path', 'aPath', ...PATH_ALIASES];
66
+ const DIFF_B_ALIASES = ['b', 'b_path', 'bPath'];
67
+
68
+ /** One argument value by any accepted spelling; undefined when none is set. */
69
+ function pickArg(args, names) {
70
+ if (!args || typeof args !== 'object') return undefined;
71
+ for (const name of names) {
72
+ const value = args[name];
73
+ if (value !== undefined && value !== null) return value;
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ function receivedArgKeys(args) {
79
+ const keys = Object.keys(args ?? {}).filter((key) => args?.[key] !== undefined);
80
+ return keys.length > 0 ? keys.join(', ') : 'nothing';
81
+ }
82
+
83
+ /**
84
+ * A missing required argument explains itself: which parameter, which
85
+ * spellings are accepted, what was sent instead, and one copyable example.
86
+ */
87
+ function missingArgument(toolName, canonical, accepted, args, example) {
88
+ return new Error(
89
+ `missing "${canonical}" on ${toolName} ` +
90
+ `(accepted: ${accepted.join(', ')}; received: ${receivedArgKeys(args)}). ` +
91
+ `Example: ${example}`,
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Resolve a required path parameter through its alias spellings. The message
97
+ * advertises the canonical name plus the DSH-style `file_path`.
98
+ */
99
+ function requirePath(
100
+ args,
101
+ toolName,
102
+ example,
103
+ { names = PATH_ALIASES, canonical = 'path', accepted = ['path', 'file_path'] } = {},
104
+ ) {
105
+ const value = pickArg(args, names);
106
+ if (value === undefined || value === '') throw missingArgument(toolName, canonical, accepted, args, example);
107
+ return value;
108
+ }
109
+
41
110
  function shq(value) {
42
111
  return `'${String(value).replaceAll("'", "'\\''")}'`;
43
112
  }
@@ -45,21 +114,150 @@ function shq(value) {
45
114
  function clip(text, cap = OUTPUT_CAP) {
46
115
  if (typeof text !== 'string') return '';
47
116
  if (text.length <= cap) return text;
48
- return `${text.slice(0, cap)}\n鈥?(${text.length - cap} more characters truncated)`;
117
+ return `${text.slice(0, cap)}\n... (${text.length - cap} more characters truncated)`;
49
118
  }
50
119
 
51
- /** Head + tail clipping: keeps the start (what ran) and the end (why it failed). */
52
- function clipMiddle(text, cap = EXEC_OUTPUT_CAP) {
53
- if (typeof text !== 'string') return '';
54
- if (text.length <= cap) return text;
55
- const head = Math.floor(cap * 0.4);
56
- const tail = cap - head;
120
+ /** `presence_exec.tailLines`: a display budget, whole lines, 0..5000, 0 = off. */
121
+ function execTailLines(value) {
122
+ if (value === undefined || value === null || value === '') return 0;
123
+ const number = Number(value);
124
+ if (!Number.isFinite(number)) return 0;
125
+ return Math.max(0, Math.min(EXEC_MAX_TAIL_LINES, Math.floor(number)));
126
+ }
127
+
128
+ /** Only the three progress modes the connection contract defines are forwarded. */
129
+ function execProgress(value) {
130
+ return value === 'all' || value === 'strip' ? value : 'last';
131
+ }
132
+
133
+ /**
134
+ * Commands that start long-lived work: `nohup`, a trailing `&`, or
135
+ * `tmux new-session`. They belong in `background:true`/`presence_terminal`,
136
+ * where the job registry or the pty keeps them alive.
137
+ */
138
+ function longLivedCommand(command) {
139
+ const value = String(command ?? '');
140
+ return /\bnohup\b/.test(value) || /tmux\s+new-session/.test(value) || /(?:^|[\s;])&(?!&)\s*$/.test(value);
141
+ }
142
+
143
+ function backgroundCommandHint(command) {
144
+ if (!longLivedCommand(command)) return null;
57
145
  return (
58
- `${text.slice(0, head)}\n鈥?(${text.length - cap} characters truncated; full output below) 鈥n` +
59
- text.slice(text.length - tail)
146
+ 'this command starts long-lived background work; use presence_exec {background:true} with presence_jobs ' +
147
+ '{action:"wait"} to track it, or presence_terminal for an interactive/tmux session; nohup/tmux/& children ' +
148
+ 'may be cleaned up when this SSH channel closes'
60
149
  );
61
150
  }
62
151
 
152
+ /** One job-list command line, clipped so a long command cannot dominate the list. */
153
+ function clipCommandLine(value, max = 200) {
154
+ const text = stripPipefailPrelude(value);
155
+ if (text.length <= max) return text;
156
+ return `${text.slice(0, max)}... (${text.length - max} more characters)`;
157
+ }
158
+
159
+ /** Head slice plus a tail chosen by `tailLines` (or by the remaining budget). */
160
+ function execTailSlice(captured, { tailChars, tailLines }) {
161
+ if (tailLines > 0) {
162
+ const rows = captured.split('\n');
163
+ if (rows.length > 0 && rows.at(-1) === '') rows.pop();
164
+ const tail = rows.slice(-tailLines).join('\n');
165
+ // A few very long lines must not blow the display budget either.
166
+ if (tailChars > 0 && tail.length > tailChars) return tail.slice(-tailChars);
167
+ return tail;
168
+ }
169
+ return captured.slice(Math.max(0, captured.length - Math.max(0, tailChars)));
170
+ }
171
+
172
+ /**
173
+ * One `--- stdout ---` / `--- stderr ---` block of the presence_exec text
174
+ * report: what the caller can read inline, how much was left out, where the
175
+ * full stream lives, and (with `tailLines`) the last N lines of the capture.
176
+ */
177
+ function execStreamSection(name, rawValue, result, { inlineCap, tailLines, plain }) {
178
+ const captured = plain(rawValue);
179
+ const truncated = (name === 'stdout' ? result.stdoutTruncated : result.stderrTruncated) === true;
180
+ const file = name === 'stdout' ? result.stdoutFile : result.stderrFile;
181
+ const fileBytes = name === 'stdout' ? result.stdoutFileBytes : result.stderrFileBytes;
182
+ const fileError = name === 'stdout' ? result.stdoutFileError : result.stderrFileError;
183
+ if (!captured && !file) return null;
184
+ const lines = [`--- ${name} ---`];
185
+ if (captured.length <= inlineCap && !truncated) {
186
+ lines.push(captured);
187
+ } else {
188
+ const headChars = Math.max(1, Math.floor(inlineCap * 0.4));
189
+ const head = captured.slice(0, headChars);
190
+ const tail = execTailSlice(captured, { tailChars: inlineCap - headChars, tailLines });
191
+ const omitted = Math.max(0, captured.length - head.length - tail.length);
192
+ const where = file ? `; full ${name}: ${file}` : '';
193
+ lines.push(head, `... (${omitted} characters omitted${where})`, tail);
194
+ }
195
+ if (file) {
196
+ const size = Number.isFinite(Number(fileBytes)) && Number(fileBytes) > 0 ? ` (${fileBytes} bytes)` : '';
197
+ lines.push(`${name}: ${file}${size}`);
198
+ } else if (truncated) {
199
+ lines.push(`${name}: full output not saved${fileError ? ` (${fileError})` : ''}`);
200
+ }
201
+ return lines.join('\n');
202
+ }
203
+
204
+ /** Character-bigram overlap, used only to suggest edit anchors. */
205
+ function commonBigrams(left, right) {
206
+ if (left.length < 2 || right.length < 2) return 0;
207
+ const grams = new Map();
208
+ for (let index = 0; index < left.length - 1; index += 1) {
209
+ const gram = left.slice(index, index + 2);
210
+ grams.set(gram, (grams.get(gram) ?? 0) + 1);
211
+ }
212
+ let shared = 0;
213
+ for (let index = 0; index < right.length - 1; index += 1) {
214
+ const gram = right.slice(index, index + 2);
215
+ const count = grams.get(gram) ?? 0;
216
+ if (count > 0) {
217
+ grams.set(gram, count - 1);
218
+ shared += 1;
219
+ }
220
+ }
221
+ return (2 * shared) / (left.length + right.length - 2);
222
+ }
223
+
224
+ /**
225
+ * Up to `limit` lines that are the closest match for an anchor an edit could
226
+ * not find, so a stale read is easy to recognize and fix.
227
+ */
228
+ function closestLines(content, needle, limit = 3) {
229
+ const target =
230
+ String(needle ?? '')
231
+ .split('\n')
232
+ .map((line) => line.trim())
233
+ .find((line) => line.length > 0) ?? '';
234
+ if (!target) return [];
235
+ const targetLower = target.toLowerCase();
236
+ const token = /[a-z0-9_$.-]{3,}/i.exec(targetLower)?.[0] ?? '';
237
+ const scored = [];
238
+ String(content ?? '')
239
+ .split('\n')
240
+ .forEach((raw, index) => {
241
+ const line = raw.trim();
242
+ if (!line) return;
243
+ const lower = line.toLowerCase();
244
+ let score = 0;
245
+ if (lower === targetLower) score = 1;
246
+ else if (lower.includes(targetLower) || targetLower.includes(lower)) {
247
+ score = 0.9 * (Math.min(lower.length, targetLower.length) / Math.max(lower.length, targetLower.length));
248
+ } else if (token && lower.includes(token)) {
249
+ score = 0.5;
250
+ } else {
251
+ score = commonBigrams(targetLower, lower);
252
+ }
253
+ if (score > 0.05) {
254
+ scored.push({ line: index + 1, text: line.length > 160 ? `${line.slice(0, 160)}...` : line, score });
255
+ }
256
+ });
257
+ scored.sort((left, right) => right.score - left.score || left.line - right.line);
258
+ return scored.slice(0, limit).map(({ line, text: value }) => ({ line, text: value }));
259
+ }
260
+
63
261
  const ANSI_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]|[0-?]*[ -/]*[@-~])/g;
64
262
 
65
263
  /** Drop ANSI escapes and other control characters that corrupt model-facing text. */
@@ -97,7 +295,7 @@ function isSandboxMessage(message) {
97
295
  return /sandbox|escalation available/i.test(String(message ?? ''));
98
296
  }
99
297
 
100
- /** "755" | "0755" | "0o755" | 0o755 鈫?octal mode number, or undefined when unset. */
298
+ /** "755" | "0755" | "0o755" | 0o755 → octal mode number, or undefined when unset. */
101
299
  function parseMode(value) {
102
300
  if (value === undefined || value === null || value === '') return undefined;
103
301
  if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
@@ -124,7 +322,12 @@ async function readRemoteTail(connection, logPath, lines = 20) {
124
322
  if (!logPath) return '';
125
323
  const count = Math.max(1, Math.min(200, Number(lines) || 20));
126
324
  try {
127
- const result = await connection.exec(`tail -n ${count} ${shq(logPath)} 2>/dev/null; true`, { timeoutMs: 15_000 });
325
+ // A log tail is content, not progress output: normalize CRs into line
326
+ // breaks instead of keeping only the state after the last `\r`.
327
+ const result = await connection.exec(`tail -n ${count} ${shq(logPath)} 2>/dev/null; true`, {
328
+ timeoutMs: 15_000,
329
+ progress: 'all',
330
+ });
128
331
  return String(result?.stdout ?? '').replace(/\n$/, '');
129
332
  } catch {
130
333
  return '';
@@ -638,7 +841,9 @@ async function waitForJobFallback(pool, id, { timeoutMs, tailLines }) {
638
841
  const connection = await pool.connection(job.host);
639
842
  const result = await connection.exec(
640
843
  `wc -c < ${shq(job.logPath)} 2>/dev/null; tail -n ${Math.max(1, tailLines)} ${shq(job.logPath)} 2>/dev/null`,
641
- { timeoutMs: 15_000 },
844
+ // Same reasoning as readRemoteTail: show the log content, not only the
845
+ // final state of each progress-bar line.
846
+ { timeoutMs: 15_000, progress: 'all' },
642
847
  );
643
848
  const stdout = String(result?.stdout ?? '');
644
849
  const newline = stdout.indexOf('\n');
@@ -843,7 +1048,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
843
1048
 
844
1049
  /**
845
1050
  * Where this call belongs. Inside a workspace the session's own directory
846
- * decides the machine 鈥?every workspace in the sidebar just works, with no
1051
+ * decides the machine — every workspace in the sidebar just works, with no
847
1052
  * "make it current" step. Explicit host still wins; a session outside any
848
1053
  * remote workspace falls back to the last opened one.
849
1054
  */
@@ -891,7 +1096,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
891
1096
  /**
892
1097
  * Resolve any spelling of a remote path to one absolute remote path:
893
1098
  * `presence://host/path`, a local mount path, `~/x`, an absolute path, or a
894
- * workspace-relative path. One file, several names 鈥?all accepted.
1099
+ * workspace-relative path. One file, several names — all accepted.
895
1100
  */
896
1101
  const remotePath = async (connection, workspace, path) => {
897
1102
  const value = String(path);
@@ -936,7 +1141,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
936
1141
  tool({
937
1142
  name: 'presence_hosts',
938
1143
  description:
939
- 'List the machines Presence can reach, taken from the user\'s ~/.ssh/config, with whether each one is connected right now. Call this before targeting a host for the first time. The default host (the active workspace\'s machine) is marked with "*". format:\'json\' returns one object per host: {alias, address, user, hostname, port, source, state, connected, default}.',
1144
+ 'List the machines Presence can reach, taken from the user\'s ~/.ssh/config, with whether each one is connected right now. Call this before targeting a host for the first time. The default host is this session\'s workspace machine (falling back to the configured default host) and is marked with "*". format:\'json\' returns one object per host: {alias, address, user, hostname, port, source, state, connected, default, workspace, lastError?}. state is connected|degraded|idle|error; a degraded row carries lastError.',
940
1145
  parameters: {
941
1146
  format: {
942
1147
  type: 'string',
@@ -944,9 +1149,29 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
944
1149
  description: 'Return one text block containing JSON: an array of host objects. Defaults to text.',
945
1150
  },
946
1151
  },
947
- async execute(args) {
1152
+ async execute(args, exec) {
948
1153
  const hosts = pool.hosts();
949
- const defaultHost = settings.defaultHost ?? activeWorkspace()?.host ?? null;
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;
1157
+ try {
1158
+ const cwd = (await cwdOf?.(exec)) ?? null;
1159
+ workspaceHost = cwd ? parseMountPath(cwd)?.host ?? null : null;
1160
+ } catch {
1161
+ workspaceHost = null;
1162
+ }
1163
+ const defaultHost = workspaceHost ?? settings.defaultHost ?? activeWorkspace()?.host ?? null;
1164
+ const stateOf = (host) => host.state ?? (host.connected ? 'connected' : 'idle');
1165
+ const stateText = (host) => {
1166
+ const state = stateOf(host);
1167
+ const lastError = host.lastError ?? null;
1168
+ if (state === 'degraded') return `degraded (last error: ${lastError ?? 'unknown'})`;
1169
+ if (state === 'error') return `error${lastError ? ` (last error: ${lastError})` : ''}`;
1170
+ if (state === 'connected' || (state !== 'idle' && host.connected)) {
1171
+ return `connected${host.terminals ? `, ${host.terminals} terminal(s)` : ''}`;
1172
+ }
1173
+ return 'not connected';
1174
+ };
950
1175
  if (args.format === 'json') {
951
1176
  return jsonText(
952
1177
  hosts.map((host) => ({
@@ -956,15 +1181,17 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
956
1181
  hostname: host.hostname,
957
1182
  port: host.port,
958
1183
  source: host.source ?? 'config',
959
- state: host.connected ? 'connected' : 'idle',
1184
+ state: stateOf(host),
960
1185
  connected: host.connected === true,
961
1186
  default: host.alias === defaultHost,
1187
+ workspace: host.alias === defaultHost,
1188
+ ...(host.lastError ? { lastError: host.lastError } : {}),
962
1189
  })),
963
1190
  );
964
1191
  }
965
1192
  if (hosts.length === 0) return 'No hosts with an explicit HostName were found in ~/.ssh/config.';
966
1193
  const lines = hosts.map((host) => {
967
- const state = host.connected ? `connected${host.terminals ? `, ${host.terminals} terminal(s)` : ''}` : 'not connected';
1194
+ const state = stateText(host);
968
1195
  const key = host.identityFile ? `key ${host.identityFile}` : 'default keys';
969
1196
  const marker = host.alias === defaultHost ? '*' : '';
970
1197
  return `${marker}${host.alias} ${host.user ?? '?'}@${host.hostname}:${host.port} ${state} (${key})`;
@@ -1023,28 +1250,45 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1023
1250
  tool({
1024
1251
  name: 'presence_exec',
1025
1252
  description:
1026
- '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. 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. format:\'json\' returns one text block of JSON ({cwd, command, exitCode, signal, durationMs, timedOut, stdout, stderr, stdoutTruncated, stderrTruncated}) with stdout/stderr clipped to 16 KiB each; saveStdout writes a successful foreground command\'s stdout to a remote file.\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; format:\'json\' returns that dry run as {dryRun:true, requiresConfirm:true, targets, command, report}. Prefer presence_trash for recoverable deletes. Output is ANSI-stripped and middle-truncated by default.',
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.',
1027
1254
  parameters: {
1028
1255
  host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
1029
1256
  command: { type: 'string', required: true, description: 'Command line, interpreted by the remote shell.' },
1030
1257
  cwd: { type: 'string', description: 'Directory to run in, absolute or workspace-relative. Defaults to the workspace root, else the remote login directory.' },
1031
- timeoutMs: { type: 'number', description: 'Abort after this many milliseconds (the remote process tree is killed). Defaults to 120000. Ignored for background jobs, which are meant to outlive the call.' },
1258
+ timeoutMs: {
1259
+ type: 'number',
1260
+ description:
1261
+ 'Abort after this many milliseconds (the remote process tree is terminated with SIGTERM, then SIGKILL after 5s, and the partial output is returned). Defaults to 300000 (5 minutes). Ignored for background jobs, which are meant to outlive the call; use presence_jobs {action:\'wait\'} instead.',
1262
+ },
1032
1263
  background: { type: 'boolean', description: 'Start detached and return immediately with pid and a remote log file. Use for servers and long-running jobs.' },
1033
1264
  label: { type: 'string', description: 'For background: short label shown by presence_jobs. Defaults to the command.' },
1034
1265
  pipefail: { type: 'boolean', description: 'Set the remote shell\'s pipefail so a failing left side of a pipeline is reported. Defaults to true; pass false to disable.' },
1266
+ tailLines: {
1267
+ type: 'number',
1268
+ description:
1269
+ 'Text report only: when the output is too long, keep the first 40% of the inline budget and append the last N lines (0-5000 gives exactly that many). Defaults to 0 (character-based head+tail clipping).',
1270
+ },
1271
+ progress: {
1272
+ type: 'string',
1273
+ enum: ['last', 'all', 'strip'],
1274
+ description:
1275
+ 'Carriage-return normalization applied before clipping: "last" keeps only the text after each line\'s final CR (progress bars keep their final state), "all" turns every remaining CR into a line break, "strip" removes CRs. Defaults to "last".',
1276
+ },
1035
1277
  confirm: {
1036
- type: 'boolean',
1037
- description: 'Required to actually run a command classified as destructive; without it the tool returns a dry-run report of the resolved targets instead.',
1278
+ oneOf: [{ type: 'boolean' }, { type: 'string', enum: ['partial'] }],
1279
+ description:
1280
+ 'Required to actually run a command classified as destructive: true runs the whole command, "partial" runs only the non-destructive segments and reports the skipped ones. Without confirm the tool returns a dry-run report of the resolved targets and the safe segments.',
1038
1281
  },
1039
1282
  maxOutput: {
1040
1283
  type: 'number',
1041
- description: 'Max characters of stdout/stderr shown inline. Defaults to 16384 (up to 262144); the full output is always written to a log file whose path is reported.',
1284
+ description: 'Max characters of stdout/stderr shown inline. Defaults to 16384 (up to 262144); the full output is always written to a remote file whose path is reported.',
1042
1285
  },
1043
1286
  raw: { type: 'boolean', description: 'Keep ANSI escape sequences in the output. Defaults to false (stripped).' },
1044
1287
  format: {
1045
1288
  type: 'string',
1046
1289
  enum: ['text', 'json'],
1047
- description: 'Return one text block containing JSON: {cwd, command, exitCode, signal, durationMs, timedOut, stdout, stderr, stdoutTruncated, stderrTruncated}. Streams are clipped to 16 KiB each. For a destructive command without confirm:true the JSON is {dryRun:true, requiresConfirm:true, targets, command, report}. Defaults to text.',
1290
+ description:
1291
+ 'Return one text block containing JSON: {cwd, command, exitCode, signal, durationMs, timedOut, stdout, stderr, stdoutTruncated, stderrTruncated, hints, tailLines} with stdout/stderr clipped to 16 KiB each; spill files add stdoutFile/stderrFile/stdoutFileBytes/stderrFileBytes (and stdoutFileError/stderrFileError when a spill write failed). For a destructive command without confirm the JSON is {dryRun:true, requiresConfirm:true, targets, command, report, safeSegments, destructiveSegments, partialAvailable}; confirm:"partial" returns {dryRun:false, partial:true, executedSegments, skippedSegments}. Defaults to text.',
1048
1292
  },
1049
1293
  saveStdout: {
1050
1294
  type: 'string',
@@ -1056,18 +1300,85 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1056
1300
  },
1057
1301
  },
1058
1302
  async execute(args, exec) {
1303
+ const commandText = args.command;
1304
+ if (typeof commandText !== 'string' || commandText.trim() === '') {
1305
+ throw missingArgument('presence_exec', 'command', ['command'], args, '{"command": "uname -a"}');
1306
+ }
1059
1307
  const { host, workspace } = await pickScope(args, exec);
1060
1308
  const connection = await pool.connection(host);
1061
- const directory = args.cwd ? await remotePath(connection, workspace, args.cwd) : workspace?.path;
1309
+ const cwdInput = pickArg(args, EXEC_CWD_ALIASES);
1310
+ const directory = cwdInput !== undefined ? await remotePath(connection, workspace, cwdInput) : workspace?.path;
1062
1311
  const pipefail = args.pipefail !== false;
1312
+ const progress = execProgress(args.progress);
1313
+ const tailLines = execTailLines(args.tailLines);
1314
+ const timeoutMs =
1315
+ args.timeoutMs === undefined || args.timeoutMs === null || args.timeoutMs === ''
1316
+ ? EXEC_DEFAULT_TIMEOUT_MS
1317
+ : 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.
1320
+ const effectiveCwd =
1321
+ directory ??
1322
+ (typeof connection.homePath === 'function' ? await connection.homePath().catch(() => null) : null);
1323
+ const cwdLine = `cwd: ${effectiveCwd ?? '(remote login directory)'}`;
1324
+ const backgroundHint = backgroundCommandHint(commandText);
1325
+ const hints = backgroundHint ? [backgroundHint] : [];
1063
1326
 
1064
1327
  // Destructive commands are dry-run first: show the resolved targets, and
1065
1328
  // refuse the cases where an empty variable or stray glob would delete a
1066
- // whole tree. Nothing runs until the caller repeats the call with confirm.
1067
- if (!args.confirm) {
1329
+ // whole tree. Nothing runs until the caller confirms. `confirm:"partial"`
1330
+ // runs only the segments the guard classified as non-destructive.
1331
+ const confirm = args.confirm;
1332
+ const partialRequested = confirm === 'partial';
1333
+ const confirmed = Boolean(confirm) && !partialRequested;
1334
+ let command = commandText;
1335
+ let partialInfo = null;
1336
+ if (partialRequested || confirmed) {
1068
1337
  const guard = await guardDestructive({
1069
1338
  connection,
1070
- command: args.command,
1339
+ command: commandText,
1340
+ cwd: directory ?? null,
1341
+ workspace,
1342
+ host,
1343
+ confirm: true,
1344
+ });
1345
+ if (partialRequested && guard) {
1346
+ const split = guard.split ?? { safeSegments: [], destructiveSegments: [] };
1347
+ const executedSegments = (Array.isArray(split.safeSegments) ? split.safeSegments : []).filter(
1348
+ (segment) => typeof segment === 'string' && segment.trim() !== '',
1349
+ );
1350
+ const skippedSegments = (Array.isArray(split.destructiveSegments) ? split.destructiveSegments : []).map(
1351
+ (entry) => (typeof entry === 'string' ? entry : entry?.segment ?? String(entry)),
1352
+ );
1353
+ if (executedSegments.length === 0) {
1354
+ const report = [
1355
+ 'partial run: the command has no non-destructive segments to run, so nothing was executed.',
1356
+ guard.report,
1357
+ ].join('\n');
1358
+ if (args.format === 'json') {
1359
+ return jsonText({
1360
+ dryRun: false,
1361
+ partial: true,
1362
+ executedSegments: [],
1363
+ skippedSegments,
1364
+ requiresConfirm: true,
1365
+ targets: guard.targets ?? [],
1366
+ command: commandText,
1367
+ report,
1368
+ });
1369
+ }
1370
+ return report;
1371
+ }
1372
+ partialInfo = { executedSegments, skippedSegments };
1373
+ command = executedSegments.join('; ');
1374
+ } else if (partialRequested) {
1375
+ // A benign command has nothing to skip: "partial" runs it whole.
1376
+ partialInfo = { executedSegments: [commandText], skippedSegments: [] };
1377
+ }
1378
+ } else if (!confirm) {
1379
+ const guard = await guardDestructive({
1380
+ connection,
1381
+ command: commandText,
1071
1382
  cwd: directory ?? null,
1072
1383
  workspace,
1073
1384
  host,
@@ -1075,27 +1386,32 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1075
1386
  });
1076
1387
  if (guard) {
1077
1388
  // JSON callers get the guard report as data instead of having to
1078
- // parse the text report; `targets` mirrors the report's target block.
1389
+ // parse the text report; `targets` mirrors the report's target block
1390
+ // and `safeSegments`/`destructiveSegments` back confirm:"partial".
1079
1391
  if (args.format === 'json') {
1080
- return jsonText({
1392
+ const split = guard.split ?? null;
1393
+ const payload = {
1081
1394
  dryRun: true,
1082
1395
  requiresConfirm: true,
1083
1396
  targets: guard.targets ?? [],
1084
- command: args.command,
1397
+ command: commandText,
1085
1398
  report: guard.report,
1086
- });
1399
+ };
1400
+ if (split) {
1401
+ payload.safeSegments = Array.isArray(split.safeSegments) ? split.safeSegments : [];
1402
+ payload.destructiveSegments = Array.isArray(split.destructiveSegments)
1403
+ ? split.destructiveSegments.map((entry) => ({
1404
+ segment: entry?.segment ?? String(entry),
1405
+ kind: entry?.kind ?? null,
1406
+ targets: entry?.targets ?? [],
1407
+ }))
1408
+ : [];
1409
+ payload.partialAvailable = payload.safeSegments.length > 0;
1410
+ }
1411
+ return jsonText(payload);
1087
1412
  }
1088
1413
  return guard.report;
1089
1414
  }
1090
- } else {
1091
- await guardDestructive({
1092
- connection,
1093
- command: args.command,
1094
- cwd: directory ?? null,
1095
- workspace,
1096
- host,
1097
- confirm: true,
1098
- });
1099
1415
  }
1100
1416
 
1101
1417
  if (args.background) {
@@ -1105,7 +1421,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1105
1421
  throw new Error(`waitForPort must be a TCP port number, got ${args.waitForPort}`);
1106
1422
  }
1107
1423
  }
1108
- const body = `${pipefail ? pipefailPrelude() : ''}${args.command}`;
1424
+ const body = `${pipefail ? pipefailPrelude() : ''}${command}`;
1109
1425
  let job;
1110
1426
  try {
1111
1427
  job = await pool.startJob(host, {
@@ -1124,6 +1440,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1124
1440
  const size =
1125
1441
  job.logSize === undefined || job.logSize === null ? null : ` (${formatBytes(job.logSize)})`;
1126
1442
  return [
1443
+ cwdLine,
1127
1444
  `background job ${job.id ?? '(unknown)'} exited immediately on ${host} (it did not stay running)`,
1128
1445
  `exit: ${job.exitCode ?? 'unknown'}`,
1129
1446
  job.logPath ? `log: ${job.logPath}${size ?? ''}` : null,
@@ -1134,6 +1451,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1134
1451
  }
1135
1452
 
1136
1453
  const parts = [
1454
+ cwdLine,
1137
1455
  `started in background on ${host}`,
1138
1456
  `job: ${job.id}`,
1139
1457
  `pid: ${job.pid}`,
@@ -1144,7 +1462,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1144
1462
 
1145
1463
  if (args.timeoutMs !== undefined && args.timeoutMs !== null) {
1146
1464
  parts.push(
1147
- 'note: timeoutMs does not apply to background jobs 鈥?they are meant to outlive the call; ' +
1465
+ 'note: timeoutMs does not apply to background jobs — they are meant to outlive the call; ' +
1148
1466
  `use presence_jobs {action:'wait', jobId:'${job.id}', timeoutMs} to wait for it`,
1149
1467
  );
1150
1468
  }
@@ -1154,7 +1472,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1154
1472
  parts.push(
1155
1473
  outcome.ready
1156
1474
  ? `port ${args.waitForPort} is listening on ${host} (after ${outcome.waitedMs}ms)`
1157
- : `port ${args.waitForPort} is NOT listening on ${host} after ${outcome.waitedMs}ms 鈥?check the log: tail -n 50 ${job.logPath}`,
1475
+ : `port ${args.waitForPort} is NOT listening on ${host} after ${outcome.waitedMs}ms — check the log: tail -n 50 ${job.logPath}`,
1158
1476
  );
1159
1477
  }
1160
1478
  if (args.saveStdout) {
@@ -1164,12 +1482,12 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1164
1482
  }
1165
1483
 
1166
1484
  const saveTarget = args.saveStdout ? await remotePath(connection, workspace, args.saveStdout) : null;
1167
- let command = args.command;
1168
- if (directory) command = `cd ${shq(directory)} && ${command}`;
1169
- if (pipefail) command = `${pipefailPrelude()}${command}`;
1485
+ let shellCommand = command;
1486
+ if (directory) shellCommand = `cd ${shq(directory)} && ${shellCommand}`;
1487
+ if (pipefail) shellCommand = `${pipefailPrelude()}${shellCommand}`;
1170
1488
  let result;
1171
1489
  try {
1172
- result = await connection.exec(command, { timeoutMs: args.timeoutMs ?? 120_000 });
1490
+ result = await connection.exec(shellCommand, { timeoutMs, progress });
1173
1491
  } catch (error) {
1174
1492
  throw new Error(withSandboxNote(error?.message ?? String(error)));
1175
1493
  }
@@ -1197,8 +1515,8 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1197
1515
  const stdoutRaw = plain(result.stdout);
1198
1516
  const stderrRaw = plain(result.stderr);
1199
1517
  const payload = {
1200
- cwd: directory ?? null,
1201
- command: args.command,
1518
+ cwd: effectiveCwd ?? null,
1519
+ command: commandText,
1202
1520
  exitCode: result.code ?? null,
1203
1521
  signal: result.signal ?? null,
1204
1522
  durationMs: result.durationMs ?? null,
@@ -1207,36 +1525,75 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1207
1525
  stderr: clip(stderrRaw, EXEC_OUTPUT_CAP),
1208
1526
  stdoutTruncated: result.stdoutTruncated === true || stdoutRaw.length > EXEC_OUTPUT_CAP,
1209
1527
  stderrTruncated: result.stderrTruncated === true || stderrRaw.length > EXEC_OUTPUT_CAP,
1528
+ hints,
1529
+ tailLines,
1210
1530
  };
1531
+ if (partialInfo) {
1532
+ payload.dryRun = false;
1533
+ payload.partial = true;
1534
+ payload.executedSegments = partialInfo.executedSegments;
1535
+ payload.skippedSegments = partialInfo.skippedSegments;
1536
+ }
1537
+ // Timeout/SIGPIPE facts from the connection contract; absent fields
1538
+ // stay absent so older connections keep the same JSON shape.
1539
+ if (result.killed !== undefined) payload.killed = result.killed === true;
1540
+ if (Array.isArray(result.killSequence)) payload.killSequence = result.killSequence;
1541
+ if (result.sigpipe === true) payload.sigpipe = true;
1542
+ for (const stream of ['stdout', 'stderr']) {
1543
+ const file = stream === 'stdout' ? result.stdoutFile : result.stderrFile;
1544
+ const fileBytes = stream === 'stdout' ? result.stdoutFileBytes : result.stderrFileBytes;
1545
+ const fileError = stream === 'stdout' ? result.stdoutFileError : result.stderrFileError;
1546
+ const truncated = stream === 'stdout' ? result.stdoutTruncated === true : result.stderrTruncated === true;
1547
+ if (file) {
1548
+ payload[`${stream}File`] = file;
1549
+ payload[`${stream}FileBytes`] = Number.isFinite(Number(fileBytes)) ? Number(fileBytes) : null;
1550
+ } else if (truncated) {
1551
+ // The capture exceeded the cap but the spill write failed: keep an
1552
+ // explicit null plus the reason instead of dropping the fact.
1553
+ payload[`${stream}File`] = null;
1554
+ if (fileError) payload[`${stream}FileError`] = fileError;
1555
+ }
1556
+ }
1211
1557
  if (savedStdout) payload.savedStdout = savedStdout;
1212
1558
  if (saveError) payload.savedStdoutError = saveError;
1213
1559
  if (saveTarget && !savedStdout && !saveError) {
1214
1560
  payload.savedStdoutSkipped = { path: saveTarget, reason: `exit ${result.code ?? '?'} is not 0` };
1215
1561
  }
1216
- return JSON.stringify(payload, null, 2);
1562
+ return jsonText(payload, EXEC_JSON_CAP);
1217
1563
  }
1218
1564
 
1219
- const parts = [`exit: ${result.code} (${result.durationMs}ms)`];
1220
- if (result.timedOut) parts.push('timed out 鈥?the remote process tree was terminated');
1221
1565
  const inlineCap = Math.max(1000, Math.min(Number(args.maxOutput) || EXEC_OUTPUT_CAP, 256 * 1024));
1222
- const note = (stream) => {
1223
- const captured = plain(stream === 'stdout' ? result.stdout : result.stderr);
1224
- const truncated = stream === 'stdout' ? result.stdoutTruncated : result.stderrTruncated;
1225
- const file = stream === 'stdout' ? result.stdoutFile : result.stderrFile;
1226
- if (!truncated && captured.length <= inlineCap) return null;
1227
- const where = file ? `full ${stream}: ${file} on ${host}` : `no ${stream} file could be saved`;
1228
- return `(${truncated ? `${stream} exceeded the 256 KiB capture cap; ` : ''}showing first/last ${inlineCap} characters 鈥?${where})`;
1229
- };
1230
- if (result.stdout) {
1231
- parts.push(`--- stdout ---\n${clipMiddle(plain(result.stdout), inlineCap)}`);
1232
- const hint = note('stdout');
1233
- if (hint) parts.push(hint);
1566
+ const parts = [cwdLine];
1567
+ if (hints.length > 0) parts.push(`hint: ${hints[0]}`);
1568
+ if (partialInfo) {
1569
+ parts.push(
1570
+ `partial: ran ${partialInfo.executedSegments.length} safe segment(s); ` +
1571
+ `skipped ${partialInfo.skippedSegments.length} destructive segment(s)`,
1572
+ );
1573
+ for (const segment of partialInfo.skippedSegments) parts.push(`skipped: ${segment}`);
1574
+ parts.push(`ran: ${partialInfo.executedSegments.join('; ')}`);
1575
+ }
1576
+ if (result.sigpipe === true) {
1577
+ parts.push(`exit ${result.code ?? 141} (SIGPIPE — downstream closed early; usually harmless)`);
1578
+ } else {
1579
+ parts.push(`exit: ${result.code} (${result.durationMs}ms)`);
1234
1580
  }
1235
- if (result.stderr) {
1236
- parts.push(`--- stderr ---\n${clipMiddle(plain(result.stderr), inlineCap)}`);
1237
- const hint = note('stderr');
1238
- if (hint) parts.push(hint);
1581
+ if (result.timedOut === true) {
1582
+ parts.push(
1583
+ `timed out after ${timeoutMs}ms — remote process tree terminated (SIGTERM, then SIGKILL after 5s); partial output below`,
1584
+ );
1585
+ const spillFiles = [result.stdoutFile, result.stderrFile].filter(Boolean);
1586
+ if (spillFiles.length > 0) parts.push(`partial output saved to: ${spillFiles.join(', ')}`);
1587
+ if (longLivedCommand(commandText)) {
1588
+ parts.push(
1589
+ 'background children started by this command may still be running — use background:true / presence_terminal to track them',
1590
+ );
1591
+ }
1239
1592
  }
1593
+ const stdoutSection = execStreamSection('stdout', result.stdout, result, { inlineCap, tailLines, plain });
1594
+ if (stdoutSection) parts.push(stdoutSection);
1595
+ const stderrSection = execStreamSection('stderr', result.stderr, result, { inlineCap, tailLines, plain });
1596
+ if (stderrSection) parts.push(stderrSection);
1240
1597
  if (result.code !== 0 && isSandboxMessage(result.stderr)) {
1241
1598
  parts.push(
1242
1599
  'note: ctx.fs does not write remote /tmp, so an escalation here cannot help; ' +
@@ -1245,9 +1602,9 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1245
1602
  }
1246
1603
  // Nudge the model away from shell one-liners for edits: they skip the
1247
1604
  // atomic write, the ambiguity check and the version guard.
1248
- if (EDIT_SHELL.test(args.command)) {
1605
+ if (EDIT_SHELL.test(commandText)) {
1249
1606
  parts.push(
1250
- 'note: prefer presence_edit (or the built-in `edit` tool) for remote file changes 鈥?' +
1607
+ 'note: prefer presence_edit (or the built-in `edit` tool) for remote file changes — ' +
1251
1608
  '`sed -i`/`perl -pi`/`python -c` rewrites bypass the atomic write and the ambiguity check.',
1252
1609
  );
1253
1610
  }
@@ -1281,16 +1638,17 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1281
1638
  },
1282
1639
  },
1283
1640
  async execute(args, exec) {
1641
+ const pathInput = requirePath(args, 'presence_read', '{"path": "/srv/app/a.txt"}');
1284
1642
  const { host, workspace } = await pickScope(args, exec);
1285
1643
  const connection = await pool.connection(host);
1286
- const target = await remotePath(connection, workspace, args.path);
1644
+ const target = await remotePath(connection, workspace, pathInput);
1287
1645
  const view = await readFileWindow(connection, target, { offset: args.offset, limit: args.limit });
1288
1646
  const raw = args.raw === true;
1289
1647
  const numbered = !raw && args.numbered !== false;
1290
1648
  const body = numbered
1291
1649
  ? view.lines.map((line, index) => `${view.offset + index}: ${line}`).join('\n')
1292
1650
  : view.rawWindow;
1293
- await markObserved(exec, args.path);
1651
+ await markObserved(exec, pathInput);
1294
1652
  if (raw) return body;
1295
1653
  const footer = numbered ? `\n\n${readFooter(view)}` : '';
1296
1654
  return `<path>${target}</path>\n${body}${footer}`;
@@ -1313,10 +1671,25 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1313
1671
  limit: { type: 'number', description: 'Maximum lines per file. Defaults to 2000.' },
1314
1672
  },
1315
1673
  async execute(args, exec) {
1674
+ const listed = Array.isArray(args.paths) ? args.paths : [];
1675
+ const singlePath = listed.length === 0 ? pickArg(args, PATH_ALIASES) : undefined;
1676
+ const inputs =
1677
+ listed.length > 0
1678
+ ? listed
1679
+ : singlePath !== undefined && singlePath !== ''
1680
+ ? [singlePath]
1681
+ : [];
1682
+ if (inputs.length === 0) {
1683
+ throw missingArgument(
1684
+ 'presence_read_many',
1685
+ 'paths',
1686
+ ['paths', 'path', 'file_path'],
1687
+ args,
1688
+ '{"paths": ["/srv/app/a.txt", "/srv/app/b.txt"]}',
1689
+ );
1690
+ }
1316
1691
  const { host, workspace } = await pickScope(args, exec);
1317
1692
  const connection = await pool.connection(host);
1318
- const inputs = Array.isArray(args.paths) ? args.paths : [];
1319
- if (inputs.length === 0) throw new Error('paths is required: pass 1-50 remote paths');
1320
1693
  if (inputs.length > READ_MANY_MAX_FILES) {
1321
1694
  throw new Error(`presence_read_many reads at most ${READ_MANY_MAX_FILES} files per call, got ${inputs.length}`);
1322
1695
  }
@@ -1368,9 +1741,13 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1368
1741
  },
1369
1742
  async execute(args, exec) {
1370
1743
  ensureWritable();
1744
+ const pathInput = requirePath(args, 'presence_write', '{"path": "/srv/app/a.txt", "content": "hi"}');
1745
+ if (args.content === undefined || args.content === null) {
1746
+ throw missingArgument('presence_write', 'content', ['content'], args, '{"path": "/srv/app/a.txt", "content": "hi"}');
1747
+ }
1371
1748
  const { host, workspace } = await pickScope(args, exec);
1372
1749
  const connection = await pool.connection(host);
1373
- const target = await remotePath(connection, workspace, args.path);
1750
+ const target = await remotePath(connection, workspace, pathInput);
1374
1751
  const expected = typeof args.ifMatchSha256 === 'string' ? args.ifMatchSha256.trim().toLowerCase() : '';
1375
1752
  if (expected) {
1376
1753
  const current = await remoteFileSha256(connection, target);
@@ -1399,7 +1776,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1399
1776
  const sizeBytes = info.exists ? info.size : result.bytes;
1400
1777
  const mode = info.exists ? info.mode : result.mode;
1401
1778
  const octal = modeLabel(mode);
1402
- await markObserved(exec, args.path);
1779
+ await markObserved(exec, pathInput);
1403
1780
  if (args.format === 'json') {
1404
1781
  return jsonText({ path: target, sizeBytes, mode: octal, sha256: digest });
1405
1782
  }
@@ -1417,14 +1794,15 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1417
1794
  to: { type: 'string', description: 'Optional local destination inside the export folder (defaults to a content-addressed name).' },
1418
1795
  },
1419
1796
  async execute(args, exec) {
1797
+ const pathInput = requirePath(args, 'presence_export', '{"path": "/srv/app/report.pdf"}');
1420
1798
  const { host, workspace } = await pickScope(args, exec);
1421
1799
  const connection = await pool.connection(host);
1422
- const target = await remotePath(connection, workspace, args.path);
1800
+ const target = await remotePath(connection, workspace, pathInput);
1423
1801
  const info = await connection.stat(target);
1424
1802
  if (!info.exists) throw new Error(`${target} does not exist on ${host}`);
1425
1803
  if (info.isDir) throw new Error(`${target} is a directory; export files one by one, or use presence_exec with tar/scp`);
1426
1804
  if (info.size > EXPORT_CAP) {
1427
- throw new Error(`${target} is ${info.size} bytes; export is capped at ${EXPORT_CAP} bytes 鈥?use presence_exec with scp/rsync for larger files`);
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`);
1428
1806
  }
1429
1807
  const { buffer } = await connection.readBytes(target, { offset: 0, length: info.size });
1430
1808
  const name = target.split('/').filter(Boolean).pop() ?? 'export.bin';
@@ -1459,7 +1837,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1459
1837
  tool({
1460
1838
  name: 'presence_import',
1461
1839
  description:
1462
- 'Upload a LOCAL file to the remote machine 鈥?the missing local鈫抮emote 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.',
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.',
1463
1841
  parameters: {
1464
1842
  source: {
1465
1843
  type: 'string',
@@ -1482,8 +1860,26 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1482
1860
  },
1483
1861
  async execute(args, exec) {
1484
1862
  ensureWritable();
1863
+ const toInput = pickArg(args, DESTINATION_ALIASES);
1864
+ 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
+ );
1872
+ }
1485
1873
  const rawSource = String(args.source ?? '').trim();
1486
- if (!rawSource) throw new Error('source is required');
1874
+ if (!rawSource) {
1875
+ throw missingArgument(
1876
+ 'presence_import',
1877
+ 'source',
1878
+ ['source'],
1879
+ args,
1880
+ '{"source": "C:\\\\Temp\\\\report.pdf", "to": "/srv/app/report.pdf"}',
1881
+ );
1882
+ }
1487
1883
  const localSource = resolvePath(rawSource.startsWith('~') ? joinPath(homedir(), rawSource.slice(1)) : rawSource);
1488
1884
  let localStat;
1489
1885
  try {
@@ -1493,7 +1889,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1493
1889
  }
1494
1890
  if (!localStat.isFile()) throw new Error(`${localSource} is not a regular file`);
1495
1891
  if (localStat.size > EXPORT_CAP) {
1496
- throw new Error(`${localSource} is ${localStat.size} bytes; import is capped at ${EXPORT_CAP} bytes 鈥?use presence_exec with scp/rsync for larger files`);
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`);
1497
1893
  }
1498
1894
 
1499
1895
  const buffer = readFileSync(localSource);
@@ -1501,7 +1897,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1501
1897
 
1502
1898
  const { host, workspace } = await pickScope(args, exec);
1503
1899
  const connection = await pool.connection(host);
1504
- const target = await remotePath(connection, workspace, args.to);
1900
+ const target = await remotePath(connection, workspace, toInput);
1505
1901
  const existing = await connection.stat(target);
1506
1902
  if (existing.exists && existing.isDir) throw new Error(`${target} is a directory on ${host}`);
1507
1903
  if (existing.exists && args.overwrite === false) {
@@ -1558,12 +1954,27 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1558
1954
  async execute(args, exec) {
1559
1955
  const { host, workspace } = await pickScope(args, exec);
1560
1956
  const connection = await pool.connection(host);
1957
+ const pathInput = pickArg(args, PATH_ALIASES);
1958
+ const toInput = pickArg(args, TO_ALIASES);
1561
1959
  if (args.action === 'trash') {
1562
- if (args.path !== undefined && args.paths !== undefined) {
1960
+ if (pathInput !== undefined && args.paths !== undefined) {
1563
1961
  throw new Error('pass either path or paths, not both');
1564
1962
  }
1565
- const inputs = Array.isArray(args.paths) && args.paths.length > 0 ? args.paths : args.path ? [args.path] : [];
1566
- if (inputs.length === 0) throw new Error('trash requires a path (or paths)');
1963
+ const inputs =
1964
+ Array.isArray(args.paths) && args.paths.length > 0
1965
+ ? args.paths
1966
+ : pathInput !== undefined && pathInput !== ''
1967
+ ? [pathInput]
1968
+ : [];
1969
+ if (inputs.length === 0) {
1970
+ throw missingArgument(
1971
+ 'presence_trash',
1972
+ 'path',
1973
+ ['path', 'file_path', 'paths'],
1974
+ args,
1975
+ '{"action": "trash", "path": "/srv/app/old.log"}',
1976
+ );
1977
+ }
1567
1978
  if (inputs.length > TRASH_MAX_PATHS) {
1568
1979
  throw new Error(`trash moves at most ${TRASH_MAX_PATHS} paths per call, got ${inputs.length}`);
1569
1980
  }
@@ -1638,7 +2049,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1638
2049
  }
1639
2050
  if (args.action === 'list') {
1640
2051
  if (typeof connection.listTrash !== 'function') throw new Error('this Presence build has no trash support; update the plugin');
1641
- const needle = args.path === undefined || args.path === null ? '' : String(args.path).trim();
2052
+ const needle = pathInput === undefined || pathInput === null ? '' : String(pathInput).trim();
1642
2053
  // A filter should search more than the newest page, then sort/trim.
1643
2054
  const entries = await connection.listTrash({ limit: needle ? 1000 : 20 });
1644
2055
  const rows = entries
@@ -1657,21 +2068,37 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1657
2068
  if (rows.length === 0) return `trash is empty on ${host}${needle ? ` for "${needle}"` : ''}`;
1658
2069
  return clip(rows.map((row) => `${formatMtime(row.movedAtMs ?? Number.NaN)} ${row.trashPath}`).join('\n'));
1659
2070
  }
1660
- if (!args.trashPath) throw new Error('restore requires trashPath');
1661
- if (!args.to) throw new Error('restore requires to (the original location; the container does not record it)');
2071
+ if (!args.trashPath) {
2072
+ throw missingArgument(
2073
+ 'presence_trash',
2074
+ 'trashPath',
2075
+ ['trashPath'],
2076
+ args,
2077
+ '{"action": "restore", "trashPath": "/home/me/.dsh/presence-trash/20261028-120000/a.txt", "to": "/srv/app/a.txt"}',
2078
+ );
2079
+ }
2080
+ if (toInput === undefined || toInput === '') {
2081
+ throw missingArgument(
2082
+ 'presence_trash',
2083
+ 'to',
2084
+ ['to', 'to_path'],
2085
+ args,
2086
+ '{"action": "restore", "trashPath": "/home/me/.dsh/presence-trash/20261028-120000/a.txt", "to": "/srv/app/a.txt"}',
2087
+ );
2088
+ }
1662
2089
  if (typeof connection.restoreTrash !== 'function') throw new Error('this Presence build has no trash support; update the plugin');
1663
- const restored = await connection.restoreTrash(args.trashPath, { to: args.to });
2090
+ const restored = await connection.restoreTrash(args.trashPath, { to: toInput });
1664
2091
  if (args.format === 'json') {
1665
- return JSON.stringify({ restored: true, from: args.trashPath, to: restored.to ?? args.to }, null, 2);
2092
+ return JSON.stringify({ restored: true, from: args.trashPath, to: restored.to ?? toInput }, null, 2);
1666
2093
  }
1667
- return `restored ${args.trashPath} to ${restored.to ?? args.to} on ${host}`;
2094
+ return `restored ${args.trashPath} to ${restored.to ?? toInput} on ${host}`;
1668
2095
  },
1669
2096
  }),
1670
2097
 
1671
2098
  tool({
1672
2099
  name: 'presence_edit',
1673
2100
  description:
1674
- '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.',
2101
+ '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.',
1675
2102
  parameters: {
1676
2103
  host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
1677
2104
  path: { type: 'string', required: true, description: 'Absolute path, ~/relative, or workspace-relative.' },
@@ -1692,22 +2119,42 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1692
2119
  },
1693
2120
  },
1694
2121
  async execute(args, exec) {
1695
- const oldText = args.old ?? args.oldString;
1696
- const newText = args.new ?? args.newString;
1697
- if (typeof oldText !== 'string' || oldText === '') throw new Error('old (or oldString) is required');
1698
- if (typeof newText !== 'string') throw new Error('new (or newString) is required');
1699
- const dryRun = args.dryRun === true;
2122
+ const example = '{"path": "/srv/app/a.txt", "old": "before", "new": "after"}';
2123
+ const pathInput = requirePath(args, 'presence_edit', example);
2124
+ const oldText = pickArg(args, EDIT_OLD_ALIASES);
2125
+ if (typeof oldText !== 'string' || oldText === '') {
2126
+ throw missingArgument('presence_edit', 'old', EDIT_OLD_ALIASES, args, example);
2127
+ }
2128
+ const newText = pickArg(args, EDIT_NEW_ALIASES);
2129
+ if (typeof newText !== 'string') {
2130
+ throw missingArgument('presence_edit', 'new', EDIT_NEW_ALIASES, args, example);
2131
+ }
2132
+ const dryRun = pickArg(args, EDIT_DRY_RUN_ALIASES) === true;
2133
+ const replaceAll = pickArg(args, EDIT_REPLACE_ALL_ALIASES) === true;
1700
2134
  if (!dryRun) ensureWritable();
1701
2135
  const { host, workspace } = await pickScope(args, exec);
1702
2136
  const connection = await pool.connection(host);
1703
- const target = await remotePath(connection, workspace, args.path);
2137
+ const target = await remotePath(connection, workspace, pathInput);
1704
2138
  const info = await connection.stat(target);
1705
- if (!info.exists) throw new Error(`${target} does not exist on ${host} 鈥?nothing to edit (use presence_write to create it)`);
2139
+ if (!info.exists) throw new Error(`${target} does not exist on ${host} — nothing to edit (use presence_write to create it)`);
1706
2140
  if (info.isDir) throw new Error(`${target} is a directory on ${host}`);
1707
- const replaceAll = Boolean(args.replaceAll ?? args.replace_all);
1708
2141
  const before = (await connection.readText(target, { length: Number.MAX_SAFE_INTEGER })).content;
1709
2142
  const occurrences = before.split(oldText).length - 1;
1710
- if (occurrences === 0) throw new Error('text to replace was not found');
2143
+ if (occurrences === 0) {
2144
+ // Another session may have changed the file since it was read; point
2145
+ // at the closest lines so the stale anchor is easy to recognize.
2146
+ const closest = closestLines(before, oldText, 3);
2147
+ const message = [`text to replace was not found in ${target} on ${host}.`];
2148
+ if (closest.length > 0) {
2149
+ message.push('closest lines:');
2150
+ for (const entry of closest) message.push(` line ${entry.line}: ${entry.text}`);
2151
+ }
2152
+ message.push(
2153
+ 'hint: re-read the file if another session changed it; ' +
2154
+ 'ifMatchSha256 is available on presence_write for a hash-checked overwrite.',
2155
+ );
2156
+ throw new Error(message.join('\n'));
2157
+ }
1711
2158
  if (occurrences > 1 && !replaceAll) {
1712
2159
  throw new Error(`text occurs ${occurrences} times; pass replaceAll or a longer anchor`);
1713
2160
  }
@@ -1726,7 +2173,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1726
2173
  actualSizeBytes = result.bytes;
1727
2174
  }
1728
2175
  const diff = diffLines(before, after);
1729
- await markObserved(exec, args.path);
2176
+ await markObserved(exec, pathInput);
1730
2177
  if (args.format === 'json') {
1731
2178
  return jsonText({
1732
2179
  path: target,
@@ -1751,7 +2198,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1751
2198
  tool({
1752
2199
  name: 'presence_search',
1753
2200
  description:
1754
- '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.',
2201
+ '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.',
1755
2202
  parameters: {
1756
2203
  host: { type: 'string', description: 'ssh alias. Defaults to the active workspace\'s machine.' },
1757
2204
  pattern: { type: 'string', required: true, description: 'Regular expression to search for (ripgrep syntax).' },
@@ -1766,9 +2213,13 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1766
2213
  },
1767
2214
  },
1768
2215
  async execute(args, exec) {
2216
+ const pathInput = pickArg(args, PATH_ALIASES);
1769
2217
  const { host, workspace } = await pickScope(args, exec);
1770
2218
  const connection = await pool.connection(host);
1771
- const target = args.path ? await remotePath(connection, workspace, args.path) : workspace?.path ?? (await connection.homePath());
2219
+ const target =
2220
+ pathInput !== undefined && pathInput !== ''
2221
+ ? await remotePath(connection, workspace, pathInput)
2222
+ : workspace?.path ?? (await connection.homePath());
1772
2223
 
1773
2224
  // Fail loudly instead of pretending "no matches": a missing path or a
1774
2225
  // bad regex must not look like an empty result.
@@ -1785,7 +2236,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1785
2236
  smartCase: true,
1786
2237
  countOnly: Boolean(args.countOnly),
1787
2238
  });
1788
- const warn = warning ? `\n(warning: some paths were not searchable 鈥?${warning.slice(0, 300)})` : '';
2239
+ const warn = warning ? `\n(warning: some paths were not searchable — ${warning.slice(0, 300)})` : '';
1789
2240
 
1790
2241
  if (args.format === 'json') {
1791
2242
  const matchCount = total ?? matches.length;
@@ -1818,7 +2269,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1818
2269
  : `showing ${matches.length} of ${total} matches (truncated; limit ${limit}) on ${host}:`
1819
2270
  : `${total ?? matches.length} match(es) on ${host} (not truncated; limit ${limit}):`;
1820
2271
  const footer = truncated
1821
- ? '\n(more matches exist 鈥?use countOnly for an exact total, narrow path/include, or raise limit)'
2272
+ ? '\n(more matches exist — use countOnly for an exact total, narrow path/include, or raise limit)'
1822
2273
  : '';
1823
2274
  return `${header}\n${clip(lines.join('\n'))}${footer}${warn}`;
1824
2275
  },
@@ -1840,6 +2291,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1840
2291
  },
1841
2292
  },
1842
2293
  async execute(args, exec) {
2294
+ const pathInput = pickArg(args, PATH_ALIASES);
1843
2295
  const { host, workspace } = await pickScope(args, exec);
1844
2296
  const connection = await pool.connection(host);
1845
2297
  const limit = Math.max(1, args.limit ?? 200);
@@ -1848,8 +2300,8 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1848
2300
  const pattern = absolute ? absolute.pattern : rawPattern;
1849
2301
  const base = absolute
1850
2302
  ? absolute.root
1851
- : args.path
1852
- ? await remotePath(connection, workspace, args.path)
2303
+ : pathInput !== undefined && pathInput !== ''
2304
+ ? await remotePath(connection, workspace, pathInput)
1853
2305
  : workspace?.path ?? (await connection.homePath());
1854
2306
  const info = await connection.stat(base);
1855
2307
  if (!info.exists) throw new Error(`${base} does not exist on ${host}`);
@@ -1887,7 +2339,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1887
2339
  : `${totalLabel} file(s) on ${host} under ${base} (not truncated; limit ${limit}${newest}):`;
1888
2340
  const footer = [];
1889
2341
  if (detailed.truncated) {
1890
- footer.push(`(more files match 鈥?raise limit or narrow the pattern)`);
2342
+ footer.push(`(more files match — raise limit or narrow the pattern)`);
1891
2343
  }
1892
2344
  if (detailed.scanCapped) {
1893
2345
  footer.push('(the host-side scan stopped at its file cap, so the total is a lower bound)');
@@ -1926,8 +2378,23 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1926
2378
  },
1927
2379
  },
1928
2380
  async execute(args, exec) {
1929
- const inputs = Array.isArray(args.paths) ? args.paths : [];
1930
- if (inputs.length === 0) throw new Error('paths is required: pass 1-100 remote paths');
2381
+ const listed = Array.isArray(args.paths) ? args.paths : [];
2382
+ const singlePath = listed.length === 0 ? pickArg(args, PATH_ALIASES) : undefined;
2383
+ const inputs =
2384
+ listed.length > 0
2385
+ ? listed
2386
+ : singlePath !== undefined && singlePath !== ''
2387
+ ? [singlePath]
2388
+ : [];
2389
+ if (inputs.length === 0) {
2390
+ throw missingArgument(
2391
+ 'presence_stat',
2392
+ 'paths',
2393
+ ['paths', 'path', 'file_path'],
2394
+ args,
2395
+ '{"paths": ["/srv/app/a.txt", "/srv/app/b.txt"]}',
2396
+ );
2397
+ }
1931
2398
  if (inputs.length > STAT_MAX_PATHS) {
1932
2399
  throw new Error(`presence_stat reports at most ${STAT_MAX_PATHS} paths per call, got ${inputs.length}`);
1933
2400
  }
@@ -1973,9 +2440,10 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1973
2440
  annotations: { destructiveHint: false, readOnlyHint: false, idempotentHint: true },
1974
2441
  async execute(args, exec) {
1975
2442
  ensureWritable();
2443
+ const pathInput = requirePath(args, 'presence_mkdir', '{"path": "/srv/app/build"}');
1976
2444
  const { host, workspace } = await pickScope(args, exec);
1977
2445
  const connection = await pool.connection(host);
1978
- const target = await remotePath(connection, workspace, args.path);
2446
+ const target = await remotePath(connection, workspace, pathInput);
1979
2447
  const before = await connection.stat(target).catch(() => null);
1980
2448
  const existed = before?.exists === true && before.isDir === true;
1981
2449
  await makeDirectory(connection, target, { recursive: args.parents !== false });
@@ -1996,12 +2464,22 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
1996
2464
  annotations: { destructiveHint: true, readOnlyHint: false },
1997
2465
  async execute(args, exec) {
1998
2466
  ensureWritable();
2467
+ const example = '{"from": "/srv/app/a.txt", "to": "/srv/app/b.txt"}';
2468
+ const fromInput = requirePath(args, 'presence_move', example, {
2469
+ names: SOURCE_ALIASES,
2470
+ canonical: 'from',
2471
+ accepted: ['from', 'from_path', 'file_path'],
2472
+ });
2473
+ const toInput = pickArg(args, TO_ALIASES);
2474
+ if (toInput === undefined || toInput === '') {
2475
+ throw missingArgument('presence_move', 'to', ['to', 'to_path'], args, example);
2476
+ }
1999
2477
  const { host, workspace } = await pickScope(args, exec);
2000
2478
  const connection = await pool.connection(host);
2001
- const from = await remotePath(connection, workspace, args.from);
2002
- const to = await remotePath(connection, workspace, args.to);
2479
+ const from = await remotePath(connection, workspace, fromInput);
2480
+ const to = await remotePath(connection, workspace, toInput);
2003
2481
  const result = await movePath(connection, from, to, { overwrite: args.overwrite === true });
2004
- await markObserved(exec, args.to);
2482
+ await markObserved(exec, toInput);
2005
2483
  return `moved ${result.from} -> ${result.to} on ${host}`;
2006
2484
  },
2007
2485
  }),
@@ -2020,15 +2498,25 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2020
2498
  annotations: { destructiveHint: true, readOnlyHint: false },
2021
2499
  async execute(args, exec) {
2022
2500
  ensureWritable();
2501
+ const example = '{"from": "/srv/app/a.txt", "to": "/srv/app/b.txt"}';
2502
+ const fromInput = requirePath(args, 'presence_copy', example, {
2503
+ names: SOURCE_ALIASES,
2504
+ canonical: 'from',
2505
+ accepted: ['from', 'from_path', 'file_path'],
2506
+ });
2507
+ const toInput = pickArg(args, TO_ALIASES);
2508
+ if (toInput === undefined || toInput === '') {
2509
+ throw missingArgument('presence_copy', 'to', ['to', 'to_path'], args, example);
2510
+ }
2023
2511
  const { host, workspace } = await pickScope(args, exec);
2024
2512
  const connection = await pool.connection(host);
2025
- const from = await remotePath(connection, workspace, args.from);
2026
- const to = await remotePath(connection, workspace, args.to);
2513
+ const from = await remotePath(connection, workspace, fromInput);
2514
+ const to = await remotePath(connection, workspace, toInput);
2027
2515
  const result = await copyPath(connection, from, to, {
2028
2516
  recursive: args.recursive === true,
2029
2517
  overwrite: args.overwrite === true,
2030
2518
  });
2031
- await markObserved(exec, args.to);
2519
+ await markObserved(exec, toInput);
2032
2520
  const facts = [];
2033
2521
  if (result.files !== undefined) facts.push(`${result.files} file(s)`);
2034
2522
  if (result.bytes !== undefined) facts.push(formatBytes(result.bytes));
@@ -2053,12 +2541,13 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2053
2541
  annotations: { destructiveHint: true, readOnlyHint: false },
2054
2542
  async execute(args, exec) {
2055
2543
  ensureWritable();
2544
+ const pathInput = requirePath(args, 'presence_chmod', '{"path": "/srv/app/run.sh", "mode": "755"}');
2056
2545
  const { host, workspace } = await pickScope(args, exec);
2057
2546
  const connection = await pool.connection(host);
2058
- const target = await remotePath(connection, workspace, args.path);
2547
+ const target = await remotePath(connection, workspace, pathInput);
2059
2548
  const mode = parseChmodMode(args.mode);
2060
2549
  const result = await chmodPath(connection, target, mode, { recursive: args.recursive === true });
2061
- await markObserved(exec, args.path);
2550
+ await markObserved(exec, pathInput);
2062
2551
  return `chmod ${args.recursive === true ? '-R ' : ''}${result.mode} ${result.path} on ${host}`;
2063
2552
  },
2064
2553
  }),
@@ -2081,27 +2570,34 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2081
2570
  },
2082
2571
  annotations: { readOnlyHint: true },
2083
2572
  async execute(args, exec) {
2084
- const hasRemoteB = typeof args.b === 'string' && args.b.trim() !== '';
2573
+ const example = '{"a": "/srv/app/old.txt", "b": "/srv/app/new.txt"}';
2574
+ const aInput = requirePath(args, 'presence_diff', example, {
2575
+ names: DIFF_A_ALIASES,
2576
+ canonical: 'a',
2577
+ accepted: ['a', 'file_path'],
2578
+ });
2579
+ const bInput = pickArg(args, DIFF_B_ALIASES);
2580
+ const hasRemoteB = typeof bInput === 'string' && bInput.trim() !== '';
2085
2581
  const hasLocal = typeof args.localPath === 'string' && args.localPath.trim() !== '';
2086
2582
  if (hasRemoteB === hasLocal) {
2087
2583
  throw new Error('pass exactly one of b (another remote path) or localPath (a local file) as the right side');
2088
2584
  }
2089
2585
  const { host, workspace } = await pickScope(args, exec);
2090
2586
  const connection = await pool.connection(host);
2091
- const leftTarget = await remotePath(connection, workspace, args.a);
2587
+ const leftTarget = await remotePath(connection, workspace, aInput);
2092
2588
  const left = await readRemoteTextForDiff(connection, leftTarget, host);
2093
2589
  let right;
2094
2590
  let rightLabel;
2095
2591
  if (hasRemoteB) {
2096
- rightLabel = await remotePath(connection, workspace, args.b);
2592
+ rightLabel = await remotePath(connection, workspace, bInput);
2097
2593
  right = await readRemoteTextForDiff(connection, rightLabel, host);
2098
- await markObserved(exec, args.b);
2594
+ await markObserved(exec, bInput);
2099
2595
  } else {
2100
2596
  const local = readLocalTextForDiff(args.localPath);
2101
2597
  right = local.content;
2102
2598
  rightLabel = local.path;
2103
2599
  }
2104
- await markObserved(exec, args.a);
2600
+ await markObserved(exec, aInput);
2105
2601
  const context = clampedNumber(args.context, 3, 0, 20);
2106
2602
  const result = buildUnifiedDiff(left, right, { context });
2107
2603
  if (args.format === 'json') {
@@ -2144,9 +2640,10 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2144
2640
  },
2145
2641
  annotations: { readOnlyHint: true },
2146
2642
  async execute(args, exec) {
2643
+ const pathInput = requirePath(args, 'presence_tail', '{"path": "/srv/app/app.log", "lines": 200}');
2147
2644
  const { host, workspace } = await pickScope(args, exec);
2148
2645
  const connection = await pool.connection(host);
2149
- const target = await remotePath(connection, workspace, args.path);
2646
+ const target = await remotePath(connection, workspace, pathInput);
2150
2647
  const follow = args.follow === true;
2151
2648
  const result = await tailRemote(connection, target, {
2152
2649
  lines: clampedNumber(args.lines, TAIL_DEFAULT_LINES, 1, TAIL_MAX_LINES),
@@ -2154,7 +2651,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2154
2651
  timeoutMs: clampedNumber(args.timeoutMs, TAIL_DEFAULT_TIMEOUT_MS, 0, TAIL_MAX_TIMEOUT_MS),
2155
2652
  signal: exec?.signal,
2156
2653
  });
2157
- await markObserved(exec, args.path);
2654
+ await markObserved(exec, pathInput);
2158
2655
  if (args.format === 'json') {
2159
2656
  let text = result.text;
2160
2657
  let lines = result.lines;
@@ -2233,17 +2730,29 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2233
2730
  ? 'no background jobs'
2234
2731
  : 'no background jobs for this session (use all:true to list every session)';
2235
2732
  }
2236
- if (args.format === 'json') return JSON.stringify(visible.map((job) => jobRecord(job)), null, 2);
2733
+ if (args.format === 'json') {
2734
+ return jsonText(
2735
+ visible.map((job) => {
2736
+ const record = jobRecord(job);
2737
+ if (typeof record.command === 'string') record.command = clipCommandLine(record.command);
2738
+ return record;
2739
+ }),
2740
+ );
2741
+ }
2237
2742
  const lines = visible.map((job) => {
2238
2743
  // The stored command carries the pool's pipefail probe; the list
2239
- // should show what the caller actually asked to run.
2240
- const label = stripPipefailPrelude(job.label ?? job.command ?? '(no label)') || '(no label)';
2744
+ // should show what the caller actually asked to run, label first and
2745
+ // the command clipped to one short line.
2746
+ const label = clipCommandLine(stripPipefailPrelude(job.label ?? '(no label)') || '(no label)');
2747
+ const command = job.command ? clipCommandLine(stripPipefailPrelude(job.command)) : null;
2241
2748
  const age = job.startedAt ? `${Math.round((Date.now() - job.startedAt) / 1000)}s` : '?';
2242
2749
  const exit =
2243
2750
  job.exitCode === undefined || job.exitCode === null ? (job.state === 'running' ? '-' : '?') : job.exitCode;
2244
2751
  return (
2245
- `${job.id} ${label}\n` +
2246
- ` state=${job.state ?? 'unknown'} exit=${exit} pid=${job.pid ?? '?'} host=${job.host} age=${age} log=${job.logPath ?? '?'}`
2752
+ `${label}\n` +
2753
+ ` job=${job.id} state=${job.state ?? 'unknown'} exit=${exit} pid=${job.pid ?? '?'} ` +
2754
+ `host=${job.host} age=${age} log=${job.logPath ?? '?'}` +
2755
+ (command ? `\n command: ${command}` : '')
2247
2756
  );
2248
2757
  });
2249
2758
  return `${visible.length} job(s):\n${lines.join('\n')}`;
@@ -2692,7 +3201,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2692
3201
  tool({
2693
3202
  name: 'presence_terminal',
2694
3203
  description:
2695
- 'Drive a persistent interactive terminal on a remote machine. open starts a shell that survives between tool calls; pass `session` to run it inside a named tmux session on the target, so it also survives an SSH drop or an app restart 鈥?reopen with the same name to reattach. write sends keystrokes, read returns new output since the last read, close ends it. Use this for REPLs, ssh sessions, long-running interactive programs and pagers 鈥?not for one-shot commands, which presence_exec handles.',
3204
+ 'Drive a persistent interactive terminal on a remote machine. open starts a shell that survives between tool calls; pass `session` to run it inside a named tmux session on the target, so it also survives an SSH drop or an app restart — reopen with the same name to reattach. write sends keystrokes, read returns new output since the last read, close ends it. Use this for REPLs, ssh sessions, long-running interactive programs and pagers — not for one-shot commands, which presence_exec handles.',
2696
3205
  parameters: {
2697
3206
  action: {
2698
3207
  type: 'string',
@@ -2828,7 +3337,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
2828
3337
  return jsonText({ terminalId: args.terminalId, closed: true, session, killedSession, host });
2829
3338
  }
2830
3339
  if (killedSession) return `closed ${args.terminalId} and killed tmux session "${session}"`;
2831
- if (session) return `closed ${args.terminalId}; tmux session "${session}" keeps running 鈥?reopen with session:"${session}" to reattach`;
3340
+ if (session) return `closed ${args.terminalId}; tmux session "${session}" keeps running — reopen with session:"${session}" to reattach`;
2832
3341
  return `closed ${args.terminalId}`;
2833
3342
  }
2834
3343
  throw new Error(`unknown action ${args.action}`);