@viraatdas/rudder 3.0.3 → 3.1.3

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/README.md CHANGED
@@ -479,6 +479,7 @@ control surface; you decide whether a task runs locally or is handed to the clou
479
479
  rudder login # sign in with Google (opens straight to the account picker)
480
480
  rudder cloud # open this repo on your home machine and attach to it
481
481
  rudder cloud home # home machine status: projects, agents, previews
482
+ rudder cloud move # move this repo's running agents to the home machine
482
483
  rudder cloud app # the phone app link
483
484
  rudder cloud list # list cloud workers
484
485
  rudder cloud logs <id> # worker status
@@ -503,7 +504,9 @@ machine of its own.
503
504
 
504
505
  **Phone.** Open `rudder cloud app` on your phone and sign in with the same
505
506
  Google account. You see every project and agent, read conversations as they
506
- happen, text any agent, start tasks, stop or merge, and open previews. Claude
507
+ happen, text any agent, start tasks (`/model` picks Claude or Codex for new ones),
508
+ stop, merge or delete agents (swipe left), and open previews. A message that can't
509
+ reach an agent (a menu is open in its pane) says so instead of vanishing. Claude
507
510
  panes in the cloud also run with Remote Control, so they appear in the Claude
508
511
  app too.
509
512
 
@@ -520,11 +523,16 @@ running when this laptop disconnects (`/cloud off` returns to local workers).
520
523
  selected auth/config) so the whole dashboard runs on a cloud machine. Completed
521
524
  cloud work returns through the same review and merge path as local work.
522
525
 
