@love-moon/conductor-cli 0.11.2 → 0.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b749180: Add `conductor remote cp` for copying a single file to or from another daemon's
8
+ host, and fold the existing `remote-exec` command into a `conductor remote`
9
+ umbrella alongside it.
10
+
11
+ - `conductor remote exec ...` replaces `conductor remote-exec ...`. Behaviour is
12
+ unchanged; only the command path moved.
13
+ - `conductor remote cp <src> <dst>` follows scp: exactly one side is written as
14
+ `<daemon>:<path>`, and that decides the direction. Contents are verified end
15
+ to end with SHA-256 and written via a temporary file plus rename, so an
16
+ interrupted copy never truncates an existing file.
17
+ - `conductor remote cp -r` copies a directory, matching scp's destination
18
+ rules. It packs with `tar`, moves the one tarball through the ordinary
19
+ transfer and unpacks on the other side, so permissions and symlinks survive
20
+ and the wire protocol is unchanged. Needs `tar` and `remote_exec` on the
21
+ target; the limit applies to the compressed size.
22
+ - New `remote_file` daemon capability, declinable with `remote_file: false` in
23
+ the config or `CONDUCTOR_REMOTE_FILE=0`, kept separate from `remote_exec` so
24
+ version skew fails with a clean 409 instead of a timeout.
25
+ - Transfers are chunked at 32 MiB with `Content-Range` and resume automatically
26
+ after a network blip, from the server's offset going up and from the local
27
+ partial file coming down. Chunking is also what keeps the server safe at the
28
+ raised limit: no single request is large or long-lived.
29
+ - The per-file limit is now 1 GiB, bounded by a global staged-bytes budget
30
+ (8 GiB), a per-user budget (2 GiB), a free-disk floor, and reservation by
31
+ declared size at creation.
32
+ - Bytes travel over HTTP through the backend rather than the agent WebSocket.
33
+ The socket only carries a small control message. See RFC 0037.
34
+
35
+ Deploying this needs the updated `web/nginx_conf`: the file-transfer routes set
36
+ `client_max_body_size` to 64m — a per-chunk cap, not a per-file one — and turn
37
+ off `proxy_request_buffering`.
38
+
39
+ ### Patch Changes
40
+
41
+ - @love-moon/conductor-sdk@0.12.0
42
+ - @love-moon/ai-sdk@0.12.0
43
+
3
44
  ## 0.11.2
4
45
 
5
46
  ### Patch Changes
