@love-moon/conductor-cli 0.11.2 → 0.13.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,70 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8edbed5: `conductor remote exec` is safer to drive from an agent, and gains a `wait` verb.
8
+
9
+ - New `conductor remote wait -t <daemon> <runId>` re-attaches to a command that
10
+ `exec` left running past its `--timeout`, polling and reporting it exactly as
11
+ `exec` would have. `exec` now prints that command in its "still running" hint
12
+ instead of a raw API path.
13
+ - `exec` retries the initial request on `429 too many concurrent remote exec
14
+ requests` with backoff (up to ~15 s), so a burst of parallel short commands no
15
+ longer fails outright. Other errors are still not retried: a 5xx may already
16
+ have started the command.
17
+ - `exec` and `wait` handle SIGINT/SIGTERM: the run id is printed before exiting,
18
+ and with `--kill-on-timeout` the remote command is cancelled as well. Before,
19
+ a tool timeout that killed the CLI silently orphaned the remote process.
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies [80ab13e]
24
+ - @love-moon/conductor-sdk@0.13.0
25
+ - @love-moon/ai-sdk@0.13.0
26
+
27
+ ## 0.12.0
28
+
29
+ ### Minor Changes
30
+
31
+ - b749180: Add `conductor remote cp` for copying a single file to or from another daemon's
32
+ host, and fold the existing `remote-exec` command into a `conductor remote`
33
+ umbrella alongside it.
34
+
35
+ - `conductor remote exec ...` replaces `conductor remote-exec ...`. Behaviour is
36
+ unchanged; only the command path moved.
37
+ - `conductor remote cp <src> <dst>` follows scp: exactly one side is written as
38
+ `<daemon>:<path>`, and that decides the direction. Contents are verified end
39
+ to end with SHA-256 and written via a temporary file plus rename, so an
40
+ interrupted copy never truncates an existing file.
41
+ - `conductor remote cp -r` copies a directory, matching scp's destination
42
+ rules. It packs with `tar`, moves the one tarball through the ordinary
43
+ transfer and unpacks on the other side, so permissions and symlinks survive
44
+ and the wire protocol is unchanged. Needs `tar` and `remote_exec` on the
45
+ target; the limit applies to the compressed size.
46
+ - New `remote_file` daemon capability, declinable with `remote_file: false` in
47
+ the config or `CONDUCTOR_REMOTE_FILE=0`, kept separate from `remote_exec` so
48
+ version skew fails with a clean 409 instead of a timeout.
49
+ - Transfers are chunked at 32 MiB with `Content-Range` and resume automatically
50
+ after a network blip, from the server's offset going up and from the local
51
+ partial file coming down. Chunking is also what keeps the server safe at the
52
+ raised limit: no single request is large or long-lived.
53
+ - The per-file limit is now 1 GiB, bounded by a global staged-bytes budget
54
+ (8 GiB), a per-user budget (2 GiB), a free-disk floor, and reservation by
55
+ declared size at creation.
56
+ - Bytes travel over HTTP through the backend rather than the agent WebSocket.
57
+ The socket only carries a small control message. See RFC 0037.
58
+
59
+ Deploying this needs the updated `web/nginx_conf`: the file-transfer routes set
60
+ `client_max_body_size` to 64m — a per-chunk cap, not a per-file one — and turn
61
+ off `proxy_request_buffering`.
62
+
63
+ ### Patch Changes
64
+
65
+ - @love-moon/conductor-sdk@0.12.0
66
+ - @love-moon/ai-sdk@0.12.0
67
+
3
68
  ## 0.11.2
4
69
 
