@love-moon/conductor-cli 0.15.2 → 0.17.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 +148 -0
- package/bin/conductor-auth.js +162 -0
- package/bin/conductor-config.js +6 -9
- package/bin/conductor-daemon-query.js +13 -17
- package/bin/conductor-daemon.js +6 -1
- package/bin/conductor-fire.js +91 -2
- package/bin/conductor-issue.js +215 -13
- package/bin/conductor-project.js +840 -27
- package/bin/conductor-search.js +150 -0
- package/bin/conductor-settings.js +608 -0
- package/bin/conductor-task.js +344 -9
- package/bin/conductor-update.js +17 -0
- package/bin/conductor.js +29 -8
- package/package.json +5 -5
- package/src/backend-http.js +246 -0
- package/src/daemon-share-commands.js +528 -0
- package/src/daemon-update.js +3 -0
- package/src/daemon.js +51 -11
- package/src/remote/mcp-launch.js +3 -3
- package/src/remote-file-handlers.js +65 -6
- package/src/task-commands.js +813 -0
- package/src/version-check.js +10 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write / remote-control verbs for `conductor daemon`, mirroring the web
|
|
3
|
+
* frontend's daemon panel (AI manager, custom commands, update button, resume
|
|
4
|
+
* sessions) and daemon sharing (DaemonSharingCard, /app/daemon-share/[token]).
|
|
5
|
+
*
|
|
6
|
+
* restart <host> POST /api/agents/:host/restart
|
|
7
|
+
* upgrade <host> [--status] [--wait] POST|GET /api/agents/:host/update
|
|
8
|
+
* sessions <host> GET /api/agents/:host/sessions
|
|
9
|
+
* accounts <host> GET /api/ai-manager/accounts?agentHost=
|
|
10
|
+
* switch-account <host> <name> POST /api/ai-manager/switch
|
|
11
|
+
* commands list <host> GET /api/agents/:host/custom-commands
|
|
12
|
+
* commands run <host> <key> [--wait] POST /api/agents/:host/custom-commands/run
|
|
13
|
+
* commands status <host> <runId> GET /api/agents/:host/custom-commands/runs/:runId
|
|
14
|
+
* share create <host> POST /api/daemon-shares
|
|
15
|
+
* share list [--host h] GET /api/daemon-shares
|
|
16
|
+
* share revoke <id> DELETE /api/daemon-shares/:id
|
|
17
|
+
* share show-invite <token|url> GET /api/daemon-shares/invitations/:token
|
|
18
|
+
* share accept <token|url> POST /api/daemon-shares/accept/:token
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { apiPath, argsError, buildHttp, sendOrPreview } from "./backend-http.js";
|
|
22
|
+
import { EXIT, pad, printJson, printPretty } from "./entity-helpers.js";
|
|
23
|
+
|
|
24
|
+
export const DAEMON_REMOTE_VERBS = [
|
|
25
|
+
"restart",
|
|
26
|
+
"upgrade",
|
|
27
|
+
"sessions",
|
|
28
|
+
"accounts",
|
|
29
|
+
"switch-account",
|
|
30
|
+
"commands",
|
|
31
|
+
"share",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// The frontend's SDK client gives ai-manager round trips 45s (the server waits
|
|
35
|
+
// up to 30s for the daemon on a switch).
|
|
36
|
+
const AI_MANAGER_TIMEOUT_MS = 45_000;
|
|
37
|
+
// Same cadence as UpdateDaemonButton / CustomCommandsPanel.
|
|
38
|
+
const POLL_INTERVAL_MS = 2_000;
|
|
39
|
+
// UpdateDaemonButton gives install + verify + restart 15 minutes.
|
|
40
|
+
const UPGRADE_WAIT_TIMEOUT_MS = 15 * 60_000;
|
|
41
|
+
const COMMAND_WAIT_TIMEOUT_MS = 60 * 60_000;
|
|
42
|
+
|
|
43
|
+
export function printTable(stream, header, rows) {
|
|
44
|
+
const widths = header
|
|
45
|
+
.slice(0, -1)
|
|
46
|
+
.map((title, i) => Math.max(title.length, ...rows.map((row) => String(row[i] ?? "").length)));
|
|
47
|
+
for (const row of [header, ...rows]) {
|
|
48
|
+
printPretty(stream, row.map((cell, i) => (i < widths.length ? pad(cell, widths[i]) : (cell ?? ""))).join(" "));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
53
|
+
|
|
54
|
+
function oneLine(text, max = 80) {
|
|
55
|
+
const flat = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
56
|
+
return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Accept a bare invite token or a full `.../app/daemon-share/<token>` URL. */
|
|
60
|
+
export function extractInviteToken(input) {
|
|
61
|
+
const raw = String(input ?? "").trim();
|
|
62
|
+
if (!raw) throw argsError("invite token or URL is required");
|
|
63
|
+
const match = raw.match(/\/daemon-share\/([^/?#]+)/);
|
|
64
|
+
if (match) return decodeURIComponent(match[1]);
|
|
65
|
+
if (/^[a-z]+:\/\//i.test(raw)) throw argsError(`not a daemon share invite URL: ${raw}`);
|
|
66
|
+
return raw;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------- restart
|
|
70
|
+
|
|
71
|
+
async function handleRestart(argv, deps) {
|
|
72
|
+
const http = await buildHttp(deps);
|
|
73
|
+
const body = argv.targetVersion ? { targetVersion: argv.targetVersion } : {};
|
|
74
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", apiPath("agents", argv.host, "restart"), body);
|
|
75
|
+
if (dryRun) return EXIT.OK;
|
|
76
|
+
if (argv.json) printJson(deps.stdout, data);
|
|
77
|
+
else printPretty(deps.stdout, `Restart requested for ${argv.host}`);
|
|
78
|
+
return EXIT.OK;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------- upgrade
|
|
82
|
+
|
|
83
|
+
function describeUpgrade(status) {
|
|
84
|
+
if (!status || typeof status !== "object") return "(no update status)";
|
|
85
|
+
const parts = [`status: ${status.status ?? "unknown"}`];
|
|
86
|
+
if (status.phase) parts.push(`phase: ${status.phase}`);
|
|
87
|
+
if (status.fromVersion || status.toVersion) {
|
|
88
|
+
parts.push(`version: ${status.fromVersion ?? "?"} -> ${status.toVersion ?? "?"}`);
|
|
89
|
+
}
|
|
90
|
+
if (status.message) parts.push(`message: ${status.message}`);
|
|
91
|
+
if (status.error) parts.push(`error: ${status.error}`);
|
|
92
|
+
if (status.logPath) parts.push(`log: ${status.logPath}`);
|
|
93
|
+
return parts.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function upgradeExitCode(status) {
|
|
97
|
+
return status?.status === "failed" ? EXIT.GENERIC : EXIT.OK;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function waitForUpgrade(http, argv, deps, initial) {
|
|
101
|
+
const sleep = deps.sleep || defaultSleep;
|
|
102
|
+
const now = deps.now || Date.now;
|
|
103
|
+
const path = apiPath("agents", argv.host, "update");
|
|
104
|
+
const deadline = now() + (argv.waitTimeout ?? UPGRADE_WAIT_TIMEOUT_MS / 1000) * 1000;
|
|
105
|
+
let last = initial;
|
|
106
|
+
let lastLine = "";
|
|
107
|
+
const progress = (status) => {
|
|
108
|
+
if (argv.json) return;
|
|
109
|
+
const line = [status?.status, status?.phase, status?.message].filter(Boolean).join(" · ");
|
|
110
|
+
if (line && line !== lastLine) {
|
|
111
|
+
deps.stderr.write(`${line}\n`);
|
|
112
|
+
lastLine = line;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
if (last) progress(last);
|
|
116
|
+
// The daemon restarts itself midway through a successful update, so a failed
|
|
117
|
+
// poll means "not answering right now", not "the update failed".
|
|
118
|
+
while (!last || last.status === "running") {
|
|
119
|
+
if (now() > deadline) {
|
|
120
|
+
throw new Error(`Lost track of the daemon update: ${argv.host} did not reach a final state in time`);
|
|
121
|
+
}
|
|
122
|
+
await sleep(POLL_INTERVAL_MS);
|
|
123
|
+
try {
|
|
124
|
+
last = await http.get(path);
|
|
125
|
+
progress(last);
|
|
126
|
+
} catch {
|
|
127
|
+
if (!argv.json && lastLine !== "(daemon not answering, likely restarting)") {
|
|
128
|
+
lastLine = "(daemon not answering, likely restarting)";
|
|
129
|
+
deps.stderr.write(`${lastLine}\n`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return last;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function handleUpgrade(argv, deps) {
|
|
137
|
+
const http = await buildHttp(deps);
|
|
138
|
+
const path = apiPath("agents", argv.host, "update");
|
|
139
|
+
let status;
|
|
140
|
+
if (argv.status) {
|
|
141
|
+
if (argv.dryRun) throw argsError("--dry-run cannot be combined with --status");
|
|
142
|
+
status = await http.get(path);
|
|
143
|
+
} else {
|
|
144
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", path, undefined);
|
|
145
|
+
if (dryRun) return EXIT.OK;
|
|
146
|
+
status = data;
|
|
147
|
+
}
|
|
148
|
+
if (argv.wait) status = await waitForUpgrade(http, argv, deps, status);
|
|
149
|
+
if (argv.json) printJson(deps.stdout, status);
|
|
150
|
+
else printPretty(deps.stdout, describeUpgrade(status));
|
|
151
|
+
return argv.wait || argv.status ? upgradeExitCode(status) : EXIT.OK;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------- sessions
|
|
155
|
+
|
|
156
|
+
async function handleSessions(argv, deps) {
|
|
157
|
+
const http = await buildHttp(deps);
|
|
158
|
+
const backends = (argv.backend ?? [])
|
|
159
|
+
.flatMap((entry) => String(entry).split(","))
|
|
160
|
+
.map((entry) => entry.trim())
|
|
161
|
+
.filter(Boolean);
|
|
162
|
+
const data = await http.get(apiPath("agents", argv.host, "sessions"), {
|
|
163
|
+
query: {
|
|
164
|
+
backends: backends.length > 0 ? backends.join(",") : undefined,
|
|
165
|
+
limit: argv.limit,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
if (argv.json) {
|
|
169
|
+
printJson(deps.stdout, data);
|
|
170
|
+
return EXIT.OK;
|
|
171
|
+
}
|
|
172
|
+
const sessions = data?.sessions ?? [];
|
|
173
|
+
if (sessions.length === 0) {
|
|
174
|
+
printPretty(deps.stdout, "(no resumable sessions)");
|
|
175
|
+
} else {
|
|
176
|
+
printTable(deps.stdout, ["BACKEND", "SESSION", "UPDATED", "TASK", "CWD", "TITLE"], sessions.map((s) => [
|
|
177
|
+
s.backend ?? "",
|
|
178
|
+
s.session_id ?? "",
|
|
179
|
+
s.updated_at ?? "",
|
|
180
|
+
s.linked_task_id ?? "",
|
|
181
|
+
s.cwd ?? "",
|
|
182
|
+
oneLine(s.title || s.first_user_message || ""),
|
|
183
|
+
]));
|
|
184
|
+
}
|
|
185
|
+
for (const error of data?.errors ?? []) {
|
|
186
|
+
deps.stderr.write(`warning: ${error.backend}: ${error.message}\n`);
|
|
187
|
+
}
|
|
188
|
+
return EXIT.OK;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------- accounts
|
|
192
|
+
|
|
193
|
+
async function handleAccounts(argv, deps) {
|
|
194
|
+
const http = await buildHttp(deps);
|
|
195
|
+
const data = await http.get("/api/ai-manager/accounts", {
|
|
196
|
+
query: { agentHost: argv.host },
|
|
197
|
+
timeoutMs: AI_MANAGER_TIMEOUT_MS,
|
|
198
|
+
});
|
|
199
|
+
if (argv.json) {
|
|
200
|
+
printJson(deps.stdout, data);
|
|
201
|
+
return EXIT.OK;
|
|
202
|
+
}
|
|
203
|
+
const accounts = data?.accounts ?? [];
|
|
204
|
+
if (accounts.length === 0) {
|
|
205
|
+
printPretty(deps.stdout, "(no codex accounts)");
|
|
206
|
+
return EXIT.OK;
|
|
207
|
+
}
|
|
208
|
+
printTable(deps.stdout, ["", "NAME", "EMAIL", "PLAN", "LAST REFRESH"], accounts.map((a) => [
|
|
209
|
+
a.isCurrent ? "*" : "",
|
|
210
|
+
a.name ?? "",
|
|
211
|
+
a.email ?? "",
|
|
212
|
+
a.planType ?? "",
|
|
213
|
+
a.lastRefresh ?? "",
|
|
214
|
+
]));
|
|
215
|
+
return EXIT.OK;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function handleSwitchAccount(argv, deps) {
|
|
219
|
+
const name = argv.account ?? argv.name;
|
|
220
|
+
if (!name) throw argsError("account name is required (positional <name> or --account)");
|
|
221
|
+
const http = await buildHttp(deps);
|
|
222
|
+
const { dryRun, data } = await sendOrPreview(
|
|
223
|
+
http,
|
|
224
|
+
argv,
|
|
225
|
+
deps,
|
|
226
|
+
"POST",
|
|
227
|
+
"/api/ai-manager/switch",
|
|
228
|
+
{ agentHost: argv.host, name },
|
|
229
|
+
{ timeoutMs: AI_MANAGER_TIMEOUT_MS },
|
|
230
|
+
);
|
|
231
|
+
if (dryRun) return EXIT.OK;
|
|
232
|
+
if (argv.json) printJson(deps.stdout, data);
|
|
233
|
+
else {
|
|
234
|
+
const from = data?.previousName ? ` (was ${data.previousName})` : "";
|
|
235
|
+
printPretty(deps.stdout, `Switched codex account on ${argv.host} to ${data?.newName ?? name}${from}`);
|
|
236
|
+
}
|
|
237
|
+
return EXIT.OK;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------- custom commands
|
|
241
|
+
|
|
242
|
+
async function handleCommandsList(argv, deps) {
|
|
243
|
+
const http = await buildHttp(deps);
|
|
244
|
+
const data = await http.get(apiPath("agents", argv.host, "custom-commands"));
|
|
245
|
+
if (argv.json) {
|
|
246
|
+
printJson(deps.stdout, data);
|
|
247
|
+
return EXIT.OK;
|
|
248
|
+
}
|
|
249
|
+
const commands = data?.commands ?? [];
|
|
250
|
+
if (commands.length === 0) {
|
|
251
|
+
printPretty(deps.stdout, "(no custom commands configured)");
|
|
252
|
+
return EXIT.OK;
|
|
253
|
+
}
|
|
254
|
+
printTable(deps.stdout, ["KEY", "STATE", "RUN ID"], commands.map((c) => [
|
|
255
|
+
c.key,
|
|
256
|
+
c.running ? "running" : "idle",
|
|
257
|
+
c.runId ?? "",
|
|
258
|
+
]));
|
|
259
|
+
return EXIT.OK;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function printRunStatus(stream, run) {
|
|
263
|
+
const lines = [`run: ${run?.runId ?? ""}`, `command: ${run?.key ?? ""}`, `status: ${run?.status ?? "unknown"}`];
|
|
264
|
+
if (run?.exitCode != null) lines.push(`exit code: ${run.exitCode}`);
|
|
265
|
+
if (run?.signal) lines.push(`signal: ${run.signal}`);
|
|
266
|
+
if (run?.error) lines.push(`error: ${run.error}`);
|
|
267
|
+
printPretty(stream, lines.join("\n"));
|
|
268
|
+
if (run?.stdoutTail) printPretty(stream, `--- stdout (tail) ---\n${run.stdoutTail.replace(/\n$/, "")}`);
|
|
269
|
+
if (run?.stderrTail) printPretty(stream, `--- stderr (tail) ---\n${run.stderrTail.replace(/\n$/, "")}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function runExitCode(run) {
|
|
273
|
+
return run?.status === "failed" ? EXIT.GENERIC : EXIT.OK;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function handleCommandsRun(argv, deps) {
|
|
277
|
+
const http = await buildHttp(deps);
|
|
278
|
+
const { dryRun, data } = await sendOrPreview(
|
|
279
|
+
http,
|
|
280
|
+
argv,
|
|
281
|
+
deps,
|
|
282
|
+
"POST",
|
|
283
|
+
apiPath("agents", argv.host, "custom-commands", "run"),
|
|
284
|
+
{ key: argv.key },
|
|
285
|
+
);
|
|
286
|
+
if (dryRun) return EXIT.OK;
|
|
287
|
+
if (!argv.wait) {
|
|
288
|
+
if (argv.json) printJson(deps.stdout, data);
|
|
289
|
+
else {
|
|
290
|
+
printPretty(deps.stdout, `Started ${data?.key ?? argv.key} (run ${data?.runId ?? "?"}, ${data?.status ?? "running"})`);
|
|
291
|
+
if (data?.runId) printPretty(deps.stdout, `Follow with: conductor daemon commands status ${argv.host} ${data.runId}`);
|
|
292
|
+
}
|
|
293
|
+
return runExitCode(data);
|
|
294
|
+
}
|
|
295
|
+
const sleep = deps.sleep || defaultSleep;
|
|
296
|
+
const now = deps.now || Date.now;
|
|
297
|
+
const deadline = now() + (argv.waitTimeout ?? COMMAND_WAIT_TIMEOUT_MS / 1000) * 1000;
|
|
298
|
+
let run = data;
|
|
299
|
+
let pollWarned = false;
|
|
300
|
+
const statusPath = apiPath("agents", argv.host, "custom-commands", "runs", data?.runId ?? "");
|
|
301
|
+
while (run?.status === "running") {
|
|
302
|
+
if (!data?.runId) throw new Error("server did not return a runId to wait on");
|
|
303
|
+
if (now() > deadline) throw new Error(`timed out waiting for run ${data.runId}`);
|
|
304
|
+
await sleep(POLL_INTERVAL_MS);
|
|
305
|
+
try {
|
|
306
|
+
run = await http.get(statusPath);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
// A 5xx / network blip on the status route says nothing about the run,
|
|
309
|
+
// which keeps going on the daemon: keep polling until the deadline.
|
|
310
|
+
// 4xx (unknown run, lost access) will not heal, so surface it.
|
|
311
|
+
const status = error?.statusCode;
|
|
312
|
+
if (typeof status === "number" && status < 500) throw error;
|
|
313
|
+
if (!argv.json && !pollWarned) {
|
|
314
|
+
deps.stderr.write(`(status poll failed: ${error?.message ?? error}; retrying)\n`);
|
|
315
|
+
pollWarned = true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (argv.json) printJson(deps.stdout, run);
|
|
320
|
+
else printRunStatus(deps.stdout, run);
|
|
321
|
+
return runExitCode(run);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function handleCommandsStatus(argv, deps) {
|
|
325
|
+
const http = await buildHttp(deps);
|
|
326
|
+
const run = await http.get(apiPath("agents", argv.host, "custom-commands", "runs", argv.runId));
|
|
327
|
+
if (argv.json) printJson(deps.stdout, run);
|
|
328
|
+
else printRunStatus(deps.stdout, run);
|
|
329
|
+
return EXIT.OK;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------- sharing
|
|
333
|
+
|
|
334
|
+
async function handleShareCreate(argv, deps) {
|
|
335
|
+
const http = await buildHttp(deps);
|
|
336
|
+
const body = { daemonHost: argv.host };
|
|
337
|
+
if (argv.workspaceRoot) body.workspaceRoot = argv.workspaceRoot;
|
|
338
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", "/api/daemon-shares", body);
|
|
339
|
+
if (dryRun) return EXIT.OK;
|
|
340
|
+
if (argv.json) {
|
|
341
|
+
printJson(deps.stdout, data);
|
|
342
|
+
return EXIT.OK;
|
|
343
|
+
}
|
|
344
|
+
printPretty(deps.stdout, `Created share ${data?.id} for ${data?.ownerDaemonHost ?? argv.host}`);
|
|
345
|
+
if (data?.expiresAt) printPretty(deps.stdout, `Invite expires: ${data.expiresAt}`);
|
|
346
|
+
printPretty(deps.stdout, `Invite link: ${data?.inviteUrl ?? ""}`);
|
|
347
|
+
return EXIT.OK;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function handleShareList(argv, deps) {
|
|
351
|
+
const http = await buildHttp(deps);
|
|
352
|
+
const data = await http.get("/api/daemon-shares", { query: { daemonHost: argv.host } });
|
|
353
|
+
if (argv.json) {
|
|
354
|
+
printJson(deps.stdout, data);
|
|
355
|
+
return EXIT.OK;
|
|
356
|
+
}
|
|
357
|
+
const shares = data?.shares ?? [];
|
|
358
|
+
if (shares.length === 0) {
|
|
359
|
+
printPretty(deps.stdout, "(no active shares)");
|
|
360
|
+
return EXIT.OK;
|
|
361
|
+
}
|
|
362
|
+
printTable(deps.stdout, ["ID", "DAEMON", "STATUS", "GRANTEE", "GUEST HOST", "EXPIRES", "INVITE"], shares.map((s) => [
|
|
363
|
+
s.id,
|
|
364
|
+
s.ownerDaemonHost ?? "",
|
|
365
|
+
s.status ?? "",
|
|
366
|
+
s.granteeLabel ?? "",
|
|
367
|
+
s.guestHost ?? "",
|
|
368
|
+
s.status === "pending" ? (s.expiresAt ?? "") : "",
|
|
369
|
+
s.status === "pending" ? (s.inviteUrl ?? "") : "",
|
|
370
|
+
]));
|
|
371
|
+
return EXIT.OK;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function handleShareRevoke(argv, deps) {
|
|
375
|
+
const http = await buildHttp(deps);
|
|
376
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "DELETE", apiPath("daemon-shares", argv.id), undefined);
|
|
377
|
+
if (dryRun) return EXIT.OK;
|
|
378
|
+
if (argv.json) printJson(deps.stdout, data);
|
|
379
|
+
else printPretty(deps.stdout, data?.alreadyRevoked ? `Share ${argv.id} was already revoked` : `Revoked share ${argv.id}`);
|
|
380
|
+
return EXIT.OK;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function handleShareShowInvite(argv, deps) {
|
|
384
|
+
const token = extractInviteToken(argv.token);
|
|
385
|
+
const http = await buildHttp(deps);
|
|
386
|
+
const data = await http.get(apiPath("daemon-shares", "invitations", token));
|
|
387
|
+
if (argv.json) {
|
|
388
|
+
printJson(deps.stdout, data);
|
|
389
|
+
return EXIT.OK;
|
|
390
|
+
}
|
|
391
|
+
const lines = [
|
|
392
|
+
`owner: ${data?.ownerLabel ?? ""}`,
|
|
393
|
+
`daemon: ${data?.ownerDaemonHost ?? ""}`,
|
|
394
|
+
`status: ${data?.status ?? ""}${data?.isSelf ? " (your own daemon)" : ""}`,
|
|
395
|
+
];
|
|
396
|
+
if (data?.workspaceRoot) lines.push(`workspace root: ${data.workspaceRoot}`);
|
|
397
|
+
if (data?.expiresAt) lines.push(`expires: ${data.expiresAt}`);
|
|
398
|
+
if (data?.guestHost) lines.push(`guest host: ${data.guestHost}`);
|
|
399
|
+
printPretty(deps.stdout, lines.join("\n"));
|
|
400
|
+
return EXIT.OK;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function handleShareAccept(argv, deps) {
|
|
404
|
+
const token = extractInviteToken(argv.token);
|
|
405
|
+
const http = await buildHttp(deps);
|
|
406
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", apiPath("daemon-shares", "accept", token), {});
|
|
407
|
+
if (dryRun) return EXIT.OK;
|
|
408
|
+
if (argv.json) printJson(deps.stdout, data);
|
|
409
|
+
else printPretty(deps.stdout, `Accepted. The shared machine appears as ${data?.guestHost ?? "(unknown)"}`);
|
|
410
|
+
return EXIT.OK;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------- registration
|
|
414
|
+
|
|
415
|
+
const hostPositional = (cmd) => cmd.positional("host", { type: "string", describe: "Daemon host name" });
|
|
416
|
+
|
|
417
|
+
/** Register the verbs on the `conductor daemon` yargs instance. */
|
|
418
|
+
/**
|
|
419
|
+
* `--dry-run` is a global daemon option but only write verbs can preview a
|
|
420
|
+
* request; reject it on read-only verbs instead of silently ignoring it.
|
|
421
|
+
*/
|
|
422
|
+
export function readOnly(handler) {
|
|
423
|
+
return (argv, deps) => {
|
|
424
|
+
if (argv.dryRun) throw argsError("--dry-run only applies to verbs that change something; this one only reads");
|
|
425
|
+
return handler(argv, deps);
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function registerDaemonRemoteCommands(y, run) {
|
|
430
|
+
return y
|
|
431
|
+
.command(
|
|
432
|
+
"restart <host>",
|
|
433
|
+
"Restart a daemon (it reconnects on its own)",
|
|
434
|
+
(cmd) => hostPositional(cmd)
|
|
435
|
+
.option("target-version", { type: "string", describe: "'latest' or a semver to restart onto" }),
|
|
436
|
+
run(handleRestart),
|
|
437
|
+
)
|
|
438
|
+
.command(
|
|
439
|
+
"upgrade <host>",
|
|
440
|
+
"Upgrade the conductor CLI on a daemon and restart it",
|
|
441
|
+
(cmd) => hostPositional(cmd)
|
|
442
|
+
.option("status", { type: "boolean", default: false, describe: "Only show the current/last upgrade run" })
|
|
443
|
+
.option("wait", { type: "boolean", default: false, describe: "Poll until the upgrade completes or fails" })
|
|
444
|
+
.option("wait-timeout", { type: "number", describe: "Seconds to wait with --wait (default 900)" }),
|
|
445
|
+
run(handleUpgrade),
|
|
446
|
+
)
|
|
447
|
+
.command(
|
|
448
|
+
"sessions <host>",
|
|
449
|
+
"List resumable AI sessions (claude/codex/...) on a daemon",
|
|
450
|
+
(cmd) => hostPositional(cmd)
|
|
451
|
+
.option("backend", { type: "array", string: true, describe: "Only these backends (repeat or comma-separate)" })
|
|
452
|
+
.option("limit", { type: "number", describe: "Max sessions (1-200)" }),
|
|
453
|
+
run(readOnly(handleSessions)),
|
|
454
|
+
)
|
|
455
|
+
.command(
|
|
456
|
+
"accounts <host>",
|
|
457
|
+
"List the codex accounts stored on a daemon (* = current)",
|
|
458
|
+
hostPositional,
|
|
459
|
+
run(readOnly(handleAccounts)),
|
|
460
|
+
)
|
|
461
|
+
.command(
|
|
462
|
+
"switch-account <host> [name]",
|
|
463
|
+
"Switch the active codex account on a daemon",
|
|
464
|
+
(cmd) => hostPositional(cmd)
|
|
465
|
+
.positional("name", { type: "string", describe: "Account name (see `daemon accounts`)" })
|
|
466
|
+
.option("account", { type: "string", describe: "Account name (alternative to the positional)" }),
|
|
467
|
+
run(handleSwitchAccount),
|
|
468
|
+
)
|
|
469
|
+
.command(
|
|
470
|
+
"commands",
|
|
471
|
+
"List, run and inspect a daemon's custom commands",
|
|
472
|
+
(cmd) => cmd
|
|
473
|
+
.command("list <host>", "List custom commands configured on a daemon", hostPositional, run(readOnly(handleCommandsList)))
|
|
474
|
+
.command(
|
|
475
|
+
"run <host> <key>",
|
|
476
|
+
"Start a custom command",
|
|
477
|
+
(sub) => hostPositional(sub)
|
|
478
|
+
.positional("key", { type: "string", describe: "Command key (see `commands list`)" })
|
|
479
|
+
.option("wait", { type: "boolean", default: false, describe: "Poll until the run finishes and print its output" })
|
|
480
|
+
.option("wait-timeout", { type: "number", describe: "Seconds to wait with --wait (default 3600)" }),
|
|
481
|
+
run(handleCommandsRun),
|
|
482
|
+
)
|
|
483
|
+
.command(
|
|
484
|
+
"status <host> <runId>",
|
|
485
|
+
"Show a custom command run's status, exit code and output tail",
|
|
486
|
+
(sub) => hostPositional(sub).positional("runId", { type: "string" }),
|
|
487
|
+
run(readOnly(handleCommandsStatus)),
|
|
488
|
+
)
|
|
489
|
+
.demandCommand(1),
|
|
490
|
+
)
|
|
491
|
+
.command(
|
|
492
|
+
"share",
|
|
493
|
+
"Share a daemon with another user, or accept a share invite",
|
|
494
|
+
(cmd) => cmd
|
|
495
|
+
.command(
|
|
496
|
+
"create <host>",
|
|
497
|
+
"Create an invite link for one of your online daemons",
|
|
498
|
+
(sub) => hostPositional(sub)
|
|
499
|
+
.option("workspace-root", { type: "string", describe: "Restrict the guest to this directory" }),
|
|
500
|
+
run(handleShareCreate),
|
|
501
|
+
)
|
|
502
|
+
.command(
|
|
503
|
+
"list",
|
|
504
|
+
"List the live shares of your daemons",
|
|
505
|
+
(sub) => sub.option("host", { type: "string", describe: "Only shares of this daemon" }),
|
|
506
|
+
run(readOnly(handleShareList)),
|
|
507
|
+
)
|
|
508
|
+
.command(
|
|
509
|
+
"revoke <id>",
|
|
510
|
+
"Revoke a share (as owner or grantee)",
|
|
511
|
+
(sub) => sub.positional("id", { type: "string" }),
|
|
512
|
+
run(handleShareRevoke),
|
|
513
|
+
)
|
|
514
|
+
.command(
|
|
515
|
+
"show-invite <token>",
|
|
516
|
+
"Show what an invite token/link grants",
|
|
517
|
+
(sub) => sub.positional("token", { type: "string", describe: "Invite token or full invite URL" }),
|
|
518
|
+
run(readOnly(handleShareShowInvite)),
|
|
519
|
+
)
|
|
520
|
+
.command(
|
|
521
|
+
"accept <token>",
|
|
522
|
+
"Accept a daemon share invite",
|
|
523
|
+
(sub) => sub.positional("token", { type: "string", describe: "Invite token or full invite URL" }),
|
|
524
|
+
run(handleShareAccept),
|
|
525
|
+
)
|
|
526
|
+
.demandCommand(1),
|
|
527
|
+
);
|
|
528
|
+
}
|
package/src/daemon-update.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
detectPackageManager,
|
|
12
12
|
fetchLatestVersion,
|
|
13
13
|
isNewerVersion,
|
|
14
|
+
resolveBundledNodeDir,
|
|
14
15
|
resolveGlobalInstallPrefix,
|
|
15
16
|
} from "./version-check.js";
|
|
16
17
|
import {
|
|
@@ -283,6 +284,8 @@ function buildInstallEnv(packageManager, packageRoot, env) {
|
|
|
283
284
|
const prefix = resolveGlobalInstallPrefix(packageRoot);
|
|
284
285
|
if (prefix) installEnv.npm_config_prefix = prefix;
|
|
285
286
|
}
|
|
287
|
+
const nodeDir = resolveBundledNodeDir();
|
|
288
|
+
if (nodeDir && !installEnv.npm_config_nodedir) installEnv.npm_config_nodedir = nodeDir;
|
|
286
289
|
return installEnv;
|
|
287
290
|
}
|
|
288
291
|
|
package/src/daemon.js
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
import { REMOTE_WORKTREE_ENV, remoteWorktreeFireEnv } from "./remote/mcp-launch.js";
|
|
48
48
|
import {
|
|
49
49
|
REMOTE_FILE_CAPABILITY,
|
|
50
|
+
REMOTE_FILE_PREVIEW_CAPABILITY,
|
|
50
51
|
createRemoteFileHandlers,
|
|
51
52
|
handleRemoteFileRequest,
|
|
52
53
|
} from "./remote-file-handlers.js";
|
|
@@ -68,7 +69,7 @@ import {
|
|
|
68
69
|
normalizeRuntimeBackendAlias,
|
|
69
70
|
normalizeRuntimeBackendName,
|
|
70
71
|
} from "./runtime-backends.js";
|
|
71
|
-
import {
|
|
72
|
+
import { buildClaudeRootSandboxNotice, claudeCommandNeedsRootSandbox } from "@love-moon/ai-sdk";
|
|
72
73
|
import {
|
|
73
74
|
PACKAGE_NAME,
|
|
74
75
|
buildUpgradeCommand,
|
|
@@ -573,15 +574,20 @@ export function ensureNodePtySpawnHelperExecutable(deps = {}) {
|
|
|
573
574
|
}
|
|
574
575
|
|
|
575
576
|
// A tool-preset PTY task runs the configured allow_cli_list command through a
|
|
576
|
-
// login shell, bypassing ai-sdk entirely.
|
|
577
|
-
// `--dangerously-skip-permissions
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
export function resolvePtyToolPresetCommand(cliCommand, env = process.env) {
|
|
577
|
+
// login shell, bypassing ai-sdk entirely. The command is never rewritten: as
|
|
578
|
+
// root without IS_SANDBOX=1, claude refuses `--dangerously-skip-permissions`
|
|
579
|
+
// and exits, so print the same IS_SANDBOX hint the chat path shows, right in
|
|
580
|
+
// the terminal before claude's own error.
|
|
581
|
+
export function resolvePtyToolPresetCommand(cliCommand, env = process.env, options = {}) {
|
|
581
582
|
if (inferBuiltInRuntimeBackendFromCommand(cliCommand) !== "claude") {
|
|
582
583
|
return cliCommand;
|
|
583
584
|
}
|
|
584
|
-
|
|
585
|
+
if (!claudeCommandNeedsRootSandbox(cliCommand, env)) {
|
|
586
|
+
return cliCommand;
|
|
587
|
+
}
|
|
588
|
+
const notice = buildClaudeRootSandboxNotice({ configFile: options.configFile });
|
|
589
|
+
const quoted = `'${notice.replace(/'/g, "'\\''")}'`;
|
|
590
|
+
return `printf '%s\\n\\n' ${quoted} >&2; ${cliCommand}`;
|
|
585
591
|
}
|
|
586
592
|
|
|
587
593
|
export function isSafeTaskWorktreeRoot(projectWorkspacePath, worktreeRoot) {
|
|
@@ -989,6 +995,21 @@ function buildPtyTaskEnv(baseEnv = process.env, launchEnv = {}) {
|
|
|
989
995
|
};
|
|
990
996
|
}
|
|
991
997
|
|
|
998
|
+
const CLAUDE_ROOT_OPT_OUT_ENV_KEYS = ["IS_SANDBOX", "CLAUDE_CODE_BUBBLEWRAP"];
|
|
999
|
+
|
|
1000
|
+
function pickClaudeRootOptOutEnv(configEnv) {
|
|
1001
|
+
const picked = {};
|
|
1002
|
+
if (!configEnv || typeof configEnv !== "object") {
|
|
1003
|
+
return picked;
|
|
1004
|
+
}
|
|
1005
|
+
for (const key of CLAUDE_ROOT_OPT_OUT_ENV_KEYS) {
|
|
1006
|
+
if (configEnv[key] !== undefined && configEnv[key] !== null) {
|
|
1007
|
+
picked[key] = String(configEnv[key]);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return picked;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
992
1013
|
// Module-level so the PTY launch wiring is directly testable: the tool-preset
|
|
993
1014
|
// branch has to hand the *child's* env to the root check, and that seam is
|
|
994
1015
|
// exactly where a bug hid before (the check read the daemon's own process.env,
|
|
@@ -1037,9 +1058,17 @@ export function buildPtyLaunchSpec(launchConfig, fallbackCwd, deps = {}) {
|
|
|
1037
1058
|
if (!cliCommand) {
|
|
1038
1059
|
throw new Error(`Unsupported tool preset: ${toolPreset}`);
|
|
1039
1060
|
}
|
|
1040
|
-
|
|
1061
|
+
// The PTY child inherits the daemon env, not the config's `envs:` block,
|
|
1062
|
+
// yet the root hint tells users to set `envs.IS_SANDBOX: "1"`. Forward
|
|
1063
|
+
// just claude's root opt-outs so that advice works for terminal tasks too.
|
|
1064
|
+
// launch_config.env still wins, matching the spawned env.
|
|
1065
|
+
const isClaudePreset = inferBuiltInRuntimeBackendFromCommand(cliCommand) === "claude";
|
|
1066
|
+
const launchEnv = isClaudePreset ? { ...pickClaudeRootOptOutEnv(deps.configEnv), ...env } : env;
|
|
1067
|
+
const launchCommand = resolvePtyToolPresetCommand(cliCommand, buildPtyTaskEnv(baseEnv, launchEnv), {
|
|
1068
|
+
configFile: deps.configFile,
|
|
1069
|
+
});
|
|
1041
1070
|
if (launchCommand !== cliCommand) {
|
|
1042
|
-
log(`[pty]
|
|
1071
|
+
log(`[pty] ${toolPreset} runs as root without IS_SANDBOX=1; claude will refuse bypassPermissions`);
|
|
1043
1072
|
}
|
|
1044
1073
|
return {
|
|
1045
1074
|
entrypointType,
|
|
@@ -1048,7 +1077,7 @@ export function buildPtyLaunchSpec(launchConfig, fallbackCwd, deps = {}) {
|
|
|
1048
1077
|
args: ["-lc", launchCommand],
|
|
1049
1078
|
shell: preferredShell,
|
|
1050
1079
|
cwd,
|
|
1051
|
-
env,
|
|
1080
|
+
env: launchEnv,
|
|
1052
1081
|
cols,
|
|
1053
1082
|
rows,
|
|
1054
1083
|
};
|
|
@@ -3909,7 +3938,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3909
3938
|
log("[remote-exec] Disabled by config (remote_exec: false); capability not advertised");
|
|
3910
3939
|
}
|
|
3911
3940
|
if (remoteFileEnabled) {
|
|
3912
|
-
advertisedCapabilities.push(REMOTE_FILE_CAPABILITY);
|
|
3941
|
+
advertisedCapabilities.push(REMOTE_FILE_CAPABILITY, REMOTE_FILE_PREVIEW_CAPABILITY);
|
|
3913
3942
|
} else {
|
|
3914
3943
|
log("[remote-file] Disabled by config (remote_file: false); capability not advertised");
|
|
3915
3944
|
}
|
|
@@ -3985,6 +4014,15 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3985
4014
|
config: sdkConfig,
|
|
3986
4015
|
agentHost: AGENT_NAME,
|
|
3987
4016
|
guestRoot: IS_GUEST_DAEMON ? GUEST_ROOT : null,
|
|
4017
|
+
// Every Fire logs to `<task dir>/conductor.log`, so the log path is the
|
|
4018
|
+
// one place the working directory survives a daemon restart (adopted
|
|
4019
|
+
// tmux records carry it too).
|
|
4020
|
+
resolveTaskCwd: (taskId) => {
|
|
4021
|
+
const ptyCwd = activePtySessions.get(taskId)?.cwd;
|
|
4022
|
+
if (ptyCwd) return ptyCwd;
|
|
4023
|
+
const logPath = activeTaskProcesses.get(taskId)?.logPath;
|
|
4024
|
+
return logPath ? path.dirname(logPath) : null;
|
|
4025
|
+
},
|
|
3988
4026
|
})
|
|
3989
4027
|
: null;
|
|
3990
4028
|
|
|
@@ -5554,6 +5592,8 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5554
5592
|
function resolvePtyLaunchSpec(launchConfig, fallbackCwd) {
|
|
5555
5593
|
return buildPtyLaunchSpec(launchConfig, fallbackCwd, {
|
|
5556
5594
|
allowCliList: ALLOW_CLI_LIST,
|
|
5595
|
+
configEnv: userConfig?.envs,
|
|
5596
|
+
configFile: effectiveConfigPath,
|
|
5557
5597
|
supportedBackends: SUPPORTED_BACKENDS,
|
|
5558
5598
|
existsSync: existsSyncFn,
|
|
5559
5599
|
log,
|
package/src/remote/mcp-launch.js
CHANGED
|
@@ -139,9 +139,9 @@ export function buildRemoteWorktreeSessionOptions({
|
|
|
139
139
|
...(sessionOptions.mcpServers || {}),
|
|
140
140
|
[MCP_SERVER_NAME]: { type: "stdio", command: execPath, args },
|
|
141
141
|
},
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// user chose on purpose is left alone rather than widened.
|
|
142
|
+
// Pre-approve the remote_* tools under the default bypassPermissions so
|
|
143
|
+
// they keep working if claude ever runs in a prompting mode headless. A
|
|
144
|
+
// mode the user chose on purpose is left alone rather than widened.
|
|
145
145
|
...(mode === undefined || mode === "bypassPermissions"
|
|
146
146
|
? { allowedTools: [...new Set([...allowed, `mcp__${MCP_SERVER_NAME}`])] }
|
|
147
147
|
: {}),
|