523
- Bare `/handoff` moves the **selected agent pane** to Rudder Cloud: the local
524
- worker is quiesced, its workspace and Claude session upload with the snapshot,
525
- and the pane reattaches to a cloud machine that resumes the same conversation
526
- (`claude --resume`) — so the work keeps going after this laptop disconnects.
527
- (`/handoff <session-id>` still adopts an outside CLI chat, like `/resume`.)
526
+ **Taking agents with you.** About to close the laptop? Press `H` in the
527
+ dashboard and every running agent moves to your home machine: its work so far
528
+ (the jj change, including unpushed local changes it builds on) and its
529
+ conversation go along, nothing is pushed to GitHub, and the conversation resumes
530
+ there, so it keeps going with the laptop closed. Quitting with agents running
531
+ offers the same thing ("3 agents are running here. Move them to the home
532
+ machine?"), and `rudder cloud` in a repo with agents asks too. Bare `/handoff`
533
+ moves just the selected agent. (`/handoff <session-id>` still adopts an outside
534
+ CLI chat, like `/resume`.) Delete an agent anywhere (`dd` here, swipe left on the
535
+ phone) and it is gone everywhere.
528
536
 
529
537
  Cloud comes in two shapes: a **sail** is an ephemeral, task-scoped worker (it goes away
530
538
  when the task is done; idle sails pause and can resume), while a **workspace** is a
package/dist/cloud.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { type MigrationCandidate, type MigrationPlan } from "./migration.js";
2
+ import { type MoveCandidate } from "./move-home.js";
3
+ import type { JsonValue } from "./types.js";
2
4
  type CloudCommandOptions = {
3
5
  json?: boolean;
4
6
  homePaths?: string[];
@@ -6,9 +8,18 @@ type CloudCommandOptions = {
6
8
  noAttach?: boolean;
7
9
  quietBanner?: boolean;
8
10
  latencyProbe?: boolean;
11
+ /** Coming back to a repo already open on the home machine: skip the first-run offers. */
12
+ reattach?: boolean;
9
13
  /** Skip the y/N confirmation of a destructive command (`--yes`/`--force`). */
10
14
  yes?: boolean;
11
15
  };
16
+ type CloudClient = {
17
+ baseUrl: string;
18
+ request<T>(pathOrUrl: string, init: {
19
+ method: string;
20
+ body?: JsonValue;
21
+ }): Promise<T>;
22
+ };
12
23
  export declare function runCloudCommand(command: string, args: string[], options?: CloudCommandOptions): Promise<void>;
13
24
  /**
14
25
  * Scope a fleet migration plan down to one run: only `runId` moves, every
@@ -25,4 +36,12 @@ export declare function copyRudderState(repoRoot: string, repoStage: string, onl
25
36
  runs: number;
26
37
  files: string[];
27
38
  }>;
39
+ /** Bundle, upload, wait for the home machine to stage them, then hand the local records over. */
40
+ export declare function moveAgentsToHome(client: CloudClient, repoRoot: string, slug: string, candidates: MoveCandidate[], options: CloudCommandOptions): Promise<{
41
+ adopted: string[];
42
+ failed: Array<{
43
+ runId: string;
44
+ error: string;
45
+ }>;
46
+ }>;
28
47
  export {};
package/dist/cloud.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { spawn } from "node:child_process";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import fsp from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { WebSocket } from "ws";
7
7
  import { currentBranch, currentCommit, findRepoRoot } from "./git.js";
8
8
  import { applyDefaultDecisions, buildFreshHandoffPrompt, cloudWorkspaceAbsolutePath, findMigrationCandidates, formatCandidateReason, migrationSummary, summaryAsJson, } from "./migration.js";
9
+ import { buildMoveBundle, findMoveCandidates, markRunsMovedHome } from "./move-home.js";
9
10
  import { cloudAuthPath } from "./state.js";
10
11
  import { ensureDir, commandExists, expandHome, isTty, newRunId, nowIso, pathExists, promptConfirm, promptText, promptSelect, promptSecret, readJson, runCommand, shortenHome, shellQuote, writeJson, } from "./util.js";
11
12
  const DEFAULT_LOGIN_INTERVAL_MS = 2000;
@@ -17,6 +18,9 @@ const DEFAULT_HOME_PATHS = [
17
18
  // Agent CLIs
18
19
  "~/.claude/.credentials.json",
19
20
  "~/.claude/settings.json",
21
+ // Hook scripts settings.json points at; the home machine rewrites their
22
+ // /Users/<name> paths to its own home (cloud/worker/hook-paths.mjs).
23
+ "~/.claude/hooks",
20
24
  "~/.claude/CLAUDE.md",
21
25
  "~/.claude.json",
22
26
  "~/.codex/auth.json",
@@ -180,6 +184,9 @@ export async function runCloudCommand(command, args, options = {}) {
180
184
  case "onload":
181
185
  await onload(rest, options);
182
186
  return;
187
+ case "move":
188
+ await moveCommand(rest, options);
189
+ return;
183
190
  case "bootstrap":
184
191
  await bootstrap(rest, options);
185
192
  return;
@@ -544,6 +551,9 @@ async function handoffRunToCloud(repoRoot, runId, options) {
544
551
  body,
545
552
  });
546
553
  await printResult(result, options);
554
+ // The sail owns the conversation now. The dashboard watches this record
555
+ // for exactly that ("migrated") instead of assuming it at spawn time.
556
+ await markRunsMigrated(repoRoot, [runId]);
547
557
  await maybeAutoAttach(result, options);
548
558
  // The handoff SUCCEEDED the moment the sail launched: the cloud machine
549
559
  // owns the conversation now. An attach that drops afterwards (network
@@ -2728,10 +2738,17 @@ async function workspaceList(options) {
2728
2738
  async function attach(args, options) {
2729
2739
  const sailId = args[0];
2730
2740
  if (!sailId) {
2731
- throw new Error("Usage: rudder cloud attach <id>");
2741
+ // In a repo, `rudder cloud attach` is coming back to it: open it on the
2742
+ // home machine (waking it if needed) and attach, without the first-run
2743
+ // offers `rudder cloud` makes.
2744
+ await openOnHome(false, { ...options, reattach: true });
2745
+ return;
2732
2746
  }
2733
2747
  await runAttach({ kind: "sail", id: sailId, label: sailId }, options);
2734
2748
  }
2749
+ const ATTACH_PING_MS = 5_000;
2750
+ const ATTACH_PONG_TIMEOUT_MS = 20_000;
2751
+ const ATTACH_SLEEP_GAP_MS = 15_000;
2735
2752
  // The remote TUI pushes the kitty keyboard enhancement flags once, in its own
2736
2753
  // setup_terminal(), and the worker only keeps the TAIL of its output — so by the
2737
2754
  // time an attach connects, that push is long gone and this terminal never sees
@@ -2750,7 +2767,7 @@ function containsCtrlCInput(data) {
2750
2767
  async function attachWithReconnect(target, options, reopen) {
2751
2768
  let result = await runAttach(target, { ...options, quietBanner: false });
2752
2769
  let attempts = 0;
2753
- while (result === "dropped" && attempts < 40) {
2770
+ while (result === "dropped" && attempts < 120) {
2754
2771
  attempts += 1;
2755
2772
  process.stderr.write(`\r\nLost the connection to Rudder Cloud (it may be restarting). Reconnecting${attempts > 1 ? ` (try ${attempts})` : ""}...\r\n`);
2756
2773
  await sleep(Math.min(15_000, 2_000 * attempts));
@@ -2901,11 +2918,40 @@ async function runAttach(target, options) {
2901
2918
  }
2902
2919
  splash?.redraw();
2903
2920
  };
2921
+ // Liveness. A laptop that slept leaves this socket half-open: nothing
2922
+ // errors, the screen just freezes, and the relay has meanwhile dropped us
2923
+ // (or handed control to the next terminal). Ping the relay, and treat a
2924
+ // missed pong or a timer that fired far too late (the machine was asleep)
2925
+ // as a dropped connection, which attachWithReconnect reopens.
2926
+ let lastHeard = Date.now();
2927
+ let lastTick = Date.now();
2928
+ socket.on("pong", () => { lastHeard = Date.now(); });
2929
+ socket.on("message", () => { lastHeard = Date.now(); });
2930
+ const liveness = setInterval(() => {
2931
+ const now = Date.now();
2932
+ const slept = now - lastTick > ATTACH_SLEEP_GAP_MS;
2933
+ lastTick = now;
2934
+ if (socket.readyState !== WebSocket.OPEN) {
2935
+ return;
2936
+ }
2937
+ if (slept || now - lastHeard > ATTACH_PONG_TIMEOUT_MS) {
2938
+ socket.terminate();
2939
+ return;
2940
+ }
2941
+ try {
2942
+ socket.ping();
2943
+ }
2944
+ catch {
2945
+ socket.terminate();
2946
+ }
2947
+ }, ATTACH_PING_MS);
2948
+ liveness.unref?.();
2904
2949
  const cleanup = () => {
2905
2950
  if (cleaned) {
2906
2951
  return;
2907
2952
  }
2908
2953
  cleaned = true;
2954
+ clearInterval(liveness);
2909
2955
  stdin.off("data", onStdin);
2910
2956
  stdout.off("resize", onResize);
2911
2957
  process.off("SIGINT", onSigint);
@@ -3407,6 +3453,10 @@ Usage:
3407
3453
  holds every repo) and attach to its dashboard. --snapshot uploads this
3408
3454
  folder to its own machine instead.
3409
3455
  rudder cloud home [status|start|stop|open <owner/repo>]
3456
+ rudder cloud move [runId...] [--yes]
3457
+ move this repo's unfinished agents (or just the named ones) to the
3458
+ home machine: their jj changes and conversations go along, nothing is
3459
+ pushed to GitHub, and they keep working with this laptop closed
3410
3460
  rudder cloud app
3411
3461
  a one-time code that signs the phone app in to this account
3412
3462
  rudder cloud doctor
@@ -3425,7 +3475,7 @@ Usage:
3425
3475
  resumes its conversation there; no runId uploads the current
3426
3476
  Rudder workspace state
3427
3477
  rudder cloud logs <id>
3428
- rudder cloud attach <id>
3478
+ rudder cloud attach [id] (no id: reattach this repo on the home machine)
3429
3479
  stream the live cloud worker terminal into this pane
3430
3480
  rudder cloud talk <id> "<message>"
3431
3481
  send a message to a running instance and show its reply
@@ -3509,6 +3559,15 @@ function cloudNameFromTask(task) {
3509
3559
  .replace(/-+$/g, "");
3510
3560
  return slug || randomCloudName();
3511
3561
  }
3562
+ async function repoOpenOnHome(client, slug) {
3563
+ try {
3564
+ const result = await client.request("/api/rudder/home", { method: "GET" });
3565
+ return (result.projects ?? []).some((project) => String(project.repo ?? "").toLowerCase() === slug.toLowerCase());
3566
+ }
3567
+ catch {
3568
+ return false;
3569
+ }
3570
+ }
3512
3571
  async function openOnHome(forceSnapshot, options) {
3513
3572
  if (forceSnapshot) {
3514
3573
  await workspaceAttach([], options);
@@ -3525,11 +3584,21 @@ async function openOnHome(forceSnapshot, options) {
3525
3584
  }
3526
3585
  const client = await cloudClient({ requireToken: true });
3527
3586
  const name = slug.split("/")[1];
3587
+ // Coming back (laptop reopened, terminal closed): a repo already open on
3588
+ // the home machine reattaches straight away, like `rudder cloud attach`.
3589
+ // Credentials are still compared, and running local agents still offered a
3590
+ // move, because those are about this laptop now.
3591
+ const reattach = options.reattach || await repoOpenOnHome(client, slug);
3528
3592
  if (!options.json) {
3529
- await warnLocalOnlyWork(repoRoot);
3593
+ if (!reattach) {
3594
+ await warnLocalOnlyWork(repoRoot);
3595
+ }
3530
3596
  await ensureVaultGithubToken(client, slug);
3531
3597
  await ensureVaultAws(client);
3532
- await offerEnvFilesToVault(client, repoRoot, name);
3598
+ if (!reattach) {
3599
+ await offerEnvFilesToVault(client, repoRoot, name);
3600
+ }
3601
+ await offerToMoveAgents(client, repoRoot, slug);
3533
3602
  process.stderr.write(`Opening ${slug} on your home machine...\n`);
3534
3603
  }
3535
3604
  const branch = await pushedBranch(repoRoot);
@@ -3615,9 +3684,15 @@ async function ensureVaultAws(client) {
3615
3684
  if (changed.length === 0) {
3616
3685
  return;
3617
3686
  }
3618
- const yes = await promptConfirm(`Your AWS setup (${changed.map((file) => file.name).join(", ")}) differs from the cloud vault's copy. Copy it so cloud agents use the same AWS?`, true);
3619
- if (!yes) {
3620
- return;
3687
+ // Asked once. After AWS is in the vault, keeping it matched with this laptop
3688
+ // is the point (rotating SSO/session credentials change it daily), so later
3689
+ // changes are copied without a prompt on every `rudder cloud`.
3690
+ const alreadyShared = files.some((file) => stored.has(file.name));
3691
+ if (!alreadyShared) {
3692
+ const yes = await promptConfirm(`Your AWS setup (${changed.map((file) => file.name).join(", ")}) differs from the cloud vault's copy. Copy it so cloud agents use the same AWS?`, true);
3693
+ if (!yes) {
3694
+ return;
3695
+ }
3621
3696
  }
3622
3697
  for (const file of changed) {
3623
3698
  await client.request("/api/rudder/secrets/item", {
@@ -3638,7 +3713,7 @@ async function localGithubToken() {
3638
3713
  // The cloud machine clones from GitHub, so a private repo needs a GitHub token
3639
3714
  // in the vault. gh keeps its token in the OS keychain (hosts.yml has none), so
3640
3715
  // it never arrived with the synced gh config; offer to put it there.
3641
- async function ensureVaultGithubToken(client, slug) {
3716
+ async function ensureVaultGithubToken(client, slug, interactive = true) {
3642
3717
  const vault = await client.request("/api/rudder/secrets", { method: "GET" }).catch(() => null);
3643
3718
  if (!vault || (vault.secrets ?? []).some((secret) => secret.name === "GH_TOKEN" || secret.name === "GITHUB_TOKEN")) {
3644
3719
  return;
@@ -3648,7 +3723,7 @@ async function ensureVaultGithubToken(client, slug) {
3648
3723
  process.stderr.write(`The cloud vault has no GitHub token, so private repos can't be cloned there. Run \`gh auth login\` here, or \`rudder cloud secrets set GH_TOKEN\`.\n`);
3649
3724
  return;
3650
3725
  }
3651
- if (!isTty()) {
3726
+ if (!isTty() || !interactive) {
3652
3727
  process.stderr.write("The cloud vault has no GitHub token; private repos will fail to clone. Run `rudder cloud secrets sync` from a terminal.\n");
3653
3728
  return;
3654
3729
  }
@@ -3662,6 +3737,211 @@ async function ensureVaultGithubToken(client, slug) {
3662
3737
  });
3663
3738
  process.stderr.write("Stored GH_TOKEN in the cloud vault.\n");
3664
3739
  }
3740
+ const HOME_MOVE_POLL_MS = 1500;
3741
+ const HOME_MOVE_DEADLINE_MS = 15 * 60 * 1000;
3742
+ async function moveCommand(args, options) {
3743
+ const yes = options.yes || args.includes("--yes") || args.includes("-y");
3744
+ const runIds = args.filter((arg) => !arg.startsWith("-"));
3745
+ const repoRoot = findRepoRoot();
3746
+ const slug = await githubSlugFromOrigin(repoRoot);
3747
+ if (!slug) {
3748
+ throw new Error("This repo has no GitHub origin, so the home machine can't clone it. `rudder cloud workspace attach` moves agents into a snapshot workspace instead.");
3749
+ }
3750
+ const candidates = await findMoveCandidates(repoRoot, runIds.length > 0 ? runIds : undefined);
3751
+ const missing = runIds.filter((id) => !candidates.some((candidate) => candidate.runId === id));
3752
+ if (missing.length > 0) {
3753
+ throw new Error(`Not movable (finished, not in a workspace of its own, or unknown): ${missing.join(", ")}`);
3754
+ }
3755
+ if (candidates.length === 0) {
3756
+ if (!options.json) {
3757
+ process.stderr.write("No unfinished agents with workspaces of their own to move.\n");
3758
+ }
3759
+ return;
3760
+ }
3761
+ if (!yes && !options.json) {
3762
+ if (!isTty()) {
3763
+ throw new Error("Refusing to move agents without a terminal to confirm in; pass --yes.");
3764
+ }
3765
+ process.stderr.write(`${describeMoveCandidates(candidates)}\n`);
3766
+ if (!(await promptConfirm(`Move ${agentCount(candidates.length)} to the home machine?`, true))) {
3767
+ process.stderr.write("Nothing moved.\n");
3768
+ return;
3769
+ }
3770
+ }
3771
+ const client = await cloudClient({ requireToken: true });
3772
+ if (!options.json) {
3773
+ await ensureVaultGithubToken(client, slug, !yes);
3774
+ }
3775
+ const moved = await moveAgentsToHome(client, repoRoot, slug, candidates, options);
3776
+ if (options.json) {
3777
+ printJson(moved);
3778
+ }
3779
+ if (moved.adopted.length === 0) {
3780
+ process.exitCode = 1;
3781
+ }
3782
+ }
3783
+ function agentCount(count) {
3784
+ return `${count} agent${count === 1 ? "" : "s"}`;
3785
+ }
3786
+ function describeMoveCandidates(candidates) {
3787
+ return candidates
3788
+ .map((candidate) => ` ${candidate.status.padEnd(8)} ${(candidate.taskSummary || candidate.task || candidate.runId).replace(/\s+/g, " ").slice(0, 72)}`)
3789
+ .join("\n");
3790
+ }
3791
+ /** Bundle, upload, wait for the home machine to stage them, then hand the local records over. */
3792
+ export async function moveAgentsToHome(client, repoRoot, slug, candidates, options) {
3793
+ const say = (line) => {
3794
+ if (!options.json) {
3795
+ process.stderr.write(`${line}\n`);
3796
+ }
3797
+ };
3798
+ const stageDir = await fsp.mkdtemp(path.join(os.tmpdir(), "rudder-move-stage-"));
3799
+ const archiveDir = await fsp.mkdtemp(path.join(os.tmpdir(), "rudder-move-"));
3800
+ let job;
3801
+ try {
3802
+ say(`Packing ${agentCount(candidates.length)} (their jj changes and conversations)...`);
3803
+ await buildMoveBundle(repoRoot, candidates, stageDir);
3804
+ const archivePath = path.join(archiveDir, "move.tgz");
3805
+ await runCommand("tar", ["-czf", archivePath, "-C", stageDir, "."], { cwd: stageDir });
3806
+ const branch = await pushedBranch(repoRoot);
3807
+ job = await client.request("/api/rudder/home/move", {
3808
+ method: "POST",
3809
+ body: {
3810
+ repo: slug,
3811
+ ...(branch ? { branch } : {}),
3812
+ bundle: await fsp.readFile(archivePath, "base64"),
3813
+ },
3814
+ });
3815
+ }
3816
+ finally {
3817
+ await fsp.rm(stageDir, { recursive: true, force: true });
3818
+ await fsp.rm(archiveDir, { recursive: true, force: true });
3819
+ }
3820
+ const moveId = job.move?.id;
3821
+ if (!moveId) {
3822
+ throw new Error("the control plane did not start the move");
3823
+ }
3824
+ const deadline = Date.now() + HOME_MOVE_DEADLINE_MS;
3825
+ let lastDetail = "";
3826
+ while (job.move?.status !== "done" && job.move?.status !== "failed") {
3827
+ if (Date.now() > deadline) {
3828
+ throw new Error("the home machine did not take the agents in time; they stay here");
3829
+ }
3830
+ const detail = job.move?.detail ?? "";
3831
+ if (detail && detail !== lastDetail) {
3832
+ say(detail);
3833
+ lastDetail = detail;
3834
+ }
3835
+ await new Promise((resolve) => setTimeout(resolve, HOME_MOVE_POLL_MS));
3836
+ job = await client.request(`/api/rudder/home/move/${encodeURIComponent(moveId)}`, { method: "GET" });
3837
+ }
3838
+ if (job.move?.status === "failed") {
3839
+ throw new Error(`the home machine could not take the agents: ${job.move.error || "unknown error"}`);
3840
+ }
3841
+ const adopted = (job.move?.result?.adopted ?? []).map((entry) => entry.runId);
3842
+ const failed = job.move?.result?.failed ?? [];
3843
+ await markRunsMovedHome(repoRoot, adopted, slug);
3844
+ if (adopted.length > 0) {
3845
+ const fresh = (job.move?.result?.adopted ?? []).filter((entry) => entry.resumable === false).length;
3846
+ say(`Moved ${agentCount(adopted.length)} to the home machine. They keep working there with this laptop closed.`);
3847
+ if (fresh > 0) {
3848
+ say(`${agentCount(fresh)} had no saved conversation and restart from a handoff over their diff.`);
3849
+ }
3850
+ say(`Watch them: \`rudder cloud\` here, or on your phone: ${appUrl(client.baseUrl)}`);
3851
+ }
3852
+ for (const entry of failed) {
3853
+ say(`Could not move ${entry.runId}: ${entry.error}. It stays here.`);
3854
+ }
3855
+ return { adopted, failed };
3856
+ }
3857
+ /**
3858
+ * `rudder cloud` in a repo with agents: offer to take them along. A live
3859
+ * dashboard owns its agents' processes, so it does the move (it pauses them
3860
+ * first); with no dashboard running the agents are already stopped and this
3861
+ * process can move them itself.
3862
+ */
3863
+ async function offerToMoveAgents(client, repoRoot, slug) {
3864
+ if (!isTty() || process.env.RUDDER_CLOUD_RUNTIME === "home" || process.env.RUDDER_CLOUD_NO_MOVE_OFFER === "1") {
3865
+ return;
3866
+ }
3867
+ const candidates = await findMoveCandidates(repoRoot).catch(() => []);
3868
+ if (candidates.length === 0) {
3869
+ return;
3870
+ }
3871
+ const dashboardLive = await dashboardServing(repoRoot);
3872
+ const running = candidates.filter((candidate) => candidate.status === "running");
3873
+ if (dashboardLive && running.length > 0) {
3874
+ const yes = await promptConfirm(`${agentCount(running.length)} ${running.length === 1 ? "is" : "are"} running here. Move them to the home machine?`, true);
3875
+ if (!yes) {
3876
+ return;
3877
+ }
3878
+ const receipt = await requestDashboardMove(repoRoot);
3879
+ process.stderr.write(receipt === "failed"
3880
+ ? "The dashboard could not start the move; press H there to try again.\n"
3881
+ : "The dashboard is moving them; they appear on the home machine as they land.\n");
3882
+ return;
3883
+ }
3884
+ if (dashboardLive) {
3885
+ return;
3886
+ }
3887
+ process.stderr.write(`${describeMoveCandidates(candidates)}\n`);
3888
+ const yes = await promptConfirm(`${agentCount(candidates.length)} ${candidates.length === 1 ? "was" : "were"} left unfinished here. Move them to the home machine?`, true);
3889
+ if (!yes) {
3890
+ return;
3891
+ }
3892
+ try {
3893
+ await moveAgentsToHome(client, repoRoot, slug, candidates, {});
3894
+ }
3895
+ catch (error) {
3896
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
3897
+ }
3898
+ }
3899
+ async function dashboardServing(repoRoot) {
3900
+ let socketPath;
3901
+ try {
3902
+ socketPath = await fsp.realpath(path.join(repoRoot, ".rudder", "attach.sock"));
3903
+ }
3904
+ catch {
3905
+ return false;
3906
+ }
3907
+ const net = await import("node:net");
3908
+ return await new Promise((resolve) => {
3909
+ const socket = net.connect(socketPath);
3910
+ const timer = setTimeout(() => {
3911
+ socket.destroy();
3912
+ resolve(false);
3913
+ }, 500);
3914
+ socket.once("connect", () => {
3915
+ clearTimeout(timer);
3916
+ socket.destroy();
3917
+ resolve(true);
3918
+ });
3919
+ socket.once("error", () => {
3920
+ clearTimeout(timer);
3921
+ resolve(false);
3922
+ });
3923
+ });
3924
+ }
3925
+ /** Ask the live dashboard (through its steer inbox) to move every agent it is running. */
3926
+ async function requestDashboardMove(repoRoot) {
3927
+ const requestId = randomUUID();
3928
+ const inbox = path.join(repoRoot, ".rudder", "steer");
3929
+ await ensureDir(inbox);
3930
+ const file = path.join(inbox, `${String(Date.now()).padStart(13, "0")}-move-home-${requestId}.json`);
3931
+ const temp = `${file}.${process.pid}.tmp`;
3932
+ await fsp.writeFile(temp, JSON.stringify({ requestId, kind: "move-home", taskId: "", instruction: "move-home", ts: nowIso() }));
3933
+ await fsp.rename(temp, file);
3934
+ const receiptPath = path.join(repoRoot, ".rudder", "steer-receipts", `${requestId}.json`);
3935
+ const deadline = Date.now() + 10_000;
3936
+ while (Date.now() < deadline) {
3937
+ await new Promise((resolve) => setTimeout(resolve, 250));
3938
+ const receipt = await readJson(receiptPath);
3939
+ if (receipt?.status === "delivered" || receipt?.status === "failed") {
3940
+ return receipt.status;
3941
+ }
3942
+ }
3943
+ return "queued";
3944
+ }
3665
3945
  async function localIdentities() {
3666
3946
  const [github, claude] = await Promise.all([
3667
3947
  commandExists("gh")
@@ -3852,6 +4132,56 @@ async function printAppLink(options) {
3852
4132
  or open ${pairing.link}
3853
4133
  Browser: ${pairing.url}`);
3854
4134
  }
4135
+ /**
4136
+ * `rudder cloud home runs <runId...>`: which of these agents (moved here from a
4137
+ * laptop) still exist on the home machine; `known: false` when it cannot
4138
+ * answer, which callers must read as "unknown", never "deleted".
4139
+ * `rudder cloud home delete <runId...>`: delete them there, through that repo's
4140
+ * dashboard, which also takes them off the phone. Queued if the machine is
4141
+ * asleep. This is what `dd` on a moved row in the laptop's dashboard runs.
4142
+ */
4143
+ async function homeAgentsCommand(client, sub, args, options) {
4144
+ const yes = options.yes || args.includes("--yes") || args.includes("-y");
4145
+ const runIds = args.filter((arg) => !arg.startsWith("-"));
4146
+ if (runIds.length === 0) {
4147
+ throw new Error(`Usage: rudder cloud home ${sub} <runId...>`);
4148
+ }
4149
+ const repo = await githubSlugFromOrigin(findRepoRoot());
4150
+ if (!repo) {
4151
+ throw new Error("This repo has no GitHub origin, so none of its agents live on the home machine.");
4152
+ }
4153
+ if (sub === "delete" && !yes && !options.json) {
4154
+ if (!isTty()) {
4155
+ throw new Error("Refusing to delete agents without a terminal to confirm in; pass --yes.");
4156
+ }
4157
+ if (!(await promptConfirm(`Delete ${runIds.length === 1 ? "this agent" : `${runIds.length} agents`} on the home machine? This cannot be undone.`, false))) {
4158
+ console.log("Nothing deleted.");
4159
+ return;
4160
+ }
4161
+ }
4162
+ const result = await client.request("/api/rudder/home/agents", {
4163
+ method: "POST",
4164
+ body: { repo, action: sub === "runs" ? "check" : "delete", runIds },
4165
+ });
4166
+ if (options.json) {
4167
+ printJson(result);
4168
+ return;
4169
+ }
4170
+ if (sub === "runs") {
4171
+ if (!result.known) {
4172
+ console.log("The home machine is not connected, so it can't say.");
4173
+ return;
4174
+ }
4175
+ const existing = new Set(result.existing ?? []);
4176
+ for (const id of runIds) {
4177
+ console.log(`${existing.has(id) ? "there " : "gone "} ${id}`);
4178
+ }
4179
+ return;
4180
+ }
4181
+ console.log(result.status === "queued"
4182
+ ? "The home machine is asleep; it deletes them as soon as it is back."
4183
+ : "Deleted on the home machine.");
4184
+ }
3855
4185
  async function homeCommand(args, options) {
3856
4186
  const client = await cloudClient({ requireToken: true });
3857
4187
  const sub = args[0] ?? "status";
@@ -3885,8 +4215,12 @@ async function homeCommand(args, options) {
3885
4215
  }
3886
4216
  return;
3887
4217
  }
4218
+ if (sub === "runs" || sub === "delete") {
4219
+ await homeAgentsCommand(client, sub, args.slice(1), options);
4220
+ return;
4221
+ }
3888
4222
  if (sub !== "status") {
3889
- throw new Error("Usage: rudder cloud home [status|start|stop|open <owner/repo>]");
4223
+ throw new Error("Usage: rudder cloud home [status|start|stop|open <owner/repo>|runs <runId...>|delete <runId...>]");
3890
4224
  }
3891
4225
  const result = await client.request("/api/rudder/home", { method: "GET" });
3892
4226
  if (options.json) {