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