@@ -387,6 +387,10 @@ async function main() {
387
387
  "# Env: CONDUCTOR_REMOTE_EXEC",
388
388
  "# remote_exec: true",
389
389
  "",
390
+ "# Accept `conductor remote cp` file transfers for this host.",
391
+ "# Env: CONDUCTOR_REMOTE_FILE",
392
+ "# remote_file: true",
393
+ "",
390
394
  "# Launch each Fire process inside a detached tmux session so it survives a",
391
395
  "# daemon restart. Requires tmux on PATH. Env: CONDUCTOR_FIRE_TMUX_MODE",
392
396
  "# fire_tmux_mode: false",
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * conductor remote — act on another daemon's host.
5
+ *
6
+ * conductor remote exec --target ubuntu -- bash -lc "pnpm build 2>&1 | tail -20"
7
+ * conductor remote cp ./build.tar.gz ubuntu:/srv/app/build.tar.gz
8
+ * conductor remote cp ubuntu:/var/log/conductor.log ./conductor.log
9
+ *
10
+ * Verb dispatch is hand-rolled rather than delegated to yargs on purpose:
11
+ * `exec` has to hand an arbitrary remote argv through untouched, and yargs'
12
+ * `strict()` would try to interpret the remote command's own flags.
13
+ */
14
+
15
+ import path from "node:path";
16
+ import process from "node:process";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import { EXIT } from "../src/remote/client.js";
20
+ import { runRemoteExec, showHelp as showExecHelp } from "../src/remote/exec.js";
21
+ import { runRemoteCp, showHelp as showCpHelp } from "../src/remote/cp.js";
22
+
23
+ export { EXIT };
24
+
25
+ const VERBS = new Map([
26
+ ["exec", runRemoteExec],
27
+ ["cp", runRemoteCp],
28
+ ]);
29
+
30
+ const isMainModule = (() => {
31
+ const currentFile = fileURLToPath(import.meta.url);
32
+ const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
33
+ return entryFile === currentFile;
34
+ })();
35
+
36
+ export function showHelp(consoleImpl = console) {
37
+ consoleImpl.log(`conductor remote - act on another daemon's host
38
+
39
+ Usage:
40
+ conductor remote <verb> [options]
41
+
42
+ Verbs:
43
+ exec Run a command on another daemon's host
44
+ cp Copy a file to or from another daemon's host
45
+
46
+ Options:
47
+ -h, --help Show this help
48
+
49
+ Examples:
50
+ conductor remote exec --target ubuntu --workspace /srv/app -- git log --oneline -5
51
+ conductor remote cp ./build.tar.gz ubuntu:/srv/app/build.tar.gz
52
+ conductor remote cp ubuntu:/var/log/conductor.log ./conductor.log
53
+
54
+ For verb-specific help:
55
+ conductor remote exec --help
56
+ conductor remote cp --help
57
+ `);
58
+ }
59
+
60
+ export async function runRemote(argv, deps = {}) {
61
+ const consoleImpl = deps.console || console;
62
+
63
+ if (argv.length === 0) {
64
+ showHelp(consoleImpl);
65
+ return EXIT.CLI_ERROR;
66
+ }
67
+
68
+ const verb = argv[0];
69
+ if (verb === "--help" || verb === "-h") {
70
+ showHelp(consoleImpl);
71
+ return EXIT.OK;
72
+ }
73
+
74
+ const handler = VERBS.get(verb);
75
+ if (!handler) {
76
+ // `--help` on a verb belongs to the verb, but a bare flag in the verb slot
77
+ // is a usage mistake worth naming precisely.
78
+ consoleImpl.error(`Error: unknown verb '${verb}'`);
79
+ consoleImpl.error(`Valid verbs: ${[...VERBS.keys()].join(", ")}`);
80
+ showHelp(consoleImpl);
81
+ return EXIT.CLI_ERROR;
82
+ }
83
+
84
+ return handler(argv.slice(1), deps);
85
+ }
86
+
87
+ export { showExecHelp, showCpHelp };
88
+
89
+ if (isMainModule) {
90
+ // `process.exitCode` rather than `process.exit()`: writes to a pipe are async,
91
+ // and exiting outright truncates them. Let the loop drain and end naturally.
92
+ process.exitCode = await runRemote(process.argv.slice(2));
93
+ }
@@ -182,34 +182,56 @@ export async function sendFileToTask(options) {
182
182
  body.set("file", new Blob([fileBuffer], { type: mimeType }), fileName);
183
183
 
184
184
  const content = typeof options.content === "string" ? options.content.trim() : "";
185
- if (content) {
186
- body.set("content", content);
187
- }
188
-
189
185
  const role = typeof options.role === "string" && options.role.trim()
190
186
  ? options.role.trim().toLowerCase()
191
187
  : "sdk";
192
- body.set("role", role);
188
+ const authHeaders = {
189
+ Authorization: `Bearer ${config.agentToken}`,
190
+ Accept: "application/json",
191
+ };
193
192
 
194
- const url = new URL(`/api/tasks/${encodeURIComponent(taskId)}/attachments`, config.backendUrl);
195
- const response = await fetchImpl(String(url), {
193
+ // Phase 1 stages the bytes. This endpoint only stores the file; the row it
194
+ // creates carries no message and expires within the attachment TTL.
195
+ const uploadUrl = new URL(`/api/tasks/${encodeURIComponent(taskId)}/attachments`, config.backendUrl);
196
+ const uploadResponse = await fetchImpl(String(uploadUrl), {
196
197
  method: "POST",
197
- headers: {
198
- Authorization: `Bearer ${config.agentToken}`,
199
- Accept: "application/json",
200
- },
198
+ headers: authHeaders,
201
199
  body,
202
200
  });
203
201
 
204
- const rawText = await response.text();
205
- if (!response.ok) {
206
- const details = formatErrorBody(rawText);
207
- throw new Error(`Upload failed (${response.status})${details ? `: ${details}` : ""}`);
202
+ const uploadText = await uploadResponse.text();
203
+ if (!uploadResponse.ok) {
204
+ const details = formatErrorBody(uploadText);
205
+ throw new Error(`Upload failed (${uploadResponse.status})${details ? `: ${details}` : ""}`);
206
+ }
207
+ const attachment = (uploadText ? JSON.parse(uploadText) : {}).attachment;
208
+ if (!attachment || !attachment.id) {
209
+ throw new Error("Upload succeeded but the server returned no attachment id");
210
+ }
211
+
212
+ // Phase 2 binds the staged file to a message. Without it the attachment
213
+ // never reaches the chat UI and the janitor deletes it when staging expires.
214
+ const messageUrl = new URL(`/api/tasks/${encodeURIComponent(taskId)}/messages`, config.backendUrl);
215
+ const messageResponse = await fetchImpl(String(messageUrl), {
216
+ method: "POST",
217
+ headers: { ...authHeaders, "Content-Type": "application/json" },
218
+ body: JSON.stringify({
219
+ content: content || `Attached file: ${attachment.name}`,
220
+ role,
221
+ attachmentIds: [attachment.id],
222
+ }),
223
+ });
224
+
225
+ const messageText = await messageResponse.text();
226
+ if (!messageResponse.ok) {
227
+ const details = formatErrorBody(messageText);
228
+ throw new Error(`Attach failed (${messageResponse.status})${details ? `: ${details}` : ""}`);
208
229
  }
209
230
 
210
231
  return {
211
232
  taskId,
212
- response: rawText ? JSON.parse(rawText) : {},
233
+ attachment,
234
+ response: messageText ? JSON.parse(messageText) : {},
213
235
  };
214
236
  }
215
237
 
package/bin/conductor.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * project - Manage Conductor projects (list/show/create/...)
16
16
  * issue - Manage issues (list/show/create/update/start/done)
17
17
  * task - Manage tasks (create/list/show/send/messages/schedule)
18
- * remote-exec - Run a command on another daemon's host
18
+ * remote - Run commands and copy files on another daemon's host
19
19
  */
20
20
 
21
21
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -51,7 +51,7 @@ export function runConductorCli(args = argv, deps = {}) {
51
51
  "project",
52
52
  "issue",
53
53
  "task",
54
- "remote-exec",
54
+ "remote",
55
55
  ];
56
56
 
57
57
  if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
@@ -137,7 +137,7 @@ Subcommands:
137
137
  project Manage Conductor projects (list/show/create/...)
138
138
  issue Manage issues (list/show/create/update/start/done)
139
139
  task Manage tasks (create/list/show/send/messages/schedule)
140
- remote-exec Run a command on another daemon's host
140
+ remote Run commands and copy files on another daemon's host (exec/cp)
141
141
 
142
142
  Options:
143
143
  -h, --help Show this help message
@@ -162,7 +162,8 @@ Examples:
162
162
  conductor task create --title "Refactor module" --prompt "Extract the parser" --backend codex
163
163
  conductor task send <task-id> "please add a unit test"
164
164
  conductor task schedule create <task-id> "follow up" --delay 10m
165
- conductor remote-exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
165
+ conductor remote exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
166
+ conductor remote cp ./build.tar.gz ubuntu:/srv/app/build.tar.gz
166
167
 
167
168
  For subcommand-specific help:
168
169
  conductor fire --help
@@ -176,7 +177,7 @@ For subcommand-specific help:
176
177
  conductor project --help
177
178
  conductor issue --help
178
179
  conductor task --help
179
- conductor remote-exec --help
180
+ conductor remote --help
180
181
 
181
182
  Version: ${pkgJson.version}
182
183
  `);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.11.2",
4
- "gitCommitId": "067735d",
3
+ "version": "0.12.0",
4
+ "gitCommitId": "c1047e8",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/lovemoon-ai/conductor.git"
@@ -24,8 +24,8 @@
24
24
  "test": "node --test test/*.test.js"
25
25
  },
26
26
  "dependencies": {
27
- "@love-moon/ai-sdk": "0.11.2",
28
- "@love-moon/conductor-sdk": "0.11.2",
27
+ "@love-moon/ai-sdk": "0.12.0",
28
+ "@love-moon/conductor-sdk": "0.12.0",
29
29
  "@github/copilot-sdk": "^0.3.0",
30
30
  "chrome-launcher": "^1.2.1",
31
31
  "chrome-remote-interface": "^0.33.0",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "optionalDependencies": {
40
40
  "@roamhq/wrtc": "^0.10.0",
41
- "@love-moon/chat-web": "0.11.2"
41
+ "@love-moon/chat-web": "0.12.0"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
package/src/daemon.js CHANGED
@@ -42,6 +42,11 @@ import {
42
42
  createRemoteExecHandlers,
43
43
  handleRemoteExecRequest,
44
44
  } from "./remote-exec-handlers.js";
45
+ import {
46
+ REMOTE_FILE_CAPABILITY,
47
+ createRemoteFileHandlers,
48
+ handleRemoteFileRequest,
49
+ } from "./remote-file-handlers.js";
45
50
  import {
46
51
  UPDATE_DAEMON_CAPABILITY,
47
52
  createDaemonUpdateHandlers,
@@ -288,6 +293,33 @@ function getRemoteExecEnabled(userConfig) {
288
293
  return true;
289
294
  }
290
295
 
296
+ // Whether this host will accept `conductor remote cp` file transfers (RFC
297
+ // 0037). Kept separate from `remote_exec` on purpose: the two are not distinct
298
+ // trust levels — anyone with exec can already `base64` a file — but a host may
299
+ // well want scripted commands without letting the account push bytes onto its
300
+ // disk, and a separate flag is the only way to say that.
301
+ //
302
+ // Resolution order:
303
+ // 1. CONDUCTOR_REMOTE_FILE env var ("1"/"true"/"on" enable, "0"/"false"/"off" disable)
304
+ // 2. remote_file boolean in the resolved Conductor config.yaml
305
+ // 3. Default: true
306
+ function getRemoteFileEnabled(userConfig) {
307
+ const rawEnv = process.env.CONDUCTOR_REMOTE_FILE;
308
+ if (typeof rawEnv === "string" && rawEnv.trim()) {
309
+ const normalized = rawEnv.trim().toLowerCase();
310
+ if (normalized === "1" || normalized === "true" || normalized === "on" || normalized === "yes") {
311
+ return true;
312
+ }
313
+ if (normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no") {
314
+ return false;
315
+ }
316
+ }
317
+ if (userConfig && typeof userConfig === "object" && userConfig.remote_file === false) {
318
+ return false;
319
+ }
320
+ return true;
321
+ }
322
+
291
323
  function normalizePlanLimitType(limitType) {
292
324
  if (typeof limitType !== "string") {
293
325
  return null;
@@ -1154,6 +1186,10 @@ export function startDaemon(config = {}, deps = {}) {
1154
1186
  // path would cost function without removing reach (RFC 0034 makes the same
1155
1187
  // argument). An owner who wants it off can set `remote_exec: false`.
1156
1188
  const remoteExecEnabled = getRemoteExecEnabled(userConfig);
1189
+ // Same reasoning for file transfer, with one addition: when the share scoped
1190
+ // the guest to a root, transfers are confined to it (`isPathInsideGuestRoot`,
1191
+ // a lexical check — misuse prevention, not a security boundary).
1192
+ const remoteFileEnabled = getRemoteFileEnabled(userConfig);
1157
1193
 
1158
1194
  // Get allow_cli_list from config
1159
1195
  const RAW_ALLOW_CLI_LIST = getRawAllowCliList(userConfig);
@@ -3764,6 +3800,11 @@ export function startDaemon(config = {}, deps = {}) {
3764
3800
  } else {
3765
3801
  log("[remote-exec] Disabled by config (remote_exec: false); capability not advertised");
3766
3802
  }
3803
+ if (remoteFileEnabled) {
3804
+ advertisedCapabilities.push(REMOTE_FILE_CAPABILITY);
3805
+ } else {
3806
+ log("[remote-file] Disabled by config (remote_file: false); capability not advertised");
3807
+ }
3767
3808
  if (ptyTaskCapabilityEnabled) {
3768
3809
  advertisedCapabilities.push("pty_task", "terminal_snapshot");
3769
3810
  }
@@ -3831,6 +3872,13 @@ export function startDaemon(config = {}, deps = {}) {
3831
3872
  const remoteExecHandlers = remoteExecEnabled
3832
3873
  ? createRemoteExecHandlers({ defaultWorkspace: homeDir })
3833
3874
  : null;
3875
+ const remoteFileHandlers = remoteFileEnabled
3876
+ ? createRemoteFileHandlers({
3877
+ config: sdkConfig,
3878
+ agentHost: AGENT_NAME,
3879
+ guestRoot: IS_GUEST_DAEMON ? GUEST_ROOT : null,
3880
+ })
3881
+ : null;
3834
3882
 
3835
3883
  const client = createWebSocketClient(sdkConfig, {
3836
3884
  extraHeaders,
@@ -6334,6 +6382,50 @@ export function startDaemon(config = {}, deps = {}) {
6334
6382
  logError(`Unhandled remote_exec_request failure: ${error?.message || error}`);
6335
6383
  });
6336
6384
  }
6385
+ if (event.type === "remote_file_request") {
6386
+ // Same reason as remote exec: a transfer leaves no Task row and no
6387
+ // per-run log, so it would be invisible after a restart. Only the action,
6388
+ // the path and the byte count are recorded — never file contents, and
6389
+ // never the transfer token, because this log is collected by
6390
+ // `collect_logs`.
6391
+ const fileArgs = event?.payload?.args && typeof event.payload.args === "object" ? event.payload.args : {};
6392
+ log(
6393
+ `[remote-file] ${event?.payload?.action || "?"} path=${fileArgs.remotePath || fileArgs.remote_path || ""} ` +
6394
+ `bytes=${fileArgs.sizeBytes ?? fileArgs.size_bytes ?? "?"}`,
6395
+ );
6396
+ // Fail fast rather than letting the caller wait out its full timeout —
6397
+ // it has no other way to learn that this daemon will never answer.
6398
+ const rejectReason = !remoteFileHandlers
6399
+ ? "remote file transfer is disabled on this daemon (remote_file: false)"
6400
+ : daemonShuttingDown
6401
+ ? "daemon is shutting down"
6402
+ : "";
6403
+ if (rejectReason) {
6404
+ void client
6405
+ .sendJson({
6406
+ type: "remote_file_response",
6407
+ payload: {
6408
+ request_id: event?.payload?.request_id ? String(event.payload.request_id) : "",
6409
+ action: event?.payload?.action ? String(event.payload.action) : "",
6410
+ error: rejectReason,
6411
+ },
6412
+ })
6413
+ .catch(() => {});
6414
+ return;
6415
+ }
6416
+ handleRemoteFileRequest(client, remoteFileHandlers, event.payload)
6417
+ .then((response) => {
6418
+ if (response?.result) {
6419
+ log(
6420
+ `[remote-file] ${event?.payload?.action || "?"} ok path=${response.result.path || ""} ` +
6421
+ `bytes=${response.result.bytesWritten ?? response.result.sizeBytes ?? "?"}`,
6422
+ );
6423
+ }
6424
+ })
6425
+ .catch((error) => {
6426
+ logError(`Unhandled remote_file_request failure: ${error?.message || error}`);
6427
+ });
6428
+ }
6337
6429
  if (event.type === "restart_daemon") {
6338
6430
  void handleRestartDaemon(event.payload).catch((error) => {
6339
6431
  logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
@@ -7258,6 +7350,49 @@ export function startDaemon(config = {}, deps = {}) {
7258
7350
  });
7259
7351
  }
7260
7352
 
7353
+ /**
7354
+ * Recover the workspace of a resumable session from the backend's own
7355
+ * session store. Used when neither a launch-config cwd nor a bound project
7356
+ * path is available -- the Default Project resuming a local session -- so the
7357
+ * task runs (and logs) where the conversation actually lives instead of an
7358
+ * unrelated scratch directory.
7359
+ *
7360
+ * Returns "" when the session cannot be located; callers keep their own
7361
+ * fallbacks. `runtimeBackend` short-circuits the alias lookup for callers
7362
+ * that already resolved it.
7363
+ */
7364
+ async function resolveSessionResumeCwd({ backendType, runtimeBackend = "", sessionId }) {
7365
+ const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
7366
+ const normalizedBackend = normalizeRuntimeBackendName(backendType);
7367
+ // opencode has no resumable local session store to look the cwd up in.
7368
+ if (!normalizedSessionId || !normalizedBackend || normalizedBackend === "opencode") {
7369
+ return "";
7370
+ }
7371
+ try {
7372
+ let resumeBackend = normalizeOptionalString(runtimeBackend);
7373
+ if (!resumeBackend) {
7374
+ const configuredBackend = await resolveConfiguredRuntimeBackend(normalizedBackend, ALLOW_CLI_LIST, {
7375
+ configFilePath: config.CONFIG_FILE,
7376
+ });
7377
+ resumeBackend = configuredBackend?.runtimeBackend ||
7378
+ await normalizeRuntimeBackendAlias(normalizedBackend, { configFilePath: config.CONFIG_FILE });
7379
+ }
7380
+ const resumeContext = await (deps.resolveResumeContext || resolveResumeContext)(
7381
+ resumeBackend,
7382
+ normalizedSessionId,
7383
+ {
7384
+ cwd: process.cwd(),
7385
+ configFilePath: config.CONFIG_FILE,
7386
+ },
7387
+ );
7388
+ return normalizeOptionalString(resumeContext?.cwd) || "";
7389
+ } catch {
7390
+ // Provider lookup failed (unknown id, unreadable store): let the caller
7391
+ // fall back rather than failing the task here.
7392
+ return "";
7393
+ }
7394
+ }
7395
+
7261
7396
  async function resolveRestartCwd({
7262
7397
  taskId,
7263
7398
  projectId,
@@ -7291,29 +7426,9 @@ export function startDaemon(config = {}, deps = {}) {
7291
7426
  return boundPath;
7292
7427
  }
7293
7428
 
7294
- const normalizedBackend = normalizeRuntimeBackendName(backendType);
7295
- const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
7296
- if (normalizedSessionId && normalizedBackend && normalizedBackend !== "opencode") {
7297
- try {
7298
- const configuredBackend = await resolveConfiguredRuntimeBackend(normalizedBackend, ALLOW_CLI_LIST, {
7299
- configFilePath: config.CONFIG_FILE,
7300
- });
7301
- const resumeBackend = configuredBackend?.runtimeBackend ||
7302
- await normalizeRuntimeBackendAlias(normalizedBackend, { configFilePath: config.CONFIG_FILE });
7303
- const resumeContext = await (deps.resolveResumeContext || resolveResumeContext)(
7304
- resumeBackend,
7305
- normalizedSessionId,
7306
- {
7307
- cwd: process.cwd(),
7308
- configFilePath: config.CONFIG_FILE,
7309
- },
7310
- );
7311
- if (typeof resumeContext?.cwd === "string" && resumeContext.cwd.trim()) {
7312
- return resumeContext.cwd.trim();
7313
- }
7314
- } catch {
7315
- // ignore provider-specific fallback failure here; we'll try the remaining fallbacks
7316
- }
7429
+ const sessionCwd = await resolveSessionResumeCwd({ backendType, sessionId });
7430
+ if (sessionCwd) {
7431
+ return sessionCwd;
7317
7432
  }
7318
7433
 
7319
7434
  const normalizedSessionPath =
@@ -7474,7 +7589,14 @@ export function startDaemon(config = {}, deps = {}) {
7474
7589
  launchConfig,
7475
7590
  })) ||
7476
7591
  normalizeOptionalString(launchConfig.cwd) ||
7477
- boundPath;
7592
+ boundPath ||
7593
+ (await resolveSessionResumeCwd({
7594
+ backendType: requestedBackend,
7595
+ runtimeBackend: effectiveBackend,
7596
+ sessionId:
7597
+ normalizeOptionalString(launchConfig?.resumeSessionId) ||
7598
+ normalizeOptionalString(launchConfig?.resume_session_id),
7599
+ }));
7478
7600
 
7479
7601
  if (resolvedTaskWorkspace) {
7480
7602
  taskDir = resolvedTaskWorkspace;
@@ -8567,6 +8689,9 @@ export function startDaemon(config = {}, deps = {}) {
8567
8689
  clearInterval(tmuxLivenessTimer);
8568
8690
  tmuxLivenessTimer = null;
8569
8691
  }
8692
+ // An in-flight transfer holds an open socket and a `.part` file; abort
8693
+ // them so shutdown is not gated on a multi-gigabyte download finishing.
8694
+ remoteFileHandlers?.abortAll();
8570
8695
  const activeProcessEntries = [...activeTaskProcesses.entries()];
8571
8696
  const activePtyEntries = [...activePtySessions.entries()];
8572
8697
  const activeEntries = [...activeProcessEntries, ...activePtyEntries];
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Directory transfer, built on the single-file path plus `tar`.
3
+ *
4
+ * `remote cp -r` packs a directory locally (or remotely), moves the one
5
+ * resulting tarball through the ordinary transfer, then unpacks it on the other
6
+ * side. No new protocol: the wire still only ever carries one file.
7
+ *
8
+ * The remote halves run through `sh -c` with the paths passed as **positional
9
+ * arguments**, never interpolated into the script text. A directory literally
10
+ * named `; rm -rf /` is therefore just an awkward directory name.
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { spawn } from "node:child_process";
17
+ import { randomUUID } from "node:crypto";
18
+
19
+ /** Where the target stages its tarball. Overridable for hosts without /tmp. */
20
+ export function remoteTempPath(env = process.env) {
21
+ const root = (env.CONDUCTOR_REMOTE_TMP || "/tmp").replace(/\/+$/, "") || "/tmp";
22
+ return `${root}/.conductor-rcp-${randomUUID()}.tgz`;
23
+ }
24
+
25
+ export function localTempPath() {
26
+ return path.join(os.tmpdir(), `.conductor-rcp-${randomUUID()}.tgz`);
27
+ }
28
+
29
+ /**
30
+ * Unpack `$1` into `$2`, following scp: an existing destination directory
31
+ * receives the source *inside* it; a missing one becomes the copy itself.
32
+ * `$3` is the source's basename, which is the top-level entry in the tarball.
33
+ */
34
+ export const EXTRACT_SCRIPT = `
35
+ set -e
36
+ tmp=$1; dest=$2; base=$3
37
+ if [ -d "$dest" ]; then
38
+ tar xzf "$tmp" -C "$dest"
39
+ else
40
+ parent=$(dirname "$dest")
41
+ mkdir -p "$parent"
42
+ # Unpack into a private staging dir, never straight into the parent: the
43
+ # tarball's top entry is the SOURCE's name, and an unrelated neighbour of
44
+ # the destination could already have it. Extracting in place would clobber
45
+ # that neighbour, and the rename below would then carry it off.
46
+ # Staging inside the parent keeps the final mv on one filesystem.
47
+ stage=$(mktemp -d "$parent/.conductor-rcp-XXXXXX")
48
+ tar xzf "$tmp" -C "$stage"
49
+ rm -rf "$dest"
50
+ mv "$stage/$base" "$dest"
51
+ rmdir "$stage"
52
+ fi
53
+ `.trim();
54
+
55
+ /** Pack directory `$1` into tarball `$2`, keeping `$1`'s own name inside. */
56
+ export const PACK_SCRIPT = `
57
+ set -e
58
+ src=$1; tmp=$2
59
+ if [ ! -d "$src" ]; then
60
+ echo "not a directory: $src" >&2
61
+ exit 1
62
+ fi
63
+ tar czf "$tmp" -C "$(dirname "$src")" "$(basename "$src")"
64
+ `.trim();
65
+
66
+ /** Remove the staged tarball. Never fails the transfer. */
67
+ export const CLEANUP_SCRIPT = 'rm -f "$1"';
68
+
69
+ /** `sh -c <script> sh <args...>` — the `sh` placeholder becomes $0. */
70
+ export function shellArgs(script, ...args) {
71
+ return ["-c", script, "sh", ...args];
72
+ }
73
+
74
+ function runLocal(command, args, { spawnFn = spawn } = {}) {
75
+ return new Promise((resolve, reject) => {
76
+ const child = spawnFn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
77
+ let stderr = "";
78
+ child.stderr?.on("data", (chunk) => {
79
+ // Bounded: a pathological tar could otherwise stream forever.
80
+ if (stderr.length < 8192) stderr += String(chunk);
81
+ });
82
+ child.on("error", (error) =>
83
+ reject(new Error(`failed to run ${command}: ${error.message}`)));
84
+ child.on("close", (code) => {
85
+ if (code === 0) return resolve();
86
+ reject(new Error(`${command} exited ${code}${stderr.trim() ? `: ${stderr.trim()}` : ""}`));
87
+ });
88
+ });
89
+ }
90
+
91
+ /** Pack a local directory, preserving its own name as the top-level entry. */
92
+ export async function packLocalDirectory(sourceDir, tarball, deps = {}) {
93
+ await runLocal(
94
+ "tar",
95
+ ["czf", tarball, "-C", path.dirname(sourceDir), path.basename(sourceDir)],
96
+ deps,
97
+ );
98
+ return tarball;
99
+ }
100
+
101
+ /** Unpack locally with the same scp semantics as `EXTRACT_SCRIPT`. */
102
+ export async function unpackLocalDirectory(tarball, destination, baseName, deps = {}) {
103
+ const fsp = deps.fsp || fs.promises;
104
+ let destStat = null;
105
+ try {
106
+ destStat = await fsp.stat(destination);
107
+ } catch {
108
+ destStat = null;
109
+ }
110
+
111
+ if (destStat?.isDirectory()) {
112
+ await runLocal("tar", ["xzf", tarball, "-C", destination], deps);
113
+ return path.join(destination, baseName);
114
+ }
115
+
116
+ const parent = path.dirname(destination);
117
+ await fsp.mkdir(parent, { recursive: true });
118
+ // Same reasoning as EXTRACT_SCRIPT: stage in a private directory so an
119
+ // existing neighbour that happens to share the source's name survives.
120
+ const stage = await fsp.mkdtemp(path.join(parent, ".conductor-rcp-"));
121
+ try {
122
+ await runLocal("tar", ["xzf", tarball, "-C", stage], deps);
123
+ await fsp.rm(destination, { recursive: true, force: true });
124
+ await fsp.rename(path.join(stage, baseName), destination);
125
+ } finally {
126
+ await fsp.rm(stage, { recursive: true, force: true }).catch(() => {});
127
+ }
128
+ return destination;
129
+ }