5
70
  ### 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,98 @@
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
+ import { runRemoteWait, showHelp as showWaitHelp } from "../src/remote/wait.js";
23
+
24
+ export { EXIT };
25
+
26
+ const VERBS = new Map([
27
+ ["exec", runRemoteExec],
28
+ ["cp", runRemoteCp],
29
+ ["wait", runRemoteWait],
30
+ ]);
31
+
32
+ const isMainModule = (() => {
33
+ const currentFile = fileURLToPath(import.meta.url);
34
+ const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
35
+ return entryFile === currentFile;
36
+ })();
37
+
38
+ export function showHelp(consoleImpl = console) {
39
+ consoleImpl.log(`conductor remote - act on another daemon's host
40
+
41
+ Usage:
42
+ conductor remote <verb> [options]
43
+
44
+ Verbs:
45
+ exec Run a command on another daemon's host
46
+ cp Copy a file to or from another daemon's host
47
+ wait Wait for a command that \`exec\` left running (by run id)
48
+
49
+ Options:
50
+ -h, --help Show this help
51
+
52
+ Examples:
53
+ conductor remote exec --target ubuntu --workspace /srv/app -- git log --oneline -5
54
+ conductor remote cp ./build.tar.gz ubuntu:/srv/app/build.tar.gz
55
+ conductor remote cp ubuntu:/var/log/conductor.log ./conductor.log
56
+ conductor remote wait --target ubuntu <runId>
57
+
58
+ For verb-specific help:
59
+ conductor remote exec --help
60
+ conductor remote cp --help
61
+ conductor remote wait --help
62
+ `);
63
+ }
64
+
65
+ export async function runRemote(argv, deps = {}) {
66
+ const consoleImpl = deps.console || console;
67
+
68
+ if (argv.length === 0) {
69
+ showHelp(consoleImpl);
70
+ return EXIT.CLI_ERROR;
71
+ }
72
+
73
+ const verb = argv[0];
74
+ if (verb === "--help" || verb === "-h") {
75
+ showHelp(consoleImpl);
76
+ return EXIT.OK;
77
+ }
78
+
79
+ const handler = VERBS.get(verb);
80
+ if (!handler) {
81
+ // `--help` on a verb belongs to the verb, but a bare flag in the verb slot
82
+ // is a usage mistake worth naming precisely.
83
+ consoleImpl.error(`Error: unknown verb '${verb}'`);
84
+ consoleImpl.error(`Valid verbs: ${[...VERBS.keys()].join(", ")}`);
85
+ showHelp(consoleImpl);
86
+ return EXIT.CLI_ERROR;
87
+ }
88
+
89
+ return handler(argv.slice(1), deps);
90
+ }
91
+
92
+ export { showExecHelp, showCpHelp, showWaitHelp };
93
+
94
+ if (isMainModule) {
95
+ // `process.exitCode` rather than `process.exit()`: writes to a pipe are async,
96
+ // and exiting outright truncates them. Let the loop drain and end naturally.
97
+ process.exitCode = await runRemote(process.argv.slice(2));
98
+ }
@@ -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/wait)
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.13.0",
4
+ "gitCommitId": "e1e507f",
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.13.0",
28
+ "@love-moon/conductor-sdk": "0.13.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.13.0"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
package/src/daemon.js CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  } from "./conductor-paths.js";
27
27
  import {
28
28
  deleteFireSessionRecord,
29
+ listFireSessionTaskIds,
29
30
  pruneFireSessionRecords,
30
31
  readFireSessionRecord,
31
32
  resolveFireSessionRegistryDir,
@@ -42,6 +43,11 @@ import {
42
43
  createRemoteExecHandlers,
43
44
  handleRemoteExecRequest,
44
45
  } from "./remote-exec-handlers.js";
46
+ import {
47
+ REMOTE_FILE_CAPABILITY,
48
+ createRemoteFileHandlers,
49
+ handleRemoteFileRequest,
50
+ } from "./remote-file-handlers.js";
45
51
  import {
46
52
  UPDATE_DAEMON_CAPABILITY,
47
53
  createDaemonUpdateHandlers,
@@ -288,6 +294,33 @@ function getRemoteExecEnabled(userConfig) {
288
294
  return true;
289
295
  }
290
296
 
297
+ // Whether this host will accept `conductor remote cp` file transfers (RFC
298
+ // 0037). Kept separate from `remote_exec` on purpose: the two are not distinct
299
+ // trust levels — anyone with exec can already `base64` a file — but a host may
300
+ // well want scripted commands without letting the account push bytes onto its
301
+ // disk, and a separate flag is the only way to say that.
302
+ //
303
+ // Resolution order:
304
+ // 1. CONDUCTOR_REMOTE_FILE env var ("1"/"true"/"on" enable, "0"/"false"/"off" disable)
305
+ // 2. remote_file boolean in the resolved Conductor config.yaml
306
+ // 3. Default: true
307
+ function getRemoteFileEnabled(userConfig) {
308
+ const rawEnv = process.env.CONDUCTOR_REMOTE_FILE;
309
+ if (typeof rawEnv === "string" && rawEnv.trim()) {
310
+ const normalized = rawEnv.trim().toLowerCase();
311
+ if (normalized === "1" || normalized === "true" || normalized === "on" || normalized === "yes") {
312
+ return true;
313
+ }
314
+ if (normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no") {
315
+ return false;
316
+ }
317
+ }
318
+ if (userConfig && typeof userConfig === "object" && userConfig.remote_file === false) {
319
+ return false;
320
+ }
321
+ return true;
322
+ }
323
+
291
324
  function normalizePlanLimitType(limitType) {
292
325
  if (typeof limitType !== "string") {
293
326
  return null;
@@ -1154,6 +1187,10 @@ export function startDaemon(config = {}, deps = {}) {
1154
1187
  // path would cost function without removing reach (RFC 0034 makes the same
1155
1188
  // argument). An owner who wants it off can set `remote_exec: false`.
1156
1189
  const remoteExecEnabled = getRemoteExecEnabled(userConfig);
1190
+ // Same reasoning for file transfer, with one addition: when the share scoped
1191
+ // the guest to a root, transfers are confined to it (`isPathInsideGuestRoot`,
1192
+ // a lexical check — misuse prevention, not a security boundary).
1193
+ const remoteFileEnabled = getRemoteFileEnabled(userConfig);
1157
1194
 
1158
1195
  // Get allow_cli_list from config
1159
1196
  const RAW_ALLOW_CLI_LIST = getRawAllowCliList(userConfig);
@@ -2270,6 +2307,30 @@ export function startDaemon(config = {}, deps = {}) {
2270
2307
  return FIRE_TMUX_MODE_ENABLED && !FIRE_TMUX_MODE_ACTIVE && isTmuxAvailable();
2271
2308
  }
2272
2309
 
2310
+ // The third door into the same mass kill: `tmux` genuinely absent from PATH.
2311
+ // A daemon restarted by launchd/systemd/cron — or during a `brew upgrade
2312
+ // tmux` — logs the direct-spawn fallback and then cannot enumerate sessions
2313
+ // by any means, so the guard above (which needs tmux to answer) declines and
2314
+ // every Fire the predecessor deliberately left running gets killed.
2315
+ //
2316
+ // The hand-off records on disk are the remaining evidence: they name exactly
2317
+ // the tasks whose liveness this process is unable to answer for. Scoped to
2318
+ // those rather than skipping the sweep wholesale, because a host that simply
2319
+ // never installs tmux spawns its Fires directly and those DO die with their
2320
+ // daemon — blanket-skipping would strand them at `running` with nobody left
2321
+ // to report their death, which is worse than killing them. Nothing prunes
2322
+ // the registry in this state (the prune needs a conclusive listing), so a
2323
+ // later start with a healthy tmux still adopts or reaps them properly.
2324
+ function taskIdsWithUnverifiableTmuxFire() {
2325
+ if (!FIRE_TMUX_MODE_ENABLED || FIRE_TMUX_MODE_ACTIVE) return new Set();
2326
+ try {
2327
+ return listFireSessionTaskIds(FIRE_SESSION_REGISTRY_DIR);
2328
+ } catch (error) {
2329
+ logError(`Failed to read tmux hand-off records: ${error?.message || error}`);
2330
+ return new Set();
2331
+ }
2332
+ }
2333
+
2273
2334
  // Sessions already identified as orphaned shells and killed. `tmux
2274
2335
  // kill-session` is asynchronous, so a sweep running moments later still
2275
2336
  // sees the name in `list-sessions` — and by then the hand-off record is
@@ -3764,6 +3825,11 @@ export function startDaemon(config = {}, deps = {}) {
3764
3825
  } else {
3765
3826
  log("[remote-exec] Disabled by config (remote_exec: false); capability not advertised");
3766
3827
  }
3828
+ if (remoteFileEnabled) {
3829
+ advertisedCapabilities.push(REMOTE_FILE_CAPABILITY);
3830
+ } else {
3831
+ log("[remote-file] Disabled by config (remote_file: false); capability not advertised");
3832
+ }
3767
3833
  if (ptyTaskCapabilityEnabled) {
3768
3834
  advertisedCapabilities.push("pty_task", "terminal_snapshot");
3769
3835
  }
@@ -3831,6 +3897,13 @@ export function startDaemon(config = {}, deps = {}) {
3831
3897
  const remoteExecHandlers = remoteExecEnabled
3832
3898
  ? createRemoteExecHandlers({ defaultWorkspace: homeDir })
3833
3899
  : null;
3900
+ const remoteFileHandlers = remoteFileEnabled
3901
+ ? createRemoteFileHandlers({
3902
+ config: sdkConfig,
3903
+ agentHost: AGENT_NAME,
3904
+ guestRoot: IS_GUEST_DAEMON ? GUEST_ROOT : null,
3905
+ })
3906
+ : null;
3834
3907
 
3835
3908
  const client = createWebSocketClient(sdkConfig, {
3836
3909
  extraHeaders,
@@ -4808,12 +4881,26 @@ export function startDaemon(config = {}, deps = {}) {
4808
4881
  );
4809
4882
  return;
4810
4883
  }
4884
+ const unverifiableTmuxTaskIds = taskIdsWithUnverifiableTmuxFire();
4885
+ const skippedUnverifiable = [];
4811
4886
  const deadTasks = staleTasks.filter((task) => {
4887
+ const taskId = String(task?.id || "");
4812
4888
  // A spawn already in flight has no record yet either; killing it
4813
4889
  // would shoot down a Fire this daemon is in the middle of starting.
4814
- if (pendingTaskStarts.has(String(task?.id || ""))) return false;
4890
+ if (pendingTaskStarts.has(taskId)) return false;
4891
+ if (unverifiableTmuxTaskIds.has(taskId)) {
4892
+ skippedUnverifiable.push(taskId);
4893
+ return false;
4894
+ }
4815
4895
  return !adoptLiveTmuxFireForTask(task, tmuxSessions?.sessions || []);
4816
4896
  });
4897
+ if (skippedUnverifiable.length) {
4898
+ logError(
4899
+ `tmux is not on PATH, so the Fires a previous daemon left for ${skippedUnverifiable.length} ` +
4900
+ `task(s) cannot be checked; skipping stale recovery for them rather than killing what this ` +
4901
+ `daemon cannot see. Restart with tmux available to adopt or reap them.`,
4902
+ );
4903
+ }
4817
4904
 
4818
4905
  if (deadTasks.length === 0) {
4819
4906
  return;
@@ -4912,12 +4999,23 @@ export function startDaemon(config = {}, deps = {}) {
4912
4999
  );
4913
5000
  return;
4914
5001
  }
5002
+ const unverifiableTmuxTaskIds = taskIdsWithUnverifiableTmuxFire();
4915
5003
  for (const task of assigned) {
4916
5004
  const taskId = String(task?.id || "");
4917
5005
  if (!taskId) continue;
4918
5006
  if (localTaskIds.has(taskId) || pendingTaskStarts.has(taskId)) {
4919
5007
  continue;
4920
5008
  }
5009
+ // No tmux binary to ask, but a predecessor filed a hand-off record for
5010
+ // this task: we cannot see its Fire, which is not the same as it being
5011
+ // dead. See taskIdsWithUnverifiableTmuxFire.
5012
+ if (unverifiableTmuxTaskIds.has(taskId)) {
5013
+ logError(
5014
+ `Task ${taskId} has a tmux hand-off record but tmux is not on PATH; skipping reconcile ` +
5015
+ `rather than killing a Fire this daemon cannot see`,
5016
+ );
5017
+ continue;
5018
+ }
4921
5019
  // Adopts on the spot rather than merely skipping: a survivor that
4922
5020
  // dodges the kill but gains no watcher would sit at `running`
4923
5021
  // forever, because nothing else reports a tmux Fire's death.
@@ -6334,6 +6432,50 @@ export function startDaemon(config = {}, deps = {}) {
6334
6432
  logError(`Unhandled remote_exec_request failure: ${error?.message || error}`);
6335
6433
  });
6336
6434
  }
6435
+ if (event.type === "remote_file_request") {
6436
+ // Same reason as remote exec: a transfer leaves no Task row and no
6437
+ // per-run log, so it would be invisible after a restart. Only the action,
6438
+ // the path and the byte count are recorded — never file contents, and
6439
+ // never the transfer token, because this log is collected by
6440
+ // `collect_logs`.
6441
+ const fileArgs = event?.payload?.args && typeof event.payload.args === "object" ? event.payload.args : {};
6442
+ log(
6443
+ `[remote-file] ${event?.payload?.action || "?"} path=${fileArgs.remotePath || fileArgs.remote_path || ""} ` +
6444
+ `bytes=${fileArgs.sizeBytes ?? fileArgs.size_bytes ?? "?"}`,
6445
+ );
6446
+ // Fail fast rather than letting the caller wait out its full timeout —
6447
+ // it has no other way to learn that this daemon will never answer.
6448
+ const rejectReason = !remoteFileHandlers
6449
+ ? "remote file transfer is disabled on this daemon (remote_file: false)"
6450
+ : daemonShuttingDown
6451
+ ? "daemon is shutting down"
6452
+ : "";
6453
+ if (rejectReason) {
6454
+ void client
6455
+ .sendJson({
6456
+ type: "remote_file_response",
6457
+ payload: {
6458
+ request_id: event?.payload?.request_id ? String(event.payload.request_id) : "",
6459
+ action: event?.payload?.action ? String(event.payload.action) : "",
6460
+ error: rejectReason,
6461
+ },
6462
+ })
6463
+ .catch(() => {});
6464
+ return;
6465
+ }
6466
+ handleRemoteFileRequest(client, remoteFileHandlers, event.payload)
6467
+ .then((response) => {
6468
+ if (response?.result) {
6469
+ log(
6470
+ `[remote-file] ${event?.payload?.action || "?"} ok path=${response.result.path || ""} ` +
6471
+ `bytes=${response.result.bytesWritten ?? response.result.sizeBytes ?? "?"}`,
6472
+ );
6473
+ }
6474
+ })
6475
+ .catch((error) => {
6476
+ logError(`Unhandled remote_file_request failure: ${error?.message || error}`);
6477
+ });
6478
+ }
6337
6479
  if (event.type === "restart_daemon") {
6338
6480
  void handleRestartDaemon(event.payload).catch((error) => {
6339
6481
  logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
@@ -7258,6 +7400,49 @@ export function startDaemon(config = {}, deps = {}) {
7258
7400
  });
7259
7401
  }
7260
7402
 
7403
+ /**
7404
+ * Recover the workspace of a resumable session from the backend's own
7405
+ * session store. Used when neither a launch-config cwd nor a bound project
7406
+ * path is available -- the Default Project resuming a local session -- so the
7407
+ * task runs (and logs) where the conversation actually lives instead of an
7408
+ * unrelated scratch directory.
7409
+ *
7410
+ * Returns "" when the session cannot be located; callers keep their own
7411
+ * fallbacks. `runtimeBackend` short-circuits the alias lookup for callers
7412
+ * that already resolved it.
7413
+ */
7414
+ async function resolveSessionResumeCwd({ backendType, runtimeBackend = "", sessionId }) {
7415
+ const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
7416
+ const normalizedBackend = normalizeRuntimeBackendName(backendType);
7417
+ // opencode has no resumable local session store to look the cwd up in.
7418
+ if (!normalizedSessionId || !normalizedBackend || normalizedBackend === "opencode") {
7419
+ return "";
7420
+ }
7421
+ try {
7422
+ let resumeBackend = normalizeOptionalString(runtimeBackend);
7423
+ if (!resumeBackend) {
7424
+ const configuredBackend = await resolveConfiguredRuntimeBackend(normalizedBackend, ALLOW_CLI_LIST, {
7425
+ configFilePath: config.CONFIG_FILE,
7426
+ });
7427
+ resumeBackend = configuredBackend?.runtimeBackend ||
7428
+ await normalizeRuntimeBackendAlias(normalizedBackend, { configFilePath: config.CONFIG_FILE });
7429
+ }
7430
+ const resumeContext = await (deps.resolveResumeContext || resolveResumeContext)(
7431
+ resumeBackend,
7432
+ normalizedSessionId,
7433
+ {
7434
+ cwd: process.cwd(),
7435
+ configFilePath: config.CONFIG_FILE,
7436
+ },
7437
+ );
7438
+ return normalizeOptionalString(resumeContext?.cwd) || "";
7439
+ } catch {
7440
+ // Provider lookup failed (unknown id, unreadable store): let the caller
7441
+ // fall back rather than failing the task here.
7442
+ return "";
7443
+ }
7444
+ }
7445
+
7261
7446
  async function resolveRestartCwd({
7262
7447
  taskId,
7263
7448
  projectId,
@@ -7291,29 +7476,9 @@ export function startDaemon(config = {}, deps = {}) {
7291
7476
  return boundPath;
7292
7477
  }
7293
7478
 
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
- }
7479
+ const sessionCwd = await resolveSessionResumeCwd({ backendType, sessionId });
7480
+ if (sessionCwd) {
7481
+ return sessionCwd;
7317
7482
  }
7318
7483
 
7319
7484
  const normalizedSessionPath =
@@ -7474,7 +7639,14 @@ export function startDaemon(config = {}, deps = {}) {
7474
7639
  launchConfig,
7475
7640
  })) ||
7476
7641
  normalizeOptionalString(launchConfig.cwd) ||
7477
- boundPath;
7642
+ boundPath ||
7643
+ (await resolveSessionResumeCwd({
7644
+ backendType: requestedBackend,
7645
+ runtimeBackend: effectiveBackend,
7646
+ sessionId:
7647
+ normalizeOptionalString(launchConfig?.resumeSessionId) ||
7648
+ normalizeOptionalString(launchConfig?.resume_session_id),
7649
+ }));
7478
7650
 
7479
7651
  if (resolvedTaskWorkspace) {
7480
7652
  taskDir = resolvedTaskWorkspace;
@@ -8567,6 +8739,9 @@ export function startDaemon(config = {}, deps = {}) {
8567
8739
  clearInterval(tmuxLivenessTimer);
8568
8740
  tmuxLivenessTimer = null;
8569
8741
  }
8742
+ // An in-flight transfer holds an open socket and a `.part` file; abort
8743
+ // them so shutdown is not gated on a multi-gigabyte download finishing.
8744
+ remoteFileHandlers?.abortAll();
8570
8745
  const activeProcessEntries = [...activeTaskProcesses.entries()];
8571
8746
  const activePtyEntries = [...activePtySessions.entries()];
8572
8747
  const activeEntries = [...activeProcessEntries, ...activePtyEntries];