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