sisyphi 1.1.35 → 1.1.37

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/dist/cli.js CHANGED
@@ -539,14 +539,14 @@ __export(creds_exports, {
539
539
  readTailscaleEnv: () => readTailscaleEnv,
540
540
  writeTailscaleEnv: () => writeTailscaleEnv
541
541
  });
542
- import { chmodSync as chmodSync3, existsSync as existsSync27, mkdirSync as mkdirSync14, readFileSync as readFileSync30 } from "fs";
542
+ import { chmodSync as chmodSync3, existsSync as existsSync27, mkdirSync as mkdirSync15, readFileSync as readFileSync31 } from "fs";
543
543
  import { createInterface as createInterface4 } from "readline";
544
544
  function isValidProvider(value) {
545
545
  return PROVIDERS.includes(value);
546
546
  }
547
547
  function ensureDeployDir() {
548
548
  const dir = deployDir();
549
- if (!existsSync27(dir)) mkdirSync14(dir, { recursive: true, mode: 448 });
549
+ if (!existsSync27(dir)) mkdirSync15(dir, { recursive: true, mode: 448 });
550
550
  }
551
551
  function parseEnvFile(text) {
552
552
  const out = {};
@@ -573,7 +573,7 @@ function serializeEnvFile(values) {
573
573
  }
574
574
  function readEnvFile(path) {
575
575
  if (!existsSync27(path)) return null;
576
- return parseEnvFile(readFileSync30(path, "utf-8"));
576
+ return parseEnvFile(readFileSync31(path, "utf-8"));
577
577
  }
578
578
  function writeEnvFile(path, values) {
579
579
  ensureDeployDir();
@@ -668,8 +668,8 @@ var init_creds = __esm({
668
668
 
669
669
  // src/cli/index.ts
670
670
  import { Command } from "commander";
671
- import { existsSync as existsSync33, mkdirSync as mkdirSync16, readFileSync as readFileSync34 } from "fs";
672
- import { dirname as dirname12, join as join30 } from "path";
671
+ import { existsSync as existsSync34, mkdirSync as mkdirSync17, readFileSync as readFileSync36 } from "fs";
672
+ import { dirname as dirname13, join as join32 } from "path";
673
673
  import { fileURLToPath as fileURLToPath5 } from "url";
674
674
 
675
675
  // src/cli/commands/start.ts
@@ -2628,7 +2628,7 @@ function rawSend2(request, timeoutMs = 1e4) {
2628
2628
  return rawSend(request, timeoutMs);
2629
2629
  }
2630
2630
  async function sendRequest(request, timeoutMs) {
2631
- const sleep2 = (ms) => new Promise((resolve12) => setTimeout(resolve12, ms));
2631
+ const sleep3 = (ms) => new Promise((resolve12) => setTimeout(resolve12, ms));
2632
2632
  const MAX_ATTEMPTS = 5;
2633
2633
  const RETRY_DELAY_MS = 2e3;
2634
2634
  let installedDaemon = false;
@@ -2650,7 +2650,7 @@ async function sendRequest(request, timeoutMs) {
2650
2650
  } else {
2651
2651
  process.stderr.write(`Daemon not ready, retrying (${attempt}/${MAX_ATTEMPTS - 1})...
2652
2652
  `);
2653
- await sleep2(RETRY_DELAY_MS);
2653
+ await sleep3(RETRY_DELAY_MS);
2654
2654
  }
2655
2655
  }
2656
2656
  }
@@ -3102,6 +3102,21 @@ ${BOLD}Session: ${session2.id}${RESET}`);
3102
3102
  console.log(` Last activity: ${formatDuration(Date.now() - lastActivity.getTime())} ago`);
3103
3103
  }
3104
3104
  console.log(` Orchestrator cycles: ${session2.orchestratorCycles.length}`);
3105
+ if (session2.handoff) {
3106
+ const h = session2.handoff;
3107
+ if (h.lastError) {
3108
+ console.log(` Handoff: ${COLOR_CODES.red}error${RESET} \u2014 ${h.lastError}`);
3109
+ } else if (h.reclaimedAt) {
3110
+ const where = h.target ? `${h.target.provider}:${h.target.repo}` : "cloud";
3111
+ console.log(` Handoff: reclaimed from ${where} at ${h.reclaimedAt}`);
3112
+ } else if (h.sentAt && h.target) {
3113
+ console.log(` Handoff: running on ${h.target.provider}:${h.target.repo} since ${h.sentAt}`);
3114
+ } else if (h.target) {
3115
+ console.log(` Handoff: queued \u2192 ${h.target.provider}:${h.target.repo} (since ${h.queuedAt})`);
3116
+ } else {
3117
+ console.log(` Handoff: quiesce queued (since ${h.queuedAt})`);
3118
+ }
3119
+ }
3105
3120
  const runningAgents = session2.agents.filter((a) => a.status === "running");
3106
3121
  if (runningAgents.length > 0) {
3107
3122
  console.log(`
@@ -3219,6 +3234,24 @@ var STATUS_COLORS = {
3219
3234
  var RESET2 = "\x1B[0m";
3220
3235
  var BOLD2 = "\x1B[1m";
3221
3236
  var DIM2 = "\x1B[2m";
3237
+ var CLOUD = "\x1B[35m";
3238
+ var RED = "\x1B[31m";
3239
+ function handoffAnnotation(h) {
3240
+ if (!h) return "";
3241
+ if (h.lastError) {
3242
+ return ` ${RED}handoff error: ${h.lastError}${RESET2}`;
3243
+ }
3244
+ if (h.reclaimedAt) {
3245
+ return ` ${DIM2}(reclaimed)${RESET2}`;
3246
+ }
3247
+ if (h.sentAt && h.target) {
3248
+ return ` ${CLOUD}\u2192 ${h.target.provider}:${h.target.repo}${RESET2}`;
3249
+ }
3250
+ if (h.target) {
3251
+ return ` ${CLOUD}handoff queued \u2192 ${h.target.provider}:${h.target.repo}${RESET2}`;
3252
+ }
3253
+ return ` ${CLOUD}quiesce queued${RESET2}`;
3254
+ }
3222
3255
  function truncateTask(task, max) {
3223
3256
  if (task.length <= max) return task;
3224
3257
  return task.slice(0, max - 1) + "\u2026";
@@ -3248,7 +3281,8 @@ function registerList(program2) {
3248
3281
  const task = truncateTask(s.task, 60);
3249
3282
  const label = s.name ? `${s.name} ${DIM2}(${s.id.slice(0, 8)})${RESET2}` : s.id;
3250
3283
  const cwdLabel = opts.all && s.cwd ? ` ${DIM2}${basename3(s.cwd)}${RESET2}` : "";
3251
- console.log(` ${BOLD2}${label}${RESET2} ${status} ${agents} ${task}${cwdLabel}`);
3284
+ const handoffLabel = handoffAnnotation(s.handoff);
3285
+ console.log(` ${BOLD2}${label}${RESET2} ${status} ${agents} ${task}${cwdLabel}${handoffLabel}`);
3252
3286
  }
3253
3287
  if (filtered && totalCount && totalCount > sessions.length) {
3254
3288
  const otherCount = totalCount - sessions.length;
@@ -3877,6 +3911,7 @@ var ACTIONABLE_KINDS = /* @__PURE__ */ new Set([
3877
3911
  "error"
3878
3912
  ]);
3879
3913
  var HEARTBEAT_ASKED_BY = "system:heartbeat";
3914
+ var ORPHAN_ASKED_BY = "system:orphan-handler";
3880
3915
  function maybeNotifyOnAskCreated(cwd, sessionId, meta) {
3881
3916
  if (process.env.NODE_ENV === "test" || process.env.SISYPHUS_DISABLE_NOTIFY === "1") return;
3882
3917
  const isActionable = meta.kind !== void 0 && ACTIONABLE_KINDS.has(meta.kind);
@@ -3986,6 +4021,7 @@ async function autoResolveAsk(cwd, sessionId, askId, deck) {
3986
4021
  async function maybeAutoResolveAsk(cwd, sessionId, askId, deck) {
3987
4022
  try {
3988
4023
  if (!isSessionDangerous(cwd, sessionId)) return;
4024
+ if (deck.source?.askedBy === ORPHAN_ASKED_BY) return;
3989
4025
  await autoResolveAsk(cwd, sessionId, askId, deck);
3990
4026
  } catch {
3991
4027
  }
@@ -4559,16 +4595,55 @@ function readFileSafe(filePath) {
4559
4595
  function escapeXml(s) {
4560
4596
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4561
4597
  }
4562
- function buildCompanionContext(cwd) {
4598
+ function buildSessionBlock(cwd, session2) {
4599
+ const lines = [];
4600
+ const nameAttr = session2.name ? ` name="${escapeXml(session2.name)}"` : "";
4601
+ lines.push(` <session id="${escapeXml(session2.id)}"${nameAttr} status="${escapeXml(session2.status)}">`);
4602
+ lines.push(` <task>${escapeXml(session2.task)}</task>`);
4603
+ lines.push(` <created>${escapeXml(session2.createdAt)}</created>`);
4604
+ lines.push(` <cycles>${session2.orchestratorCycles.length}</cycles>`);
4605
+ if (session2.status === "completed") {
4606
+ if (session2.completionReport) {
4607
+ const snippet = session2.completionReport.slice(0, 300).replace(/\n+/g, " ").trim();
4608
+ lines.push(` <completion-report>${escapeXml(snippet)}${session2.completionReport.length > 300 ? "\u2026" : ""}</completion-report>`);
4609
+ }
4610
+ } else {
4611
+ if (session2.agents.length > 0) {
4612
+ const counts = /* @__PURE__ */ new Map();
4613
+ for (const agent2 of session2.agents) {
4614
+ counts.set(agent2.status, (counts.get(agent2.status) ?? 0) + 1);
4615
+ }
4616
+ const summary = [...counts.entries()].map(([status, n]) => `${n} ${status}`).join(", ");
4617
+ lines.push(` <agents>${escapeXml(summary)}</agents>`);
4618
+ }
4619
+ const goalContent = readFileSafe(goalPath(cwd, session2.id));
4620
+ if (goalContent) {
4621
+ const firstLine = goalContent.split("\n").map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith("#"));
4622
+ if (firstLine) lines.push(` <goal>${escapeXml(firstLine)}</goal>`);
4623
+ }
4624
+ const roadmapContent = readFileSafe(roadmapPath(cwd, session2.id));
4625
+ if (roadmapContent) {
4626
+ const todos = roadmapContent.split("\n").filter((l) => l.includes("- [ ]")).slice(0, 5).map((l) => l.trim());
4627
+ if (todos.length > 0) {
4628
+ lines.push(" <todos>");
4629
+ for (const todo of todos) lines.push(` ${escapeXml(todo)}`);
4630
+ lines.push(" </todos>");
4631
+ }
4632
+ }
4633
+ }
4634
+ lines.push(" </session>");
4635
+ return lines.join("\n");
4636
+ }
4637
+ function buildCompanionContextBlocks(cwd) {
4563
4638
  let sessionDirs;
4564
4639
  try {
4565
4640
  sessionDirs = readdirSync4(sessionsDir(cwd));
4566
4641
  } catch {
4567
- return "<sessions>No sessions found.</sessions>";
4642
+ return {};
4568
4643
  }
4569
4644
  const now = Date.now();
4570
4645
  const sevenDaysMs = 7 * 24 * 60 * 60 * 1e3;
4571
- const sessionBlocks = [];
4646
+ const blocks = {};
4572
4647
  for (const sessionId of sessionDirs) {
4573
4648
  const stateRaw = readFileSafe(statePath(cwd, sessionId));
4574
4649
  if (!stateRaw) continue;
@@ -4581,48 +4656,31 @@ function buildCompanionContext(cwd) {
4581
4656
  if (session2.status === "completed" && session2.completedAt) {
4582
4657
  if (now - new Date(session2.completedAt).getTime() > sevenDaysMs) continue;
4583
4658
  }
4584
- const lines = [];
4585
- const nameAttr = session2.name ? ` name="${escapeXml(session2.name)}"` : "";
4586
- lines.push(` <session id="${escapeXml(session2.id)}"${nameAttr} status="${escapeXml(session2.status)}">`);
4587
- lines.push(` <task>${escapeXml(session2.task)}</task>`);
4588
- lines.push(` <created>${escapeXml(session2.createdAt)}</created>`);
4589
- lines.push(` <cycles>${session2.orchestratorCycles.length}</cycles>`);
4590
- if (session2.status === "completed") {
4591
- if (session2.completionReport) {
4592
- const snippet = session2.completionReport.slice(0, 300).replace(/\n+/g, " ").trim();
4593
- lines.push(` <completion-report>${escapeXml(snippet)}${session2.completionReport.length > 300 ? "\u2026" : ""}</completion-report>`);
4594
- }
4595
- } else {
4596
- if (session2.agents.length > 0) {
4597
- const counts = /* @__PURE__ */ new Map();
4598
- for (const agent2 of session2.agents) {
4599
- counts.set(agent2.status, (counts.get(agent2.status) ?? 0) + 1);
4600
- }
4601
- const summary = [...counts.entries()].map(([status, n]) => `${n} ${status}`).join(", ");
4602
- lines.push(` <agents>${escapeXml(summary)}</agents>`);
4603
- }
4604
- const goalContent = readFileSafe(goalPath(cwd, session2.id));
4605
- if (goalContent) {
4606
- const firstLine = goalContent.split("\n").map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith("#"));
4607
- if (firstLine) lines.push(` <goal>${escapeXml(firstLine)}</goal>`);
4608
- }
4609
- const roadmapContent = readFileSafe(roadmapPath(cwd, session2.id));
4610
- if (roadmapContent) {
4611
- const todos = roadmapContent.split("\n").filter((l) => l.includes("- [ ]")).slice(0, 5).map((l) => l.trim());
4612
- if (todos.length > 0) {
4613
- lines.push(" <todos>");
4614
- for (const todo of todos) lines.push(` ${escapeXml(todo)}`);
4615
- lines.push(" </todos>");
4616
- }
4617
- }
4618
- }
4619
- lines.push(" </session>");
4620
- sessionBlocks.push(lines.join("\n"));
4659
+ blocks[session2.id] = buildSessionBlock(cwd, session2);
4621
4660
  }
4622
- if (sessionBlocks.length === 0) {
4623
- return "<sessions>No sessions found.</sessions>";
4661
+ return blocks;
4662
+ }
4663
+ function renderFullContext(blocks) {
4664
+ const entries = Object.values(blocks);
4665
+ if (entries.length === 0) return "<sessions>No sessions found.</sessions>";
4666
+ return ["<sessions>", ...entries, "</sessions>"].join("\n");
4667
+ }
4668
+ function renderContextDelta(prev, next) {
4669
+ const ids = /* @__PURE__ */ new Set([...Object.keys(prev), ...Object.keys(next)]);
4670
+ const entries = [];
4671
+ for (const id of ids) {
4672
+ const p = prev[id];
4673
+ const n = next[id];
4674
+ if (p === void 0 && n !== void 0) {
4675
+ entries.push(n.replace(/^(\s*)<session /, `$1<session change="added" `));
4676
+ } else if (p !== void 0 && n === void 0) {
4677
+ entries.push(` <session id="${escapeXml(id)}" change="removed" />`);
4678
+ } else if (p !== n && n !== void 0) {
4679
+ entries.push(n.replace(/^(\s*)<session /, `$1<session change="updated" `));
4680
+ }
4624
4681
  }
4625
- return ["<sessions>", ...sessionBlocks, "</sessions>"].join("\n");
4682
+ if (entries.length === 0) return null;
4683
+ return ["<sessions-changed-since-last-prompt>", ...entries, "</sessions-changed-since-last-prompt>"].join("\n");
4626
4684
  }
4627
4685
  function buildSessionContext(session2, cwd) {
4628
4686
  const goal = readFileSafe(goalPath(cwd, session2.id));
@@ -6194,6 +6252,30 @@ function setSessionCwd(name, cwd) {
6194
6252
  );
6195
6253
  }
6196
6254
 
6255
+ // src/cli/commands/quiesce.ts
6256
+ function registerQuiesce(parent) {
6257
+ parent.command("quiesce <session-id>").description("Pause a session at the next quiesce point (or now with --force). No cloud push.").option("--force", "Interrupt running orchestrator/agents immediately.").action(async (sessionId, opts) => {
6258
+ const cwd = process.env["SISYPHUS_CWD"] ?? process.cwd();
6259
+ const request = {
6260
+ type: "admin-quiesce",
6261
+ sessionId,
6262
+ cwd,
6263
+ force: opts.force === true
6264
+ };
6265
+ const response = await sendRequest(request);
6266
+ if (!response.ok) {
6267
+ console.error(`Error: ${response.error}`);
6268
+ process.exit(1);
6269
+ }
6270
+ const data = response.data;
6271
+ if (data?.force) {
6272
+ console.log(`Session ${sessionId} quiescing now (--force).`);
6273
+ } else {
6274
+ console.log(`Session ${sessionId} will pause at next quiesce point.`);
6275
+ }
6276
+ });
6277
+ }
6278
+
6197
6279
  // src/cli/commands/doctor.ts
6198
6280
  init_paths();
6199
6281
  import { execSync as execSync14 } from "child_process";
@@ -8996,7 +9078,9 @@ function renderRequirementsMarkdown(json) {
8996
9078
  }
8997
9079
 
8998
9080
  // src/cli/commands/companion.ts
8999
- import { basename as basename5 } from "path";
9081
+ import { basename as basename5, dirname as dirname11, join as join28 } from "path";
9082
+ import { mkdirSync as mkdirSync14, readFileSync as readFileSync30, writeFileSync as writeFileSync17 } from "fs";
9083
+ init_paths();
9000
9084
 
9001
9085
  // src/shared/companion-render.ts
9002
9086
  import stringWidth from "string-width";
@@ -10678,9 +10762,25 @@ function registerCompanion(program2) {
10678
10762
  companion.command("memory").description("Show accumulated companion observations grouped by category").option("--repo <path>", "Filter observations by repo path").action(async (opts) => {
10679
10763
  await runCompanionMemory(opts);
10680
10764
  });
10681
- companion.command("context").description("Output session context JSON for companion hook").option("--cwd <path>", "Project directory", process.cwd()).action((opts) => {
10682
- const context = buildCompanionContext(opts.cwd);
10683
- process.stdout.write(JSON.stringify({ additionalContext: context }));
10765
+ companion.command("context").description("Emit per-prompt context for the companion plugin hook. Caches the last emission per claude session and writes only the delta on subsequent calls (or nothing, when unchanged).").requiredOption("--cwd <path>", "Project directory whose sessions to summarise").requiredOption("--session-id <id>", "Claude session id (from the UserPromptSubmit stdin payload) \u2014 keys the per-session cache").action((opts) => {
10766
+ const cachePath = join28(globalDir(), "companion-context-cache", `${opts.sessionId}.json`);
10767
+ let prev = {};
10768
+ try {
10769
+ prev = JSON.parse(readFileSync30(cachePath, "utf-8"));
10770
+ } catch {
10771
+ prev = {};
10772
+ }
10773
+ const next = buildCompanionContextBlocks(opts.cwd);
10774
+ const hadPrev = Object.keys(prev).length > 0;
10775
+ if (hadPrev) {
10776
+ const delta = renderContextDelta(prev, next);
10777
+ if (delta === null) return;
10778
+ process.stdout.write(delta);
10779
+ } else {
10780
+ process.stdout.write(renderFullContext(next));
10781
+ }
10782
+ mkdirSync14(dirname11(cachePath), { recursive: true });
10783
+ writeFileSync17(cachePath, JSON.stringify(next), "utf-8");
10684
10784
  });
10685
10785
  companion.command("pane").description("Open (or focus) a side claude pane next to the dashboard").option("--cwd <path>", "Project directory", process.cwd()).action(async (opts) => {
10686
10786
  const { openCompanionPane: openCompanionPane2 } = await Promise.resolve().then(() => (init_tmux(), tmux_exports));
@@ -10703,14 +10803,14 @@ function registerCompanion(program2) {
10703
10803
 
10704
10804
  // src/cli/commands/deploy.ts
10705
10805
  import { homedir as homedir13 } from "os";
10706
- import { join as join28 } from "path";
10806
+ import { join as join29 } from "path";
10707
10807
 
10708
10808
  // src/cli/deploy/runner.ts
10709
10809
  init_paths();
10710
10810
  init_exec();
10711
10811
  init_creds();
10712
10812
  import { spawn as spawn2, spawnSync as spawnSync3 } from "child_process";
10713
- import { copyFileSync as copyFileSync2, existsSync as existsSync30, mkdirSync as mkdirSync15, readFileSync as readFileSync32 } from "fs";
10813
+ import { copyFileSync as copyFileSync2, existsSync as existsSync30, mkdirSync as mkdirSync16, readFileSync as readFileSync33 } from "fs";
10714
10814
 
10715
10815
  // src/cli/deploy/pricing.ts
10716
10816
  var LAST_VERIFIED = "2026-05-06";
@@ -10745,12 +10845,12 @@ function formatCostLine(provider, instanceType) {
10745
10845
  // src/cli/deploy/runtime.ts
10746
10846
  init_atomic();
10747
10847
  init_paths();
10748
- import { existsSync as existsSync28, readFileSync as readFileSync31, unlinkSync as unlinkSync4 } from "fs";
10848
+ import { existsSync as existsSync28, readFileSync as readFileSync32, unlinkSync as unlinkSync4 } from "fs";
10749
10849
  function readRuntimeState(provider) {
10750
10850
  const path = deployRuntimePath(provider);
10751
10851
  if (!existsSync28(path)) return null;
10752
10852
  try {
10753
- return JSON.parse(readFileSync31(path, "utf-8"));
10853
+ return JSON.parse(readFileSync32(path, "utf-8"));
10754
10854
  } catch {
10755
10855
  return null;
10756
10856
  }
@@ -10811,10 +10911,10 @@ function isTailscaleAvailable() {
10811
10911
 
10812
10912
  // src/cli/deploy/templates.ts
10813
10913
  import { existsSync as existsSync29 } from "fs";
10814
- import { dirname as dirname11, resolve as resolve10 } from "path";
10914
+ import { dirname as dirname12, resolve as resolve10 } from "path";
10815
10915
  import { fileURLToPath as fileURLToPath4 } from "url";
10816
10916
  function deployRoot() {
10817
- const here = dirname11(fileURLToPath4(import.meta.url));
10917
+ const here = dirname12(fileURLToPath4(import.meta.url));
10818
10918
  const bundled = resolve10(here, "..", "deploy");
10819
10919
  if (existsSync29(bundled)) return bundled;
10820
10920
  const sourceRoot = resolve10(here, "..", "..", "..", "deploy");
@@ -11002,7 +11102,7 @@ function ensureTerraformInstalled() {
11002
11102
  function ensureProviderStateDir(provider) {
11003
11103
  ensureDeployDir();
11004
11104
  const dir = deployProviderDir(provider);
11005
- if (!existsSync30(dir)) mkdirSync15(dir, { recursive: true, mode: 448 });
11105
+ if (!existsSync30(dir)) mkdirSync16(dir, { recursive: true, mode: 448 });
11006
11106
  }
11007
11107
  function backupState(provider) {
11008
11108
  const src = deployStatePath(provider);
@@ -11017,7 +11117,7 @@ function readSshPubkey(path) {
11017
11117
  or pass --ssh-key <path>.`
11018
11118
  );
11019
11119
  }
11020
- return readFileSync32(path, "utf-8").trim();
11120
+ return readFileSync33(path, "utf-8").trim();
11021
11121
  }
11022
11122
  function readOutputs(provider) {
11023
11123
  const result = spawnSync3("terraform", ["output", "-json", `-state=${deployStatePath(provider)}`], {
@@ -11295,7 +11395,7 @@ function registerDeploy(program2) {
11295
11395
  });
11296
11396
  for (const provider of PROVIDERS) {
11297
11397
  const sub = deploy.command(provider).description(`${provider} commands.`);
11298
- sub.command("up").description(`Provision the ${provider} box (terraform init \u2192 plan \u2192 apply).`).option("--region <region>", `Provider region (defaults: hetzner=nbg1, aws=us-east-1).`).option("--arch <arch>", "'arm' (default) or 'x86'. Picks the default --size and image.", "arm").option("--size <size>", "Instance type override (defaults follow --arch).").option("--ssh-key <path>", "Path to SSH public key.", join28(homedir13(), ".ssh", "id_ed25519.pub")).option("--no-chromium", "Skip headless Chromium install.").option("--no-auto-update", "Skip the daily auto-update systemd timer.").option("--name <name>", "Box hostname / Tailscale node name.", "sisyphus").option("-y, --yes", "Skip the re-provision confirmation prompt when state already exists.").action(async (raw) => {
11398
+ sub.command("up").description(`Provision the ${provider} box (terraform init \u2192 plan \u2192 apply).`).option("--region <region>", `Provider region (defaults: hetzner=nbg1, aws=us-east-1).`).option("--arch <arch>", "'arm' (default) or 'x86'. Picks the default --size and image.", "arm").option("--size <size>", "Instance type override (defaults follow --arch).").option("--ssh-key <path>", "Path to SSH public key.", join29(homedir13(), ".ssh", "id_ed25519.pub")).option("--no-chromium", "Skip headless Chromium install.").option("--no-auto-update", "Skip the daily auto-update systemd timer.").option("--name <name>", "Box hostname / Tailscale node name.", "sisyphus").option("-y, --yes", "Skip the re-provision confirmation prompt when state already exists.").action(async (raw) => {
11299
11399
  const opts = resolveUpOptions(provider, raw);
11300
11400
  await deployUp(provider, opts);
11301
11401
  });
@@ -11380,31 +11480,32 @@ function ensureGroveRegistered(provider, repo, instancePath) {
11380
11480
  init_exec();
11381
11481
  import { spawnSync as spawnSync5 } from "child_process";
11382
11482
  import { existsSync as existsSync31 } from "fs";
11383
- import { basename as basename6, join as join29 } from "path";
11384
- function captureGit(args2) {
11483
+ import { basename as basename6, join as join30 } from "path";
11484
+ function captureGit(args2, cwd) {
11385
11485
  const result = spawnSync5("git", args2, {
11386
11486
  encoding: "utf-8",
11387
- env: EXEC_ENV
11487
+ env: EXEC_ENV,
11488
+ cwd: cwd ?? process.cwd()
11388
11489
  });
11389
11490
  if (typeof result.stdout !== "string") {
11390
11491
  throw new Error("Internal: git spawn did not capture stdout as string");
11391
11492
  }
11392
11493
  return { stdout: result.stdout.trim(), ok: result.status === 0 };
11393
11494
  }
11394
- function inferRepoName() {
11395
- const { stdout, ok } = captureGit(["rev-parse", "--show-toplevel"]);
11495
+ function inferRepoName(cwd) {
11496
+ const { stdout, ok } = captureGit(["rev-parse", "--show-toplevel"], cwd);
11396
11497
  if (ok && stdout) return basename6(stdout);
11397
- return basename6(process.cwd());
11498
+ return basename6(cwd ?? process.cwd());
11398
11499
  }
11399
- function getOriginUrl() {
11400
- const { stdout, ok } = captureGit(["remote", "get-url", "origin"]);
11500
+ function getOriginUrl(cwd) {
11501
+ const { stdout, ok } = captureGit(["remote", "get-url", "origin"], cwd);
11401
11502
  if (!ok) return null;
11402
11503
  return stdout.length > 0 ? stdout : null;
11403
11504
  }
11404
- function getRepoToplevel() {
11405
- const { stdout, ok } = captureGit(["rev-parse", "--show-toplevel"]);
11505
+ function getRepoToplevel(cwd) {
11506
+ const { stdout, ok } = captureGit(["rev-parse", "--show-toplevel"], cwd);
11406
11507
  if (ok && stdout) return stdout;
11407
- return process.cwd();
11508
+ return cwd ?? process.cwd();
11408
11509
  }
11409
11510
  var DEFAULT_EXCLUDES = [
11410
11511
  ".sisyphus/",
@@ -11431,10 +11532,10 @@ function buildRsyncArgs(localDir, remoteTarget) {
11431
11532
  ];
11432
11533
  }
11433
11534
  function detectPackageManager(toplevel) {
11434
- if (existsSync31(join29(toplevel, "pnpm-lock.yaml"))) return "pnpm";
11435
- if (existsSync31(join29(toplevel, "bun.lockb"))) return "bun";
11436
- if (existsSync31(join29(toplevel, "yarn.lock"))) return "yarn";
11437
- if (existsSync31(join29(toplevel, "package-lock.json"))) return "npm";
11535
+ if (existsSync31(join30(toplevel, "pnpm-lock.yaml"))) return "pnpm";
11536
+ if (existsSync31(join30(toplevel, "bun.lockb"))) return "bun";
11537
+ if (existsSync31(join30(toplevel, "yarn.lock"))) return "yarn";
11538
+ if (existsSync31(join30(toplevel, "package-lock.json"))) return "npm";
11438
11539
  return null;
11439
11540
  }
11440
11541
  function packageManagerInstallCmd(pm) {
@@ -11483,10 +11584,10 @@ function writeSidecar(provider, repo, data) {
11483
11584
  }
11484
11585
 
11485
11586
  // src/cli/cloud/runner.ts
11486
- async function cloudSync(provider, repo, opts) {
11587
+ async function cloudSync(provider, repo, opts, cwd) {
11487
11588
  const target = effectiveSshTarget(provider);
11488
11589
  const remoteDir = boxRepoPath(repo);
11489
- const localOrigin = getOriginUrl();
11590
+ const localOrigin = getOriginUrl(cwd);
11490
11591
  ensureGroveInstalled(provider);
11491
11592
  const existing = readSidecar(provider, repo);
11492
11593
  if (existing && existing.originUrl && localOrigin && existing.originUrl !== localOrigin) {
@@ -11524,7 +11625,7 @@ Pass --name <slug> to disambiguate, or --fresh to overwrite.`
11524
11625
  if (mkdir.exitCode !== 0) {
11525
11626
  throw new Error(`Failed to mkdir on box: ${mkdir.stderr}`);
11526
11627
  }
11527
- const toplevel = getRepoToplevel();
11628
+ const toplevel = getRepoToplevel(cwd);
11528
11629
  const args2 = buildRsyncArgs(toplevel, `${target}:${remoteDir}/`);
11529
11630
  console.log(`\u2192 rsync ${toplevel}/ \u2192 ${target}:${remoteDir}/`);
11530
11631
  const code = await runRsync(args2);
@@ -11548,9 +11649,9 @@ function runRsync(args2) {
11548
11649
  child.on("exit", (code) => resolve12(code === null ? 1 : code));
11549
11650
  });
11550
11651
  }
11551
- async function cloudInstall(provider, repo) {
11652
+ async function cloudInstall(provider, repo, cwd) {
11552
11653
  const remoteDir = boxRepoPath(repo);
11553
- const toplevel = getRepoToplevel();
11654
+ const toplevel = getRepoToplevel(cwd);
11554
11655
  const pm = detectPackageManager(toplevel);
11555
11656
  const cmd = packageManagerInstallCmd(pm);
11556
11657
  if (!cmd) {
@@ -11563,7 +11664,7 @@ async function cloudInstall(provider, repo) {
11563
11664
  if (code !== 0) throw new Error(`${pm} install failed (exit ${code})`);
11564
11665
  const existing = readSidecar(provider, repo);
11565
11666
  const sidecar = {
11566
- originUrl: existing && existing.originUrl !== void 0 ? existing.originUrl : getOriginUrl(),
11667
+ originUrl: existing && existing.originUrl !== void 0 ? existing.originUrl : getOriginUrl(cwd),
11567
11668
  localHostname: existing ? existing.localHostname : hostname(),
11568
11669
  lastSync: existing?.lastSync,
11569
11670
  lastInstall: (/* @__PURE__ */ new Date()).toISOString(),
@@ -11641,6 +11742,13 @@ function cloudStatus(provider, repo) {
11641
11742
  }
11642
11743
  }
11643
11744
 
11745
+ // src/cli/cloud/handoff.ts
11746
+ import { spawn as spawn5 } from "child_process";
11747
+ import { existsSync as existsSync32, readFileSync as readFileSync34 } from "fs";
11748
+ init_exec();
11749
+ init_paths();
11750
+ init_shell();
11751
+
11644
11752
  // src/cli/deploy/provider-pick.ts
11645
11753
  init_creds();
11646
11754
  function pickProvider(explicit) {
@@ -11662,6 +11770,188 @@ function pickProvider(explicit) {
11662
11770
  );
11663
11771
  }
11664
11772
 
11773
+ // src/cli/cloud/handoff.ts
11774
+ import { join as join31 } from "path";
11775
+ async function cloudHandoff(sessionId, opts) {
11776
+ const cwd = process.env["SISYPHUS_CWD"] ?? process.cwd();
11777
+ const request = {
11778
+ type: "cloud-handoff",
11779
+ sessionId,
11780
+ cwd,
11781
+ provider: opts.provider,
11782
+ repo: opts.repo,
11783
+ force: opts.force
11784
+ };
11785
+ const response = await sendRequest(request);
11786
+ if (!response.ok) {
11787
+ console.error(`Error: ${response.error}`);
11788
+ process.exit(1);
11789
+ }
11790
+ const data = response.data;
11791
+ const where = `${data?.provider ?? opts.provider}:${data?.repo ?? opts.repo}`;
11792
+ if (data?.force) {
11793
+ console.log(`Forcing handoff of ${sessionId} \u2192 ${where} (interrupting in-flight work).`);
11794
+ } else {
11795
+ console.log(`Handoff of ${sessionId} \u2192 ${where} queued; will fire at next quiesce point.`);
11796
+ }
11797
+ if (!opts.wait) {
11798
+ if (!opts.force) {
11799
+ console.log(`Tip: run \`sis cloud handoff ${sessionId} --cancel\` to cancel before quiesce.`);
11800
+ }
11801
+ return;
11802
+ }
11803
+ console.log("Waiting for handoff to complete...");
11804
+ await waitForSentOrError(cwd, sessionId);
11805
+ }
11806
+ async function cloudHandoffCancel(sessionId) {
11807
+ const cwd = process.env["SISYPHUS_CWD"] ?? process.cwd();
11808
+ const response = await sendRequest({ type: "cloud-handoff-cancel", sessionId, cwd });
11809
+ if (!response.ok) {
11810
+ console.error(`Error: ${response.error}`);
11811
+ process.exit(1);
11812
+ }
11813
+ console.log(`Handoff for ${sessionId} cancelled.`);
11814
+ }
11815
+ async function waitForSentOrError(cwd, sessionId) {
11816
+ const POLL_INTERVAL_MS = 2e3;
11817
+ const MAX_WAIT_MS = 30 * 60 * 1e3;
11818
+ const start = Date.now();
11819
+ const path = statePath(cwd, sessionId);
11820
+ while (Date.now() - start < MAX_WAIT_MS) {
11821
+ await sleep2(POLL_INTERVAL_MS);
11822
+ if (!existsSync32(path)) continue;
11823
+ let session2;
11824
+ try {
11825
+ session2 = JSON.parse(readFileSync34(path, "utf-8"));
11826
+ } catch (err) {
11827
+ void err;
11828
+ continue;
11829
+ }
11830
+ if (session2.handoff?.lastError) {
11831
+ console.error(`Handoff failed: ${session2.handoff.lastError}`);
11832
+ process.exit(1);
11833
+ }
11834
+ if (session2.handoff?.sentAt) {
11835
+ const where = session2.handoff.target ? `${session2.handoff.target.provider}:${session2.handoff.target.repo}` : "cloud";
11836
+ console.log(`Handoff complete \u2192 ${where} at ${session2.handoff.sentAt}.`);
11837
+ return;
11838
+ }
11839
+ }
11840
+ console.error(`Timed out waiting for handoff after ${Math.round(MAX_WAIT_MS / 6e4)}m.`);
11841
+ process.exit(1);
11842
+ }
11843
+ function sleep2(ms) {
11844
+ return new Promise((resolve12) => setTimeout(resolve12, ms));
11845
+ }
11846
+ async function cloudReclaim(sessionId, opts) {
11847
+ const cwd = process.env["SISYPHUS_CWD"] ?? process.cwd();
11848
+ const local = readLocalSession(cwd, sessionId);
11849
+ if (!local.handoff?.sentAt) {
11850
+ console.error(`Session ${sessionId} is not on cloud (no handoff.sentAt). Nothing to reclaim.`);
11851
+ process.exit(1);
11852
+ }
11853
+ if (local.handoff.reclaimedAt) {
11854
+ console.error(`Session ${sessionId} was already reclaimed at ${local.handoff.reclaimedAt}.`);
11855
+ process.exit(1);
11856
+ }
11857
+ if (!local.handoff.target) {
11858
+ console.error(`Session ${sessionId} has handoff.sentAt but no target \u2014 corrupted state.`);
11859
+ process.exit(1);
11860
+ }
11861
+ const provider = pickProvider(opts.providerOverride ?? local.handoff.target.provider);
11862
+ const repo = local.handoff.target.repo;
11863
+ const target = effectiveSshTarget(provider);
11864
+ const remoteSessionDir = `${boxRepoPath(repo)}/.sisyphus/sessions/${sessionId}`;
11865
+ const remoteRepoDir = boxRepoPath(repo);
11866
+ console.log(`Reclaiming ${sessionId} from ${provider}:${repo}...`);
11867
+ const quiesceBase = opts.force ? `sis admin quiesce ${shellQuote(sessionId)} --force` : `sis admin quiesce ${shellQuote(sessionId)}`;
11868
+ const quiesceCmd = `cd ${shellQuoteHomePath(remoteRepoDir)} && ${quiesceBase}`;
11869
+ console.log(`\u2192 ssh box: ${quiesceCmd}`);
11870
+ const quiesceCode = await runOnBoxStreaming(provider, quiesceCmd);
11871
+ if (quiesceCode !== 0) {
11872
+ console.error(`Box-side quiesce failed (exit ${quiesceCode}).`);
11873
+ process.exit(1);
11874
+ }
11875
+ console.log(`\u2192 waiting for box-side session to reach paused...`);
11876
+ await waitForBoxPaused(provider, remoteSessionDir);
11877
+ console.log(`\u2192 rsync session state down`);
11878
+ await rsyncDown(target, `${remoteSessionDir}/`, `${sessionDir(cwd, sessionId)}/`, { withDelete: true });
11879
+ console.log(`\u2192 rsync working tree down`);
11880
+ await rsyncDown(target, `${remoteRepoDir}/`, `${cwd}/`, { withDelete: false });
11881
+ for (const name of ["config.json", "orchestrator.md", "orchestrator-settings.json"]) {
11882
+ const remotePath = `${remoteRepoDir}/.sisyphus/${name}`;
11883
+ const localPath = join31(projectDir(cwd), name);
11884
+ const probe = runOnBox(provider, `test -f ${shellQuote(remotePath.replace(/^~\//, ""))} && echo y || echo n`);
11885
+ if (probe.stdout.trim() !== "y") continue;
11886
+ await rsyncDown(target, remotePath, localPath, { withDelete: false });
11887
+ }
11888
+ const reclaimMessage = `Session reclaimed from cloud (${provider}:${repo}). Resuming locally.`;
11889
+ console.log(`\u2192 local sis resume`);
11890
+ const resumeResp = await sendRequest({
11891
+ type: "resume",
11892
+ sessionId,
11893
+ cwd,
11894
+ message: reclaimMessage
11895
+ });
11896
+ if (!resumeResp.ok) {
11897
+ console.error(`Local resume failed: ${resumeResp.error}`);
11898
+ process.exit(1);
11899
+ }
11900
+ const killCmd = `cd ${shellQuoteHomePath(remoteRepoDir)} && sis session kill ${shellQuote(sessionId)}`;
11901
+ console.log(`\u2192 ssh box: ${killCmd}`);
11902
+ const killResult = runOnBox(provider, killCmd);
11903
+ if (killResult.exitCode !== 0) {
11904
+ console.warn(`Warning: box-side kill exited ${killResult.exitCode}: ${killResult.stderr.trim()}`);
11905
+ }
11906
+ const finalizeResp = await sendRequest({ type: "cloud-reclaim-finalize", sessionId, cwd });
11907
+ if (!finalizeResp.ok) {
11908
+ console.warn(`Warning: failed to finalize reclaim state: ${finalizeResp.error}`);
11909
+ }
11910
+ console.log(`\u2713 ${sessionId} reclaimed; orchestrator respawning locally.`);
11911
+ }
11912
+ function readLocalSession(cwd, sessionId) {
11913
+ const path = statePath(cwd, sessionId);
11914
+ if (!existsSync32(path)) {
11915
+ console.error(`No local state.json for ${sessionId} at ${path}.`);
11916
+ process.exit(1);
11917
+ }
11918
+ return JSON.parse(readFileSync34(path, "utf-8"));
11919
+ }
11920
+ async function waitForBoxPaused(provider, remoteSessionDir) {
11921
+ const POLL_INTERVAL_MS = 2e3;
11922
+ const MAX_WAIT_MS = 30 * 60 * 1e3;
11923
+ const start = Date.now();
11924
+ const cmd = `cat ${shellQuote(remoteSessionDir.replace(/^~\//, ""))}/state.json 2>/dev/null | head -c 200000`;
11925
+ while (Date.now() - start < MAX_WAIT_MS) {
11926
+ const r = runOnBox(provider, cmd);
11927
+ if (r.exitCode === 0 && r.stdout.trim()) {
11928
+ try {
11929
+ const s = JSON.parse(r.stdout);
11930
+ if (s.status === "paused") return;
11931
+ } catch (err) {
11932
+ console.warn(`Could not parse box-side state.json yet: ${err instanceof Error ? err.message : String(err)}`);
11933
+ }
11934
+ }
11935
+ await sleep2(POLL_INTERVAL_MS);
11936
+ }
11937
+ console.error(`Timed out waiting for box-side session to pause.`);
11938
+ process.exit(1);
11939
+ }
11940
+ function rsyncDown(target, remotePath, localPath, opts) {
11941
+ return new Promise((resolve12, reject) => {
11942
+ const args2 = ["-avz"];
11943
+ if (opts.withDelete) args2.push("--delete");
11944
+ args2.push("--exclude=.terraform/", "--exclude=node_modules/", "--exclude=dist/", "--exclude=.next/", "--exclude=.turbo/", "--exclude=coverage/", "--exclude=tmp/", "--exclude=.git/lfs/", "--exclude=.DS_Store");
11945
+ args2.push("-e", "ssh", `${target}:${remotePath}`, localPath);
11946
+ const child = spawn5("rsync", args2, { stdio: "inherit", env: EXEC_ENV });
11947
+ child.on("error", reject);
11948
+ child.on("exit", (code) => {
11949
+ if (code === 0) resolve12();
11950
+ else reject(new Error(`rsync exited ${code} for ${remotePath}`));
11951
+ });
11952
+ });
11953
+ }
11954
+
11665
11955
  // src/cli/commands/cloud.ts
11666
11956
  init_shell();
11667
11957
  function resolve11(raw) {
@@ -11702,6 +11992,21 @@ function registerCloud(program2) {
11702
11992
  const { provider, repo } = resolve11(raw);
11703
11993
  cloudStatus(provider, repo);
11704
11994
  });
11995
+ cloud.command("handoff <session-id>").description("Hand off a live session to the cloud box. Queues at next quiesce; --force interrupts now.").option("--provider <name>", "Cloud provider (default: auto-pick).").option("--name <repo>", "Override the repo name on the box.").option("--force", "Interrupt in-flight orchestrator/agents now instead of queueing.").option("--cancel", "Cancel a queued handoff before it fires.").option("--wait", "Block until the handoff completes (or fails).").action(async (sessionId, raw) => {
11996
+ if (raw.cancel) {
11997
+ await cloudHandoffCancel(sessionId);
11998
+ return;
11999
+ }
12000
+ const provider = pickProvider(raw.provider);
12001
+ const repo = raw.name ? raw.name : inferRepoName();
12002
+ if (!validateRepoName(repo)) {
12003
+ throw new Error(`Invalid --name "${repo}": must not contain '/' '\\' or '..'.`);
12004
+ }
12005
+ await cloudHandoff(sessionId, { provider, repo, force: raw.force === true, wait: raw.wait === true });
12006
+ });
12007
+ cloud.command("reclaim <session-id>").description("Pull a handed-off session back from the cloud box and resume locally.").option("--provider <name>", "Override the cloud provider (default: read from session.handoff).").option("--force", "Force the box-side session to stop now instead of waiting for quiesce.").action(async (sessionId, raw) => {
12008
+ await cloudReclaim(sessionId, { providerOverride: raw.provider, force: raw.force === true });
12009
+ });
11705
12010
  }
11706
12011
 
11707
12012
  // src/cli/commands/notify.ts
@@ -11719,7 +12024,7 @@ function attachNotify(diagnostic2) {
11719
12024
  // src/cli/commands/tmux-sessions.ts
11720
12025
  init_paths();
11721
12026
  import { execSync as execSync18 } from "child_process";
11722
- import { readFileSync as readFileSync33, existsSync as existsSync32 } from "fs";
12027
+ import { readFileSync as readFileSync35, existsSync as existsSync33 } from "fs";
11723
12028
  var DOT_MAP = {
11724
12029
  "orchestrator:processing": { icon: "\u25CF", color: "#d4ad6a" },
11725
12030
  "orchestrator:idle": { icon: "\u25CF", color: "#d47766" },
@@ -11730,9 +12035,9 @@ var DOT_MAP = {
11730
12035
  };
11731
12036
  function readManifest() {
11732
12037
  const p = sessionsManifestPath();
11733
- if (!existsSync32(p)) return null;
12038
+ if (!existsSync33(p)) return null;
11734
12039
  try {
11735
- return JSON.parse(readFileSync33(p, "utf-8"));
12040
+ return JSON.parse(readFileSync35(p, "utf-8"));
11736
12041
  } catch {
11737
12042
  return null;
11738
12043
  }
@@ -11778,7 +12083,7 @@ if (nodeVersion < 22) {
11778
12083
  var program = new Command();
11779
12084
  program.name("sis").description("tmux-integrated orchestration daemon for Claude Code").version(
11780
12085
  JSON.parse(
11781
- readFileSync34(join30(dirname12(fileURLToPath5(import.meta.url)), "..", "package.json"), "utf-8")
12086
+ readFileSync36(join32(dirname13(fileURLToPath5(import.meta.url)), "..", "package.json"), "utf-8")
11782
12087
  ).version
11783
12088
  );
11784
12089
  program.configureHelp({
@@ -11823,6 +12128,7 @@ registerSetupKeybind(admin);
11823
12128
  registerCheckKeybinds(admin);
11824
12129
  registerCheckStatusbar(admin);
11825
12130
  registerHomeInit(admin);
12131
+ registerQuiesce(admin);
11826
12132
  registerDoctor(admin);
11827
12133
  registerInit(admin);
11828
12134
  registerUninstall(admin);
@@ -11852,8 +12158,8 @@ Run 'sis admin getting-started' for a complete usage guide.
11852
12158
  var args = process.argv.slice(2);
11853
12159
  var firstArg = args[0];
11854
12160
  var skipWelcome = ["admin", "help", "--help", "-h", "--version", "-V"];
11855
- if (!existsSync33(globalDir()) && firstArg && !skipWelcome.includes(firstArg)) {
11856
- mkdirSync16(globalDir(), { recursive: true });
12161
+ if (!existsSync34(globalDir()) && firstArg && !skipWelcome.includes(firstArg)) {
12162
+ mkdirSync17(globalDir(), { recursive: true });
11857
12163
  console.log("");
11858
12164
  console.log(" Welcome to Sisyphus. Run 'sis admin setup' to get started.");
11859
12165
  console.log("");