@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.
@@ -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
+ }
@@ -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 { resolveClaudeCommandForRoot } from "@love-moon/ai-sdk";
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. claude refuses to start as root with
577
- // `--dangerously-skip-permissions`, so rewrite the command the same way the
578
- // ai-sdk session rewrites its permission mode — one shared root check, so the
579
- // two paths cannot drift.
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
- return resolveClaudeCommandForRoot(cliCommand, env);
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
- const launchCommand = resolvePtyToolPresetCommand(cliCommand, buildPtyTaskEnv(baseEnv, env));
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] Adjusted ${toolPreset} command for root: ${launchCommand}`);
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,
@@ -139,9 +139,9 @@ export function buildRemoteWorktreeSessionOptions({
139
139
  ...(sessionOptions.mcpServers || {}),
140
140
  [MCP_SERVER_NAME]: { type: "stdio", command: execPath, args },
141
141
  },
142
- // Under the default bypassPermissions a root install is downgraded to
143
- // acceptEdits, which would deny every remote_* call headless. A mode the
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
  : {}),