@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 +65 -0
- package/bin/conductor-config.js +4 -0
- package/bin/conductor-remote.js +98 -0
- package/bin/conductor-send-file.js +38 -16
- package/bin/conductor.js +6 -5
- package/package.json +5 -5
- package/src/daemon.js +200 -25
- package/src/fire-session-registry.js +19 -0
- package/src/remote/archive.js +129 -0
- package/src/remote/client.js +204 -0
- package/src/remote/cp.js +796 -0
- package/{bin/conductor-remote-exec.js → src/remote/exec.js} +222 -179
- package/src/remote/wait.js +133 -0
- package/src/remote-file-handlers.js +703 -0
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
|
package/bin/conductor-config.js
CHANGED
|
@@ -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
|
-
|
|
188
|
+
const authHeaders = {
|
|
189
|
+
Authorization: `Bearer ${config.agentToken}`,
|
|
190
|
+
Accept: "application/json",
|
|
191
|
+
};
|
|
193
192
|
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
205
|
-
if (!
|
|
206
|
-
const details = formatErrorBody(
|
|
207
|
-
throw new Error(`Upload failed (${
|
|
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
|
-
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
4
|
-
"gitCommitId": "
|
|
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.
|
|
28
|
-
"@love-moon/conductor-sdk": "0.
|
|
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.
|
|
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(
|
|
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
|
|
7295
|
-
|
|
7296
|
-
|
|
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];
|