@viraatdas/rudder 3.0.2 → 3.1.1

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[];
@@ -9,6 +11,13 @@ type CloudCommandOptions = {
9
11
  /** Skip the y/N confirmation of a destructive command (`--yes`/`--force`). */
10
12
  yes?: boolean;
11
13
  };
14
+ type CloudClient = {
15
+ baseUrl: string;
16
+ request<T>(pathOrUrl: string, init: {
17
+ method: string;
18
+ body?: JsonValue;
19
+ }): Promise<T>;
20
+ };
12
21
  export declare function runCloudCommand(command: string, args: string[], options?: CloudCommandOptions): Promise<void>;
13
22
  /**
14
23
  * Scope a fleet migration plan down to one run: only `runId` moves, every
@@ -25,4 +34,12 @@ export declare function copyRudderState(repoRoot: string, repoStage: string, onl
25
34
  runs: number;
26
35
  files: string[];
27
36
  }>;
37
+ /** Bundle, upload, wait for the home machine to stage them, then hand the local records over. */
38
+ export declare function moveAgentsToHome(client: CloudClient, repoRoot: string, slug: string, candidates: MoveCandidate[], options: CloudCommandOptions): Promise<{
39
+ adopted: string[];
40
+ failed: Array<{
41
+ runId: string;
42
+ error: string;
43
+ }>;
44
+ }>;
28
45
  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;
@@ -180,6 +181,9 @@ export async function runCloudCommand(command, args, options = {}) {
180
181
  case "onload":
181
182
  await onload(rest, options);
182
183
  return;
184
+ case "move":
185
+ await moveCommand(rest, options);
186
+ return;
183
187
  case "bootstrap":
184
188
  await bootstrap(rest, options);
185
189
  return;
@@ -544,6 +548,9 @@ async function handoffRunToCloud(repoRoot, runId, options) {
544
548
  body,
545
549
  });
546
550
  await printResult(result, options);
551
+ // The sail owns the conversation now. The dashboard watches this record
552
+ // for exactly that ("migrated") instead of assuming it at spawn time.
553
+ await markRunsMigrated(repoRoot, [runId]);
547
554
  await maybeAutoAttach(result, options);
548
555
  // The handoff SUCCEEDED the moment the sail launched: the cloud machine
549
556
  // owns the conversation now. An attach that drops afterwards (network
@@ -3407,6 +3414,10 @@ Usage:
3407
3414
  holds every repo) and attach to its dashboard. --snapshot uploads this
3408
3415
  folder to its own machine instead.
3409
3416
  rudder cloud home [status|start|stop|open <owner/repo>]
3417
+ rudder cloud move [runId...] [--yes]
3418
+ move this repo's unfinished agents (or just the named ones) to the
3419
+ home machine: their jj changes and conversations go along, nothing is
3420
+ pushed to GitHub, and they keep working with this laptop closed
3410
3421
  rudder cloud app
3411
3422
  a one-time code that signs the phone app in to this account
3412
3423
  rudder cloud doctor
@@ -3530,6 +3541,7 @@ async function openOnHome(forceSnapshot, options) {
3530
3541
  await ensureVaultGithubToken(client, slug);
3531
3542
  await ensureVaultAws(client);
3532
3543
  await offerEnvFilesToVault(client, repoRoot, name);
3544
+ await offerToMoveAgents(client, repoRoot, slug);
3533
3545
  process.stderr.write(`Opening ${slug} on your home machine...\n`);
3534
3546
  }
3535
3547
  const branch = await pushedBranch(repoRoot);
