@ricsam/r5dctl 0.0.131 → 0.0.133

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
@@ -136,6 +136,7 @@ r5dctl session status <session-id>
136
136
  r5dctl session prompt --worker <label> --mode agent --model max --delivery steer <session-id> "Also check the migration"
137
137
  r5dctl session prompt --worker <label> --mode agent --model max --delivery force <session-id> "Stop that and fix the build first"
138
138
  r5dctl session stop <session-id>
139
+ r5dctl session set-worker <session-id> --worker <label>
139
140
  r5dctl session process-log <run-id> --tail 200
140
141
  r5dctl session process-log <run-id> --grep "error|failed"
141
142
 
@@ -169,6 +170,8 @@ Delivery is not acknowledgment. If a merge or shared-checkout change depends on
169
170
 
170
171
  `session stop` also stops every sub-agent session the stopped session started, directly or through its own children, and `session status` prints `Child sessions: <active> active of <total>` for a session that launched sub-agents.
171
172
 
173
+ `session set-worker` rebinds a stopped session to another connected worker, exactly like the composer's worker selector: it publishes the current worker's workspace, synchronizes the target worker, persists the binding, and the session resumes there on its next prompt or resume. It is refused with `409` while the session's agent runs and with `403` for a worker credential. A running project agent moves its own session with the `switch_worker` tool instead, which needs no stop; workspace chats are rebound only while stopped. Shell runs, r5dctl shells, browser port forwards, and sub-agents started earlier stay on the worker that started them.
174
+
172
175
  `session status` reports the run lifecycle and the launch-time `Source` and `Working tree` facts only. It deliberately prints no commit hash for the worktree, not even the launch baseline: a hash would be stale by the time a caller acts on it, because commits can land between the read and the action. Find the base and the branch's commits with git in the worker checkout instead, for example `git merge-base <your-branch> <branch>`, `git log --oneline <your-branch>..<branch>`, and `git status` in the branch folder, as the `Branch state` line reminds you. While a new worktree is still provisioning, a `Provisioning` line reports where its creation stands in the worker's operation queue and since when (`Provisioning: queued behind 3 worker operations since 2026-09-03T11:41:30Z`); a worker that predates operation receipts leaves that line out.
173
176
 
174
177
  `session process-log` prints a shell run's stored stdout and stderr from the server, where r5d keeps every run's full output; the `logPath` in tool results names that server-side store, not a file on the worker. Without flags it prints the whole log, `--tail <n>` keeps the last n lines, and `--grep <pattern>` keeps the lines matching a JavaScript regular expression (combine both for the last n matches). Each stream is capped at 512 KB per call and printed under a `==> stdout <==` or `==> stderr <==` header; a one-line summary with byte totals, the selection, and any truncation goes to stderr so stdout stays clean for `grep`, `head`, and `tail`. Inside an r5d worker shell the temporary credential can only read runs on that shell's worker.
