@rjfan/dsh-presence 0.18.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/README.md +170 -16
- package/README.zh-CN.md +114 -15
- package/client.js +1 -1
- package/index.js +139 -84
- package/package.json +1 -1
- package/src/api.js +6 -1
- package/src/core/connection.mjs +125 -31
- package/src/destructive.js +265 -12
- package/src/intercept.js +150 -0
- package/src/job-store.js +292 -0
- package/src/observed-store.js +136 -19
- package/src/pool.js +444 -97
- package/src/tools.js +1458 -258
- package/src/wait.js +352 -0
package/src/tools.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// The model-facing surface. Every tool targets one machine by its ssh alias and
|
|
2
|
-
// reuses that machine's persistent connection
|
|
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';
|
|
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,9 +14,15 @@ 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;
|
|
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;
|
|
@@ -37,6 +42,86 @@ const TAIL_MAX_TIMEOUT_MS = 60_000;
|
|
|
37
42
|
const TAIL_MAX_LINES = 5000;
|
|
38
43
|
const TAIL_JSON_TEXT_CAP = 48 * 1024;
|
|
39
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;
|
|
61
|
+
|
|
62
|
+
// ------------------------------------------------------- argument aliases
|
|
63
|
+
//
|
|
64
|
+
// Models habitually spell the same argument several ways (`file_path`,
|
|
65
|
+
// `oldString`, `workdir`, ...). The JSON schema keeps one canonical name per
|
|
66
|
+
// parameter; these lists are applied at execute time, and the canonical
|
|
67
|
+
// spelling always wins when a caller sends more than one.
|
|
68
|
+
|
|
69
|
+
const PATH_ALIASES = ['path', 'file_path', 'filePath'];
|
|
70
|
+
const EDIT_OLD_ALIASES = ['old', 'oldString', 'old_string'];
|
|
71
|
+
const EDIT_NEW_ALIASES = ['new', 'newString', 'new_string'];
|
|
72
|
+
const EDIT_REPLACE_ALL_ALIASES = ['replaceAll', 'replace_all'];
|
|
73
|
+
const EDIT_DRY_RUN_ALIASES = ['dryRun', 'dry_run'];
|
|
74
|
+
const EXEC_CWD_ALIASES = ['cwd', 'workdir', 'dir'];
|
|
75
|
+
const FROM_ALIASES = ['from', 'from_path', 'fromPath'];
|
|
76
|
+
const TO_ALIASES = ['to', 'to_path', 'toPath'];
|
|
77
|
+
// Two-path tools also accept a plain path spelling on their source/destination
|
|
78
|
+
// side, so `{from, path}`-style calls resolve instead of failing.
|
|
79
|
+
const SOURCE_ALIASES = [...FROM_ALIASES, ...PATH_ALIASES];
|
|
80
|
+
const DESTINATION_ALIASES = [...TO_ALIASES, ...PATH_ALIASES];
|
|
81
|
+
const DIFF_A_ALIASES = ['a', 'a_path', 'aPath', ...PATH_ALIASES];
|
|
82
|
+
const DIFF_B_ALIASES = ['b', 'b_path', 'bPath'];
|
|
83
|
+
|
|
84
|
+
/** One argument value by any accepted spelling; undefined when none is set. */
|
|
85
|
+
function pickArg(args, names) {
|
|
86
|
+
if (!args || typeof args !== 'object') return undefined;
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
const value = args[name];
|
|
89
|
+
if (value !== undefined && value !== null) return value;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function receivedArgKeys(args) {
|
|
95
|
+
const keys = Object.keys(args ?? {}).filter((key) => args?.[key] !== undefined);
|
|
96
|
+
return keys.length > 0 ? keys.join(', ') : 'nothing';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A missing required argument explains itself: which parameter, which
|
|
101
|
+
* spellings are accepted, what was sent instead, and one copyable example.
|
|
102
|
+
*/
|
|
103
|
+
function missingArgument(toolName, canonical, accepted, args, example) {
|
|
104
|
+
return new Error(
|
|
105
|
+
`missing "${canonical}" on ${toolName} ` +
|
|
106
|
+
`(accepted: ${accepted.join(', ')}; received: ${receivedArgKeys(args)}). ` +
|
|
107
|
+
`Example: ${example}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve a required path parameter through its alias spellings. The message
|
|
113
|
+
* advertises the canonical name plus the DSH-style `file_path`.
|
|
114
|
+
*/
|
|
115
|
+
function requirePath(
|
|
116
|
+
args,
|
|
117
|
+
toolName,
|
|
118
|
+
example,
|
|
119
|
+
{ names = PATH_ALIASES, canonical = 'path', accepted = ['path', 'file_path'] } = {},
|
|
120
|
+
) {
|
|
121
|
+
const value = pickArg(args, names);
|
|
122
|
+
if (value === undefined || value === '') throw missingArgument(toolName, canonical, accepted, args, example);
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
40
125
|
|
|
41
126
|
function shq(value) {
|
|
42
127
|
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
@@ -45,21 +130,150 @@ function shq(value) {
|
|
|
45
130
|
function clip(text, cap = OUTPUT_CAP) {
|
|
46
131
|
if (typeof text !== 'string') return '';
|
|
47
132
|
if (text.length <= cap) return text;
|
|
48
|
-
return `${text.slice(0, cap)}\n
|
|
133
|
+
return `${text.slice(0, cap)}\n... (${text.length - cap} more characters truncated)`;
|
|
49
134
|
}
|
|
50
135
|
|
|
51
|
-
/**
|
|
52
|
-
function
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
136
|
+
/** `presence_exec.tailLines`: a display budget, whole lines, 0..5000, 0 = off. */
|
|
137
|
+
function execTailLines(value) {
|
|
138
|
+
if (value === undefined || value === null || value === '') return 0;
|
|
139
|
+
const number = Number(value);
|
|
140
|
+
if (!Number.isFinite(number)) return 0;
|
|
141
|
+
return Math.max(0, Math.min(EXEC_MAX_TAIL_LINES, Math.floor(number)));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Only the three progress modes the connection contract defines are forwarded. */
|
|
145
|
+
function execProgress(value) {
|
|
146
|
+
return value === 'all' || value === 'strip' ? value : 'last';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Commands that start long-lived work: `nohup`, a trailing `&`, or
|
|
151
|
+
* `tmux new-session`. They belong in `background:true`/`presence_terminal`,
|
|
152
|
+
* where the job registry or the pty keeps them alive.
|
|
153
|
+
*/
|
|
154
|
+
function longLivedCommand(command) {
|
|
155
|
+
const value = String(command ?? '');
|
|
156
|
+
return /\bnohup\b/.test(value) || /tmux\s+new-session/.test(value) || /(?:^|[\s;])&(?!&)\s*$/.test(value);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function backgroundCommandHint(command) {
|
|
160
|
+
if (!longLivedCommand(command)) return null;
|
|
57
161
|
return (
|
|
58
|
-
|
|
59
|
-
|
|
162
|
+
'this command starts long-lived background work; use presence_exec {background:true} with presence_jobs ' +
|
|
163
|
+
'{action:"wait"} to track it, or presence_terminal for an interactive/tmux session; nohup/tmux/& children ' +
|
|
164
|
+
'may be cleaned up when this SSH channel closes'
|
|
60
165
|
);
|
|
61
166
|
}
|
|
62
167
|
|
|
168
|
+
/** One job-list command line, clipped so a long command cannot dominate the list. */
|
|
169
|
+
function clipCommandLine(value, max = 200) {
|
|
170
|
+
const text = stripPipefailPrelude(value);
|
|
171
|
+
if (text.length <= max) return text;
|
|
172
|
+
return `${text.slice(0, max)}... (${text.length - max} more characters)`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Head slice plus a tail chosen by `tailLines` (or by the remaining budget). */
|
|
176
|
+
function execTailSlice(captured, { tailChars, tailLines }) {
|
|
177
|
+
if (tailLines > 0) {
|
|
178
|
+
const rows = captured.split('\n');
|
|
179
|
+
if (rows.length > 0 && rows.at(-1) === '') rows.pop();
|
|
180
|
+
const tail = rows.slice(-tailLines).join('\n');
|
|
181
|
+
// A few very long lines must not blow the display budget either.
|
|
182
|
+
if (tailChars > 0 && tail.length > tailChars) return tail.slice(-tailChars);
|
|
183
|
+
return tail;
|
|
184
|
+
}
|
|
185
|
+
return captured.slice(Math.max(0, captured.length - Math.max(0, tailChars)));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* One `--- stdout ---` / `--- stderr ---` block of the presence_exec text
|
|
190
|
+
* report: what the caller can read inline, how much was left out, where the
|
|
191
|
+
* full stream lives, and (with `tailLines`) the last N lines of the capture.
|
|
192
|
+
*/
|
|
193
|
+
function execStreamSection(name, rawValue, result, { inlineCap, tailLines, plain }) {
|
|
194
|
+
const captured = plain(rawValue);
|
|
195
|
+
const truncated = (name === 'stdout' ? result.stdoutTruncated : result.stderrTruncated) === true;
|
|
196
|
+
const file = name === 'stdout' ? result.stdoutFile : result.stderrFile;
|
|
197
|
+
const fileBytes = name === 'stdout' ? result.stdoutFileBytes : result.stderrFileBytes;
|
|
198
|
+
const fileError = name === 'stdout' ? result.stdoutFileError : result.stderrFileError;
|
|
199
|
+
if (!captured && !file) return null;
|
|
200
|
+
const lines = [`--- ${name} ---`];
|
|
201
|
+
if (captured.length <= inlineCap && !truncated) {
|
|
202
|
+
lines.push(captured);
|
|
203
|
+
} else {
|
|
204
|
+
const headChars = Math.max(1, Math.floor(inlineCap * 0.4));
|
|
205
|
+
const head = captured.slice(0, headChars);
|
|
206
|
+
const tail = execTailSlice(captured, { tailChars: inlineCap - headChars, tailLines });
|
|
207
|
+
const omitted = Math.max(0, captured.length - head.length - tail.length);
|
|
208
|
+
const where = file ? `; full ${name}: ${file}` : '';
|
|
209
|
+
lines.push(head, `... (${omitted} characters omitted${where})`, tail);
|
|
210
|
+
}
|
|
211
|
+
if (file) {
|
|
212
|
+
const size = Number.isFinite(Number(fileBytes)) && Number(fileBytes) > 0 ? ` (${fileBytes} bytes)` : '';
|
|
213
|
+
lines.push(`${name}: ${file}${size}`);
|
|
214
|
+
} else if (truncated) {
|
|
215
|
+
lines.push(`${name}: full output not saved${fileError ? ` (${fileError})` : ''}`);
|
|
216
|
+
}
|
|
217
|
+
return lines.join('\n');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Character-bigram overlap, used only to suggest edit anchors. */
|
|
221
|
+
function commonBigrams(left, right) {
|
|
222
|
+
if (left.length < 2 || right.length < 2) return 0;
|
|
223
|
+
const grams = new Map();
|
|
224
|
+
for (let index = 0; index < left.length - 1; index += 1) {
|
|
225
|
+
const gram = left.slice(index, index + 2);
|
|
226
|
+
grams.set(gram, (grams.get(gram) ?? 0) + 1);
|
|
227
|
+
}
|
|
228
|
+
let shared = 0;
|
|
229
|
+
for (let index = 0; index < right.length - 1; index += 1) {
|
|
230
|
+
const gram = right.slice(index, index + 2);
|
|
231
|
+
const count = grams.get(gram) ?? 0;
|
|
232
|
+
if (count > 0) {
|
|
233
|
+
grams.set(gram, count - 1);
|
|
234
|
+
shared += 1;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return (2 * shared) / (left.length + right.length - 2);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Up to `limit` lines that are the closest match for an anchor an edit could
|
|
242
|
+
* not find, so a stale read is easy to recognize and fix.
|
|
243
|
+
*/
|
|
244
|
+
function closestLines(content, needle, limit = 3) {
|
|
245
|
+
const target =
|
|
246
|
+
String(needle ?? '')
|
|
247
|
+
.split('\n')
|
|
248
|
+
.map((line) => line.trim())
|
|
249
|
+
.find((line) => line.length > 0) ?? '';
|
|
250
|
+
if (!target) return [];
|
|
251
|
+
const targetLower = target.toLowerCase();
|
|
252
|
+
const token = /[a-z0-9_$.-]{3,}/i.exec(targetLower)?.[0] ?? '';
|
|
253
|
+
const scored = [];
|
|
254
|
+
String(content ?? '')
|
|
255
|
+
.split('\n')
|
|
256
|
+
.forEach((raw, index) => {
|
|
257
|
+
const line = raw.trim();
|
|
258
|
+
if (!line) return;
|
|
259
|
+
const lower = line.toLowerCase();
|
|
260
|
+
let score = 0;
|
|
261
|
+
if (lower === targetLower) score = 1;
|
|
262
|
+
else if (lower.includes(targetLower) || targetLower.includes(lower)) {
|
|
263
|
+
score = 0.9 * (Math.min(lower.length, targetLower.length) / Math.max(lower.length, targetLower.length));
|
|
264
|
+
} else if (token && lower.includes(token)) {
|
|
265
|
+
score = 0.5;
|
|
266
|
+
} else {
|
|
267
|
+
score = commonBigrams(targetLower, lower);
|
|
268
|
+
}
|
|
269
|
+
if (score > 0.05) {
|
|
270
|
+
scored.push({ line: index + 1, text: line.length > 160 ? `${line.slice(0, 160)}...` : line, score });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
scored.sort((left, right) => right.score - left.score || left.line - right.line);
|
|
274
|
+
return scored.slice(0, limit).map(({ line, text: value }) => ({ line, text: value }));
|
|
275
|
+
}
|
|
276
|
+
|
|
63
277
|
const ANSI_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]|[0-?]*[ -/]*[@-~])/g;
|
|
64
278
|
|
|
65
279
|
/** Drop ANSI escapes and other control characters that corrupt model-facing text. */
|
|
@@ -97,7 +311,7 @@ function isSandboxMessage(message) {
|
|
|
97
311
|
return /sandbox|escalation available/i.test(String(message ?? ''));
|
|
98
312
|
}
|
|
99
313
|
|
|
100
|
-
/** "755" | "0755" | "0o755" | 0o755
|
|
314
|
+
/** "755" | "0755" | "0o755" | 0o755 → octal mode number, or undefined when unset. */
|
|
101
315
|
function parseMode(value) {
|
|
102
316
|
if (value === undefined || value === null || value === '') return undefined;
|
|
103
317
|
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
|
|
@@ -124,7 +338,12 @@ async function readRemoteTail(connection, logPath, lines = 20) {
|
|
|
124
338
|
if (!logPath) return '';
|
|
125
339
|
const count = Math.max(1, Math.min(200, Number(lines) || 20));
|
|
126
340
|
try {
|
|
127
|
-
|
|
341
|
+
// A log tail is content, not progress output: normalize CRs into line
|
|
342
|
+
// breaks instead of keeping only the state after the last `\r`.
|
|
343
|
+
const result = await connection.exec(`tail -n ${count} ${shq(logPath)} 2>/dev/null; true`, {
|
|
344
|
+
timeoutMs: 15_000,
|
|
345
|
+
progress: 'all',
|
|
346
|
+
});
|
|
128
347
|
return String(result?.stdout ?? '').replace(/\n$/, '');
|
|
129
348
|
} catch {
|
|
130
349
|
return '';
|
|
@@ -638,7 +857,9 @@ async function waitForJobFallback(pool, id, { timeoutMs, tailLines }) {
|
|
|
638
857
|
const connection = await pool.connection(job.host);
|
|
639
858
|
const result = await connection.exec(
|
|
640
859
|
`wc -c < ${shq(job.logPath)} 2>/dev/null; tail -n ${Math.max(1, tailLines)} ${shq(job.logPath)} 2>/dev/null`,
|
|
641
|
-
|
|
860
|
+
// Same reasoning as readRemoteTail: show the log content, not only the
|
|
861
|
+
// final state of each progress-bar line.
|
|
862
|
+
{ timeoutMs: 15_000, progress: 'all' },
|
|
642
863
|
);
|
|
643
864
|
const stdout = String(result?.stdout ?? '');
|
|
644
865
|
const newline = stdout.indexOf('\n');
|
|
@@ -682,9 +903,13 @@ function jobRecord(job) {
|
|
|
682
903
|
exitCode: job.exitCode ?? null,
|
|
683
904
|
exitSignal: job.exitSignal ?? null,
|
|
684
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,
|
|
685
909
|
label: job.label ? stripPipefailPrelude(job.label) : null,
|
|
686
910
|
startedAt: job.startedAt ?? null,
|
|
687
911
|
logPath: job.logPath ?? null,
|
|
912
|
+
commandLogPath: job.commandLogPath ?? null,
|
|
688
913
|
logSize: job.logSize ?? null,
|
|
689
914
|
command: job.command ? stripPipefailPrelude(job.command) : null,
|
|
690
915
|
cwd: job.cwd ?? null,
|
|
@@ -787,6 +1012,75 @@ function clampedNumber(value, fallback, min, max) {
|
|
|
787
1012
|
return Math.min(max, Math.max(min, Math.floor(number)));
|
|
788
1013
|
}
|
|
789
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
|
+
|
|
790
1084
|
/**
|
|
791
1085
|
* JSON text that stays valid: oversized text fields are shortened (twice each
|
|
792
1086
|
* round) instead of being cut mid-string, so callers can always JSON.parse it.
|
|
@@ -842,10 +1136,13 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
842
1136
|
};
|
|
843
1137
|
|
|
844
1138
|
/**
|
|
845
|
-
* Where this call belongs.
|
|
846
|
-
*
|
|
847
|
-
* "make it current" step.
|
|
848
|
-
*
|
|
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.
|
|
849
1146
|
*/
|
|
850
1147
|
const pickScope = async (args, exec) => {
|
|
851
1148
|
let cwd = null;
|
|
@@ -855,15 +1152,24 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
855
1152
|
cwd = null;
|
|
856
1153
|
}
|
|
857
1154
|
const mounted = cwd ? parseMountPath(cwd) : null;
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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 };
|
|
864
1162
|
}
|
|
865
|
-
|
|
866
|
-
|
|
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
|
+
);
|
|
867
1173
|
};
|
|
868
1174
|
|
|
869
1175
|
/** Every alias the ssh config knows, for the "known hosts" hint. */
|
|
@@ -891,7 +1197,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
891
1197
|
/**
|
|
892
1198
|
* Resolve any spelling of a remote path to one absolute remote path:
|
|
893
1199
|
* `presence://host/path`, a local mount path, `~/x`, an absolute path, or a
|
|
894
|
-
* workspace-relative path. One file, several names
|
|
1200
|
+
* workspace-relative path. One file, several names — all accepted.
|
|
895
1201
|
*/
|
|
896
1202
|
const remotePath = async (connection, workspace, path) => {
|
|
897
1203
|
const value = String(path);
|
|
@@ -936,7 +1242,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
936
1242
|
tool({
|
|
937
1243
|
name: 'presence_hosts',
|
|
938
1244
|
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
|
|
1245
|
+
'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
1246
|
parameters: {
|
|
941
1247
|
format: {
|
|
942
1248
|
type: 'string',
|
|
@@ -944,9 +1250,37 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
944
1250
|
description: 'Return one text block containing JSON: an array of host objects. Defaults to text.',
|
|
945
1251
|
},
|
|
946
1252
|
},
|
|
947
|
-
async execute(args) {
|
|
1253
|
+
async execute(args, exec) {
|
|
948
1254
|
const hosts = pool.hosts();
|
|
949
|
-
|
|
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;
|
|
1260
|
+
try {
|
|
1261
|
+
const cwd = (await cwdOf?.(exec)) ?? null;
|
|
1262
|
+
workspace = cwd ? parseMountPath(cwd) : null;
|
|
1263
|
+
} catch {
|
|
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;
|
|
1273
|
+
const stateOf = (host) => host.state ?? (host.connected ? 'connected' : 'idle');
|
|
1274
|
+
const stateText = (host) => {
|
|
1275
|
+
const state = stateOf(host);
|
|
1276
|
+
const lastError = host.lastError ?? null;
|
|
1277
|
+
if (state === 'degraded') return `degraded (last error: ${lastError ?? 'unknown'})`;
|
|
1278
|
+
if (state === 'error') return `error${lastError ? ` (last error: ${lastError})` : ''}`;
|
|
1279
|
+
if (state === 'connected' || (state !== 'idle' && host.connected)) {
|
|
1280
|
+
return `connected${host.terminals ? `, ${host.terminals} terminal(s)` : ''}`;
|
|
1281
|
+
}
|
|
1282
|
+
return 'not connected';
|
|
1283
|
+
};
|
|
950
1284
|
if (args.format === 'json') {
|
|
951
1285
|
return jsonText(
|
|
952
1286
|
hosts.map((host) => ({
|
|
@@ -956,20 +1290,25 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
956
1290
|
hostname: host.hostname,
|
|
957
1291
|
port: host.port,
|
|
958
1292
|
source: host.source ?? 'config',
|
|
959
|
-
state: host
|
|
1293
|
+
state: stateOf(host),
|
|
960
1294
|
connected: host.connected === true,
|
|
961
1295
|
default: host.alias === defaultHost,
|
|
1296
|
+
workspace: workspace?.host === host.alias,
|
|
1297
|
+
...(host.lastError ? { lastError: host.lastError } : {}),
|
|
962
1298
|
})),
|
|
963
1299
|
);
|
|
964
1300
|
}
|
|
965
1301
|
if (hosts.length === 0) return 'No hosts with an explicit HostName were found in ~/.ssh/config.';
|
|
966
1302
|
const lines = hosts.map((host) => {
|
|
967
|
-
const state = host
|
|
1303
|
+
const state = stateText(host);
|
|
968
1304
|
const key = host.identityFile ? `key ${host.identityFile}` : 'default keys';
|
|
969
1305
|
const marker = host.alias === defaultHost ? '*' : '';
|
|
970
1306
|
return `${marker}${host.alias} ${host.user ?? '?'}@${host.hostname}:${host.port} ${state} (${key})`;
|
|
971
1307
|
});
|
|
972
|
-
|
|
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}` : ''}`;
|
|
973
1312
|
},
|
|
974
1313
|
}),
|
|
975
1314
|
|
|
@@ -1023,28 +1362,49 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1023
1362
|
tool({
|
|
1024
1363
|
name: 'presence_exec',
|
|
1025
1364
|
description:
|
|
1026
|
-
'Run a shell command on a remote machine and return its exit code, stdout and stderr.
|
|
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.',
|
|
1027
1366
|
parameters: {
|
|
1028
|
-
host: {
|
|
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
|
+
},
|
|
1029
1372
|
command: { type: 'string', required: true, description: 'Command line, interpreted by the remote shell.' },
|
|
1030
1373
|
cwd: { type: 'string', description: 'Directory to run in, absolute or workspace-relative. Defaults to the workspace root, else the remote login directory.' },
|
|
1031
|
-
timeoutMs: {
|
|
1374
|
+
timeoutMs: {
|
|
1375
|
+
type: 'number',
|
|
1376
|
+
description:
|
|
1377
|
+
'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.',
|
|
1378
|
+
},
|
|
1032
1379
|
background: { type: 'boolean', description: 'Start detached and return immediately with pid and a remote log file. Use for servers and long-running jobs.' },
|
|
1033
1380
|
label: { type: 'string', description: 'For background: short label shown by presence_jobs. Defaults to the command.' },
|
|
1034
1381
|
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.' },
|
|
1382
|
+
tailLines: {
|
|
1383
|
+
type: 'number',
|
|
1384
|
+
description:
|
|
1385
|
+
'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).',
|
|
1386
|
+
},
|
|
1387
|
+
progress: {
|
|
1388
|
+
type: 'string',
|
|
1389
|
+
enum: ['last', 'all', 'strip'],
|
|
1390
|
+
description:
|
|
1391
|
+
'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".',
|
|
1392
|
+
},
|
|
1035
1393
|
confirm: {
|
|
1036
|
-
type: 'boolean',
|
|
1037
|
-
description:
|
|
1394
|
+
oneOf: [{ type: 'boolean' }, { type: 'string', enum: ['partial'] }],
|
|
1395
|
+
description:
|
|
1396
|
+
'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
1397
|
},
|
|
1039
1398
|
maxOutput: {
|
|
1040
1399
|
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
|
|
1400
|
+
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
1401
|
},
|
|
1043
1402
|
raw: { type: 'boolean', description: 'Keep ANSI escape sequences in the output. Defaults to false (stripped).' },
|
|
1044
1403
|
format: {
|
|
1045
1404
|
type: 'string',
|
|
1046
1405
|
enum: ['text', 'json'],
|
|
1047
|
-
description:
|
|
1406
|
+
description:
|
|
1407
|
+
'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
1408
|
},
|
|
1049
1409
|
saveStdout: {
|
|
1050
1410
|
type: 'string',
|
|
@@ -1056,18 +1416,87 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1056
1416
|
},
|
|
1057
1417
|
},
|
|
1058
1418
|
async execute(args, exec) {
|
|
1419
|
+
const commandText = args.command;
|
|
1420
|
+
if (typeof commandText !== 'string' || commandText.trim() === '') {
|
|
1421
|
+
throw missingArgument('presence_exec', 'command', ['command'], args, '{"command": "uname -a"}');
|
|
1422
|
+
}
|
|
1059
1423
|
const { host, workspace } = await pickScope(args, exec);
|
|
1060
1424
|
const connection = await pool.connection(host);
|
|
1061
|
-
const
|
|
1425
|
+
const cwdInput = pickArg(args, EXEC_CWD_ALIASES);
|
|
1426
|
+
const directory = cwdInput !== undefined ? await remotePath(connection, workspace, cwdInput) : workspace?.path;
|
|
1062
1427
|
const pipefail = args.pipefail !== false;
|
|
1428
|
+
const progress = execProgress(args.progress);
|
|
1429
|
+
const tailLines = execTailLines(args.tailLines);
|
|
1430
|
+
const timeoutMs =
|
|
1431
|
+
args.timeoutMs === undefined || args.timeoutMs === null || args.timeoutMs === ''
|
|
1432
|
+
? EXEC_DEFAULT_TIMEOUT_MS
|
|
1433
|
+
: Number(args.timeoutMs) || 0;
|
|
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.
|
|
1438
|
+
const effectiveCwd =
|
|
1439
|
+
directory ??
|
|
1440
|
+
(typeof connection.homePath === 'function' ? await connection.homePath().catch(() => null) : null);
|
|
1441
|
+
const scopeLine = `host: ${host} remote cwd: ${effectiveCwd ?? '(remote login directory)'}`;
|
|
1442
|
+
const backgroundHint = backgroundCommandHint(commandText);
|
|
1443
|
+
const hints = backgroundHint ? [backgroundHint] : [];
|
|
1063
1444
|
|
|
1064
1445
|
// Destructive commands are dry-run first: show the resolved targets, and
|
|
1065
1446
|
// refuse the cases where an empty variable or stray glob would delete a
|
|
1066
|
-
// whole tree. Nothing runs until the caller
|
|
1067
|
-
|
|
1447
|
+
// whole tree. Nothing runs until the caller confirms. `confirm:"partial"`
|
|
1448
|
+
// runs only the segments the guard classified as non-destructive.
|
|
1449
|
+
const confirm = args.confirm;
|
|
1450
|
+
const partialRequested = confirm === 'partial';
|
|
1451
|
+
const confirmed = Boolean(confirm) && !partialRequested;
|
|
1452
|
+
let command = commandText;
|
|
1453
|
+
let partialInfo = null;
|
|
1454
|
+
if (partialRequested || confirmed) {
|
|
1455
|
+
const guard = await guardDestructive({
|
|
1456
|
+
connection,
|
|
1457
|
+
command: commandText,
|
|
1458
|
+
cwd: directory ?? null,
|
|
1459
|
+
workspace,
|
|
1460
|
+
host,
|
|
1461
|
+
confirm: true,
|
|
1462
|
+
});
|
|
1463
|
+
if (partialRequested && guard) {
|
|
1464
|
+
const split = guard.split ?? { safeSegments: [], destructiveSegments: [] };
|
|
1465
|
+
const executedSegments = (Array.isArray(split.safeSegments) ? split.safeSegments : []).filter(
|
|
1466
|
+
(segment) => typeof segment === 'string' && segment.trim() !== '',
|
|
1467
|
+
);
|
|
1468
|
+
const skippedSegments = (Array.isArray(split.destructiveSegments) ? split.destructiveSegments : []).map(
|
|
1469
|
+
(entry) => (typeof entry === 'string' ? entry : entry?.segment ?? String(entry)),
|
|
1470
|
+
);
|
|
1471
|
+
if (executedSegments.length === 0) {
|
|
1472
|
+
const report = [
|
|
1473
|
+
'partial run: the command has no non-destructive segments to run, so nothing was executed.',
|
|
1474
|
+
guard.report,
|
|
1475
|
+
].join('\n');
|
|
1476
|
+
if (args.format === 'json') {
|
|
1477
|
+
return jsonText({
|
|
1478
|
+
dryRun: false,
|
|
1479
|
+
partial: true,
|
|
1480
|
+
executedSegments: [],
|
|
1481
|
+
skippedSegments,
|
|
1482
|
+
requiresConfirm: true,
|
|
1483
|
+
targets: guard.targets ?? [],
|
|
1484
|
+
command: commandText,
|
|
1485
|
+
report,
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
return report;
|
|
1489
|
+
}
|
|
1490
|
+
partialInfo = { executedSegments, skippedSegments };
|
|
1491
|
+
command = executedSegments.join('; ');
|
|
1492
|
+
} else if (partialRequested) {
|
|
1493
|
+
// A benign command has nothing to skip: "partial" runs it whole.
|
|
1494
|
+
partialInfo = { executedSegments: [commandText], skippedSegments: [] };
|
|
1495
|
+
}
|
|
1496
|
+
} else if (!confirm) {
|
|
1068
1497
|
const guard = await guardDestructive({
|
|
1069
1498
|
connection,
|
|
1070
|
-
command:
|
|
1499
|
+
command: commandText,
|
|
1071
1500
|
cwd: directory ?? null,
|
|
1072
1501
|
workspace,
|
|
1073
1502
|
host,
|
|
@@ -1075,27 +1504,32 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1075
1504
|
});
|
|
1076
1505
|
if (guard) {
|
|
1077
1506
|
// JSON callers get the guard report as data instead of having to
|
|
1078
|
-
// parse the text report; `targets` mirrors the report's target block
|
|
1507
|
+
// parse the text report; `targets` mirrors the report's target block
|
|
1508
|
+
// and `safeSegments`/`destructiveSegments` back confirm:"partial".
|
|
1079
1509
|
if (args.format === 'json') {
|
|
1080
|
-
|
|
1510
|
+
const split = guard.split ?? null;
|
|
1511
|
+
const payload = {
|
|
1081
1512
|
dryRun: true,
|
|
1082
1513
|
requiresConfirm: true,
|
|
1083
1514
|
targets: guard.targets ?? [],
|
|
1084
|
-
command:
|
|
1515
|
+
command: commandText,
|
|
1085
1516
|
report: guard.report,
|
|
1086
|
-
}
|
|
1517
|
+
};
|
|
1518
|
+
if (split) {
|
|
1519
|
+
payload.safeSegments = Array.isArray(split.safeSegments) ? split.safeSegments : [];
|
|
1520
|
+
payload.destructiveSegments = Array.isArray(split.destructiveSegments)
|
|
1521
|
+
? split.destructiveSegments.map((entry) => ({
|
|
1522
|
+
segment: entry?.segment ?? String(entry),
|
|
1523
|
+
kind: entry?.kind ?? null,
|
|
1524
|
+
targets: entry?.targets ?? [],
|
|
1525
|
+
}))
|
|
1526
|
+
: [];
|
|
1527
|
+
payload.partialAvailable = payload.safeSegments.length > 0;
|
|
1528
|
+
}
|
|
1529
|
+
return jsonText(payload);
|
|
1087
1530
|
}
|
|
1088
1531
|
return guard.report;
|
|
1089
1532
|
}
|
|
1090
|
-
} else {
|
|
1091
|
-
await guardDestructive({
|
|
1092
|
-
connection,
|
|
1093
|
-
command: args.command,
|
|
1094
|
-
cwd: directory ?? null,
|
|
1095
|
-
workspace,
|
|
1096
|
-
host,
|
|
1097
|
-
confirm: true,
|
|
1098
|
-
});
|
|
1099
1533
|
}
|
|
1100
1534
|
|
|
1101
1535
|
if (args.background) {
|
|
@@ -1105,7 +1539,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1105
1539
|
throw new Error(`waitForPort must be a TCP port number, got ${args.waitForPort}`);
|
|
1106
1540
|
}
|
|
1107
1541
|
}
|
|
1108
|
-
const body = `${pipefail ? pipefailPrelude() : ''}${
|
|
1542
|
+
const body = `${pipefail ? pipefailPrelude() : ''}${command}`;
|
|
1109
1543
|
let job;
|
|
1110
1544
|
try {
|
|
1111
1545
|
job = await pool.startJob(host, {
|
|
@@ -1124,6 +1558,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1124
1558
|
const size =
|
|
1125
1559
|
job.logSize === undefined || job.logSize === null ? null : ` (${formatBytes(job.logSize)})`;
|
|
1126
1560
|
return [
|
|
1561
|
+
scopeLine,
|
|
1127
1562
|
`background job ${job.id ?? '(unknown)'} exited immediately on ${host} (it did not stay running)`,
|
|
1128
1563
|
`exit: ${job.exitCode ?? 'unknown'}`,
|
|
1129
1564
|
job.logPath ? `log: ${job.logPath}${size ?? ''}` : null,
|
|
@@ -1134,6 +1569,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1134
1569
|
}
|
|
1135
1570
|
|
|
1136
1571
|
const parts = [
|
|
1572
|
+
scopeLine,
|
|
1137
1573
|
`started in background on ${host}`,
|
|
1138
1574
|
`job: ${job.id}`,
|
|
1139
1575
|
`pid: ${job.pid}`,
|
|
@@ -1144,7 +1580,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1144
1580
|
|
|
1145
1581
|
if (args.timeoutMs !== undefined && args.timeoutMs !== null) {
|
|
1146
1582
|
parts.push(
|
|
1147
|
-
'note: timeoutMs does not apply to background jobs
|
|
1583
|
+
'note: timeoutMs does not apply to background jobs — they are meant to outlive the call; ' +
|
|
1148
1584
|
`use presence_jobs {action:'wait', jobId:'${job.id}', timeoutMs} to wait for it`,
|
|
1149
1585
|
);
|
|
1150
1586
|
}
|
|
@@ -1154,7 +1590,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1154
1590
|
parts.push(
|
|
1155
1591
|
outcome.ready
|
|
1156
1592
|
? `port ${args.waitForPort} is listening on ${host} (after ${outcome.waitedMs}ms)`
|
|
1157
|
-
: `port ${args.waitForPort} is NOT listening on ${host} after ${outcome.waitedMs}ms
|
|
1593
|
+
: `port ${args.waitForPort} is NOT listening on ${host} after ${outcome.waitedMs}ms — check the log: tail -n 50 ${job.logPath}`,
|
|
1158
1594
|
);
|
|
1159
1595
|
}
|
|
1160
1596
|
if (args.saveStdout) {
|
|
@@ -1164,12 +1600,12 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1164
1600
|
}
|
|
1165
1601
|
|
|
1166
1602
|
const saveTarget = args.saveStdout ? await remotePath(connection, workspace, args.saveStdout) : null;
|
|
1167
|
-
let
|
|
1168
|
-
if (directory)
|
|
1169
|
-
if (pipefail)
|
|
1603
|
+
let shellCommand = command;
|
|
1604
|
+
if (directory) shellCommand = `cd ${shq(directory)} && ${shellCommand}`;
|
|
1605
|
+
if (pipefail) shellCommand = `${pipefailPrelude()}${shellCommand}`;
|
|
1170
1606
|
let result;
|
|
1171
1607
|
try {
|
|
1172
|
-
result = await connection.exec(
|
|
1608
|
+
result = await connection.exec(shellCommand, { timeoutMs, progress });
|
|
1173
1609
|
} catch (error) {
|
|
1174
1610
|
throw new Error(withSandboxNote(error?.message ?? String(error)));
|
|
1175
1611
|
}
|
|
@@ -1197,8 +1633,8 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1197
1633
|
const stdoutRaw = plain(result.stdout);
|
|
1198
1634
|
const stderrRaw = plain(result.stderr);
|
|
1199
1635
|
const payload = {
|
|
1200
|
-
cwd:
|
|
1201
|
-
command:
|
|
1636
|
+
cwd: effectiveCwd ?? null,
|
|
1637
|
+
command: commandText,
|
|
1202
1638
|
exitCode: result.code ?? null,
|
|
1203
1639
|
signal: result.signal ?? null,
|
|
1204
1640
|
durationMs: result.durationMs ?? null,
|
|
@@ -1207,36 +1643,75 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1207
1643
|
stderr: clip(stderrRaw, EXEC_OUTPUT_CAP),
|
|
1208
1644
|
stdoutTruncated: result.stdoutTruncated === true || stdoutRaw.length > EXEC_OUTPUT_CAP,
|
|
1209
1645
|
stderrTruncated: result.stderrTruncated === true || stderrRaw.length > EXEC_OUTPUT_CAP,
|
|
1646
|
+
hints,
|
|
1647
|
+
tailLines,
|
|
1210
1648
|
};
|
|
1649
|
+
if (partialInfo) {
|
|
1650
|
+
payload.dryRun = false;
|
|
1651
|
+
payload.partial = true;
|
|
1652
|
+
payload.executedSegments = partialInfo.executedSegments;
|
|
1653
|
+
payload.skippedSegments = partialInfo.skippedSegments;
|
|
1654
|
+
}
|
|
1655
|
+
// Timeout/SIGPIPE facts from the connection contract; absent fields
|
|
1656
|
+
// stay absent so older connections keep the same JSON shape.
|
|
1657
|
+
if (result.killed !== undefined) payload.killed = result.killed === true;
|
|
1658
|
+
if (Array.isArray(result.killSequence)) payload.killSequence = result.killSequence;
|
|
1659
|
+
if (result.sigpipe === true) payload.sigpipe = true;
|
|
1660
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
1661
|
+
const file = stream === 'stdout' ? result.stdoutFile : result.stderrFile;
|
|
1662
|
+
const fileBytes = stream === 'stdout' ? result.stdoutFileBytes : result.stderrFileBytes;
|
|
1663
|
+
const fileError = stream === 'stdout' ? result.stdoutFileError : result.stderrFileError;
|
|
1664
|
+
const truncated = stream === 'stdout' ? result.stdoutTruncated === true : result.stderrTruncated === true;
|
|
1665
|
+
if (file) {
|
|
1666
|
+
payload[`${stream}File`] = file;
|
|
1667
|
+
payload[`${stream}FileBytes`] = Number.isFinite(Number(fileBytes)) ? Number(fileBytes) : null;
|
|
1668
|
+
} else if (truncated) {
|
|
1669
|
+
// The capture exceeded the cap but the spill write failed: keep an
|
|
1670
|
+
// explicit null plus the reason instead of dropping the fact.
|
|
1671
|
+
payload[`${stream}File`] = null;
|
|
1672
|
+
if (fileError) payload[`${stream}FileError`] = fileError;
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1211
1675
|
if (savedStdout) payload.savedStdout = savedStdout;
|
|
1212
1676
|
if (saveError) payload.savedStdoutError = saveError;
|
|
1213
1677
|
if (saveTarget && !savedStdout && !saveError) {
|
|
1214
1678
|
payload.savedStdoutSkipped = { path: saveTarget, reason: `exit ${result.code ?? '?'} is not 0` };
|
|
1215
1679
|
}
|
|
1216
|
-
return
|
|
1680
|
+
return jsonText(payload, EXEC_JSON_CAP);
|
|
1217
1681
|
}
|
|
1218
1682
|
|
|
1219
|
-
const parts = [`exit: ${result.code} (${result.durationMs}ms)`];
|
|
1220
|
-
if (result.timedOut) parts.push('timed out 鈥?the remote process tree was terminated');
|
|
1221
1683
|
const inlineCap = Math.max(1000, Math.min(Number(args.maxOutput) || EXEC_OUTPUT_CAP, 256 * 1024));
|
|
1222
|
-
const
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
parts.push(`--- stdout ---\n${clipMiddle(plain(result.stdout), inlineCap)}`);
|
|
1232
|
-
const hint = note('stdout');
|
|
1233
|
-
if (hint) parts.push(hint);
|
|
1684
|
+
const parts = [scopeLine];
|
|
1685
|
+
if (hints.length > 0) parts.push(`hint: ${hints[0]}`);
|
|
1686
|
+
if (partialInfo) {
|
|
1687
|
+
parts.push(
|
|
1688
|
+
`partial: ran ${partialInfo.executedSegments.length} safe segment(s); ` +
|
|
1689
|
+
`skipped ${partialInfo.skippedSegments.length} destructive segment(s)`,
|
|
1690
|
+
);
|
|
1691
|
+
for (const segment of partialInfo.skippedSegments) parts.push(`skipped: ${segment}`);
|
|
1692
|
+
parts.push(`ran: ${partialInfo.executedSegments.join('; ')}`);
|
|
1234
1693
|
}
|
|
1235
|
-
if (result.
|
|
1236
|
-
parts.push(
|
|
1237
|
-
|
|
1238
|
-
|
|
1694
|
+
if (result.sigpipe === true) {
|
|
1695
|
+
parts.push(`exit ${result.code ?? 141} (SIGPIPE — downstream closed early; usually harmless)`);
|
|
1696
|
+
} else {
|
|
1697
|
+
parts.push(`exit: ${result.code} (${result.durationMs}ms)`);
|
|
1239
1698
|
}
|
|
1699
|
+
if (result.timedOut === true) {
|
|
1700
|
+
parts.push(
|
|
1701
|
+
`timed out after ${timeoutMs}ms — remote process tree terminated (SIGTERM, then SIGKILL after 5s); partial output below`,
|
|
1702
|
+
);
|
|
1703
|
+
const spillFiles = [result.stdoutFile, result.stderrFile].filter(Boolean);
|
|
1704
|
+
if (spillFiles.length > 0) parts.push(`partial output saved to: ${spillFiles.join(', ')}`);
|
|
1705
|
+
if (longLivedCommand(commandText)) {
|
|
1706
|
+
parts.push(
|
|
1707
|
+
'background children started by this command may still be running — use background:true / presence_terminal to track them',
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
const stdoutSection = execStreamSection('stdout', result.stdout, result, { inlineCap, tailLines, plain });
|
|
1712
|
+
if (stdoutSection) parts.push(stdoutSection);
|
|
1713
|
+
const stderrSection = execStreamSection('stderr', result.stderr, result, { inlineCap, tailLines, plain });
|
|
1714
|
+
if (stderrSection) parts.push(stderrSection);
|
|
1240
1715
|
if (result.code !== 0 && isSandboxMessage(result.stderr)) {
|
|
1241
1716
|
parts.push(
|
|
1242
1717
|
'note: ctx.fs does not write remote /tmp, so an escalation here cannot help; ' +
|
|
@@ -1245,9 +1720,9 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1245
1720
|
}
|
|
1246
1721
|
// Nudge the model away from shell one-liners for edits: they skip the
|
|
1247
1722
|
// atomic write, the ambiguity check and the version guard.
|
|
1248
|
-
if (EDIT_SHELL.test(
|
|
1723
|
+
if (EDIT_SHELL.test(commandText)) {
|
|
1249
1724
|
parts.push(
|
|
1250
|
-
'note: prefer presence_edit (or the built-in `edit` tool) for remote file changes
|
|
1725
|
+
'note: prefer presence_edit (or the built-in `edit` tool) for remote file changes — ' +
|
|
1251
1726
|
'`sed -i`/`perl -pi`/`python -c` rewrites bypass the atomic write and the ambiguity check.',
|
|
1252
1727
|
);
|
|
1253
1728
|
}
|
|
@@ -1262,12 +1737,227 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1262
1737
|
},
|
|
1263
1738
|
}),
|
|
1264
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
|
+
|
|
1265
1951
|
tool({
|
|
1266
1952
|
name: 'presence_read',
|
|
1267
1953
|
description:
|
|
1268
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.',
|
|
1269
1955
|
parameters: {
|
|
1270
|
-
host: {
|
|
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
|
+
},
|
|
1271
1961
|
path: { type: 'string', required: true, description: 'Absolute path, ~/relative, or workspace-relative.' },
|
|
1272
1962
|
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
|
|
1273
1963
|
limit: { type: 'number', description: 'Maximum lines to return. Defaults to 2000.' },
|
|
@@ -1281,16 +1971,17 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1281
1971
|
},
|
|
1282
1972
|
},
|
|
1283
1973
|
async execute(args, exec) {
|
|
1974
|
+
const pathInput = requirePath(args, 'presence_read', '{"path": "/srv/app/a.txt"}');
|
|
1284
1975
|
const { host, workspace } = await pickScope(args, exec);
|
|
1285
1976
|
const connection = await pool.connection(host);
|
|
1286
|
-
const target = await remotePath(connection, workspace,
|
|
1977
|
+
const target = await remotePath(connection, workspace, pathInput);
|
|
1287
1978
|
const view = await readFileWindow(connection, target, { offset: args.offset, limit: args.limit });
|
|
1288
1979
|
const raw = args.raw === true;
|
|
1289
1980
|
const numbered = !raw && args.numbered !== false;
|
|
1290
1981
|
const body = numbered
|
|
1291
1982
|
? view.lines.map((line, index) => `${view.offset + index}: ${line}`).join('\n')
|
|
1292
1983
|
: view.rawWindow;
|
|
1293
|
-
await markObserved(exec,
|
|
1984
|
+
await markObserved(exec, pathInput);
|
|
1294
1985
|
if (raw) return body;
|
|
1295
1986
|
const footer = numbered ? `\n\n${readFooter(view)}` : '';
|
|
1296
1987
|
return `<path>${target}</path>\n${body}${footer}`;
|
|
@@ -1308,15 +1999,34 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1308
1999
|
required: true,
|
|
1309
2000
|
description: 'Remote paths (absolute, ~/..., presence://host/path, or workspace-relative). 1-50 entries.',
|
|
1310
2001
|
},
|
|
1311
|
-
host: {
|
|
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
|
+
},
|
|
1312
2007
|
raw: { type: 'boolean', description: 'Return verbatim content without line numbers. Defaults to false (numbered lines).' },
|
|
1313
2008
|
limit: { type: 'number', description: 'Maximum lines per file. Defaults to 2000.' },
|
|
1314
2009
|
},
|
|
1315
2010
|
async execute(args, exec) {
|
|
2011
|
+
const listed = Array.isArray(args.paths) ? args.paths : [];
|
|
2012
|
+
const singlePath = listed.length === 0 ? pickArg(args, PATH_ALIASES) : undefined;
|
|
2013
|
+
const inputs =
|
|
2014
|
+
listed.length > 0
|
|
2015
|
+
? listed
|
|
2016
|
+
: singlePath !== undefined && singlePath !== ''
|
|
2017
|
+
? [singlePath]
|
|
2018
|
+
: [];
|
|
2019
|
+
if (inputs.length === 0) {
|
|
2020
|
+
throw missingArgument(
|
|
2021
|
+
'presence_read_many',
|
|
2022
|
+
'paths',
|
|
2023
|
+
['paths', 'path', 'file_path'],
|
|
2024
|
+
args,
|
|
2025
|
+
'{"paths": ["/srv/app/a.txt", "/srv/app/b.txt"]}',
|
|
2026
|
+
);
|
|
2027
|
+
}
|
|
1316
2028
|
const { host, workspace } = await pickScope(args, exec);
|
|
1317
2029
|
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
2030
|
if (inputs.length > READ_MANY_MAX_FILES) {
|
|
1321
2031
|
throw new Error(`presence_read_many reads at most ${READ_MANY_MAX_FILES} files per call, got ${inputs.length}`);
|
|
1322
2032
|
}
|
|
@@ -1347,31 +2057,104 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1347
2057
|
tool({
|
|
1348
2058
|
name: 'presence_write',
|
|
1349
2059
|
description:
|
|
1350
|
-
'Create or
|
|
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.',
|
|
1351
2061
|
parameters: {
|
|
1352
|
-
host: {
|
|
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
|
+
},
|
|
1353
2067
|
path: { type: 'string', required: true, description: 'Absolute path, ~/relative, or workspace-relative.' },
|
|
1354
|
-
content: { type: 'string', required: true, description: '
|
|
2068
|
+
content: { type: 'string', required: true, description: 'Content to write (overwrite) or append.' },
|
|
1355
2069
|
mode: {
|
|
1356
|
-
type: 'string',
|
|
1357
|
-
description:
|
|
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).',
|
|
1358
2073
|
},
|
|
1359
2074
|
ifMatchSha256: {
|
|
1360
2075
|
type: 'string',
|
|
1361
|
-
description:
|
|
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.',
|
|
1362
2078
|
},
|
|
1363
2079
|
format: {
|
|
1364
2080
|
type: 'string',
|
|
1365
2081
|
enum: ['text', 'json'],
|
|
1366
|
-
description:
|
|
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.',
|
|
1367
2084
|
},
|
|
1368
2085
|
},
|
|
1369
2086
|
async execute(args, exec) {
|
|
1370
2087
|
ensureWritable();
|
|
2088
|
+
const example = '{"path": "/srv/app/a.txt", "content": "hi"}';
|
|
2089
|
+
const pathInput = requirePath(args, 'presence_write', example);
|
|
2090
|
+
if (args.content === undefined || args.content === null) {
|
|
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
|
+
);
|
|
2101
|
+
}
|
|
1371
2102
|
const { host, workspace } = await pickScope(args, exec);
|
|
1372
2103
|
const connection = await pool.connection(host);
|
|
1373
|
-
const target = await remotePath(connection, workspace,
|
|
1374
|
-
const
|
|
2104
|
+
const target = await remotePath(connection, workspace, pathInput);
|
|
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
|
+
|
|
1375
2158
|
if (expected) {
|
|
1376
2159
|
const current = await remoteFileSha256(connection, target);
|
|
1377
2160
|
if (!current) {
|
|
@@ -1387,21 +2170,15 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1387
2170
|
);
|
|
1388
2171
|
}
|
|
1389
2172
|
}
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
const made = await connection.exec(`mkdir -p ${shq(parent)}`, { timeoutMs: 30_000 });
|
|
1393
|
-
if (made.code !== 0) throw new Error(`cannot create parent directory ${parent}: ${made.stderr.trim()}`);
|
|
1394
|
-
}
|
|
1395
|
-
const payload = Buffer.from(args.content ?? '', 'utf8');
|
|
1396
|
-
const digest = createHash('sha256').update(payload).digest('hex');
|
|
1397
|
-
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) });
|
|
1398
2175
|
const info = await connection.stat(target);
|
|
1399
2176
|
const sizeBytes = info.exists ? info.size : result.bytes;
|
|
1400
2177
|
const mode = info.exists ? info.mode : result.mode;
|
|
1401
2178
|
const octal = modeLabel(mode);
|
|
1402
|
-
await markObserved(exec,
|
|
2179
|
+
await markObserved(exec, pathInput);
|
|
1403
2180
|
if (args.format === 'json') {
|
|
1404
|
-
return jsonText({ path: target, sizeBytes, mode: octal, sha256: digest });
|
|
2181
|
+
return jsonText({ path: target, writeMode: 'overwrite', sizeBytes, mode: octal, sha256: digest });
|
|
1405
2182
|
}
|
|
1406
2183
|
return `wrote ${sizeBytes} bytes to ${target} on ${host} mode ${octal ?? 'unknown'} sha256 ${digest}`;
|
|
1407
2184
|
},
|
|
@@ -1410,125 +2187,371 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1410
2187
|
tool({
|
|
1411
2188
|
name: 'presence_export',
|
|
1412
2189
|
description:
|
|
1413
|
-
'Copy
|
|
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.',
|
|
1414
2191
|
parameters: {
|
|
1415
|
-
host: {
|
|
1416
|
-
|
|
1417
|
-
|
|
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
|
+
},
|
|
1418
2219
|
},
|
|
1419
2220
|
async execute(args, exec) {
|
|
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
|
+
}
|
|
1420
2239
|
const { host, workspace } = await pickScope(args, exec);
|
|
1421
2240
|
const connection = await pool.connection(host);
|
|
1422
|
-
const target = await remotePath(connection, workspace, args.path);
|
|
1423
|
-
const info = await connection.stat(target);
|
|
1424
|
-
if (!info.exists) throw new Error(`${target} does not exist on ${host}`);
|
|
1425
|
-
if (info.isDir) throw new Error(`${target} is a directory; export files one by one, or use presence_exec with tar/scp`);
|
|
1426
|
-
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`);
|
|
1428
|
-
}
|
|
1429
|
-
const { buffer } = await connection.readBytes(target, { offset: 0, length: info.size });
|
|
1430
|
-
const name = target.split('/').filter(Boolean).pop() ?? 'export.bin';
|
|
1431
2241
|
const folder = exportDirectory();
|
|
1432
2242
|
mkdirSync(folder, { recursive: true });
|
|
1433
|
-
const checksum = createHash('sha256').update(buffer).digest('hex');
|
|
1434
|
-
const destination = args.to ?? joinPath(folder, `${checksum.slice(0, 12)}-${name}`);
|
|
1435
|
-
const absolute = resolvePath(destination);
|
|
1436
|
-
|
|
1437
2243
|
// Writes are confined to the export folder (or the local workspace, for
|
|
1438
2244
|
// local sessions): an agent must not be able to drop files anywhere.
|
|
1439
2245
|
const allowedRoots = [resolvePath(folder)];
|
|
1440
2246
|
if (workspace && !parseMountPath(workspace.path)) allowedRoots.push(resolvePath(workspace.path));
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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}`);
|
|
1444
2281
|
}
|
|
1445
2282
|
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
+
}
|
|
1448
2318
|
pruneExports(folder);
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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'));
|
|
1456
2340
|
},
|
|
1457
2341
|
}),
|
|
1458
2342
|
|
|
1459
2343
|
tool({
|
|
1460
2344
|
name: 'presence_import',
|
|
1461
2345
|
description:
|
|
1462
|
-
'Upload
|
|
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.',
|
|
1463
2347
|
parameters: {
|
|
1464
2348
|
source: {
|
|
1465
2349
|
type: 'string',
|
|
1466
|
-
|
|
1467
|
-
|
|
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.',
|
|
1468
2362
|
},
|
|
1469
2363
|
to: {
|
|
1470
2364
|
type: 'string',
|
|
1471
|
-
|
|
1472
|
-
|
|
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.',
|
|
1473
2380
|
},
|
|
1474
|
-
host: { type: 'string', description: 'ssh alias. Defaults to this session\'s workspace machine.' },
|
|
1475
|
-
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.' },
|
|
1476
|
-
overwrite: { type: 'boolean', description: 'Allow replacing an existing remote file. Defaults to true; pass false to fail instead.' },
|
|
1477
2381
|
format: {
|
|
1478
2382
|
type: 'string',
|
|
1479
2383
|
enum: ['text', 'json'],
|
|
1480
|
-
description:
|
|
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.',
|
|
1481
2386
|
},
|
|
1482
2387
|
},
|
|
1483
2388
|
async execute(args, exec) {
|
|
1484
2389
|
ensureWritable();
|
|
1485
|
-
const
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
try {
|
|
1490
|
-
localStat = statSync(localSource);
|
|
1491
|
-
} catch {
|
|
1492
|
-
throw new Error(`local file not found: ${localSource}`);
|
|
2390
|
+
const example = '{"source": "C:\\\\Temp\\\\report.pdf", "to": "/srv/app/report.pdf"}';
|
|
2391
|
+
const toInput = pickArg(args, DESTINATION_ALIASES);
|
|
2392
|
+
if (toInput === undefined || toInput === '') {
|
|
2393
|
+
throw missingArgument('presence_import', 'to', ['to', 'to_path', 'file_path'], args, example);
|
|
1493
2394
|
}
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
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');
|
|
1497
2400
|
}
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
2401
|
+
if (!rawSource && listed.length === 0 && !globInput) {
|
|
2402
|
+
throw missingArgument(
|
|
2403
|
+
'presence_import',
|
|
2404
|
+
'source',
|
|
2405
|
+
['source', 'paths', 'glob'],
|
|
2406
|
+
args,
|
|
2407
|
+
'{"source": "C:\\\\Temp\\\\report.pdf", "to": "/srv/app/report.pdf"}',
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
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());
|
|
2429
|
+
}
|
|
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);
|
|
2436
|
+
}
|
|
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;
|
|
1501
2441
|
|
|
1502
2442
|
const { host, workspace } = await pickScope(args, exec);
|
|
1503
2443
|
const connection = await pool.connection(host);
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1506
|
-
if (
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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
|
+
}
|
|
1519
2534
|
if (args.format === 'json') {
|
|
1520
|
-
return jsonText({
|
|
1521
|
-
remotePath: target,
|
|
1522
|
-
localPath: localSource,
|
|
1523
|
-
sizeBytes: result.bytes ?? buffer.length,
|
|
1524
|
-
sha256: digest,
|
|
1525
|
-
mode: modeText,
|
|
1526
|
-
});
|
|
2535
|
+
return jsonText({ files, imported: files.length, failed: errors.length, errors });
|
|
1527
2536
|
}
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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
|
+
);
|
|
2544
|
+
}
|
|
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'),
|
|
1532
2555
|
);
|
|
1533
2556
|
},
|
|
1534
2557
|
}),
|
|
@@ -1539,7 +2562,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1539
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).',
|
|
1540
2563
|
parameters: {
|
|
1541
2564
|
action: { type: 'string', required: true, enum: ['trash', 'list', 'restore'], description: 'What to do.' },
|
|
1542
|
-
host: {
|
|
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
|
+
},
|
|
1543
2570
|
path: { type: 'string', description: 'For trash: the remote path to move into the trash. For list: substring filter on trashPath/name.' },
|
|
1544
2571
|
paths: {
|
|
1545
2572
|
type: 'array',
|
|
@@ -1558,12 +2585,27 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1558
2585
|
async execute(args, exec) {
|
|
1559
2586
|
const { host, workspace } = await pickScope(args, exec);
|
|
1560
2587
|
const connection = await pool.connection(host);
|
|
2588
|
+
const pathInput = pickArg(args, PATH_ALIASES);
|
|
2589
|
+
const toInput = pickArg(args, TO_ALIASES);
|
|
1561
2590
|
if (args.action === 'trash') {
|
|
1562
|
-
if (
|
|
2591
|
+
if (pathInput !== undefined && args.paths !== undefined) {
|
|
1563
2592
|
throw new Error('pass either path or paths, not both');
|
|
1564
2593
|
}
|
|
1565
|
-
const inputs =
|
|
1566
|
-
|
|
2594
|
+
const inputs =
|
|
2595
|
+
Array.isArray(args.paths) && args.paths.length > 0
|
|
2596
|
+
? args.paths
|
|
2597
|
+
: pathInput !== undefined && pathInput !== ''
|
|
2598
|
+
? [pathInput]
|
|
2599
|
+
: [];
|
|
2600
|
+
if (inputs.length === 0) {
|
|
2601
|
+
throw missingArgument(
|
|
2602
|
+
'presence_trash',
|
|
2603
|
+
'path',
|
|
2604
|
+
['path', 'file_path', 'paths'],
|
|
2605
|
+
args,
|
|
2606
|
+
'{"action": "trash", "path": "/srv/app/old.log"}',
|
|
2607
|
+
);
|
|
2608
|
+
}
|
|
1567
2609
|
if (inputs.length > TRASH_MAX_PATHS) {
|
|
1568
2610
|
throw new Error(`trash moves at most ${TRASH_MAX_PATHS} paths per call, got ${inputs.length}`);
|
|
1569
2611
|
}
|
|
@@ -1638,7 +2680,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1638
2680
|
}
|
|
1639
2681
|
if (args.action === 'list') {
|
|
1640
2682
|
if (typeof connection.listTrash !== 'function') throw new Error('this Presence build has no trash support; update the plugin');
|
|
1641
|
-
const needle =
|
|
2683
|
+
const needle = pathInput === undefined || pathInput === null ? '' : String(pathInput).trim();
|
|
1642
2684
|
// A filter should search more than the newest page, then sort/trim.
|
|
1643
2685
|
const entries = await connection.listTrash({ limit: needle ? 1000 : 20 });
|
|
1644
2686
|
const rows = entries
|
|
@@ -1657,23 +2699,43 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1657
2699
|
if (rows.length === 0) return `trash is empty on ${host}${needle ? ` for "${needle}"` : ''}`;
|
|
1658
2700
|
return clip(rows.map((row) => `${formatMtime(row.movedAtMs ?? Number.NaN)} ${row.trashPath}`).join('\n'));
|
|
1659
2701
|
}
|
|
1660
|
-
if (!args.trashPath)
|
|
1661
|
-
|
|
2702
|
+
if (!args.trashPath) {
|
|
2703
|
+
throw missingArgument(
|
|
2704
|
+
'presence_trash',
|
|
2705
|
+
'trashPath',
|
|
2706
|
+
['trashPath'],
|
|
2707
|
+
args,
|
|
2708
|
+
'{"action": "restore", "trashPath": "/home/me/.dsh/presence-trash/20261028-120000/a.txt", "to": "/srv/app/a.txt"}',
|
|
2709
|
+
);
|
|
2710
|
+
}
|
|
2711
|
+
if (toInput === undefined || toInput === '') {
|
|
2712
|
+
throw missingArgument(
|
|
2713
|
+
'presence_trash',
|
|
2714
|
+
'to',
|
|
2715
|
+
['to', 'to_path'],
|
|
2716
|
+
args,
|
|
2717
|
+
'{"action": "restore", "trashPath": "/home/me/.dsh/presence-trash/20261028-120000/a.txt", "to": "/srv/app/a.txt"}',
|
|
2718
|
+
);
|
|
2719
|
+
}
|
|
1662
2720
|
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:
|
|
2721
|
+
const restored = await connection.restoreTrash(args.trashPath, { to: toInput });
|
|
1664
2722
|
if (args.format === 'json') {
|
|
1665
|
-
return JSON.stringify({ restored: true, from: args.trashPath, to: restored.to ??
|
|
2723
|
+
return JSON.stringify({ restored: true, from: args.trashPath, to: restored.to ?? toInput }, null, 2);
|
|
1666
2724
|
}
|
|
1667
|
-
return `restored ${args.trashPath} to ${restored.to ??
|
|
2725
|
+
return `restored ${args.trashPath} to ${restored.to ?? toInput} on ${host}`;
|
|
1668
2726
|
},
|
|
1669
2727
|
}),
|
|
1670
2728
|
|
|
1671
2729
|
tool({
|
|
1672
2730
|
name: 'presence_edit',
|
|
1673
2731
|
description:
|
|
1674
|
-
'Replace exact text inside a file on a remote machine. Use this
|
|
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.',
|
|
1675
2733
|
parameters: {
|
|
1676
|
-
host: {
|
|
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
|
+
},
|
|
1677
2739
|
path: { type: 'string', required: true, description: 'Absolute path, ~/relative, or workspace-relative.' },
|
|
1678
2740
|
old: { type: 'string', description: 'Exact text to replace, including indentation.' },
|
|
1679
2741
|
oldString: { type: 'string', description: 'Alias for `old` (same spelling as the built-in edit tool).' },
|
|
@@ -1692,22 +2754,42 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1692
2754
|
},
|
|
1693
2755
|
},
|
|
1694
2756
|
async execute(args, exec) {
|
|
1695
|
-
const
|
|
1696
|
-
const
|
|
1697
|
-
|
|
1698
|
-
if (typeof
|
|
1699
|
-
|
|
2757
|
+
const example = '{"path": "/srv/app/a.txt", "old": "before", "new": "after"}';
|
|
2758
|
+
const pathInput = requirePath(args, 'presence_edit', example);
|
|
2759
|
+
const oldText = pickArg(args, EDIT_OLD_ALIASES);
|
|
2760
|
+
if (typeof oldText !== 'string' || oldText === '') {
|
|
2761
|
+
throw missingArgument('presence_edit', 'old', EDIT_OLD_ALIASES, args, example);
|
|
2762
|
+
}
|
|
2763
|
+
const newText = pickArg(args, EDIT_NEW_ALIASES);
|
|
2764
|
+
if (typeof newText !== 'string') {
|
|
2765
|
+
throw missingArgument('presence_edit', 'new', EDIT_NEW_ALIASES, args, example);
|
|
2766
|
+
}
|
|
2767
|
+
const dryRun = pickArg(args, EDIT_DRY_RUN_ALIASES) === true;
|
|
2768
|
+
const replaceAll = pickArg(args, EDIT_REPLACE_ALL_ALIASES) === true;
|
|
1700
2769
|
if (!dryRun) ensureWritable();
|
|
1701
2770
|
const { host, workspace } = await pickScope(args, exec);
|
|
1702
2771
|
const connection = await pool.connection(host);
|
|
1703
|
-
const target = await remotePath(connection, workspace,
|
|
2772
|
+
const target = await remotePath(connection, workspace, pathInput);
|
|
1704
2773
|
const info = await connection.stat(target);
|
|
1705
|
-
if (!info.exists) throw new Error(`${target} does not exist on ${host}
|
|
2774
|
+
if (!info.exists) throw new Error(`${target} does not exist on ${host} — nothing to edit (use presence_write to create it)`);
|
|
1706
2775
|
if (info.isDir) throw new Error(`${target} is a directory on ${host}`);
|
|
1707
|
-
const replaceAll = Boolean(args.replaceAll ?? args.replace_all);
|
|
1708
2776
|
const before = (await connection.readText(target, { length: Number.MAX_SAFE_INTEGER })).content;
|
|
1709
2777
|
const occurrences = before.split(oldText).length - 1;
|
|
1710
|
-
if (occurrences === 0)
|
|
2778
|
+
if (occurrences === 0) {
|
|
2779
|
+
// Another session may have changed the file since it was read; point
|
|
2780
|
+
// at the closest lines so the stale anchor is easy to recognize.
|
|
2781
|
+
const closest = closestLines(before, oldText, 3);
|
|
2782
|
+
const message = [`text to replace was not found in ${target} on ${host}.`];
|
|
2783
|
+
if (closest.length > 0) {
|
|
2784
|
+
message.push('closest lines:');
|
|
2785
|
+
for (const entry of closest) message.push(` line ${entry.line}: ${entry.text}`);
|
|
2786
|
+
}
|
|
2787
|
+
message.push(
|
|
2788
|
+
'hint: re-read the file if another session changed it; ' +
|
|
2789
|
+
'ifMatchSha256 is available on presence_write for a hash-checked overwrite.',
|
|
2790
|
+
);
|
|
2791
|
+
throw new Error(message.join('\n'));
|
|
2792
|
+
}
|
|
1711
2793
|
if (occurrences > 1 && !replaceAll) {
|
|
1712
2794
|
throw new Error(`text occurs ${occurrences} times; pass replaceAll or a longer anchor`);
|
|
1713
2795
|
}
|
|
@@ -1726,7 +2808,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1726
2808
|
actualSizeBytes = result.bytes;
|
|
1727
2809
|
}
|
|
1728
2810
|
const diff = diffLines(before, after);
|
|
1729
|
-
await markObserved(exec,
|
|
2811
|
+
await markObserved(exec, pathInput);
|
|
1730
2812
|
if (args.format === 'json') {
|
|
1731
2813
|
return jsonText({
|
|
1732
2814
|
path: target,
|
|
@@ -1751,9 +2833,13 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1751
2833
|
tool({
|
|
1752
2834
|
name: 'presence_search',
|
|
1753
2835
|
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
|
|
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.',
|
|
1755
2837
|
parameters: {
|
|
1756
|
-
host: {
|
|
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
|
+
},
|
|
1757
2843
|
pattern: { type: 'string', required: true, description: 'Regular expression to search for (ripgrep syntax).' },
|
|
1758
2844
|
path: { type: 'string', description: 'File or directory to search, absolute or workspace-relative. Defaults to the workspace root, else the remote home directory.' },
|
|
1759
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).' },
|
|
@@ -1766,9 +2852,13 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1766
2852
|
},
|
|
1767
2853
|
},
|
|
1768
2854
|
async execute(args, exec) {
|
|
2855
|
+
const pathInput = pickArg(args, PATH_ALIASES);
|
|
1769
2856
|
const { host, workspace } = await pickScope(args, exec);
|
|
1770
2857
|
const connection = await pool.connection(host);
|
|
1771
|
-
const target =
|
|
2858
|
+
const target =
|
|
2859
|
+
pathInput !== undefined && pathInput !== ''
|
|
2860
|
+
? await remotePath(connection, workspace, pathInput)
|
|
2861
|
+
: workspace?.path ?? (await connection.homePath());
|
|
1772
2862
|
|
|
1773
2863
|
// Fail loudly instead of pretending "no matches": a missing path or a
|
|
1774
2864
|
// bad regex must not look like an empty result.
|
|
@@ -1785,7 +2875,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1785
2875
|
smartCase: true,
|
|
1786
2876
|
countOnly: Boolean(args.countOnly),
|
|
1787
2877
|
});
|
|
1788
|
-
const warn = warning ? `\n(warning: some paths were not searchable
|
|
2878
|
+
const warn = warning ? `\n(warning: some paths were not searchable — ${warning.slice(0, 300)})` : '';
|
|
1789
2879
|
|
|
1790
2880
|
if (args.format === 'json') {
|
|
1791
2881
|
const matchCount = total ?? matches.length;
|
|
@@ -1818,7 +2908,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1818
2908
|
: `showing ${matches.length} of ${total} matches (truncated; limit ${limit}) on ${host}:`
|
|
1819
2909
|
: `${total ?? matches.length} match(es) on ${host} (not truncated; limit ${limit}):`;
|
|
1820
2910
|
const footer = truncated
|
|
1821
|
-
? '\n(more matches exist
|
|
2911
|
+
? '\n(more matches exist — use countOnly for an exact total, narrow path/include, or raise limit)'
|
|
1822
2912
|
: '';
|
|
1823
2913
|
return `${header}\n${clip(lines.join('\n'))}${footer}${warn}`;
|
|
1824
2914
|
},
|
|
@@ -1830,7 +2920,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1830
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.',
|
|
1831
2921
|
parameters: {
|
|
1832
2922
|
pattern: { type: 'string', required: true, description: 'Glob relative to the search root, e.g. "src/**/*.ts", or absolute, e.g. "/srv/app/*.py".' },
|
|
1833
|
-
host: {
|
|
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
|
+
},
|
|
1834
2928
|
path: { type: 'string', description: 'Directory to search from. Ignored by an absolute pattern; defaults to the workspace root.' },
|
|
1835
2929
|
limit: { type: 'number', description: 'Maximum files to return, applied after the newest-first sort. Defaults to 200.' },
|
|
1836
2930
|
format: {
|
|
@@ -1840,6 +2934,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1840
2934
|
},
|
|
1841
2935
|
},
|
|
1842
2936
|
async execute(args, exec) {
|
|
2937
|
+
const pathInput = pickArg(args, PATH_ALIASES);
|
|
1843
2938
|
const { host, workspace } = await pickScope(args, exec);
|
|
1844
2939
|
const connection = await pool.connection(host);
|
|
1845
2940
|
const limit = Math.max(1, args.limit ?? 200);
|
|
@@ -1848,8 +2943,8 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1848
2943
|
const pattern = absolute ? absolute.pattern : rawPattern;
|
|
1849
2944
|
const base = absolute
|
|
1850
2945
|
? absolute.root
|
|
1851
|
-
:
|
|
1852
|
-
? await remotePath(connection, workspace,
|
|
2946
|
+
: pathInput !== undefined && pathInput !== ''
|
|
2947
|
+
? await remotePath(connection, workspace, pathInput)
|
|
1853
2948
|
: workspace?.path ?? (await connection.homePath());
|
|
1854
2949
|
const info = await connection.stat(base);
|
|
1855
2950
|
if (!info.exists) throw new Error(`${base} does not exist on ${host}`);
|
|
@@ -1887,7 +2982,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1887
2982
|
: `${totalLabel} file(s) on ${host} under ${base} (not truncated; limit ${limit}${newest}):`;
|
|
1888
2983
|
const footer = [];
|
|
1889
2984
|
if (detailed.truncated) {
|
|
1890
|
-
footer.push(`(more files match
|
|
2985
|
+
footer.push(`(more files match — raise limit or narrow the pattern)`);
|
|
1891
2986
|
}
|
|
1892
2987
|
if (detailed.scanCapped) {
|
|
1893
2988
|
footer.push('(the host-side scan stopped at its file cap, so the total is a lower bound)');
|
|
@@ -1910,7 +3005,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1910
3005
|
required: true,
|
|
1911
3006
|
description: 'Remote paths (absolute, ~/..., presence://host/path, or workspace-relative). 1-100 entries.',
|
|
1912
3007
|
},
|
|
1913
|
-
host: {
|
|
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
|
+
},
|
|
1914
3013
|
hash: {
|
|
1915
3014
|
type: 'boolean',
|
|
1916
3015
|
description: 'Also compute sha256 for existing regular files. Defaults to false.',
|
|
@@ -1926,8 +3025,23 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1926
3025
|
},
|
|
1927
3026
|
},
|
|
1928
3027
|
async execute(args, exec) {
|
|
1929
|
-
const
|
|
1930
|
-
|
|
3028
|
+
const listed = Array.isArray(args.paths) ? args.paths : [];
|
|
3029
|
+
const singlePath = listed.length === 0 ? pickArg(args, PATH_ALIASES) : undefined;
|
|
3030
|
+
const inputs =
|
|
3031
|
+
listed.length > 0
|
|
3032
|
+
? listed
|
|
3033
|
+
: singlePath !== undefined && singlePath !== ''
|
|
3034
|
+
? [singlePath]
|
|
3035
|
+
: [];
|
|
3036
|
+
if (inputs.length === 0) {
|
|
3037
|
+
throw missingArgument(
|
|
3038
|
+
'presence_stat',
|
|
3039
|
+
'paths',
|
|
3040
|
+
['paths', 'path', 'file_path'],
|
|
3041
|
+
args,
|
|
3042
|
+
'{"paths": ["/srv/app/a.txt", "/srv/app/b.txt"]}',
|
|
3043
|
+
);
|
|
3044
|
+
}
|
|
1931
3045
|
if (inputs.length > STAT_MAX_PATHS) {
|
|
1932
3046
|
throw new Error(`presence_stat reports at most ${STAT_MAX_PATHS} paths per call, got ${inputs.length}`);
|
|
1933
3047
|
}
|
|
@@ -1963,7 +3077,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1963
3077
|
description:
|
|
1964
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.',
|
|
1965
3079
|
parameters: {
|
|
1966
|
-
host: {
|
|
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
|
+
},
|
|
1967
3085
|
path: { type: 'string', required: true, description: 'Absolute path, ~/relative, or workspace-relative.' },
|
|
1968
3086
|
parents: {
|
|
1969
3087
|
type: 'boolean',
|
|
@@ -1973,9 +3091,10 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1973
3091
|
annotations: { destructiveHint: false, readOnlyHint: false, idempotentHint: true },
|
|
1974
3092
|
async execute(args, exec) {
|
|
1975
3093
|
ensureWritable();
|
|
3094
|
+
const pathInput = requirePath(args, 'presence_mkdir', '{"path": "/srv/app/build"}');
|
|
1976
3095
|
const { host, workspace } = await pickScope(args, exec);
|
|
1977
3096
|
const connection = await pool.connection(host);
|
|
1978
|
-
const target = await remotePath(connection, workspace,
|
|
3097
|
+
const target = await remotePath(connection, workspace, pathInput);
|
|
1979
3098
|
const before = await connection.stat(target).catch(() => null);
|
|
1980
3099
|
const existed = before?.exists === true && before.isDir === true;
|
|
1981
3100
|
await makeDirectory(connection, target, { recursive: args.parents !== false });
|
|
@@ -1988,7 +3107,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1988
3107
|
description:
|
|
1989
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.',
|
|
1990
3109
|
parameters: {
|
|
1991
|
-
host: {
|
|
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
|
+
},
|
|
1992
3115
|
from: { type: 'string', required: true, description: 'Remote path to move (absolute, ~/relative, or workspace-relative).' },
|
|
1993
3116
|
to: { type: 'string', required: true, description: 'Exact new remote path.' },
|
|
1994
3117
|
overwrite: { type: 'boolean', description: 'Replace an existing destination file. Defaults to false.' },
|
|
@@ -1996,12 +3119,22 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
1996
3119
|
annotations: { destructiveHint: true, readOnlyHint: false },
|
|
1997
3120
|
async execute(args, exec) {
|
|
1998
3121
|
ensureWritable();
|
|
3122
|
+
const example = '{"from": "/srv/app/a.txt", "to": "/srv/app/b.txt"}';
|
|
3123
|
+
const fromInput = requirePath(args, 'presence_move', example, {
|
|
3124
|
+
names: SOURCE_ALIASES,
|
|
3125
|
+
canonical: 'from',
|
|
3126
|
+
accepted: ['from', 'from_path', 'file_path'],
|
|
3127
|
+
});
|
|
3128
|
+
const toInput = pickArg(args, TO_ALIASES);
|
|
3129
|
+
if (toInput === undefined || toInput === '') {
|
|
3130
|
+
throw missingArgument('presence_move', 'to', ['to', 'to_path'], args, example);
|
|
3131
|
+
}
|
|
1999
3132
|
const { host, workspace } = await pickScope(args, exec);
|
|
2000
3133
|
const connection = await pool.connection(host);
|
|
2001
|
-
const from = await remotePath(connection, workspace,
|
|
2002
|
-
const to = await remotePath(connection, workspace,
|
|
3134
|
+
const from = await remotePath(connection, workspace, fromInput);
|
|
3135
|
+
const to = await remotePath(connection, workspace, toInput);
|
|
2003
3136
|
const result = await movePath(connection, from, to, { overwrite: args.overwrite === true });
|
|
2004
|
-
await markObserved(exec,
|
|
3137
|
+
await markObserved(exec, toInput);
|
|
2005
3138
|
return `moved ${result.from} -> ${result.to} on ${host}`;
|
|
2006
3139
|
},
|
|
2007
3140
|
}),
|
|
@@ -2011,7 +3144,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2011
3144
|
description:
|
|
2012
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.',
|
|
2013
3146
|
parameters: {
|
|
2014
|
-
host: {
|
|
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
|
+
},
|
|
2015
3152
|
from: { type: 'string', required: true, description: 'Remote source path.' },
|
|
2016
3153
|
to: { type: 'string', required: true, description: 'Exact remote destination path.' },
|
|
2017
3154
|
recursive: { type: 'boolean', description: 'Copy a directory tree. Required when from is a directory. Defaults to false.' },
|
|
@@ -2020,15 +3157,25 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2020
3157
|
annotations: { destructiveHint: true, readOnlyHint: false },
|
|
2021
3158
|
async execute(args, exec) {
|
|
2022
3159
|
ensureWritable();
|
|
3160
|
+
const example = '{"from": "/srv/app/a.txt", "to": "/srv/app/b.txt"}';
|
|
3161
|
+
const fromInput = requirePath(args, 'presence_copy', example, {
|
|
3162
|
+
names: SOURCE_ALIASES,
|
|
3163
|
+
canonical: 'from',
|
|
3164
|
+
accepted: ['from', 'from_path', 'file_path'],
|
|
3165
|
+
});
|
|
3166
|
+
const toInput = pickArg(args, TO_ALIASES);
|
|
3167
|
+
if (toInput === undefined || toInput === '') {
|
|
3168
|
+
throw missingArgument('presence_copy', 'to', ['to', 'to_path'], args, example);
|
|
3169
|
+
}
|
|
2023
3170
|
const { host, workspace } = await pickScope(args, exec);
|
|
2024
3171
|
const connection = await pool.connection(host);
|
|
2025
|
-
const from = await remotePath(connection, workspace,
|
|
2026
|
-
const to = await remotePath(connection, workspace,
|
|
3172
|
+
const from = await remotePath(connection, workspace, fromInput);
|
|
3173
|
+
const to = await remotePath(connection, workspace, toInput);
|
|
2027
3174
|
const result = await copyPath(connection, from, to, {
|
|
2028
3175
|
recursive: args.recursive === true,
|
|
2029
3176
|
overwrite: args.overwrite === true,
|
|
2030
3177
|
});
|
|
2031
|
-
await markObserved(exec,
|
|
3178
|
+
await markObserved(exec, toInput);
|
|
2032
3179
|
const facts = [];
|
|
2033
3180
|
if (result.files !== undefined) facts.push(`${result.files} file(s)`);
|
|
2034
3181
|
if (result.bytes !== undefined) facts.push(formatBytes(result.bytes));
|
|
@@ -2041,7 +3188,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2041
3188
|
description:
|
|
2042
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.',
|
|
2043
3190
|
parameters: {
|
|
2044
|
-
host: {
|
|
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
|
+
},
|
|
2045
3196
|
path: { type: 'string', required: true, description: 'Absolute path, ~/relative, or workspace-relative.' },
|
|
2046
3197
|
mode: {
|
|
2047
3198
|
oneOf: [{ type: 'string' }, { type: 'number' }],
|
|
@@ -2053,12 +3204,13 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2053
3204
|
annotations: { destructiveHint: true, readOnlyHint: false },
|
|
2054
3205
|
async execute(args, exec) {
|
|
2055
3206
|
ensureWritable();
|
|
3207
|
+
const pathInput = requirePath(args, 'presence_chmod', '{"path": "/srv/app/run.sh", "mode": "755"}');
|
|
2056
3208
|
const { host, workspace } = await pickScope(args, exec);
|
|
2057
3209
|
const connection = await pool.connection(host);
|
|
2058
|
-
const target = await remotePath(connection, workspace,
|
|
3210
|
+
const target = await remotePath(connection, workspace, pathInput);
|
|
2059
3211
|
const mode = parseChmodMode(args.mode);
|
|
2060
3212
|
const result = await chmodPath(connection, target, mode, { recursive: args.recursive === true });
|
|
2061
|
-
await markObserved(exec,
|
|
3213
|
+
await markObserved(exec, pathInput);
|
|
2062
3214
|
return `chmod ${args.recursive === true ? '-R ' : ''}${result.mode} ${result.path} on ${host}`;
|
|
2063
3215
|
},
|
|
2064
3216
|
}),
|
|
@@ -2068,7 +3220,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2068
3220
|
description:
|
|
2069
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.',
|
|
2070
3222
|
parameters: {
|
|
2071
|
-
host: {
|
|
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
|
+
},
|
|
2072
3228
|
a: { type: 'string', required: true, description: 'Left side: remote path (absolute, ~/relative, or workspace-relative).' },
|
|
2073
3229
|
b: { type: 'string', description: 'Right side: another remote path. Mutually exclusive with localPath.' },
|
|
2074
3230
|
localPath: { type: 'string', description: 'Right side: a local file on the host process. Mutually exclusive with b.' },
|
|
@@ -2081,27 +3237,34 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2081
3237
|
},
|
|
2082
3238
|
annotations: { readOnlyHint: true },
|
|
2083
3239
|
async execute(args, exec) {
|
|
2084
|
-
const
|
|
3240
|
+
const example = '{"a": "/srv/app/old.txt", "b": "/srv/app/new.txt"}';
|
|
3241
|
+
const aInput = requirePath(args, 'presence_diff', example, {
|
|
3242
|
+
names: DIFF_A_ALIASES,
|
|
3243
|
+
canonical: 'a',
|
|
3244
|
+
accepted: ['a', 'file_path'],
|
|
3245
|
+
});
|
|
3246
|
+
const bInput = pickArg(args, DIFF_B_ALIASES);
|
|
3247
|
+
const hasRemoteB = typeof bInput === 'string' && bInput.trim() !== '';
|
|
2085
3248
|
const hasLocal = typeof args.localPath === 'string' && args.localPath.trim() !== '';
|
|
2086
3249
|
if (hasRemoteB === hasLocal) {
|
|
2087
3250
|
throw new Error('pass exactly one of b (another remote path) or localPath (a local file) as the right side');
|
|
2088
3251
|
}
|
|
2089
3252
|
const { host, workspace } = await pickScope(args, exec);
|
|
2090
3253
|
const connection = await pool.connection(host);
|
|
2091
|
-
const leftTarget = await remotePath(connection, workspace,
|
|
3254
|
+
const leftTarget = await remotePath(connection, workspace, aInput);
|
|
2092
3255
|
const left = await readRemoteTextForDiff(connection, leftTarget, host);
|
|
2093
3256
|
let right;
|
|
2094
3257
|
let rightLabel;
|
|
2095
3258
|
if (hasRemoteB) {
|
|
2096
|
-
rightLabel = await remotePath(connection, workspace,
|
|
3259
|
+
rightLabel = await remotePath(connection, workspace, bInput);
|
|
2097
3260
|
right = await readRemoteTextForDiff(connection, rightLabel, host);
|
|
2098
|
-
await markObserved(exec,
|
|
3261
|
+
await markObserved(exec, bInput);
|
|
2099
3262
|
} else {
|
|
2100
3263
|
const local = readLocalTextForDiff(args.localPath);
|
|
2101
3264
|
right = local.content;
|
|
2102
3265
|
rightLabel = local.path;
|
|
2103
3266
|
}
|
|
2104
|
-
await markObserved(exec,
|
|
3267
|
+
await markObserved(exec, aInput);
|
|
2105
3268
|
const context = clampedNumber(args.context, 3, 0, 20);
|
|
2106
3269
|
const result = buildUnifiedDiff(left, right, { context });
|
|
2107
3270
|
if (args.format === 'json') {
|
|
@@ -2128,7 +3291,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2128
3291
|
description:
|
|
2129
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.',
|
|
2130
3293
|
parameters: {
|
|
2131
|
-
host: {
|
|
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
|
+
},
|
|
2132
3299
|
path: { type: 'string', required: true, description: 'Absolute path, ~/relative, or workspace-relative.' },
|
|
2133
3300
|
lines: { type: 'number', description: 'How many trailing lines to return, 1-5000. Defaults to 200.' },
|
|
2134
3301
|
follow: { type: 'boolean', description: 'Keep appending new bytes for timeoutMs before returning. Defaults to false.' },
|
|
@@ -2144,9 +3311,10 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2144
3311
|
},
|
|
2145
3312
|
annotations: { readOnlyHint: true },
|
|
2146
3313
|
async execute(args, exec) {
|
|
3314
|
+
const pathInput = requirePath(args, 'presence_tail', '{"path": "/srv/app/app.log", "lines": 200}');
|
|
2147
3315
|
const { host, workspace } = await pickScope(args, exec);
|
|
2148
3316
|
const connection = await pool.connection(host);
|
|
2149
|
-
const target = await remotePath(connection, workspace,
|
|
3317
|
+
const target = await remotePath(connection, workspace, pathInput);
|
|
2150
3318
|
const follow = args.follow === true;
|
|
2151
3319
|
const result = await tailRemote(connection, target, {
|
|
2152
3320
|
lines: clampedNumber(args.lines, TAIL_DEFAULT_LINES, 1, TAIL_MAX_LINES),
|
|
@@ -2154,7 +3322,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2154
3322
|
timeoutMs: clampedNumber(args.timeoutMs, TAIL_DEFAULT_TIMEOUT_MS, 0, TAIL_MAX_TIMEOUT_MS),
|
|
2155
3323
|
signal: exec?.signal,
|
|
2156
3324
|
});
|
|
2157
|
-
await markObserved(exec,
|
|
3325
|
+
await markObserved(exec, pathInput);
|
|
2158
3326
|
if (args.format === 'json') {
|
|
2159
3327
|
let text = result.text;
|
|
2160
3328
|
let lines = result.lines;
|
|
@@ -2233,17 +3401,29 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2233
3401
|
? 'no background jobs'
|
|
2234
3402
|
: 'no background jobs for this session (use all:true to list every session)';
|
|
2235
3403
|
}
|
|
2236
|
-
if (args.format === 'json')
|
|
3404
|
+
if (args.format === 'json') {
|
|
3405
|
+
return jsonText(
|
|
3406
|
+
visible.map((job) => {
|
|
3407
|
+
const record = jobRecord(job);
|
|
3408
|
+
if (typeof record.command === 'string') record.command = clipCommandLine(record.command);
|
|
3409
|
+
return record;
|
|
3410
|
+
}),
|
|
3411
|
+
);
|
|
3412
|
+
}
|
|
2237
3413
|
const lines = visible.map((job) => {
|
|
2238
3414
|
// The stored command carries the pool's pipefail probe; the list
|
|
2239
|
-
// should show what the caller actually asked to run
|
|
2240
|
-
|
|
3415
|
+
// should show what the caller actually asked to run, label first and
|
|
3416
|
+
// the command clipped to one short line.
|
|
3417
|
+
const label = clipCommandLine(stripPipefailPrelude(job.label ?? '(no label)') || '(no label)');
|
|
3418
|
+
const command = job.command ? clipCommandLine(stripPipefailPrelude(job.command)) : null;
|
|
2241
3419
|
const age = job.startedAt ? `${Math.round((Date.now() - job.startedAt) / 1000)}s` : '?';
|
|
2242
3420
|
const exit =
|
|
2243
3421
|
job.exitCode === undefined || job.exitCode === null ? (job.state === 'running' ? '-' : '?') : job.exitCode;
|
|
2244
3422
|
return (
|
|
2245
|
-
`${
|
|
2246
|
-
` state=${job.state ?? 'unknown'} exit=${exit} pid=${job.pid ?? '?'}
|
|
3423
|
+
`${label}\n` +
|
|
3424
|
+
` job=${job.id} state=${job.state ?? 'unknown'} exit=${exit} pid=${job.pid ?? '?'} ` +
|
|
3425
|
+
`host=${job.host} age=${age} log=${job.logPath ?? '?'}` +
|
|
3426
|
+
(command ? `\n command: ${command}` : '')
|
|
2247
3427
|
);
|
|
2248
3428
|
});
|
|
2249
3429
|
return `${visible.length} job(s):\n${lines.join('\n')}`;
|
|
@@ -2271,6 +3451,10 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2271
3451
|
if (body.logPath) {
|
|
2272
3452
|
lines.push(`log: ${body.logPath}${body.logSize !== null ? ` (${formatBytes(body.logSize)})` : ''}`);
|
|
2273
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)' : ''}`);
|
|
2274
3458
|
return clip(lines.join('\n'));
|
|
2275
3459
|
}
|
|
2276
3460
|
|
|
@@ -2400,7 +3584,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2400
3584
|
localPort: { type: 'number', description: 'For open: local port to bind. Defaults to an OS-assigned free port.' },
|
|
2401
3585
|
remoteHost: { type: 'string', description: 'For open: remote bind host. Defaults to 127.0.0.1.' },
|
|
2402
3586
|
forwardId: { type: 'string', description: 'For close: the id returned by open.' },
|
|
2403
|
-
host: {
|
|
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
|
+
},
|
|
2404
3592
|
format: {
|
|
2405
3593
|
type: 'string',
|
|
2406
3594
|
enum: ['text', 'json'],
|
|
@@ -2483,7 +3671,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2483
3671
|
required: true,
|
|
2484
3672
|
description: 'TCP ports to check (1-20 entries).',
|
|
2485
3673
|
},
|
|
2486
|
-
host: {
|
|
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
|
+
},
|
|
2487
3679
|
expect: {
|
|
2488
3680
|
type: 'string',
|
|
2489
3681
|
enum: ['open', 'closed'],
|
|
@@ -2575,7 +3767,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2575
3767
|
type: 'boolean',
|
|
2576
3768
|
description: 'Wait up to timeoutMs for the remote port to accept TCP before forwarding. Defaults to true.',
|
|
2577
3769
|
},
|
|
2578
|
-
host: {
|
|
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
|
+
},
|
|
2579
3775
|
format: {
|
|
2580
3776
|
type: 'string',
|
|
2581
3777
|
enum: ['text', 'json'],
|
|
@@ -2692,7 +3888,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2692
3888
|
tool({
|
|
2693
3889
|
name: 'presence_terminal',
|
|
2694
3890
|
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
|
|
3891
|
+
'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
3892
|
parameters: {
|
|
2697
3893
|
action: {
|
|
2698
3894
|
type: 'string',
|
|
@@ -2700,7 +3896,11 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2700
3896
|
enum: ['open', 'write', 'read', 'close', 'list'],
|
|
2701
3897
|
description: 'What to do with the terminal.',
|
|
2702
3898
|
},
|
|
2703
|
-
host: {
|
|
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
|
+
},
|
|
2704
3904
|
session: {
|
|
2705
3905
|
type: 'string',
|
|
2706
3906
|
description:
|
|
@@ -2828,7 +4028,7 @@ export function definePresenceTools(pool, settings, { cwdOf, observe } = {}) {
|
|
|
2828
4028
|
return jsonText({ terminalId: args.terminalId, closed: true, session, killedSession, host });
|
|
2829
4029
|
}
|
|
2830
4030
|
if (killedSession) return `closed ${args.terminalId} and killed tmux session "${session}"`;
|
|
2831
|
-
if (session) return `closed ${args.terminalId}; tmux session "${session}" keeps running
|
|
4031
|
+
if (session) return `closed ${args.terminalId}; tmux session "${session}" keeps running — reopen with session:"${session}" to reattach`;
|
|
2832
4032
|
return `closed ${args.terminalId}`;
|
|
2833
4033
|
}
|
|
2834
4034
|
throw new Error(`unknown action ${args.action}`);
|