@@ -3638,7 +3650,7 @@ async function localGithubToken() {
3638
3650
  // The cloud machine clones from GitHub, so a private repo needs a GitHub token
3639
3651
  // in the vault. gh keeps its token in the OS keychain (hosts.yml has none), so
3640
3652
  // it never arrived with the synced gh config; offer to put it there.
3641
- async function ensureVaultGithubToken(client, slug) {
3653
+ async function ensureVaultGithubToken(client, slug, interactive = true) {
3642
3654
  const vault = await client.request("/api/rudder/secrets", { method: "GET" }).catch(() => null);
3643
3655
  if (!vault || (vault.secrets ?? []).some((secret) => secret.name === "GH_TOKEN" || secret.name === "GITHUB_TOKEN")) {
3644
3656
  return;
@@ -3648,7 +3660,7 @@ async function ensureVaultGithubToken(client, slug) {
3648
3660
  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
3661
  return;
3650
3662
  }
3651
- if (!isTty()) {
3663
+ if (!isTty() || !interactive) {
3652
3664
  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
3665
  return;
3654
3666
  }
@@ -3662,6 +3674,211 @@ async function ensureVaultGithubToken(client, slug) {
3662
3674
  });
3663
3675
  process.stderr.write("Stored GH_TOKEN in the cloud vault.\n");
3664
3676
  }
3677
+ const HOME_MOVE_POLL_MS = 1500;
3678
+ const HOME_MOVE_DEADLINE_MS = 15 * 60 * 1000;
3679
+ async function moveCommand(args, options) {
3680
+ const yes = options.yes || args.includes("--yes") || args.includes("-y");
3681
+ const runIds = args.filter((arg) => !arg.startsWith("-"));
3682
+ const repoRoot = findRepoRoot();
3683
+ const slug = await githubSlugFromOrigin(repoRoot);
3684
+ if (!slug) {
3685
+ 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.");
3686
+ }
3687
+ const candidates = await findMoveCandidates(repoRoot, runIds.length > 0 ? runIds : undefined);
3688
+ const missing = runIds.filter((id) => !candidates.some((candidate) => candidate.runId === id));
3689
+ if (missing.length > 0) {
3690
+ throw new Error(`Not movable (finished, not in a workspace of its own, or unknown): ${missing.join(", ")}`);
3691
+ }
3692
+ if (candidates.length === 0) {
3693
+ if (!options.json) {
3694
+ process.stderr.write("No unfinished agents with workspaces of their own to move.\n");
3695
+ }
3696
+ return;
3697
+ }
3698
+ if (!yes && !options.json) {
3699
+ if (!isTty()) {
3700
+ throw new Error("Refusing to move agents without a terminal to confirm in; pass --yes.");
3701
+ }
3702
+ process.stderr.write(`${describeMoveCandidates(candidates)}\n`);
3703
+ if (!(await promptConfirm(`Move ${agentCount(candidates.length)} to the home machine?`, true))) {
3704
+ process.stderr.write("Nothing moved.\n");
3705
+ return;
3706
+ }
3707
+ }
3708
+ const client = await cloudClient({ requireToken: true });
3709
+ if (!options.json) {
3710
+ await ensureVaultGithubToken(client, slug, !yes);
3711
+ }
3712
+ const moved = await moveAgentsToHome(client, repoRoot, slug, candidates, options);
3713
+ if (options.json) {
3714
+ printJson(moved);
3715
+ }
3716
+ if (moved.adopted.length === 0) {
3717
+ process.exitCode = 1;
3718
+ }
3719
+ }
3720
+ function agentCount(count) {
3721
+ return `${count} agent${count === 1 ? "" : "s"}`;
3722
+ }
3723
+ function describeMoveCandidates(candidates) {
3724
+ return candidates
3725
+ .map((candidate) => ` ${candidate.status.padEnd(8)} ${(candidate.taskSummary || candidate.task || candidate.runId).replace(/\s+/g, " ").slice(0, 72)}`)
3726
+ .join("\n");
3727
+ }
3728
+ /** Bundle, upload, wait for the home machine to stage them, then hand the local records over. */
3729
+ export async function moveAgentsToHome(client, repoRoot, slug, candidates, options) {
3730
+ const say = (line) => {
3731
+ if (!options.json) {
3732
+ process.stderr.write(`${line}\n`);
3733
+ }
3734
+ };
3735
+ const stageDir = await fsp.mkdtemp(path.join(os.tmpdir(), "rudder-move-stage-"));
3736
+ const archiveDir = await fsp.mkdtemp(path.join(os.tmpdir(), "rudder-move-"));
3737
+ let job;
3738
+ try {
3739
+ say(`Packing ${agentCount(candidates.length)} (their jj changes and conversations)...`);
3740
+ await buildMoveBundle(repoRoot, candidates, stageDir);
3741
+ const archivePath = path.join(archiveDir, "move.tgz");
3742
+ await runCommand("tar", ["-czf", archivePath, "-C", stageDir, "."], { cwd: stageDir });
3743
+ const branch = await pushedBranch(repoRoot);
3744
+ job = await client.request("/api/rudder/home/move", {
3745
+ method: "POST",
3746
+ body: {
3747
+ repo: slug,
3748
+ ...(branch ? { branch } : {}),
3749
+ bundle: await fsp.readFile(archivePath, "base64"),
3750
+ },
3751
+ });
3752
+ }
3753
+ finally {
3754
+ await fsp.rm(stageDir, { recursive: true, force: true });
3755
+ await fsp.rm(archiveDir, { recursive: true, force: true });
3756
+ }
3757
+ const moveId = job.move?.id;
3758
+ if (!moveId) {
3759
+ throw new Error("the control plane did not start the move");
3760
+ }
3761
+ const deadline = Date.now() + HOME_MOVE_DEADLINE_MS;
3762
+ let lastDetail = "";
3763
+ while (job.move?.status !== "done" && job.move?.status !== "failed") {
3764
+ if (Date.now() > deadline) {
3765
+ throw new Error("the home machine did not take the agents in time; they stay here");
3766
+ }
3767
+ const detail = job.move?.detail ?? "";
3768
+ if (detail && detail !== lastDetail) {
3769
+ say(detail);
3770
+ lastDetail = detail;
3771
+ }
3772
+ await new Promise((resolve) => setTimeout(resolve, HOME_MOVE_POLL_MS));
3773
+ job = await client.request(`/api/rudder/home/move/${encodeURIComponent(moveId)}`, { method: "GET" });
3774
+ }
3775
+ if (job.move?.status === "failed") {
3776
+ throw new Error(`the home machine could not take the agents: ${job.move.error || "unknown error"}`);
3777
+ }
3778
+ const adopted = (job.move?.result?.adopted ?? []).map((entry) => entry.runId);
3779
+ const failed = job.move?.result?.failed ?? [];
3780
+ await markRunsMovedHome(repoRoot, adopted, slug);
3781
+ if (adopted.length > 0) {
3782
+ const fresh = (job.move?.result?.adopted ?? []).filter((entry) => entry.resumable === false).length;
3783
+ say(`Moved ${agentCount(adopted.length)} to the home machine. They keep working there with this laptop closed.`);
3784
+ if (fresh > 0) {
3785
+ say(`${agentCount(fresh)} had no saved conversation and restart from a handoff over their diff.`);
3786
+ }
3787
+ say(`Watch them: \`rudder cloud\` here, or on your phone: ${appUrl(client.baseUrl)}`);
3788
+ }
3789
+ for (const entry of failed) {
3790
+ say(`Could not move ${entry.runId}: ${entry.error}. It stays here.`);
3791
+ }
3792
+ return { adopted, failed };
3793
+ }
3794
+ /**
3795
+ * `rudder cloud` in a repo with agents: offer to take them along. A live
3796
+ * dashboard owns its agents' processes, so it does the move (it pauses them
3797
+ * first); with no dashboard running the agents are already stopped and this
3798
+ * process can move them itself.
3799
+ */
3800
+ async function offerToMoveAgents(client, repoRoot, slug) {
3801
+ if (!isTty() || process.env.RUDDER_CLOUD_RUNTIME === "home" || process.env.RUDDER_CLOUD_NO_MOVE_OFFER === "1") {
3802
+ return;
3803
+ }
3804
+ const candidates = await findMoveCandidates(repoRoot).catch(() => []);
3805
+ if (candidates.length === 0) {
3806
+ return;
3807
+ }
3808
+ const dashboardLive = await dashboardServing(repoRoot);
3809
+ const running = candidates.filter((candidate) => candidate.status === "running");
3810
+ if (dashboardLive && running.length > 0) {
3811
+ const yes = await promptConfirm(`${agentCount(running.length)} ${running.length === 1 ? "is" : "are"} running here. Move them to the home machine?`, true);
3812
+ if (!yes) {
3813
+ return;
3814
+ }
3815
+ const receipt = await requestDashboardMove(repoRoot);
3816
+ process.stderr.write(receipt === "failed"
3817
+ ? "The dashboard could not start the move; press H there to try again.\n"
3818
+ : "The dashboard is moving them; they appear on the home machine as they land.\n");
3819
+ return;
3820
+ }
3821
+ if (dashboardLive) {
3822
+ return;
3823
+ }
3824
+ process.stderr.write(`${describeMoveCandidates(candidates)}\n`);
3825
+ const yes = await promptConfirm(`${agentCount(candidates.length)} ${candidates.length === 1 ? "was" : "were"} left unfinished here. Move them to the home machine?`, true);
3826
+ if (!yes) {
3827
+ return;
3828
+ }
3829
+ try {
3830
+ await moveAgentsToHome(client, repoRoot, slug, candidates, {});
3831
+ }
3832
+ catch (error) {
3833
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
3834
+ }
3835
+ }
3836
+ async function dashboardServing(repoRoot) {
3837
+ let socketPath;
3838
+ try {
3839
+ socketPath = await fsp.realpath(path.join(repoRoot, ".rudder", "attach.sock"));
3840
+ }
3841
+ catch {
3842
+ return false;
3843
+ }
3844
+ const net = await import("node:net");
3845
+ return await new Promise((resolve) => {
3846
+ const socket = net.connect(socketPath);
3847
+ const timer = setTimeout(() => {
3848
+ socket.destroy();
3849
+ resolve(false);
3850
+ }, 500);
3851
+ socket.once("connect", () => {
3852
+ clearTimeout(timer);
3853
+ socket.destroy();
3854
+ resolve(true);
3855
+ });
3856
+ socket.once("error", () => {
3857
+ clearTimeout(timer);
3858
+ resolve(false);
3859
+ });
3860
+ });
3861
+ }
3862
+ /** Ask the live dashboard (through its steer inbox) to move every agent it is running. */
3863
+ async function requestDashboardMove(repoRoot) {
3864
+ const requestId = randomUUID();
3865
+ const inbox = path.join(repoRoot, ".rudder", "steer");
3866
+ await ensureDir(inbox);
3867
+ const file = path.join(inbox, `${String(Date.now()).padStart(13, "0")}-move-home-${requestId}.json`);
3868
+ const temp = `${file}.${process.pid}.tmp`;
3869
+ await fsp.writeFile(temp, JSON.stringify({ requestId, kind: "move-home", taskId: "", instruction: "move-home", ts: nowIso() }));
3870
+ await fsp.rename(temp, file);
3871
+ const receiptPath = path.join(repoRoot, ".rudder", "steer-receipts", `${requestId}.json`);
3872
+ const deadline = Date.now() + 10_000;
3873
+ while (Date.now() < deadline) {
3874
+ await new Promise((resolve) => setTimeout(resolve, 250));
3875
+ const receipt = await readJson(receiptPath);
3876
+ if (receipt?.status === "delivered" || receipt?.status === "failed") {
3877
+ return receipt.status;
3878
+ }
3879
+ }
3880
+ return "queued";
3881
+ }
3665
3882
  async function localIdentities() {
3666
3883
  const [github, claude] = await Promise.all([
3667
3884
  commandExists("gh")
@@ -3852,6 +4069,56 @@ async function printAppLink(options) {
3852
4069
  or open ${pairing.link}
3853
4070
  Browser: ${pairing.url}`);
3854
4071
  }
4072
+ /**
4073
+ * `rudder cloud home runs <runId...>`: which of these agents (moved here from a
4074
+ * laptop) still exist on the home machine; `known: false` when it cannot
4075
+ * answer, which callers must read as "unknown", never "deleted".
4076
+ * `rudder cloud home delete <runId...>`: delete them there, through that repo's
4077
+ * dashboard, which also takes them off the phone. Queued if the machine is
4078
+ * asleep. This is what `dd` on a moved row in the laptop's dashboard runs.
4079
+ */
4080
+ async function homeAgentsCommand(client, sub, args, options) {
4081
+ const yes = options.yes || args.includes("--yes") || args.includes("-y");
4082
+ const runIds = args.filter((arg) => !arg.startsWith("-"));
4083
+ if (runIds.length === 0) {
4084
+ throw new Error(`Usage: rudder cloud home ${sub} <runId...>`);
4085
+ }
4086
+ const repo = await githubSlugFromOrigin(findRepoRoot());
4087
+ if (!repo) {
4088
+ throw new Error("This repo has no GitHub origin, so none of its agents live on the home machine.");
4089
+ }
4090
+ if (sub === "delete" && !yes && !options.json) {
4091
+ if (!isTty()) {
4092
+ throw new Error("Refusing to delete agents without a terminal to confirm in; pass --yes.");
4093
+ }
4094
+ if (!(await promptConfirm(`Delete ${runIds.length === 1 ? "this agent" : `${runIds.length} agents`} on the home machine? This cannot be undone.`, false))) {
4095
+ console.log("Nothing deleted.");
4096
+ return;
4097
+ }
4098
+ }
4099
+ const result = await client.request("/api/rudder/home/agents", {
4100
+ method: "POST",
4101
+ body: { repo, action: sub === "runs" ? "check" : "delete", runIds },
4102
+ });
4103
+ if (options.json) {
4104
+ printJson(result);
4105
+ return;
4106
+ }
4107
+ if (sub === "runs") {
4108
+ if (!result.known) {
4109
+ console.log("The home machine is not connected, so it can't say.");
4110
+ return;
4111
+ }
4112
+ const existing = new Set(result.existing ?? []);
4113
+ for (const id of runIds) {
4114
+ console.log(`${existing.has(id) ? "there " : "gone "} ${id}`);
4115
+ }
4116
+ return;
4117
+ }
4118
+ console.log(result.status === "queued"
4119
+ ? "The home machine is asleep; it deletes them as soon as it is back."
4120
+ : "Deleted on the home machine.");
4121
+ }
3855
4122
  async function homeCommand(args, options) {
3856
4123
  const client = await cloudClient({ requireToken: true });
3857
4124
  const sub = args[0] ?? "status";
@@ -3885,8 +4152,12 @@ async function homeCommand(args, options) {
3885
4152
  }
3886
4153
  return;
3887
4154
  }
4155
+ if (sub === "runs" || sub === "delete") {
4156
+ await homeAgentsCommand(client, sub, args.slice(1), options);
4157
+ return;
4158
+ }
3888
4159
  if (sub !== "status") {
3889
- throw new Error("Usage: rudder cloud home [status|start|stop|open <owner/repo>]");
4160
+ throw new Error("Usage: rudder cloud home [status|start|stop|open <owner/repo>|runs <runId...>|delete <runId...>]");
3890
4161
  }
3891
4162
  const result = await client.request("/api/rudder/home", { method: "GET" });
3892
4163
  if (options.json) {