package/dist/cjs/cli.cjs CHANGED
@@ -30,10 +30,12 @@ var cli_exports = {};
30
30
  __export(cli_exports, {
31
31
  BRANCH_DELETION_POLL_INTERVAL_MS: () => BRANCH_DELETION_POLL_INTERVAL_MS,
32
32
  BRANCH_DELETION_WAIT_TIMEOUT_MS: () => BRANCH_DELETION_WAIT_TIMEOUT_MS,
33
+ R5dctlClaimScopeMismatchError: () => R5dctlClaimScopeMismatchError,
33
34
  advanceTransientDevicePollBackoff: () => advanceTransientDevicePollBackoff,
34
35
  branchDeletionConfirmationMessage: () => branchDeletionConfirmationMessage,
35
36
  branchDeletionsConfirmationMessage: () => branchDeletionsConfirmationMessage,
36
37
  branchDeletionsFailureMessage: () => branchDeletionsFailureMessage,
38
+ claimScopeMatches: () => claimScopeMatches,
37
39
  createBranchThroughPlatform: () => createBranchThroughPlatform,
38
40
  deleteBranchThroughPlatform: () => deleteBranchThroughPlatform,
39
41
  deleteBranchesThroughPlatform: () => deleteBranchesThroughPlatform,
@@ -53,6 +55,7 @@ __export(cli_exports, {
53
55
  parseAnswerQuestionsCommandArgs: () => parseAnswerQuestionsCommandArgs,
54
56
  parseBranchCreateArgs: () => parseBranchCreateArgs,
55
57
  parseBranchDeleteArgs: () => parseBranchDeleteArgs,
58
+ parseCheckoutPathArgs: () => parseCheckoutPathArgs,
56
59
  parseClaimDuration: () => parseClaimDuration,
57
60
  parseConversationForkArgs: () => parseConversationForkArgs,
58
61
  parseConversationInspectWorkArgs: () => parseConversationInspectWorkArgs,
@@ -78,6 +81,7 @@ __export(cli_exports, {
78
81
  renderBranchDeletionStatus: () => renderBranchDeletionStatus,
79
82
  renderBranchList: () => renderBranchList,
80
83
  renderBranchOperation: () => renderBranchOperation,
84
+ renderCheckoutPath: () => renderCheckoutPath,
81
85
  renderConversationForkResponse: () => renderConversationForkResponse,
82
86
  renderConversationHeadResponse: () => renderConversationHeadResponse,
83
87
  renderConversationNodeResponse: () => renderConversationNodeResponse,
@@ -95,6 +99,7 @@ __export(cli_exports, {
95
99
  renderSessionList: () => renderSessionList,
96
100
  renderSessionRunStart: () => renderSessionRunStart,
97
101
  renderSessionRunStatus: () => renderSessionRunStatus,
102
+ renderSessionSetWorker: () => renderSessionSetWorker,
98
103
  renderWorkerCapacity: () => renderWorkerCapacity,
99
104
  renderWorkspaceStatus: () => renderWorkspaceStatus,
100
105
  renderWorkspaceSync: () => renderWorkspaceSync,
@@ -175,10 +180,12 @@ const SESSION_RUN_HELP_TEXT = [
175
180
  " r5dctl session status <session-id>",
176
181
  ' r5dctl session prompt --worker <label> --mode <mode> --model <tier> [--delivery queue|steer|force] [--sender <name>] <session-id> "<prompt>"',
177
182
  " r5dctl session stop <session-id>",
183
+ " r5dctl session set-worker <session-id> --worker <label>",
178
184
  " r5dctl session process-log <run-id> [--tail <n>] [--grep <pattern>]",
179
- " r5dctl [-s <session-id>] session claim <topic> [--ttl <duration>] [--note <text>]",
180
- " r5dctl [-s <session-id>] session claim <topic> --status | --renew [--ttl <duration>] | --release",
181
- " r5dctl [-s <session-id>] session claims",
185
+ " r5dctl [-s <session-id>] [-p <project> --branch <branch>] session claim <topic> [--ttl <duration>] [--note <text>]",
186
+ " r5dctl [-s <session-id>] [-p <project> --branch <branch>] session claim <topic> --status | --renew [--ttl <duration>] | --release",
187
+ " r5dctl [-s <session-id>] [-p <project> --branch <branch>] session claims",
188
+ " r5dctl [-s <session-id>] -p <project> --branch <branch> checkout path",
182
189
  "",
183
190
  "Create, inspect, resume, and stop ordinary agent sessions.",
184
191
  "prompt reaches a busy session by --delivery: steer (default) lands after the agent's next tool result,",
@@ -191,12 +198,19 @@ const SESSION_RUN_HELP_TEXT = [
191
198
  "on a pause, wait for the recipient's explicit reply before proceeding.",
192
199
  "claim takes atomic ownership of a topic within the session's project branch (one winner; a losing call prints the holder and expiry).",
193
200
  "Claims expire at --ttl (default 30m, max 24h) unless renewed; --renew and --release are fenced to the holder and its claim generation.",
201
+ "-p <project> --branch <branch> (both or neither) claims on a sibling checkout the same user owns instead of the session's own branch.",
202
+ "The reserved topic `checkout` declares tracked-file writes in that checkout: claim it before running the command, hold it until the",
203
+ "write is done, and release it. A running session native to that branch holds `checkout` implicitly, so the call names who to message.",
204
+ "Commands started while a cross-scope `checkout` claim is held also fence the claimed checkout on the bound worker (the fence line says",
205
+ "whether the worker supports it). checkout path prints where a project branch is checked out on the session's bound worker.",
194
206
  "Agent shells act as their own session; user credentials select the acting session with -s or R5D_SESSION_ID.",
195
207
  "A new worktree starts with the source checkout's uncommitted changes (--working-tree carry, the default);",
196
208
  "--working-tree clean checks out the source branch's current commit only. Start and status print the mode, never a commit hash.",
197
209
  "Use -f/--follow/--watch to attach after a start; Ctrl-C detaches without stopping the session.",
198
210
  "process-log prints a shell run's stored stdout/stderr from the server: the whole log, its last n lines,",
199
211
  "or the lines matching a JavaScript regular expression. The log is not a file on the worker.",
212
+ "set-worker rebinds a stopped session to another connected worker: it publishes the current worker's workspace,",
213
+ "synchronizes the target, and the session resumes there. A running agent moves itself with its switch_worker tool.",
200
214
  ""
201
215
  ].join("\n");
202
216
  const SHARED_HELP_ENTRIES = [
@@ -211,6 +225,11 @@ const SHARED_HELP_ENTRIES = [
211
225
  { section: "auth", usage: "auth api-key list", description: "List API keys for the current account." },
212
226
  { section: "auth", usage: "auth api-key revoke <key-id>", description: "Revoke an API key." },
213
227
  { section: "workspace", usage: "workspace status", description: "Inspect per-user workspace Git state and active remediation." },
228
+ {
229
+ section: "workspace",
230
+ usage: "workspace recover-hydration --worker <label> --transaction <id> --confirm",
231
+ description: "Preserve current checkout files and archive interrupted hydration evidence, then reconcile from the previous recorded basis. Stop active worker processes first."
232
+ },
214
233
  {
215
234
  section: "workspace",
216
235
  usage: "workspace sync [--worker <label>]",
@@ -337,13 +356,23 @@ const SHARED_HELP_ENTRIES = [
337
356
  },
338
357
  {
339
358
  section: "sessions",
340
- usage: "[-s <session-id>] session claim <topic> [--ttl <duration>] [--note <text>] [--status|--renew|--release]",
341
- description: "Atomically own a coordination topic within the session's project branch; one winner, and a losing call prints the current holder and expiry. --status reads it, --renew extends your lease, --release gives it up. Agent shells act as their own session."
359
+ usage: "session set-worker <session-id> --worker <label>",
360
+ description: "Rebind a stopped session to another connected worker: publish the current worker's workspace, synchronize the target, and resume there. Refused while the agent runs; a running agent switches itself with switch_worker."
342
361
  },
343
362
  {
344
363
  section: "sessions",
345
- usage: "[-s <session-id>] session claims",
346
- description: "List the claims on the session's project branch with holder, generation, and expiry."
364
+ usage: "[-s <session-id>] [-p <project> --branch <branch>] session claim <topic> [--ttl <duration>] [--note <text>] [--status|--renew|--release]",
365
+ description: "Atomically own a coordination topic within the session's project branch, or with -p and --branch on a sibling checkout; one winner, and a losing call prints the current holder and expiry. --status reads it, --renew extends your lease, --release gives it up. Claim the reserved topic `checkout` before writing tracked files in another checkout. Agent shells act as their own session."
366
+ },
367
+ {
368
+ section: "sessions",
369
+ usage: "[-s <session-id>] [-p <project> --branch <branch>] session claims",
370
+ description: "List the claims on the session's project branch, or on a sibling checkout, with holder, origin branch, generation, and expiry."
371
+ },
372
+ {
373
+ section: "branches",
374
+ usage: "[-s <session-id>] -p <project> --branch <branch> checkout path",
375
+ description: "Print the absolute path of a project branch checkout on the acting session's bound worker."
347
376
  },
348
377
  {
349
378
  section: "sessions",
@@ -903,6 +932,12 @@ function parseSessionRunCommand(options, args, env = process.env) {
903
932
  if (parsed.positionals.length > 1) throw new Error(`Unexpected session ${subcommand} argument: ${parsed.positionals[1]}`);
904
933
  return { kind: subcommand, sessionId };
905
934
  }
935
+ if (subcommand === "set-worker") {
936
+ const parsed = parseCommandOperands(commandArgs, /* @__PURE__ */ new Set(["--worker"]), /* @__PURE__ */ new Map(), "session set-worker");
937
+ const sessionId = requireValue(parsed.positionals[0], "Session id is required for `session set-worker`");
938
+ if (parsed.positionals.length > 1) throw new Error(`Unexpected session set-worker argument: ${parsed.positionals[1]}`);
939
+ return { kind: "set-worker", sessionId, worker: requireWorker(parsed.values) };
940
+ }
906
941
  if (subcommand === "prompt") {
907
942
  const parsed = parseCommandOperands(
908
943
  commandArgs,
@@ -940,13 +975,25 @@ function parseSessionProcessLogArgs(args) {
940
975
  const grep = parsed.values.get("--grep");
941
976
  return { runId, ...tail !== void 0 ? { tail } : {}, ...grep !== void 0 ? { grep } : {} };
942
977
  }
978
+ function resolveClaimScope(explicitProject, explicitBranch, options) {
979
+ const project = explicitProject?.trim() || options.project?.trim() || void 0;
980
+ const branch = explicitBranch?.trim() || options.branch?.trim() || void 0;
981
+ if (project === void 0 && branch === void 0) return void 0;
982
+ if (project === void 0 || branch === void 0) {
983
+ throw new Error(
984
+ "A scoped claim needs both -p/--project and --branch/-b; pass both to act on a sibling checkout, or neither for the session's own branch"
985
+ );
986
+ }
987
+ return { projectId: project, branchName: branch };
988
+ }
943
989
  const CLAIM_DURATION_PATTERN = /^(\d+)([smh])$/;
944
990
  function parseClaimDuration(value) {
945
991
  const match = CLAIM_DURATION_PATTERN.exec(value.trim());
946
992
  if (!match) throw new Error("Invalid --ttl. Expected a duration such as 90s, 30m, or 2h");
947
993
  const amount = Number(match[1]);
948
994
  const seconds = match[2] === "s" ? amount : match[2] === "m" ? amount * 60 : amount * 3600;
949
- if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 86400) throw new Error("Invalid --ttl. Use a duration between 1s and 24h");
995
+ if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 86400)
996
+ throw new Error("Invalid --ttl. Use a duration between 1s and 24h");
950
997
  return seconds;
951
998
  }
952
999
  function resolveClaimSessionId(explicit, options, env) {
@@ -955,7 +1002,7 @@ function resolveClaimSessionId(explicit, options, env) {
955
1002
  function parseSessionClaimArgs(args, options = {}, env = process.env) {
956
1003
  const parsed = parseCommandOperands(
957
1004
  args,
958
- /* @__PURE__ */ new Set(["--ttl", "--note", "--generation", "--session"]),
1005
+ /* @__PURE__ */ new Set(["--ttl", "--note", "--generation", "--session", "--project", "--branch"]),
959
1006
  /* @__PURE__ */ new Map([
960
1007
  ["--status", "--status"],
961
1008
  ["--renew", "--renew"],
@@ -968,7 +1015,8 @@ function parseSessionClaimArgs(args, options = {}, env = process.env) {
968
1015
  if (parsed.booleans.size > 1) throw new Error("Use only one of --status, --renew, or --release");
969
1016
  const action = parsed.booleans.has("--status") ? "status" : parsed.booleans.has("--renew") ? "renew" : parsed.booleans.has("--release") ? "release" : "acquire";
970
1017
  const ttl = parsed.values.get("--ttl");
971
- if (ttl !== void 0 && action !== "acquire" && action !== "renew") throw new Error("--ttl is only supported when acquiring or renewing a claim");
1018
+ if (ttl !== void 0 && action !== "acquire" && action !== "renew")
1019
+ throw new Error("--ttl is only supported when acquiring or renewing a claim");
972
1020
  const note = parsed.values.get("--note");
973
1021
  if (note !== void 0 && action !== "acquire") throw new Error("--note is only supported when acquiring a claim");
974
1022
  const generationValue = parsed.values.get("--generation");
@@ -976,22 +1024,52 @@ function parseSessionClaimArgs(args, options = {}, env = process.env) {
976
1024
  throw new Error("--generation is only supported with --renew or --release");
977
1025
  }
978
1026
  const generation = generationValue === void 0 ? void 0 : Number(generationValue);
979
- if (generation !== void 0 && (!Number.isSafeInteger(generation) || generation < 1)) throw new Error("--generation must be a positive integer");
1027
+ if (generation !== void 0 && (!Number.isSafeInteger(generation) || generation < 1))
1028
+ throw new Error("--generation must be a positive integer");
980
1029
  const sessionId = resolveClaimSessionId(parsed.values.get("--session"), options, env);
1030
+ const scope = resolveClaimScope(parsed.values.get("--project"), parsed.values.get("--branch"), options);
981
1031
  return {
982
1032
  action,
983
1033
  topic,
984
1034
  ...sessionId ? { sessionId } : {},
1035
+ ...scope ? { scope } : {},
985
1036
  ...ttl !== void 0 ? { ttlSeconds: parseClaimDuration(ttl) } : {},
986
1037
  ...note !== void 0 ? { note } : {},
987
1038
  ...generation !== void 0 ? { generation } : {}
988
1039
  };
989
1040
  }
990
1041
  function parseSessionClaimsListArgs(args, options = {}, env = process.env) {
991
- const parsed = parseCommandOperands(args, /* @__PURE__ */ new Set(["--session"]), /* @__PURE__ */ new Map(), "session claims");
1042
+ const parsed = parseCommandOperands(args, /* @__PURE__ */ new Set(["--session", "--project", "--branch"]), /* @__PURE__ */ new Map(), "session claims");
992
1043
  if (parsed.positionals.length > 0) throw new Error(`Unexpected session claims argument: ${parsed.positionals[0]}`);
993
1044
  const sessionId = resolveClaimSessionId(parsed.values.get("--session"), options, env);
994
- return sessionId ? { sessionId } : {};
1045
+ const scope = resolveClaimScope(parsed.values.get("--project"), parsed.values.get("--branch"), options);
1046
+ return { ...sessionId ? { sessionId } : {}, ...scope ? { scope } : {} };
1047
+ }
1048
+ function parseCheckoutPathArgs(args, options = {}, env = process.env) {
1049
+ const parsed = parseCommandOperands(args, /* @__PURE__ */ new Set(["--session", "--project", "--branch"]), /* @__PURE__ */ new Map(), "checkout path");
1050
+ if (parsed.positionals.length > 0) throw new Error(`Unexpected checkout path argument: ${parsed.positionals[0]}`);
1051
+ const projectId = parsed.values.get("--project")?.trim() || options.project?.trim();
1052
+ const branchName = parsed.values.get("--branch")?.trim() || options.branch?.trim();
1053
+ if (!projectId) throw new Error("--project/-p is required for `checkout path`");
1054
+ if (!branchName) throw new Error("--branch/-b is required for `checkout path`");
1055
+ const sessionId = resolveClaimSessionId(parsed.values.get("--session"), options, env);
1056
+ return { projectId, branchName, ...sessionId ? { sessionId } : {} };
1057
+ }
1058
+ class R5dctlClaimScopeMismatchError extends Error {
1059
+ constructor(requested, answered) {
1060
+ super(
1061
+ `The server did not apply the requested claim scope ${requested.projectId}#${requested.branchName}; it answered for ${answered.projectPath ?? answered.projectId}#${answered.branchName}. Update the r5d server before using scoped claims`
1062
+ );
1063
+ this.name = "R5dctlClaimScopeMismatchError";
1064
+ }
1065
+ }
1066
+ function claimScopeMatches(requested, answered) {
1067
+ if (!requested || !answered) return true;
1068
+ const projectMatches = answered.projectId === requested.projectId || answered.projectPath === requested.projectId;
1069
+ return projectMatches && answered.branchName === requested.branchName;
1070
+ }
1071
+ function assertClaimScopeEcho(requested, answered) {
1072
+ if (!claimScopeMatches(requested, answered)) throw new R5dctlClaimScopeMismatchError(requested, answered);
995
1073
  }
996
1074
  const CLAIM_OUTCOME_TEXT = {
997
1075
  acquired: "acquired",
@@ -1004,35 +1082,55 @@ const CLAIM_OUTCOME_TEXT = {
1004
1082
  released: "released"
1005
1083
  };
1006
1084
  function formatClaimExpiry(expiresAt, nowMs) {
1085
+ if (expiresAt === null) return "while the holder runs (implicit)";
1007
1086
  const expiresAtMs = Date.parse(expiresAt);
1008
1087
  if (!Number.isFinite(expiresAtMs)) return expiresAt;
1009
1088
  return expiresAtMs <= nowMs ? `${expiresAt} (expired ${formatProcessAge(expiresAt, nowMs)} ago)` : `${expiresAt} (in ${formatProcessAge(new Date(nowMs).toISOString(), expiresAtMs)})`;
1010
1089
  }
1090
+ function describeClaimScope(scope) {
1091
+ return `${scope.projectPath ?? scope.projectId}#${scope.branchName}`;
1092
+ }
1011
1093
  function describeClaimHolder(claim) {
1012
1094
  const name = claim.holderSessionName ? ` ${JSON.stringify(claim.holderSessionName)}` : "";
1013
- return `${claim.holderSessionId}${name}${claim.heldByActor ? " (this session)" : ""}`;
1095
+ const self = claim.heldByActor ? " (this session)" : "";
1096
+ let origin = "";
1097
+ if (claim.holderBranchName !== void 0 && claim.holderBranchName !== null) {
1098
+ const native = claim.holderProjectId === claim.projectId && claim.holderBranchName === claim.branchName;
1099
+ if (native) origin = claim.implicit ? " (native, running)" : " (native)";
1100
+ else if (claim.holderProjectId === claim.projectId) origin = ` (foreign: native to branch ${claim.holderBranchName})`;
1101
+ else origin = ` (foreign: native to ${claim.holderProjectId ?? "unknown"}#${claim.holderBranchName})`;
1102
+ }
1103
+ return `${claim.holderSessionId}${name}${self}${origin}`;
1104
+ }
1105
+ function describeClaimFence(response) {
1106
+ if (response.fenceExtended === void 0) return void 0;
1107
+ if (response.fenceExtended)
1108
+ return "Fence: commands this session starts while holding the claim also fence the claimed checkout on the bound worker";
1109
+ return `Fence: claim acquired, but ${response.fenceDetail ?? "the bound worker does not fence sibling checkouts (update the worker)"}; the claim still coordinates between sessions`;
1014
1110
  }
1015
1111
  function renderSessionClaimResponse(response, nowMs = Date.now()) {
1016
1112
  const lines = [`Outcome: ${CLAIM_OUTCOME_TEXT[response.outcome] ?? response.outcome}`, `Acting session: ${response.actorSessionId}`];
1017
1113
  if (response.claim) {
1018
1114
  lines.push(
1019
1115
  `Claim: ${response.claim.topic}`,
1020
- `Branch: ${response.claim.branchName}`,
1116
+ `Scope: ${describeClaimScope(response.claim)}`,
1021
1117
  `Holder: ${describeClaimHolder(response.claim)}`,
1022
1118
  `Generation: ${response.claim.generation}`,
1023
1119
  `Expires: ${formatClaimExpiry(response.claim.expiresAt, nowMs)}`
1024
1120
  );
1025
1121
  if (response.claim.note) lines.push(`Note: ${response.claim.note}`);
1026
1122
  }
1123
+ const fence = describeClaimFence(response);
1124
+ if (fence) lines.push(fence);
1027
1125
  return `${lines.join("\n")}
1028
1126
  `;
1029
1127
  }
1030
1128
  function renderSessionClaimList(response, nowMs = Date.now()) {
1031
- const header = `Claims on ${response.branchName} (acting session ${response.actorSessionId})`;
1129
+ const header = `Claims on ${describeClaimScope(response)} (acting session ${response.actorSessionId})`;
1032
1130
  if (response.claims.length === 0) return `${header}: none
1033
1131
  `;
1034
1132
  const rows = response.claims.map((claim) => {
1035
- const expiry = claim.expired ? "expired" : "expires";
1133
+ const expiry = claim.expired ? "expired" : claim.expiresAt === null ? "held" : "expires";
1036
1134
  const note = claim.note ? ` note: ${claim.note}` : "";
1037
1135
  return `- ${claim.topic} holder ${describeClaimHolder(claim)} generation ${claim.generation} ${expiry} ${formatClaimExpiry(claim.expiresAt, nowMs)}${note}`;
1038
1136
  });
@@ -1040,6 +1138,11 @@ function renderSessionClaimList(response, nowMs = Date.now()) {
1040
1138
  ${rows.join("\n")}
1041
1139
  `;
1042
1140
  }
1141
+ function renderCheckoutPath(response) {
1142
+ return [`Checkout: ${response.projectPath}#${response.branchName}`, `Worker: ${response.worker}`, `Path: ${response.path}`, ""].join(
1143
+ "\n"
1144
+ );
1145
+ }
1043
1146
  function assertAuthLoginArgs(args) {
1044
1147
  const valueFlags = /* @__PURE__ */ new Set(["--device-name", "--worker-label"]);
1045
1148
  const booleanFlags = /* @__PURE__ */ new Set(["--no-open", "--no-qr"]);
@@ -1881,6 +1984,12 @@ function renderWorkspaceStatus(status) {
1881
1984
  `Remediation: ${status.incident ? `${status.incident.kind} ${status.incident.status} (${status.incident.id}) on ${status.incident.originWorkerLabel}` : "none"}`,
1882
1985
  ...status.workers.flatMap((worker) => [
1883
1986
  `Worker ${worker.workerLabel}: ${worker.configured ? "configured" : "configuring"}; head ${worker.localHead ?? "(none)"}; pending checkouts ${worker.pendingCheckouts.length}`,
1987
+ ...worker.hydrationRecovery ? [
1988
+ ` Interrupted hydration ${worker.hydrationRecovery.transactionId}: files and snapshots preserved; execution is fenced.`,
1989
+ ` Evidence: ${worker.hydrationRecovery.transactionPath}`,
1990
+ ...(worker.hydrationRecovery.retainedPaths ?? []).map((retainedPath) => ` Retained writer entry: ${retainedPath}`),
1991
+ ` After stopping active processes: r5dctl workspace recover-hydration --worker ${JSON.stringify(worker.workerLabel)} --transaction ${worker.hydrationRecovery.transactionId} --confirm`
1992
+ ] : [],
1884
1993
  ...renderMissingCheckouts(worker.workerLabel, worker.missingCheckouts ?? []),
1885
1994
  ...renderWorkerCapacity(worker.workerLabel, worker.capacity)
1886
1995
  ])
@@ -1888,6 +1997,15 @@ function renderWorkspaceStatus(status) {
1888
1997
  if (status.latestSync?.missingCheckouts?.length && !status.workers.some((worker) => worker.missingCheckouts?.length)) {
1889
1998
  lines.push(...renderMissingCheckouts(status.latestSync.workerLabel, status.latestSync.missingCheckouts));
1890
1999
  }
2000
+ if (status.latestSync?.hydrationRecovery && !status.workers.some((worker) => worker.workerLabel === status.latestSync.workerLabel)) {
2001
+ lines.push(
2002
+ `Disconnected worker ${status.latestSync.workerLabel} reported interrupted hydration ${status.latestSync.hydrationRecovery.transactionId}; preserved evidence: ${status.latestSync.hydrationRecovery.transactionPath}. Reconnect it, inspect workspace status, and preserve the transaction before resuming work.`,
2003
+ ...(status.latestSync.hydrationRecovery.retainedPaths ?? []).map((retainedPath) => ` Retained writer entry: ${retainedPath}`)
2004
+ );
2005
+ }
2006
+ for (const [mountId, skip] of Object.entries(status.latestSync?.mountSkipReasons ?? {})) {
2007
+ for (const retainedPath of skip.retainedPaths ?? []) lines.push(`Retained writer entry for ${mountId}: ${retainedPath}`);
2008
+ }
1891
2009
  return `${lines.join("\n")}
1892
2010
  `;
1893
2011
  }
@@ -2174,11 +2292,17 @@ function renderSessionRunStart(session) {
2174
2292
  lines.push(`Worker: ${responseString(session, "worker") ?? "(unassigned)"}`);
2175
2293
  const sessionUrl = responseString(session, "sessionUrl");
2176
2294
  if (sessionUrl) lines.push(`URL: ${sessionUrl}`);
2295
+ lines.push(...formatSessionWarningLines(session));
2177
2296
  const sessionId = responseString(session, "sessionId");
2178
2297
  if (sessionId) lines.push("", `Next: r5dctl session status ${sessionId}`);
2179
2298
  return `${lines.join("\n")}
2180
2299
  `;
2181
2300
  }
2301
+ function formatSessionWarningLines(session) {
2302
+ const warnings = session.warnings;
2303
+ if (!Array.isArray(warnings)) return [];
2304
+ return warnings.filter((warning) => typeof warning === "string" && warning.length > 0).map((warning) => `Warning: ${warning}`);
2305
+ }
2182
2306
  function formatBranchStateHint(branch) {
2183
2307
  const target = branch ?? "<branch>";
2184
2308
  const commands = [
@@ -2229,6 +2353,7 @@ function renderSessionRunStatus(session) {
2229
2353
  }
2230
2354
  const childSessions = formatChildSessionsLine(session);
2231
2355
  if (childSessions) lines.push(childSessions);
2356
+ lines.push(...formatSessionWarningLines(session));
2232
2357
  lines.push(`Branch state: ${formatBranchStateHint(branch)}`);
2233
2358
  const error = responseString(session, "error");
2234
2359
  if (error) lines.push(`Error: ${error}`);
@@ -2279,9 +2404,23 @@ async function dispatchSessionRunCommand(client, command) {
2279
2404
  });
2280
2405
  return { data: data2, human: renderSessionRunStatus(data2) };
2281
2406
  }
2407
+ if (command.kind === "set-worker") {
2408
+ const data2 = await client.sessions.setWorker(command.sessionId, { worker: command.worker });
2409
+ return { data: data2, human: renderSessionSetWorker(data2) };
2410
+ }
2282
2411
  const data = await client.sessions.stop(command.sessionId);
2283
2412
  return { data, human: renderSessionRunStatus(data) };
2284
2413
  }
2414
+ function renderSessionSetWorker(response) {
2415
+ const previous = typeof response.previousWorker === "string" && response.previousWorker.length > 0 ? response.previousWorker : null;
2416
+ const lines = [
2417
+ `Session: ${responseString(response, "sessionId") ?? "(unknown)"}`,
2418
+ `Worker: ${responseString(response, "worker") ?? "(unassigned)"}${previous ? ` (was ${previous})` : ""}`,
2419
+ "The session resumes on this worker; shell runs and sub-agents started earlier stay where they ran."
2420
+ ];
2421
+ return `${lines.join("\n")}
2422
+ `;
2423
+ }
2285
2424
  async function waitForSessionProvisioning(client, initial, options = {}) {
2286
2425
  const sessionId = requireValue(responseString(initial, "sessionId"), "Session start did not return a session id");
2287
2426
  const wait = options.wait ?? import_promises.setTimeout;
@@ -2841,6 +2980,23 @@ async function executeR5dctlCommand(client, json, args) {
2841
2980
  write(result, renderWorkspaceSync(result));
2842
2981
  return;
2843
2982
  }
2983
+ if (first === "workspace" && second === "recover-hydration") {
2984
+ let worker;
2985
+ let transactionId;
2986
+ let confirmed = false;
2987
+ for (let index = 2; index < args.length; index += 1) {
2988
+ const arg = args[index];
2989
+ if (arg === "--worker") worker = requireValue(args[++index], "Missing worker label");
2990
+ else if (arg === "--transaction") transactionId = requireValue(args[++index], "Missing transaction id");
2991
+ else if (arg === "--confirm") confirmed = true;
2992
+ else throw new Error(`Unexpected recovery argument: ${arg}`);
2993
+ }
2994
+ if (!worker || !transactionId || !confirmed)
2995
+ throw new Error("workspace recover-hydration requires --worker, --transaction and --confirm; inspect workspace status first");
2996
+ const result = await client.workspace.recoverHydration({ worker, transactionId, confirmed: true });
2997
+ write(result, renderWorkspaceSync(result));
2998
+ return;
2999
+ }
2844
3000
  if (first === "workspace" && second === "reset") {
2845
3001
  const parsed = parseWorkspaceWorkerArgs(args.slice(2), { allowConfirm: true });
2846
3002
  const result = await client.workspace.reset({ worker: parsed.worker, confirmed: true });
@@ -2871,7 +3027,7 @@ async function executeR5dctlCommand(client, json, args) {
2871
3027
  }
2872
3028
  if (first === "session" && second === "claim") {
2873
3029
  const claim = parseSessionClaimArgs(args.slice(2), {}, {});
2874
- const scope = claim.sessionId ? { sessionId: claim.sessionId } : {};
3030
+ const scope = { ...claim.sessionId ? { sessionId: claim.sessionId } : {}, ...claim.scope ? { scope: claim.scope } : {} };
2875
3031
  const nowMs = Date.now();
2876
3032
  if (claim.action === "acquire") {
2877
3033
  const result2 = await client.claims.acquire({
@@ -2880,23 +3036,34 @@ async function executeR5dctlCommand(client, json, args) {
2880
3036
  ...claim.ttlSeconds !== void 0 ? { ttlSeconds: claim.ttlSeconds } : {},
2881
3037
  ...claim.note !== void 0 ? { note: claim.note } : {}
2882
3038
  });
3039
+ if (!claimScopeMatches(claim.scope, result2.claim)) {
3040
+ if (result2.outcome === "acquired" && result2.claim.heldByActor) {
3041
+ await client.claims.release({
3042
+ topic: claim.topic,
3043
+ ...claim.sessionId ? { sessionId: claim.sessionId } : {},
3044
+ generation: result2.claim.generation
3045
+ }).catch(() => void 0);
3046
+ }
3047
+ throw new R5dctlClaimScopeMismatchError(claim.scope, result2.claim);
3048
+ }
2883
3049
  write(result2, renderSessionClaimResponse(result2, nowMs));
2884
3050
  if (result2.outcome === "held_by_other") {
2885
- throw new Error(
2886
- `Claim ${claim.topic} is held by session ${result2.claim.holderSessionId} until ${result2.claim.expiresAt}; not acquired`
2887
- );
3051
+ const until = result2.claim.expiresAt === null ? "while it runs (native session)" : `until ${result2.claim.expiresAt}`;
3052
+ throw new Error(`Claim ${claim.topic} is held by session ${result2.claim.holderSessionId} ${until}; not acquired`);
2888
3053
  }
2889
3054
  return;
2890
3055
  }
2891
3056
  if (claim.action === "status") {
2892
3057
  const list = await client.claims.list({ ...scope, topic: claim.topic });
3058
+ assertClaimScopeEcho(claim.scope, list);
2893
3059
  write(list, renderSessionClaimList(list, nowMs));
2894
3060
  return;
2895
3061
  }
2896
3062
  let generation = claim.generation;
2897
- if (generation === void 0) {
3063
+ if (generation === void 0 || claim.scope) {
2898
3064
  const list = await client.claims.list({ ...scope, topic: claim.topic });
2899
- const current = list.claims[0];
3065
+ assertClaimScopeEcho(claim.scope, list);
3066
+ const current = list.claims.find((entry) => entry.heldByActor) ?? list.claims[0];
2900
3067
  if (!current) {
2901
3068
  write({ outcome: "missing", actorSessionId: list.actorSessionId, claim: null }, `No claim on ${claim.topic}.
2902
3069
  `);
@@ -2910,7 +3077,7 @@ async function executeR5dctlCommand(client, json, args) {
2910
3077
  );
2911
3078
  throw new Error(`Claim ${claim.topic} is held by session ${current.holderSessionId}, not by ${list.actorSessionId}`);
2912
3079
  }
2913
- generation = current.generation;
3080
+ generation ??= current.generation;
2914
3081
  }
2915
3082
  const result = claim.action === "renew" ? await client.claims.renew({
2916
3083
  topic: claim.topic,
@@ -2918,16 +3085,25 @@ async function executeR5dctlCommand(client, json, args) {
2918
3085
  generation,
2919
3086
  ...claim.ttlSeconds !== void 0 ? { ttlSeconds: claim.ttlSeconds } : {}
2920
3087
  }) : await client.claims.release({ topic: claim.topic, ...scope, generation });
3088
+ assertClaimScopeEcho(claim.scope, result.claim);
2921
3089
  write(result, renderSessionClaimResponse(result, nowMs));
2922
3090
  const succeeded = result.outcome === "renewed" || result.outcome === "released" || claim.action === "release" && result.outcome === "missing";
2923
3091
  if (!succeeded) throw new Error(`Claim ${claim.topic}: ${CLAIM_OUTCOME_TEXT[result.outcome] ?? result.outcome}`);
2924
3092
  return;
2925
3093
  }
2926
3094
  if (first === "session" && second === "claims") {
2927
- const list = await client.claims.list(parseSessionClaimsListArgs(args.slice(2), {}, {}));
3095
+ const selection = parseSessionClaimsListArgs(args.slice(2), {}, {});
3096
+ const list = await client.claims.list(selection);
3097
+ assertClaimScopeEcho(selection.scope, list);
2928
3098
  write(list, renderSessionClaimList(list));
2929
3099
  return;
2930
3100
  }
3101
+ if (first === "checkout" && second === "path") {
3102
+ const selection = parseCheckoutPathArgs(args.slice(2), {}, {});
3103
+ const result = await client.checkouts.path(selection);
3104
+ write(result, renderCheckoutPath(result));
3105
+ return;
3106
+ }
2931
3107
  if (first === "session" && second === "process-log") {
2932
3108
  const selection = parseSessionProcessLogArgs(args.slice(2));
2933
3109
  const log = await client.processes.log(selection.runId, {
@@ -3027,7 +3203,8 @@ ${result.deletion ? `Deletion: ${result.deletion.status}${result.deletion.error
3027
3203
  throw new Error(branchDeletionsConfirmationMessage(deletion.branches));
3028
3204
  }
3029
3205
  if (json) write({ results: deletion.results }, "");
3030
- if (deletion.results.some((result) => !result.deleted && !result.accepted)) throw new Error(branchDeletionsFailureMessage(deletion.results));
3206
+ if (deletion.results.some((result) => !result.deleted && !result.accepted))
3207
+ throw new Error(branchDeletionsFailureMessage(deletion.results));
3031
3208
  return;
3032
3209
  }
3033
3210
  if (first === "projects" && second === "branches" && third === "describe-deletion" || first === "describe" && second === "branch-deletion") {
@@ -3624,24 +3801,43 @@ function resolveCommandExecution(options, rest) {
3624
3801
  ...parseShellArgs(commandArgs)
3625
3802
  };
3626
3803
  }
3804
+ if (command === "checkout") {
3805
+ if (commandArgs[0] !== "path") throw new Error("Unknown checkout command");
3806
+ const parsed = parseCheckoutPathArgs(commandArgs.slice(1), options);
3807
+ return {
3808
+ kind: "plugin",
3809
+ pluginArgs: [
3810
+ "checkout",
3811
+ "path",
3812
+ `--project=${parsed.projectId}`,
3813
+ `--branch=${parsed.branchName}`,
3814
+ ...parsed.sessionId ? [`--session=${parsed.sessionId}`] : []
3815
+ ]
3816
+ };
3817
+ }
3627
3818
  if (command === "session") {
3628
3819
  if (commandArgs[0] === "claim" || commandArgs[0] === "claims") {
3629
- if (options.project) throw new Error("--project/-p is not supported for `session claim`; the acting session selects the project branch");
3630
- if (options.branch) throw new Error("--branch/-b is not supported for `session claim`; the acting session selects the branch");
3631
3820
  const parsed = commandArgs[0] === "claim" ? parseSessionClaimArgs(commandArgs.slice(1), options) : parseSessionClaimsListArgs(commandArgs.slice(1), options);
3632
- const withoutSessionValue = [];
3821
+ const normalizedFlags = /* @__PURE__ */ new Set(["--session", "--project", "--branch"]);
3822
+ const withoutNormalizedValues = [];
3633
3823
  for (let index = 0; index < commandArgs.slice(1).length; index += 1) {
3634
3824
  const arg = commandArgs.slice(1)[index];
3635
- if (arg === "--session") {
3825
+ if (normalizedFlags.has(arg)) {
3636
3826
  index += 1;
3637
3827
  continue;
3638
3828
  }
3639
- if (arg.startsWith("--session=")) continue;
3640
- withoutSessionValue.push(arg);
3829
+ if ([...normalizedFlags].some((flag) => arg.startsWith(`${flag}=`))) continue;
3830
+ withoutNormalizedValues.push(arg);
3641
3831
  }
3642
3832
  return {
3643
3833
  kind: "plugin",
3644
- pluginArgs: ["session", commandArgs[0], ...withoutSessionValue, ...parsed.sessionId ? [`--session=${parsed.sessionId}`] : []]
3834
+ pluginArgs: [
3835
+ "session",
3836
+ commandArgs[0],
3837
+ ...withoutNormalizedValues,
3838
+ ...parsed.scope ? [`--project=${parsed.scope.projectId}`, `--branch=${parsed.scope.branchName}`] : [],
3839
+ ...parsed.sessionId ? [`--session=${parsed.sessionId}`] : []
3840
+ ]
3645
3841
  };
3646
3842
  }
3647
3843
  if (commandArgs[0] === "process-log") {
@@ -3815,10 +4011,12 @@ async function main(argv = process.argv.slice(2)) {
3815
4011
  0 && (module.exports = {
3816
4012
  BRANCH_DELETION_POLL_INTERVAL_MS,
3817
4013
  BRANCH_DELETION_WAIT_TIMEOUT_MS,
4014
+ R5dctlClaimScopeMismatchError,
3818
4015
  advanceTransientDevicePollBackoff,
3819
4016
  branchDeletionConfirmationMessage,
3820
4017
  branchDeletionsConfirmationMessage,
3821
4018
  branchDeletionsFailureMessage,
4019
+ claimScopeMatches,
3822
4020
  createBranchThroughPlatform,
3823
4021
  deleteBranchThroughPlatform,
3824
4022
  deleteBranchesThroughPlatform,
@@ -3838,6 +4036,7 @@ async function main(argv = process.argv.slice(2)) {
3838
4036
  parseAnswerQuestionsCommandArgs,
3839
4037
  parseBranchCreateArgs,
3840
4038
  parseBranchDeleteArgs,
4039
+ parseCheckoutPathArgs,
3841
4040
  parseClaimDuration,
3842
4041
  parseConversationForkArgs,
3843
4042
  parseConversationInspectWorkArgs,
@@ -3863,6 +4062,7 @@ async function main(argv = process.argv.slice(2)) {
3863
4062
  renderBranchDeletionStatus,
3864
4063
  renderBranchList,
3865
4064
  renderBranchOperation,
4065
+ renderCheckoutPath,
3866
4066
  renderConversationForkResponse,
3867
4067
  renderConversationHeadResponse,
3868
4068
  renderConversationNodeResponse,
@@ -3880,6 +4080,7 @@ async function main(argv = process.argv.slice(2)) {
3880
4080
  renderSessionList,
3881
4081
  renderSessionRunStart,
3882
4082
  renderSessionRunStatus,
4083
+ renderSessionSetWorker,
3883
4084
  renderWorkerCapacity,
3884
4085
  renderWorkspaceStatus,
3885
4086
  renderWorkspaceSync,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5dctl",
3
- "version": "0.0.131",
3
+ "version": "0.0.133",
4
4
  "type": "commonjs"
5
5
  }
package/dist/mjs/cli.mjs CHANGED
@@ -70,10 +70,12 @@ const SESSION_RUN_HELP_TEXT = [
70
70
  " r5dctl session status <session-id>",
71
71
  ' r5dctl session prompt --worker <label> --mode <mode> --model <tier> [--delivery queue|steer|force] [--sender <name>] <session-id> "<prompt>"',
72
72
  " r5dctl session stop <session-id>",
73
+ " r5dctl session set-worker <session-id> --worker <label>",
73
74
  " r5dctl session process-log <run-id> [--tail <n>] [--grep <pattern>]",
74
- " r5dctl [-s <session-id>] session claim <topic> [--ttl <duration>] [--note <text>]",
75
- " r5dctl [-s <session-id>] session claim <topic> --status | --renew [--ttl <duration>] | --release",
76
- " r5dctl [-s <session-id>] session claims",
75
+ " r5dctl [-s <session-id>] [-p <project> --branch <branch>] session claim <topic> [--ttl <duration>] [--note <text>]",
76
+ " r5dctl [-s <session-id>] [-p <project> --branch <branch>] session claim <topic> --status | --renew [--ttl <duration>] | --release",
77
+ " r5dctl [-s <session-id>] [-p <project> --branch <branch>] session claims",
78
+ " r5dctl [-s <session-id>] -p <project> --branch <branch> checkout path",
77
79
  "",
78
80
  "Create, inspect, resume, and stop ordinary agent sessions.",
79
81
  "prompt reaches a busy session by --delivery: steer (default) lands after the agent's next tool result,",
@@ -86,12 +88,19 @@ const SESSION_RUN_HELP_TEXT = [
86
88
  "on a pause, wait for the recipient's explicit reply before proceeding.",
87
89
  "claim takes atomic ownership of a topic within the session's project branch (one winner; a losing call prints the holder and expiry).",
88
90
  "Claims expire at --ttl (default 30m, max 24h) unless renewed; --renew and --release are fenced to the holder and its claim generation.",
91
+ "-p <project> --branch <branch> (both or neither) claims on a sibling checkout the same user owns instead of the session's own branch.",
92
+ "The reserved topic `checkout` declares tracked-file writes in that checkout: claim it before running the command, hold it until the",
93
+ "write is done, and release it. A running session native to that branch holds `checkout` implicitly, so the call names who to message.",
94
+ "Commands started while a cross-scope `checkout` claim is held also fence the claimed checkout on the bound worker (the fence line says",
95
+ "whether the worker supports it). checkout path prints where a project branch is checked out on the session's bound worker.",
89
96
  "Agent shells act as their own session; user credentials select the acting session with -s or R5D_SESSION_ID.",
90
97
  "A new worktree starts with the source checkout's uncommitted changes (--working-tree carry, the default);",
91
98
  "--working-tree clean checks out the source branch's current commit only. Start and status print the mode, never a commit hash.",
92
99
  "Use -f/--follow/--watch to attach after a start; Ctrl-C detaches without stopping the session.",
93
100
  "process-log prints a shell run's stored stdout/stderr from the server: the whole log, its last n lines,",
94
101
  "or the lines matching a JavaScript regular expression. The log is not a file on the worker.",
102
+ "set-worker rebinds a stopped session to another connected worker: it publishes the current worker's workspace,",
103
+ "synchronizes the target, and the session resumes there. A running agent moves itself with its switch_worker tool.",
95
104
  ""
96
105
  ].join("\n");
97
106
  const SHARED_HELP_ENTRIES = [
@@ -106,6 +115,11 @@ const SHARED_HELP_ENTRIES = [
106
115
  { section: "auth", usage: "auth api-key list", description: "List API keys for the current account." },
107
116
  { section: "auth", usage: "auth api-key revoke <key-id>", description: "Revoke an API key." },
108
117
  { section: "workspace", usage: "workspace status", description: "Inspect per-user workspace Git state and active remediation." },
118
+ {
119
+ section: "workspace",
120
+ usage: "workspace recover-hydration --worker <label> --transaction <id> --confirm",
121
+ description: "Preserve current checkout files and archive interrupted hydration evidence, then reconcile from the previous recorded basis. Stop active worker processes first."
122
+ },
109
123
  {
110
124
  section: "workspace",
111
125
  usage: "workspace sync [--worker <label>]",
@@ -232,13 +246,23 @@ const SHARED_HELP_ENTRIES = [
232
246
  },
233
247
  {
234
248
  section: "sessions",
235
- usage: "[-s <session-id>] session claim <topic> [--ttl <duration>] [--note <text>] [--status|--renew|--release]",
236
- description: "Atomically own a coordination topic within the session's project branch; one winner, and a losing call prints the current holder and expiry. --status reads it, --renew extends your lease, --release gives it up. Agent shells act as their own session."
249
+ usage: "session set-worker <session-id> --worker <label>",
250
+ description: "Rebind a stopped session to another connected worker: publish the current worker's workspace, synchronize the target, and resume there. Refused while the agent runs; a running agent switches itself with switch_worker."
237
251
  },
238
252
  {
239
253
  section: "sessions",
240
- usage: "[-s <session-id>] session claims",
241
- description: "List the claims on the session's project branch with holder, generation, and expiry."
254
+ usage: "[-s <session-id>] [-p <project> --branch <branch>] session claim <topic> [--ttl <duration>] [--note <text>] [--status|--renew|--release]",
255
+ description: "Atomically own a coordination topic within the session's project branch, or with -p and --branch on a sibling checkout; one winner, and a losing call prints the current holder and expiry. --status reads it, --renew extends your lease, --release gives it up. Claim the reserved topic `checkout` before writing tracked files in another checkout. Agent shells act as their own session."
256
+ },
257
+ {
258
+ section: "sessions",
259
+ usage: "[-s <session-id>] [-p <project> --branch <branch>] session claims",
260
+ description: "List the claims on the session's project branch, or on a sibling checkout, with holder, origin branch, generation, and expiry."
261
+ },
262
+ {
263
+ section: "branches",
264
+ usage: "[-s <session-id>] -p <project> --branch <branch> checkout path",
265
+ description: "Print the absolute path of a project branch checkout on the acting session's bound worker."
242
266
  },
243
267
  {
244
268
  section: "sessions",
@@ -798,6 +822,12 @@ function parseSessionRunCommand(options, args, env = process.env) {
798
822
  if (parsed.positionals.length > 1) throw new Error(`Unexpected session ${subcommand} argument: ${parsed.positionals[1]}`);
799
823
  return { kind: subcommand, sessionId };
800
824
  }
825
+ if (subcommand === "set-worker") {
826
+ const parsed = parseCommandOperands(commandArgs, /* @__PURE__ */ new Set(["--worker"]), /* @__PURE__ */ new Map(), "session set-worker");
827
+ const sessionId = requireValue(parsed.positionals[0], "Session id is required for `session set-worker`");
828
+ if (parsed.positionals.length > 1) throw new Error(`Unexpected session set-worker argument: ${parsed.positionals[1]}`);
829
+ return { kind: "set-worker", sessionId, worker: requireWorker(parsed.values) };
830
+ }
801
831
  if (subcommand === "prompt") {
802
832
  const parsed = parseCommandOperands(
803
833
  commandArgs,
@@ -835,13 +865,25 @@ function parseSessionProcessLogArgs(args) {
835
865
  const grep = parsed.values.get("--grep");
836
866
  return { runId, ...tail !== void 0 ? { tail } : {}, ...grep !== void 0 ? { grep } : {} };
837
867
  }
868
+ function resolveClaimScope(explicitProject, explicitBranch, options) {
869
+ const project = explicitProject?.trim() || options.project?.trim() || void 0;
870
+ const branch = explicitBranch?.trim() || options.branch?.trim() || void 0;
871
+ if (project === void 0 && branch === void 0) return void 0;
872
+ if (project === void 0 || branch === void 0) {
873
+ throw new Error(
874
+ "A scoped claim needs both -p/--project and --branch/-b; pass both to act on a sibling checkout, or neither for the session's own branch"
875
+ );
876
+ }
877
+ return { projectId: project, branchName: branch };
878
+ }
838
879
  const CLAIM_DURATION_PATTERN = /^(\d+)([smh])$/;
839
880
  function parseClaimDuration(value) {
840
881
  const match = CLAIM_DURATION_PATTERN.exec(value.trim());
841
882
  if (!match) throw new Error("Invalid --ttl. Expected a duration such as 90s, 30m, or 2h");
842
883
  const amount = Number(match[1]);
843
884
  const seconds = match[2] === "s" ? amount : match[2] === "m" ? amount * 60 : amount * 3600;
844
- if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 86400) throw new Error("Invalid --ttl. Use a duration between 1s and 24h");
885
+ if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 86400)
886
+ throw new Error("Invalid --ttl. Use a duration between 1s and 24h");
845
887
  return seconds;
846
888
  }
847
889
  function resolveClaimSessionId(explicit, options, env) {
@@ -850,7 +892,7 @@ function resolveClaimSessionId(explicit, options, env) {
850
892
  function parseSessionClaimArgs(args, options = {}, env = process.env) {
851
893
  const parsed = parseCommandOperands(
852
894
  args,
853
- /* @__PURE__ */ new Set(["--ttl", "--note", "--generation", "--session"]),
895
+ /* @__PURE__ */ new Set(["--ttl", "--note", "--generation", "--session", "--project", "--branch"]),
854
896
  /* @__PURE__ */ new Map([
855
897
  ["--status", "--status"],
856
898
  ["--renew", "--renew"],
@@ -863,7 +905,8 @@ function parseSessionClaimArgs(args, options = {}, env = process.env) {
863
905
  if (parsed.booleans.size > 1) throw new Error("Use only one of --status, --renew, or --release");
864
906
  const action = parsed.booleans.has("--status") ? "status" : parsed.booleans.has("--renew") ? "renew" : parsed.booleans.has("--release") ? "release" : "acquire";
865
907
  const ttl = parsed.values.get("--ttl");
866
- if (ttl !== void 0 && action !== "acquire" && action !== "renew") throw new Error("--ttl is only supported when acquiring or renewing a claim");
908
+ if (ttl !== void 0 && action !== "acquire" && action !== "renew")
909
+ throw new Error("--ttl is only supported when acquiring or renewing a claim");
867
910
  const note = parsed.values.get("--note");
868
911
  if (note !== void 0 && action !== "acquire") throw new Error("--note is only supported when acquiring a claim");
869
912
  const generationValue = parsed.values.get("--generation");
@@ -871,22 +914,52 @@ function parseSessionClaimArgs(args, options = {}, env = process.env) {
871
914
  throw new Error("--generation is only supported with --renew or --release");
872
915
  }
873
916
  const generation = generationValue === void 0 ? void 0 : Number(generationValue);
874
- if (generation !== void 0 && (!Number.isSafeInteger(generation) || generation < 1)) throw new Error("--generation must be a positive integer");
917
+ if (generation !== void 0 && (!Number.isSafeInteger(generation) || generation < 1))
918
+ throw new Error("--generation must be a positive integer");
875
919
  const sessionId = resolveClaimSessionId(parsed.values.get("--session"), options, env);
920
+ const scope = resolveClaimScope(parsed.values.get("--project"), parsed.values.get("--branch"), options);
876
921
  return {
877
922
  action,
878
923
  topic,
879
924
  ...sessionId ? { sessionId } : {},
925
+ ...scope ? { scope } : {},
880
926
  ...ttl !== void 0 ? { ttlSeconds: parseClaimDuration(ttl) } : {},
881
927
  ...note !== void 0 ? { note } : {},
882
928
  ...generation !== void 0 ? { generation } : {}
883
929
  };
884
930
  }
885
931
  function parseSessionClaimsListArgs(args, options = {}, env = process.env) {
886
- const parsed = parseCommandOperands(args, /* @__PURE__ */ new Set(["--session"]), /* @__PURE__ */ new Map(), "session claims");
932
+ const parsed = parseCommandOperands(args, /* @__PURE__ */ new Set(["--session", "--project", "--branch"]), /* @__PURE__ */ new Map(), "session claims");
887
933
  if (parsed.positionals.length > 0) throw new Error(`Unexpected session claims argument: ${parsed.positionals[0]}`);
888
934
  const sessionId = resolveClaimSessionId(parsed.values.get("--session"), options, env);
889
- return sessionId ? { sessionId } : {};
935
+ const scope = resolveClaimScope(parsed.values.get("--project"), parsed.values.get("--branch"), options);
936
+ return { ...sessionId ? { sessionId } : {}, ...scope ? { scope } : {} };
937
+ }
938
+ function parseCheckoutPathArgs(args, options = {}, env = process.env) {
939
+ const parsed = parseCommandOperands(args, /* @__PURE__ */ new Set(["--session", "--project", "--branch"]), /* @__PURE__ */ new Map(), "checkout path");
940
+ if (parsed.positionals.length > 0) throw new Error(`Unexpected checkout path argument: ${parsed.positionals[0]}`);
941
+ const projectId = parsed.values.get("--project")?.trim() || options.project?.trim();
942
+ const branchName = parsed.values.get("--branch")?.trim() || options.branch?.trim();
943
+ if (!projectId) throw new Error("--project/-p is required for `checkout path`");
944
+ if (!branchName) throw new Error("--branch/-b is required for `checkout path`");
945
+ const sessionId = resolveClaimSessionId(parsed.values.get("--session"), options, env);
946
+ return { projectId, branchName, ...sessionId ? { sessionId } : {} };
947
+ }
948
+ class R5dctlClaimScopeMismatchError extends Error {
949
+ constructor(requested, answered) {
950
+ super(
951
+ `The server did not apply the requested claim scope ${requested.projectId}#${requested.branchName}; it answered for ${answered.projectPath ?? answered.projectId}#${answered.branchName}. Update the r5d server before using scoped claims`
952
+ );
953
+ this.name = "R5dctlClaimScopeMismatchError";
954
+ }
955
+ }
956
+ function claimScopeMatches(requested, answered) {
957
+ if (!requested || !answered) return true;
958
+ const projectMatches = answered.projectId === requested.projectId || answered.projectPath === requested.projectId;
959
+ return projectMatches && answered.branchName === requested.branchName;
960
+ }
961
+ function assertClaimScopeEcho(requested, answered) {
962
+ if (!claimScopeMatches(requested, answered)) throw new R5dctlClaimScopeMismatchError(requested, answered);
890
963
  }
891
964
  const CLAIM_OUTCOME_TEXT = {
892
965
  acquired: "acquired",
@@ -899,35 +972,55 @@ const CLAIM_OUTCOME_TEXT = {
899
972
  released: "released"
900
973
  };
901
974
  function formatClaimExpiry(expiresAt, nowMs) {
975
+ if (expiresAt === null) return "while the holder runs (implicit)";
902
976
  const expiresAtMs = Date.parse(expiresAt);
903
977
  if (!Number.isFinite(expiresAtMs)) return expiresAt;
904
978
  return expiresAtMs <= nowMs ? `${expiresAt} (expired ${formatProcessAge(expiresAt, nowMs)} ago)` : `${expiresAt} (in ${formatProcessAge(new Date(nowMs).toISOString(), expiresAtMs)})`;
905
979
  }
980
+ function describeClaimScope(scope) {
981
+ return `${scope.projectPath ?? scope.projectId}#${scope.branchName}`;
982
+ }
906
983
  function describeClaimHolder(claim) {
907
984
  const name = claim.holderSessionName ? ` ${JSON.stringify(claim.holderSessionName)}` : "";
908
- return `${claim.holderSessionId}${name}${claim.heldByActor ? " (this session)" : ""}`;
985
+ const self = claim.heldByActor ? " (this session)" : "";
986
+ let origin = "";
987
+ if (claim.holderBranchName !== void 0 && claim.holderBranchName !== null) {
988
+ const native = claim.holderProjectId === claim.projectId && claim.holderBranchName === claim.branchName;
989
+ if (native) origin = claim.implicit ? " (native, running)" : " (native)";
990
+ else if (claim.holderProjectId === claim.projectId) origin = ` (foreign: native to branch ${claim.holderBranchName})`;
991
+ else origin = ` (foreign: native to ${claim.holderProjectId ?? "unknown"}#${claim.holderBranchName})`;
992
+ }
993
+ return `${claim.holderSessionId}${name}${self}${origin}`;
994
+ }
995
+ function describeClaimFence(response) {
996
+ if (response.fenceExtended === void 0) return void 0;
997
+ if (response.fenceExtended)
998
+ return "Fence: commands this session starts while holding the claim also fence the claimed checkout on the bound worker";
999
+ return `Fence: claim acquired, but ${response.fenceDetail ?? "the bound worker does not fence sibling checkouts (update the worker)"}; the claim still coordinates between sessions`;
909
1000
  }
910
1001
  function renderSessionClaimResponse(response, nowMs = Date.now()) {
911
1002
  const lines = [`Outcome: ${CLAIM_OUTCOME_TEXT[response.outcome] ?? response.outcome}`, `Acting session: ${response.actorSessionId}`];
912
1003
  if (response.claim) {
913
1004
  lines.push(
914
1005
  `Claim: ${response.claim.topic}`,
915
- `Branch: ${response.claim.branchName}`,
1006
+ `Scope: ${describeClaimScope(response.claim)}`,
916
1007
  `Holder: ${describeClaimHolder(response.claim)}`,
917
1008
  `Generation: ${response.claim.generation}`,
918
1009
  `Expires: ${formatClaimExpiry(response.claim.expiresAt, nowMs)}`
919
1010
  );
920
1011
  if (response.claim.note) lines.push(`Note: ${response.claim.note}`);
921
1012
  }
1013
+ const fence = describeClaimFence(response);
1014
+ if (fence) lines.push(fence);
922
1015
  return `${lines.join("\n")}
923
1016
  `;
924
1017
  }
925
1018
  function renderSessionClaimList(response, nowMs = Date.now()) {
926
- const header = `Claims on ${response.branchName} (acting session ${response.actorSessionId})`;
1019
+ const header = `Claims on ${describeClaimScope(response)} (acting session ${response.actorSessionId})`;
927
1020
  if (response.claims.length === 0) return `${header}: none
928
1021
  `;
929
1022
  const rows = response.claims.map((claim) => {
930
- const expiry = claim.expired ? "expired" : "expires";
1023
+ const expiry = claim.expired ? "expired" : claim.expiresAt === null ? "held" : "expires";
931
1024
  const note = claim.note ? ` note: ${claim.note}` : "";
932
1025
  return `- ${claim.topic} holder ${describeClaimHolder(claim)} generation ${claim.generation} ${expiry} ${formatClaimExpiry(claim.expiresAt, nowMs)}${note}`;
933
1026
  });
@@ -935,6 +1028,11 @@ function renderSessionClaimList(response, nowMs = Date.now()) {
935
1028
  ${rows.join("\n")}
936
1029
  `;
937
1030
  }
1031
+ function renderCheckoutPath(response) {
1032
+ return [`Checkout: ${response.projectPath}#${response.branchName}`, `Worker: ${response.worker}`, `Path: ${response.path}`, ""].join(
1033
+ "\n"
1034
+ );
1035
+ }
938
1036
  function assertAuthLoginArgs(args) {
939
1037
  const valueFlags = /* @__PURE__ */ new Set(["--device-name", "--worker-label"]);
940
1038
  const booleanFlags = /* @__PURE__ */ new Set(["--no-open", "--no-qr"]);
@@ -1776,6 +1874,12 @@ function renderWorkspaceStatus(status) {
1776
1874
  `Remediation: ${status.incident ? `${status.incident.kind} ${status.incident.status} (${status.incident.id}) on ${status.incident.originWorkerLabel}` : "none"}`,
1777
1875
  ...status.workers.flatMap((worker) => [
1778
1876
  `Worker ${worker.workerLabel}: ${worker.configured ? "configured" : "configuring"}; head ${worker.localHead ?? "(none)"}; pending checkouts ${worker.pendingCheckouts.length}`,
1877
+ ...worker.hydrationRecovery ? [
1878
+ ` Interrupted hydration ${worker.hydrationRecovery.transactionId}: files and snapshots preserved; execution is fenced.`,
1879
+ ` Evidence: ${worker.hydrationRecovery.transactionPath}`,
1880
+ ...(worker.hydrationRecovery.retainedPaths ?? []).map((retainedPath) => ` Retained writer entry: ${retainedPath}`),
1881
+ ` After stopping active processes: r5dctl workspace recover-hydration --worker ${JSON.stringify(worker.workerLabel)} --transaction ${worker.hydrationRecovery.transactionId} --confirm`
1882
+ ] : [],
1779
1883
  ...renderMissingCheckouts(worker.workerLabel, worker.missingCheckouts ?? []),
1780
1884
  ...renderWorkerCapacity(worker.workerLabel, worker.capacity)
1781
1885
  ])
@@ -1783,6 +1887,15 @@ function renderWorkspaceStatus(status) {
1783
1887
  if (status.latestSync?.missingCheckouts?.length && !status.workers.some((worker) => worker.missingCheckouts?.length)) {
1784
1888
  lines.push(...renderMissingCheckouts(status.latestSync.workerLabel, status.latestSync.missingCheckouts));
1785
1889
  }
1890
+ if (status.latestSync?.hydrationRecovery && !status.workers.some((worker) => worker.workerLabel === status.latestSync.workerLabel)) {
1891
+ lines.push(
1892
+ `Disconnected worker ${status.latestSync.workerLabel} reported interrupted hydration ${status.latestSync.hydrationRecovery.transactionId}; preserved evidence: ${status.latestSync.hydrationRecovery.transactionPath}. Reconnect it, inspect workspace status, and preserve the transaction before resuming work.`,
1893
+ ...(status.latestSync.hydrationRecovery.retainedPaths ?? []).map((retainedPath) => ` Retained writer entry: ${retainedPath}`)
1894
+ );
1895
+ }
1896
+ for (const [mountId, skip] of Object.entries(status.latestSync?.mountSkipReasons ?? {})) {
1897
+ for (const retainedPath of skip.retainedPaths ?? []) lines.push(`Retained writer entry for ${mountId}: ${retainedPath}`);
1898
+ }
1786
1899
  return `${lines.join("\n")}
1787
1900
  `;
1788
1901
  }
@@ -2069,11 +2182,17 @@ function renderSessionRunStart(session) {
2069
2182
  lines.push(`Worker: ${responseString(session, "worker") ?? "(unassigned)"}`);
2070
2183
  const sessionUrl = responseString(session, "sessionUrl");
2071
2184
  if (sessionUrl) lines.push(`URL: ${sessionUrl}`);
2185
+ lines.push(...formatSessionWarningLines(session));
2072
2186
  const sessionId = responseString(session, "sessionId");
2073
2187
  if (sessionId) lines.push("", `Next: r5dctl session status ${sessionId}`);
2074
2188
  return `${lines.join("\n")}
2075
2189
  `;
2076
2190
  }
2191
+ function formatSessionWarningLines(session) {
2192
+ const warnings = session.warnings;
2193
+ if (!Array.isArray(warnings)) return [];
2194
+ return warnings.filter((warning) => typeof warning === "string" && warning.length > 0).map((warning) => `Warning: ${warning}`);
2195
+ }
2077
2196
  function formatBranchStateHint(branch) {
2078
2197
  const target = branch ?? "<branch>";
2079
2198
  const commands = [
@@ -2124,6 +2243,7 @@ function renderSessionRunStatus(session) {
2124
2243
  }
2125
2244
  const childSessions = formatChildSessionsLine(session);
2126
2245
  if (childSessions) lines.push(childSessions);
2246
+ lines.push(...formatSessionWarningLines(session));
2127
2247
  lines.push(`Branch state: ${formatBranchStateHint(branch)}`);
2128
2248
  const error = responseString(session, "error");
2129
2249
  if (error) lines.push(`Error: ${error}`);
@@ -2174,9 +2294,23 @@ async function dispatchSessionRunCommand(client, command) {
2174
2294
  });
2175
2295
  return { data: data2, human: renderSessionRunStatus(data2) };
2176
2296
  }
2297
+ if (command.kind === "set-worker") {
2298
+ const data2 = await client.sessions.setWorker(command.sessionId, { worker: command.worker });
2299
+ return { data: data2, human: renderSessionSetWorker(data2) };
2300
+ }
2177
2301
  const data = await client.sessions.stop(command.sessionId);
2178
2302
  return { data, human: renderSessionRunStatus(data) };
2179
2303
  }
2304
+ function renderSessionSetWorker(response) {
2305
+ const previous = typeof response.previousWorker === "string" && response.previousWorker.length > 0 ? response.previousWorker : null;
2306
+ const lines = [
2307
+ `Session: ${responseString(response, "sessionId") ?? "(unknown)"}`,
2308
+ `Worker: ${responseString(response, "worker") ?? "(unassigned)"}${previous ? ` (was ${previous})` : ""}`,
2309
+ "The session resumes on this worker; shell runs and sub-agents started earlier stay where they ran."
2310
+ ];
2311
+ return `${lines.join("\n")}
2312
+ `;
2313
+ }
2180
2314
  async function waitForSessionProvisioning(client, initial, options = {}) {
2181
2315
  const sessionId = requireValue(responseString(initial, "sessionId"), "Session start did not return a session id");
2182
2316
  const wait = options.wait ?? sleep;
@@ -2736,6 +2870,23 @@ async function executeR5dctlCommand(client, json, args) {
2736
2870
  write(result, renderWorkspaceSync(result));
2737
2871
  return;
2738
2872
  }
2873
+ if (first === "workspace" && second === "recover-hydration") {
2874
+ let worker;
2875
+ let transactionId;
2876
+ let confirmed = false;
2877
+ for (let index = 2; index < args.length; index += 1) {
2878
+ const arg = args[index];
2879
+ if (arg === "--worker") worker = requireValue(args[++index], "Missing worker label");
2880
+ else if (arg === "--transaction") transactionId = requireValue(args[++index], "Missing transaction id");
2881
+ else if (arg === "--confirm") confirmed = true;
2882
+ else throw new Error(`Unexpected recovery argument: ${arg}`);
2883
+ }
2884
+ if (!worker || !transactionId || !confirmed)
2885
+ throw new Error("workspace recover-hydration requires --worker, --transaction and --confirm; inspect workspace status first");
2886
+ const result = await client.workspace.recoverHydration({ worker, transactionId, confirmed: true });
2887
+ write(result, renderWorkspaceSync(result));
2888
+ return;
2889
+ }
2739
2890
  if (first === "workspace" && second === "reset") {
2740
2891
  const parsed = parseWorkspaceWorkerArgs(args.slice(2), { allowConfirm: true });
2741
2892
  const result = await client.workspace.reset({ worker: parsed.worker, confirmed: true });
@@ -2766,7 +2917,7 @@ async function executeR5dctlCommand(client, json, args) {
2766
2917
  }
2767
2918
  if (first === "session" && second === "claim") {
2768
2919
  const claim = parseSessionClaimArgs(args.slice(2), {}, {});
2769
- const scope = claim.sessionId ? { sessionId: claim.sessionId } : {};
2920
+ const scope = { ...claim.sessionId ? { sessionId: claim.sessionId } : {}, ...claim.scope ? { scope: claim.scope } : {} };
2770
2921
  const nowMs = Date.now();
2771
2922
  if (claim.action === "acquire") {
2772
2923
  const result2 = await client.claims.acquire({
@@ -2775,23 +2926,34 @@ async function executeR5dctlCommand(client, json, args) {
2775
2926
  ...claim.ttlSeconds !== void 0 ? { ttlSeconds: claim.ttlSeconds } : {},
2776
2927
  ...claim.note !== void 0 ? { note: claim.note } : {}
2777
2928
  });
2929
+ if (!claimScopeMatches(claim.scope, result2.claim)) {
2930
+ if (result2.outcome === "acquired" && result2.claim.heldByActor) {
2931
+ await client.claims.release({
2932
+ topic: claim.topic,
2933
+ ...claim.sessionId ? { sessionId: claim.sessionId } : {},
2934
+ generation: result2.claim.generation
2935
+ }).catch(() => void 0);
2936
+ }
2937
+ throw new R5dctlClaimScopeMismatchError(claim.scope, result2.claim);
2938
+ }
2778
2939
  write(result2, renderSessionClaimResponse(result2, nowMs));
2779
2940
  if (result2.outcome === "held_by_other") {
2780
- throw new Error(
2781
- `Claim ${claim.topic} is held by session ${result2.claim.holderSessionId} until ${result2.claim.expiresAt}; not acquired`
2782
- );
2941
+ const until = result2.claim.expiresAt === null ? "while it runs (native session)" : `until ${result2.claim.expiresAt}`;
2942
+ throw new Error(`Claim ${claim.topic} is held by session ${result2.claim.holderSessionId} ${until}; not acquired`);
2783
2943
  }
2784
2944
  return;
2785
2945
  }
2786
2946
  if (claim.action === "status") {
2787
2947
  const list = await client.claims.list({ ...scope, topic: claim.topic });
2948
+ assertClaimScopeEcho(claim.scope, list);
2788
2949
  write(list, renderSessionClaimList(list, nowMs));
2789
2950
  return;
2790
2951
  }
2791
2952
  let generation = claim.generation;
2792
- if (generation === void 0) {
2953
+ if (generation === void 0 || claim.scope) {
2793
2954
  const list = await client.claims.list({ ...scope, topic: claim.topic });
2794
- const current = list.claims[0];
2955
+ assertClaimScopeEcho(claim.scope, list);
2956
+ const current = list.claims.find((entry) => entry.heldByActor) ?? list.claims[0];
2795
2957
  if (!current) {
2796
2958
  write({ outcome: "missing", actorSessionId: list.actorSessionId, claim: null }, `No claim on ${claim.topic}.
2797
2959
  `);
@@ -2805,7 +2967,7 @@ async function executeR5dctlCommand(client, json, args) {
2805
2967
  );
2806
2968
  throw new Error(`Claim ${claim.topic} is held by session ${current.holderSessionId}, not by ${list.actorSessionId}`);
2807
2969
  }
2808
- generation = current.generation;
2970
+ generation ??= current.generation;
2809
2971
  }
2810
2972
  const result = claim.action === "renew" ? await client.claims.renew({
2811
2973
  topic: claim.topic,
@@ -2813,16 +2975,25 @@ async function executeR5dctlCommand(client, json, args) {
2813
2975
  generation,
2814
2976
  ...claim.ttlSeconds !== void 0 ? { ttlSeconds: claim.ttlSeconds } : {}
2815
2977
  }) : await client.claims.release({ topic: claim.topic, ...scope, generation });
2978
+ assertClaimScopeEcho(claim.scope, result.claim);
2816
2979
  write(result, renderSessionClaimResponse(result, nowMs));
2817
2980
  const succeeded = result.outcome === "renewed" || result.outcome === "released" || claim.action === "release" && result.outcome === "missing";
2818
2981
  if (!succeeded) throw new Error(`Claim ${claim.topic}: ${CLAIM_OUTCOME_TEXT[result.outcome] ?? result.outcome}`);
2819
2982
  return;
2820
2983
  }
2821
2984
  if (first === "session" && second === "claims") {
2822
- const list = await client.claims.list(parseSessionClaimsListArgs(args.slice(2), {}, {}));
2985
+ const selection = parseSessionClaimsListArgs(args.slice(2), {}, {});
2986
+ const list = await client.claims.list(selection);
2987
+ assertClaimScopeEcho(selection.scope, list);
2823
2988
  write(list, renderSessionClaimList(list));
2824
2989
  return;
2825
2990
  }
2991
+ if (first === "checkout" && second === "path") {
2992
+ const selection = parseCheckoutPathArgs(args.slice(2), {}, {});
2993
+ const result = await client.checkouts.path(selection);
2994
+ write(result, renderCheckoutPath(result));
2995
+ return;
2996
+ }
2826
2997
  if (first === "session" && second === "process-log") {
2827
2998
  const selection = parseSessionProcessLogArgs(args.slice(2));
2828
2999
  const log = await client.processes.log(selection.runId, {
@@ -2922,7 +3093,8 @@ ${result.deletion ? `Deletion: ${result.deletion.status}${result.deletion.error
2922
3093
  throw new Error(branchDeletionsConfirmationMessage(deletion.branches));
2923
3094
  }
2924
3095
  if (json) write({ results: deletion.results }, "");
2925
- if (deletion.results.some((result) => !result.deleted && !result.accepted)) throw new Error(branchDeletionsFailureMessage(deletion.results));
3096
+ if (deletion.results.some((result) => !result.deleted && !result.accepted))
3097
+ throw new Error(branchDeletionsFailureMessage(deletion.results));
2926
3098
  return;
2927
3099
  }
2928
3100
  if (first === "projects" && second === "branches" && third === "describe-deletion" || first === "describe" && second === "branch-deletion") {
@@ -3519,24 +3691,43 @@ function resolveCommandExecution(options, rest) {
3519
3691
  ...parseShellArgs(commandArgs)
3520
3692
  };
3521
3693
  }
3694
+ if (command === "checkout") {
3695
+ if (commandArgs[0] !== "path") throw new Error("Unknown checkout command");
3696
+ const parsed = parseCheckoutPathArgs(commandArgs.slice(1), options);
3697
+ return {
3698
+ kind: "plugin",
3699
+ pluginArgs: [
3700
+ "checkout",
3701
+ "path",
3702
+ `--project=${parsed.projectId}`,
3703
+ `--branch=${parsed.branchName}`,
3704
+ ...parsed.sessionId ? [`--session=${parsed.sessionId}`] : []
3705
+ ]
3706
+ };
3707
+ }
3522
3708
  if (command === "session") {
3523
3709
  if (commandArgs[0] === "claim" || commandArgs[0] === "claims") {
3524
- if (options.project) throw new Error("--project/-p is not supported for `session claim`; the acting session selects the project branch");
3525
- if (options.branch) throw new Error("--branch/-b is not supported for `session claim`; the acting session selects the branch");
3526
3710
  const parsed = commandArgs[0] === "claim" ? parseSessionClaimArgs(commandArgs.slice(1), options) : parseSessionClaimsListArgs(commandArgs.slice(1), options);
3527
- const withoutSessionValue = [];
3711
+ const normalizedFlags = /* @__PURE__ */ new Set(["--session", "--project", "--branch"]);
3712
+ const withoutNormalizedValues = [];
3528
3713
  for (let index = 0; index < commandArgs.slice(1).length; index += 1) {
3529
3714
  const arg = commandArgs.slice(1)[index];
3530
- if (arg === "--session") {
3715
+ if (normalizedFlags.has(arg)) {
3531
3716
  index += 1;
3532
3717
  continue;
3533
3718
  }
3534
- if (arg.startsWith("--session=")) continue;
3535
- withoutSessionValue.push(arg);
3719
+ if ([...normalizedFlags].some((flag) => arg.startsWith(`${flag}=`))) continue;
3720
+ withoutNormalizedValues.push(arg);
3536
3721
  }
3537
3722
  return {
3538
3723
  kind: "plugin",
3539
- pluginArgs: ["session", commandArgs[0], ...withoutSessionValue, ...parsed.sessionId ? [`--session=${parsed.sessionId}`] : []]
3724
+ pluginArgs: [
3725
+ "session",
3726
+ commandArgs[0],
3727
+ ...withoutNormalizedValues,
3728
+ ...parsed.scope ? [`--project=${parsed.scope.projectId}`, `--branch=${parsed.scope.branchName}`] : [],
3729
+ ...parsed.sessionId ? [`--session=${parsed.sessionId}`] : []
3730
+ ]
3540
3731
  };
3541
3732
  }
3542
3733
  if (commandArgs[0] === "process-log") {
@@ -3709,10 +3900,12 @@ async function main(argv = process.argv.slice(2)) {
3709
3900
  export {
3710
3901
  BRANCH_DELETION_POLL_INTERVAL_MS,
3711
3902
  BRANCH_DELETION_WAIT_TIMEOUT_MS,
3903
+ R5dctlClaimScopeMismatchError,
3712
3904
  advanceTransientDevicePollBackoff,
3713
3905
  branchDeletionConfirmationMessage,
3714
3906
  branchDeletionsConfirmationMessage,
3715
3907
  branchDeletionsFailureMessage,
3908
+ claimScopeMatches,
3716
3909
  createBranchThroughPlatform,
3717
3910
  deleteBranchThroughPlatform,
3718
3911
  deleteBranchesThroughPlatform,
@@ -3732,6 +3925,7 @@ export {
3732
3925
  parseAnswerQuestionsCommandArgs,
3733
3926
  parseBranchCreateArgs,
3734
3927
  parseBranchDeleteArgs,
3928
+ parseCheckoutPathArgs,
3735
3929
  parseClaimDuration,
3736
3930
  parseConversationForkArgs,
3737
3931
  parseConversationInspectWorkArgs,
@@ -3757,6 +3951,7 @@ export {
3757
3951
  renderBranchDeletionStatus,
3758
3952
  renderBranchList,
3759
3953
  renderBranchOperation,
3954
+ renderCheckoutPath,
3760
3955
  renderConversationForkResponse,
3761
3956
  renderConversationHeadResponse,
3762
3957
  renderConversationNodeResponse,
@@ -3774,6 +3969,7 @@ export {
3774
3969
  renderSessionList,
3775
3970
  renderSessionRunStart,
3776
3971
  renderSessionRunStatus,
3972
+ renderSessionSetWorker,
3777
3973
  renderWorkerCapacity,
3778
3974
  renderWorkspaceStatus,
3779
3975
  renderWorkspaceSync,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5dctl",
3
- "version": "0.0.131",
3
+ "version": "0.0.133",
4
4
  "type": "module"
5
5
  }
@@ -1,4 +1,4 @@
1
- import { R5dctlApiError, R5dctlClient, type R5dctlConversationForkResponse, type R5dctlConversationHeadResponse, type R5dctlConversationRenderOptions, type R5dctlConversationResponse, type R5dctlConversationNodeResponse, type R5dctlConversationOverviewResponse, type R5dctlConversationWorkDetail, type R5dctlConversationWorkResponse, type R5dctlEnvData, type R5dctlProcessInspection, type R5dctlProcessListInput, type R5dctlProcessLog, type R5dctlProcessRun, type R5dctlWorkerCapacityReport, type R5dctlBranch, type R5dctlBranchDeletionStatus, type R5dctlBranchDescription, type R5dctlBranchOperation, type R5dctlSessionSummary, type R5dctlSessionClaim, type R5dctlSessionClaimListResponse, type R5dctlSessionEvent, type R5dctlSessionStartInput, type R5dctlWorkspaceStatus, type R5dctlWorkspaceSyncResult, type R5dctlWorkingTreeMode, type R5dctlWorktreeSource, type R5dctlPromptDelivery, type R5dctlSessionMode, type ChatMode, type ModelTier } from "@ricsam/r5d-api";
1
+ import { R5dctlApiError, R5dctlClient, type R5dctlConversationForkResponse, type R5dctlConversationHeadResponse, type R5dctlConversationRenderOptions, type R5dctlConversationResponse, type R5dctlConversationNodeResponse, type R5dctlConversationOverviewResponse, type R5dctlConversationWorkDetail, type R5dctlConversationWorkResponse, type R5dctlEnvData, type R5dctlProcessInspection, type R5dctlProcessListInput, type R5dctlProcessLog, type R5dctlProcessRun, type R5dctlWorkerCapacityReport, type R5dctlBranch, type R5dctlBranchDeletionStatus, type R5dctlBranchDescription, type R5dctlBranchOperation, type R5dctlSessionSummary, type R5dctlCheckoutPathResponse, type R5dctlSessionClaim, type R5dctlSessionClaimListResponse, type R5dctlSessionClaimScope, type R5dctlSessionEvent, type R5dctlSessionStartInput, type R5dctlWorkspaceStatus, type R5dctlWorkspaceSyncResult, type R5dctlWorkingTreeMode, type R5dctlWorktreeSource, type R5dctlPromptDelivery, type R5dctlSessionMode, type ChatMode, type ModelTier } from "@ricsam/r5d-api";
2
2
  import { type AccountCommand } from "./account";
3
3
  export type GlobalOptions = {
4
4
  baseUrl?: string;
@@ -84,6 +84,10 @@ export type R5dctlSessionRunCommand = R5dctlSessionStartCommand | {
84
84
  } | {
85
85
  kind: "stop";
86
86
  sessionId: string;
87
+ } | {
88
+ kind: "set-worker";
89
+ sessionId: string;
90
+ worker: string;
87
91
  };
88
92
  export type R5dctlBranchCreateArgs = {
89
93
  source: R5dctlWorktreeSource;
@@ -205,23 +209,54 @@ export type R5dctlSessionClaimArgs = {
205
209
  topic: string;
206
210
  /** The acting session; omitted lets a worker credential act as its own verified session. */
207
211
  sessionId?: string;
212
+ /** A sibling checkout to claim on instead of the acting session's own branch. */
213
+ scope?: R5dctlSessionClaimScope;
208
214
  ttlSeconds?: number;
209
215
  note?: string;
210
216
  /** Claim generation observed by the caller; renew and release are fenced to it. */
211
217
  generation?: number;
212
218
  };
219
+ type ClaimScopeOptions = Pick<GlobalOptions, "session" | "project" | "branch">;
213
220
  /** `90s`, `30m`, `2h`; the server clamps to between one minute and one day. */
214
221
  export declare function parseClaimDuration(value: string): number;
215
- export declare function parseSessionClaimArgs(args: string[], options?: Pick<GlobalOptions, "session">, env?: Record<string, string | undefined>): R5dctlSessionClaimArgs;
216
- export declare function parseSessionClaimsListArgs(args: string[], options?: Pick<GlobalOptions, "session">, env?: Record<string, string | undefined>): {
222
+ export declare function parseSessionClaimArgs(args: string[], options?: ClaimScopeOptions, env?: Record<string, string | undefined>): R5dctlSessionClaimArgs;
223
+ export declare function parseSessionClaimsListArgs(args: string[], options?: ClaimScopeOptions, env?: Record<string, string | undefined>): {
224
+ sessionId?: string;
225
+ scope?: R5dctlSessionClaimScope;
226
+ };
227
+ export type R5dctlCheckoutPathArgs = {
228
+ projectId: string;
229
+ branchName: string;
217
230
  sessionId?: string;
218
231
  };
232
+ /** `checkout path` names the checkout with -p/--project and --branch/-b (both required) and acts as the selected session. */
233
+ export declare function parseCheckoutPathArgs(args: string[], options?: ClaimScopeOptions, env?: Record<string, string | undefined>): R5dctlCheckoutPathArgs;
234
+ /**
235
+ * A server that predates claim scopes strips the unknown `scope` field and
236
+ * answers for the acting session's own branch. Every scoped command must see
237
+ * its scope echoed back, so a claim can never silently land elsewhere.
238
+ */
239
+ export declare class R5dctlClaimScopeMismatchError extends Error {
240
+ constructor(requested: R5dctlSessionClaimScope, answered: {
241
+ projectId: string;
242
+ projectPath?: string;
243
+ branchName: string;
244
+ });
245
+ }
246
+ export declare function claimScopeMatches(requested: R5dctlSessionClaimScope | undefined, answered: {
247
+ projectId: string;
248
+ projectPath?: string;
249
+ branchName: string;
250
+ } | null | undefined): boolean;
219
251
  export declare function renderSessionClaimResponse(response: {
220
252
  outcome: string;
221
253
  actorSessionId: string;
222
254
  claim: R5dctlSessionClaim | null;
255
+ fenceExtended?: boolean;
256
+ fenceDetail?: string;
223
257
  }, nowMs?: number): string;
224
258
  export declare function renderSessionClaimList(response: R5dctlSessionClaimListResponse, nowMs?: number): string;
259
+ export declare function renderCheckoutPath(response: R5dctlCheckoutPathResponse): string;
225
260
  export declare function parseGlobalArgs(argv: string[]): {
226
261
  options: GlobalOptions;
227
262
  rest: string[];
@@ -312,6 +347,10 @@ export type R5dctlCommandResponse = {
312
347
  baselineCommit?: string | null;
313
348
  workspaceHead?: string | null;
314
349
  worker?: string;
350
+ /** The binding a `session set-worker` replaced; null when the session had none. */
351
+ previousWorker?: string | null;
352
+ /** Advisory notices about the run's branch (a foreign `checkout` claim, for example); older servers send none. */
353
+ warnings?: unknown;
315
354
  status?: string;
316
355
  runStatus?: unknown;
317
356
  /** Worker queue state while a new worktree provisions; older servers and workers omit it. */
@@ -347,6 +386,9 @@ export type R5dctlSessionRunClient = {
347
386
  delivery?: R5dctlPromptDelivery;
348
387
  }) => Promise<R5dctlCommandResponse>;
349
388
  stop: (sessionId: string) => Promise<R5dctlCommandResponse>;
389
+ setWorker: (sessionId: string, input: {
390
+ worker: string;
391
+ }) => Promise<R5dctlCommandResponse>;
350
392
  };
351
393
  };
352
394
  export declare function renderSessionRunStart(session: R5dctlCommandResponse): string;
@@ -356,6 +398,7 @@ export type R5dctlCommandDispatchResult = {
356
398
  human: string;
357
399
  };
358
400
  export declare function dispatchSessionRunCommand(client: R5dctlSessionRunClient, command: R5dctlSessionRunCommand): Promise<R5dctlCommandDispatchResult>;
401
+ export declare function renderSessionSetWorker(response: R5dctlCommandResponse): string;
359
402
  export declare function waitForSessionProvisioning(client: Pick<R5dctlSessionRunClient, "sessions">, initial: R5dctlCommandResponse, options?: {
360
403
  intervalMs?: number;
361
404
  wait?: (milliseconds: number) => Promise<unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5dctl",
3
- "version": "0.0.131",
3
+ "version": "0.0.133",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/cli.cjs",
6
6
  "module": "./dist/mjs/cli.mjs",
@@ -26,7 +26,7 @@
26
26
  "r5dctl": "dist/cjs/main.cjs"
27
27
  },
28
28
  "dependencies": {
29
- "@ricsam/r5d-api": "^0.0.131",
29
+ "@ricsam/r5d-api": "^0.0.133",
30
30
  "dotenv": "^17",
31
31
  "qrcode": "^1.5.4",
32
32
  "ws": "^8.18.3"