switchroom 0.20.7 → 0.20.8

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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.20.7", COMMIT_SHA = "bc107185";
2123
+ var VERSION = "0.20.8", COMMIT_SHA = "a6efc10f";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -14641,6 +14641,20 @@ var init_overlay_schema = __esm(() => {
14641
14641
  // src/config/overlay-loader.ts
14642
14642
  import { existsSync as existsSync3, readFileSync as readFileSync2, readdirSync, statSync as statSync2 } from "node:fs";
14643
14643
  import { basename, resolve as resolve2 } from "node:path";
14644
+ function recordReadFailure(agentCfg, failure) {
14645
+ const node = agentCfg;
14646
+ const existing = node[OVERLAY_READ_FAILURES];
14647
+ if (Array.isArray(existing)) {
14648
+ existing.push(failure);
14649
+ return;
14650
+ }
14651
+ Object.defineProperty(agentCfg, OVERLAY_READ_FAILURES, {
14652
+ value: [failure],
14653
+ enumerable: false,
14654
+ configurable: true,
14655
+ writable: false
14656
+ });
14657
+ }
14644
14658
  function deriveOverlayTitle(raw, fileName) {
14645
14659
  const titleFromComment = raw.match(/^#[^\S\n]*name:[^\S\n]*(\S.*?)[^\S\n]*$/m)?.[1];
14646
14660
  if (titleFromComment)
@@ -14650,17 +14664,39 @@ function deriveOverlayTitle(raw, fileName) {
14650
14664
  return;
14651
14665
  return base.length > 0 ? base : undefined;
14652
14666
  }
14667
+ function readOverlayFile(agentName, file, agentCfg, warnings) {
14668
+ try {
14669
+ return readFileSync2(file, "utf-8");
14670
+ } catch (err) {
14671
+ const code = err.code;
14672
+ if (code === "ENOENT")
14673
+ return;
14674
+ const w = {
14675
+ agent: agentName,
14676
+ file,
14677
+ reason: `read error: ${err.message}`,
14678
+ code: code ?? "EUNKNOWN"
14679
+ };
14680
+ recordReadFailure(agentCfg, { file, code: w.code });
14681
+ warnings.push(w);
14682
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${file}': ${w.reason}`);
14683
+ return;
14684
+ }
14685
+ }
14653
14686
  function overlayDirFor(agentName, subdir) {
14654
14687
  const base = resolveDualPath(`~/.switchroom/agents/${agentName}/${subdir}`);
14655
14688
  return resolve2(base);
14656
14689
  }
14657
- function listYamlFiles(dir) {
14690
+ function listYamlFiles(dir, onUnreadableDir) {
14658
14691
  if (!existsSync3(dir))
14659
14692
  return [];
14660
14693
  let entries;
14661
14694
  try {
14662
14695
  entries = readdirSync(dir);
14663
- } catch {
14696
+ } catch (err) {
14697
+ const code = err.code;
14698
+ if (code !== "ENOENT")
14699
+ onUnreadableDir?.(code ?? "EUNKNOWN");
14664
14700
  return [];
14665
14701
  }
14666
14702
  const out = [];
@@ -14698,12 +14734,24 @@ function applyAgentOverlays(config) {
14698
14734
  for (const [agentName, agentCfg] of Object.entries(agents)) {
14699
14735
  try {
14700
14736
  const scheduleDir = overlayDirFor(agentName, "schedule.d");
14701
- const files = listYamlFiles(scheduleDir);
14737
+ const files = listYamlFiles(scheduleDir, (code) => {
14738
+ const w = {
14739
+ agent: agentName,
14740
+ file: scheduleDir,
14741
+ reason: `read error: cannot list overlay directory (${code})`,
14742
+ code
14743
+ };
14744
+ recordReadFailure(agentCfg, { file: scheduleDir, code });
14745
+ warnings.push(w);
14746
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${scheduleDir}': ${w.reason}`);
14747
+ });
14702
14748
  if (files.length > 0) {
14703
14749
  const merged = [...agentCfg.schedule ?? []];
14704
14750
  for (const file of files) {
14751
+ const raw = readOverlayFile(agentName, file, agentCfg, warnings);
14752
+ if (raw === undefined)
14753
+ continue;
14705
14754
  try {
14706
- const raw = readFileSync2(file, "utf-8");
14707
14755
  const parsed = import_yaml.parse(raw);
14708
14756
  const doc = OverlayDocSchema.parse(parsed);
14709
14757
  const title = deriveOverlayTitle(raw, basename(file));
@@ -14738,13 +14786,25 @@ function applyAgentOverlays(config) {
14738
14786
  }
14739
14787
  try {
14740
14788
  const skillsDir = overlayDirFor(agentName, "skills.d");
14741
- const skillFiles = listYamlFiles(skillsDir);
14789
+ const skillFiles = listYamlFiles(skillsDir, (code) => {
14790
+ const w = {
14791
+ agent: agentName,
14792
+ file: skillsDir,
14793
+ reason: `read error: cannot list overlay directory (${code})`,
14794
+ code
14795
+ };
14796
+ recordReadFailure(agentCfg, { file: skillsDir, code });
14797
+ warnings.push(w);
14798
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${skillsDir}': ${w.reason}`);
14799
+ });
14742
14800
  if (skillFiles.length === 0) {} else {
14743
14801
  const merged = [...agentCfg.skills ?? []];
14744
14802
  const seen = new Set(merged);
14745
14803
  for (const file of skillFiles) {
14804
+ const raw = readOverlayFile(agentName, file, agentCfg, warnings);
14805
+ if (raw === undefined)
14806
+ continue;
14746
14807
  try {
14747
- const raw = readFileSync2(file, "utf-8");
14748
14808
  const parsed = import_yaml.parse(raw);
14749
14809
  const doc = OverlayDocSchema.parse(parsed);
14750
14810
  for (const skillName of doc.skills ?? []) {
@@ -14772,7 +14832,7 @@ function applyAgentOverlays(config) {
14772
14832
  }
14773
14833
  return { config, warnings };
14774
14834
  }
14775
- var import_yaml, OVERLAY_SOURCE, OVERLAY_TITLE;
14835
+ var import_yaml, OVERLAY_SOURCE, OVERLAY_TITLE, OVERLAY_READ_FAILURES;
14776
14836
  var init_overlay_loader = __esm(() => {
14777
14837
  init_zod();
14778
14838
  init_overlay_schema();
@@ -14780,6 +14840,7 @@ var init_overlay_loader = __esm(() => {
14780
14840
  import_yaml = __toESM(require_dist(), 1);
14781
14841
  OVERLAY_SOURCE = Symbol.for("switchroom.config.overlay-source");
14782
14842
  OVERLAY_TITLE = Symbol.for("switchroom.config.overlay-title");
14843
+ OVERLAY_READ_FAILURES = Symbol.for("switchroom.config.overlay-read-failures");
14783
14844
  });
14784
14845
 
14785
14846
  // src/config/merge.ts
@@ -20448,6 +20509,15 @@ var init_agent_uid = __esm(() => {
20448
20509
  import { execFileSync as execFileSync3 } from "node:child_process";
20449
20510
  import { existsSync as existsSync9, lstatSync as lstatSync2, readdirSync as readdirSync3, statSync as statSync6 } from "node:fs";
20450
20511
  import { join as join5 } from "node:path";
20512
+ function isChownVanishedRaceOnly(err) {
20513
+ const e = err;
20514
+ const raw = e?.stderr == null ? "" : e.stderr.toString();
20515
+ const lines = raw.split(`
20516
+ `).map((l) => l.trim()).filter(Boolean);
20517
+ if (lines.length === 0)
20518
+ return false;
20519
+ return lines.every((l) => /: No such file or directory$/.test(l));
20520
+ }
20451
20521
  function scopedAssertCandidates(agentDir) {
20452
20522
  return [
20453
20523
  join5(agentDir, ".claude", "settings.json"),
@@ -20518,7 +20588,9 @@ var init_agent_owned_tree = __esm(() => {
20518
20588
  SCOPED_RECURSIVE_SUBDIRS = [
20519
20589
  ".claude",
20520
20590
  ".claude-cron",
20521
- "telegram"
20591
+ "telegram",
20592
+ "schedule.d",
20593
+ "skills.d"
20522
20594
  ];
20523
20595
  SCOPED_TOP_LEVEL_CRITICAL = [
20524
20596
  "start.sh",
@@ -20530,15 +20602,26 @@ var init_agent_owned_tree = __esm(() => {
20530
20602
  ownershipRuntime = {
20531
20603
  geteuid: () => process.geteuid?.(),
20532
20604
  chownTree: (uid, gid, rootDir) => {
20533
- execFileSync3("chown", ["-h", "-R", `${uid}:${gid}`, rootDir], {
20534
- stdio: ["ignore", "ignore", "pipe"]
20535
- });
20605
+ try {
20606
+ execFileSync3("chown", ["-h", "-R", "--", `${uid}:${gid}`, rootDir], {
20607
+ stdio: ["ignore", "ignore", "pipe"],
20608
+ env: { ...process.env, LC_ALL: "C" },
20609
+ maxBuffer: 64 * 1024 * 1024
20610
+ });
20611
+ } catch (err) {
20612
+ const e = err;
20613
+ if (e?.signal != null || typeof e?.status !== "number")
20614
+ throw err;
20615
+ if (!isChownVanishedRaceOnly(err))
20616
+ throw err;
20617
+ }
20536
20618
  },
20537
20619
  chownShallow: (uid, gid, paths) => {
20538
20620
  if (paths.length === 0)
20539
20621
  return;
20540
- execFileSync3("chown", ["-h", `${uid}:${gid}`, ...paths], {
20541
- stdio: ["ignore", "ignore", "pipe"]
20622
+ execFileSync3("chown", ["-h", "--", `${uid}:${gid}`, ...paths], {
20623
+ stdio: ["ignore", "ignore", "pipe"],
20624
+ env: { ...process.env, LC_ALL: "C" }
20542
20625
  });
20543
20626
  },
20544
20627
  scopedSweepEnabled: () => process.env[SCOPED_SWEEP_ENV] === "1",
@@ -46933,7 +47016,6 @@ var ManifestSchema, WARN_ONLY_COMPONENTS;
46933
47016
  var init_manifest = __esm(() => {
46934
47017
  init_zod();
46935
47018
  ManifestSchema = exports_external.object({
46936
- switchroom_version: exports_external.string().min(1),
46937
47019
  tested_at: exports_external.string().min(1),
46938
47020
  runtime: exports_external.object({
46939
47021
  bun: exports_external.string().min(1),
@@ -65426,7 +65508,7 @@ async function checkManifestDrift(probers) {
65426
65508
  {
65427
65509
  name: "dependency manifest",
65428
65510
  status: "ok",
65429
- detail: `all versions match (manifest ${manifest.switchroom_version})`
65511
+ detail: `all versions match (switchroom ${SWITCHROOM_VERSION})`
65430
65512
  }
65431
65513
  ];
65432
65514
  }
@@ -65812,6 +65894,7 @@ var init_doctor = __esm(() => {
65812
65894
  init_accounts();
65813
65895
  init_schema();
65814
65896
  init_manifest();
65897
+ init_resolve_version();
65815
65898
  init_hindsight2();
65816
65899
  init_hindsight();
65817
65900
  init_host_capabilities();
@@ -85383,6 +85466,7 @@ Scaffolding agent: ${name}
85383
85466
  }
85384
85467
  } catch (err) {
85385
85468
  console.error(source_default.red(`Failed to restart ${n}: ${err.message}`));
85469
+ process.exitCode = 1;
85386
85470
  }
85387
85471
  }
85388
85472
  if (sawAbort) {
@@ -113945,6 +114029,16 @@ async function executeRollout(steps, target, deps, execOpts = {}) {
113945
114029
  timedOut: true
113946
114030
  });
113947
114031
  }
114032
+ if (restartRes.status !== 0) {
114033
+ const gotAfterExit = deps.probeVersion(step.agent);
114034
+ deps.log(` \u2717 ${step.agent} \u2192 restart exited ${restartRes.status} ` + `(probe says ${gotAfterExit ?? "<unreachable>"}) \u2014 STOPPING`);
114035
+ emit(isCanary ? { phase: "canary-fail", target, agent: step.agent, n: agentIndex, m: totalAgents } : { phase: "agent-done", target, agent: step.agent, n: agentIndex, m: totalAgents });
114036
+ return fail4({
114037
+ failedStep: "restart-agent",
114038
+ failedAgent: step.agent,
114039
+ got: gotAfterExit
114040
+ });
114041
+ }
113948
114042
  const got = deps.probeVersion(step.agent);
113949
114043
  if (got === null || normalizeVersion(got) !== targetNorm) {
113950
114044
  deps.log(` \u2717 ${step.agent} \u2192 ${got ?? "<unreachable>"} (expected ${target}) \u2014 STOPPING`);
@@ -114635,6 +114729,7 @@ function registerRestartCommand(program3) {
114635
114729
  }
114636
114730
  } catch (err) {
114637
114731
  console.error(source_default.red(` ${name}: restart failed: ${err.message}`));
114732
+ process.exitCode = 1;
114638
114733
  }
114639
114734
  }
114640
114735
  console.log();
@@ -114645,6 +114740,7 @@ function registerRestartCommand(program3) {
114645
114740
  // src/cli/versions.ts
114646
114741
  init_source();
114647
114742
  init_manifest();
114743
+ init_resolve_version();
114648
114744
  function formatRow(component, declared, installed, isDrift, warnOnly) {
114649
114745
  const installedStr = installed ?? source_default.red("(not installed)");
114650
114746
  if (!isDrift) {
@@ -114674,7 +114770,7 @@ function registerVersionsCommand(program3) {
114674
114770
  const driftMap = new Map(report.drift.map((d) => [d.component, d]));
114675
114771
  console.log(source_default.bold(`
114676
114772
  Dependency manifest`));
114677
- console.log(source_default.gray(` switchroom ${manifest.switchroom_version} \u00b7 tested ${manifest.tested_at}`));
114773
+ console.log(source_default.gray(` switchroom ${SWITCHROOM_VERSION} \u00b7 tested ${manifest.tested_at}`));
114678
114774
  console.log();
114679
114775
  const bunDrift = driftMap.get("bun");
114680
114776
  console.log(formatRow("bun", manifest.runtime.bun, bunDrift ? bunDrift.installed : manifest.runtime.bun, !!bunDrift, false));
@@ -117367,6 +117463,40 @@ function isEphemeralPath(path10) {
117367
117463
  }
117368
117464
  return false;
117369
117465
  }
117466
+ function pushStateFrom(opts) {
117467
+ if (opts.detached)
117468
+ return "detached";
117469
+ if (!opts.upstream)
117470
+ return "no-upstream";
117471
+ if (opts.aheadCount < 0)
117472
+ return "error";
117473
+ if (opts.aheadCount > 0)
117474
+ return "unpushed";
117475
+ return "pushed";
117476
+ }
117477
+ function isStablePerRepoBranch(branch) {
117478
+ return !!branch && /^agent\/[^/]+\/main$/.test(branch);
117479
+ }
117480
+ function classifyTaskTree(i2) {
117481
+ if (i2.isRegistryClaimed || i2.isStablePerRepoTree || i2.isEphemeralPath) {
117482
+ return "skip-protected";
117483
+ }
117484
+ if (i2.inUse === "unavailable")
117485
+ return "skip-probe-unavailable";
117486
+ if (i2.inUse === "in-use")
117487
+ return "skip-in-use";
117488
+ if (!i2.clean)
117489
+ return "skip-dirty";
117490
+ if (i2.pushed !== "pushed")
117491
+ return "skip-unpushed";
117492
+ if (!i2.idle)
117493
+ return "skip-active";
117494
+ if (i2.prSignal === "error")
117495
+ return "skip-unknown";
117496
+ if (i2.prSignal !== "merged" && i2.prSignal !== "closed")
117497
+ return "skip-unmerged";
117498
+ return "reap";
117499
+ }
117370
117500
  var defaultExec3 = (file, args, cwd) => execFileSync31(file, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).toString();
117371
117501
  function defaultPrSignal(repo, branch, exec) {
117372
117502
  try {
@@ -117399,10 +117529,28 @@ function defaultPrSignal(repo, branch, exec) {
117399
117529
  return "error";
117400
117530
  }
117401
117531
  }
117532
+ function defaultNewestTrackedMtimeMs(dir, exec) {
117533
+ let out;
117534
+ try {
117535
+ out = exec("git", ["-C", dir, "ls-files", "-z"]);
117536
+ } catch {
117537
+ return Date.now();
117538
+ }
117539
+ const files = out.split("\x00").filter(Boolean);
117540
+ let newest = 0;
117541
+ for (const f of files) {
117542
+ try {
117543
+ const m = statSync52(join102(dir, f)).mtimeMs;
117544
+ if (m > newest)
117545
+ newest = m;
117546
+ } catch {}
117547
+ }
117548
+ return newest || Date.now();
117549
+ }
117402
117550
  function trashRoot() {
117403
117551
  return resolve58(process.env.SWITCHROOM_WORKTREE_TRASH ?? join102(homedir55(), ".switchroom", "worktree-gc-trash"));
117404
117552
  }
117405
- function planGc(roots, deps = {}) {
117553
+ function planGc(roots, deps = {}, taskTreeRoots = []) {
117406
117554
  const exists = deps.existsSync ?? existsSync100;
117407
117555
  const readDir = deps.readDir ?? ((p) => readdirSync33(p));
117408
117556
  const readFile4 = deps.readFile ?? ((p) => readFileSync90(p, "utf8"));
@@ -117411,6 +117559,11 @@ function planGc(roots, deps = {}) {
117411
117559
  const prSignal = deps.prSignal ?? ((repo, branch) => defaultPrSignal(repo, branch, exec));
117412
117560
  const stamp = deps.dateStamp ?? "undated";
117413
117561
  const trash = join102(trashRoot(), stamp);
117562
+ const probeInUse = deps.probeInUse ?? probePathInUse;
117563
+ const newestMtime = deps.newestTrackedMtimeMs ?? ((dir) => defaultNewestTrackedMtimeMs(dir, exec));
117564
+ const idleDays = deps.idleDays ?? 14;
117565
+ const nowMs = deps.nowMs ?? Date.now();
117566
+ const escapeHatch = deps.escapeHatch ?? false;
117414
117567
  let claimed;
117415
117568
  try {
117416
117569
  claimed = new Set(listRecords().map((r) => resolve58(r.path)));
@@ -117522,11 +117675,137 @@ function planGc(roots, deps = {}) {
117522
117675
  registered.push({ path: wt.path, branch: wt.branch, verdict, prSignal: sig, repo });
117523
117676
  }
117524
117677
  }
117678
+ const taskTrees = [];
117679
+ for (const root of taskTreeRoots) {
117680
+ if (!exists(root))
117681
+ continue;
117682
+ let entries;
117683
+ try {
117684
+ entries = readDir(root);
117685
+ } catch {
117686
+ continue;
117687
+ }
117688
+ for (const name of entries) {
117689
+ const dir = join102(root, name);
117690
+ if (isEphemeralPath(dir))
117691
+ continue;
117692
+ const dotGit = join102(dir, ".git");
117693
+ if (!exists(dotGit))
117694
+ continue;
117695
+ let shape;
117696
+ try {
117697
+ shape = stat3(dotGit).isDirectory() ? "clone" : "worktree";
117698
+ } catch {
117699
+ continue;
117700
+ }
117701
+ let remoteOk = false;
117702
+ try {
117703
+ remoteOk = isSwitchroomRemote(exec("git", ["-C", dir, "remote", "get-url", "origin"]));
117704
+ } catch {
117705
+ remoteOk = false;
117706
+ }
117707
+ if (!remoteOk) {
117708
+ skipped.push({ dir, reason: "not a switchroom task tree" });
117709
+ continue;
117710
+ }
117711
+ let ownerRepo = null;
117712
+ if (shape === "worktree") {
117713
+ try {
117714
+ const ptr = parseGitdirPointer(readFile4(dotGit));
117715
+ if (ptr)
117716
+ ownerRepo = repoRootFromWorktreeGitdir(ptr);
117717
+ } catch {
117718
+ ownerRepo = null;
117719
+ }
117720
+ }
117721
+ let branch = null;
117722
+ let detached = false;
117723
+ try {
117724
+ const b = exec("git", ["-C", dir, "rev-parse", "--abbrev-ref", "HEAD"]).trim();
117725
+ if (b === "HEAD")
117726
+ detached = true;
117727
+ else
117728
+ branch = b;
117729
+ } catch {
117730
+ detached = true;
117731
+ }
117732
+ const claimedWt = claimed.has(resolve58(dir));
117733
+ const stableTree = isStablePerRepoBranch(branch);
117734
+ const ephemeral = isEphemeralPath(dir);
117735
+ const protectedTree = claimedWt || stableTree || ephemeral;
117736
+ let clean3 = false;
117737
+ try {
117738
+ clean3 = isEffectivelyClean(exec("git", ["-C", dir, "status", "--porcelain"]).split(`
117739
+ `));
117740
+ } catch {
117741
+ clean3 = false;
117742
+ }
117743
+ let upstream = null;
117744
+ let aheadCount = 0;
117745
+ if (!detached) {
117746
+ try {
117747
+ upstream = exec("git", ["-C", dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]).trim() || null;
117748
+ } catch {
117749
+ upstream = null;
117750
+ }
117751
+ if (upstream) {
117752
+ try {
117753
+ aheadCount = parseInt(exec("git", ["-C", dir, "rev-list", "--count", "@{upstream}..HEAD"]).trim(), 10);
117754
+ if (!Number.isFinite(aheadCount))
117755
+ aheadCount = -1;
117756
+ } catch {
117757
+ aheadCount = -1;
117758
+ }
117759
+ }
117760
+ }
117761
+ const pushed = pushStateFrom({ detached, upstream, aheadCount });
117762
+ let idle = false;
117763
+ try {
117764
+ idle = (nowMs - newestMtime(dir)) / 86400000 >= idleDays;
117765
+ } catch {
117766
+ idle = false;
117767
+ }
117768
+ let inUse = "unavailable";
117769
+ let sig = "error";
117770
+ if (!protectedTree) {
117771
+ inUse = probeInUse(dir);
117772
+ if (inUse === "free" && clean3 && pushed === "pushed" && idle && branch) {
117773
+ sig = prSignal(dir, branch);
117774
+ }
117775
+ }
117776
+ const verdict = classifyTaskTree({
117777
+ isRegistryClaimed: claimedWt,
117778
+ isStablePerRepoTree: stableTree,
117779
+ isEphemeralPath: ephemeral,
117780
+ clean: clean3,
117781
+ pushed,
117782
+ prSignal: sig,
117783
+ idle,
117784
+ inUse
117785
+ });
117786
+ const willAct = verdict === "reap" || escapeHatch && idle && (verdict === "skip-dirty" || verdict === "skip-unpushed");
117787
+ if (willAct && ownerRepo)
117788
+ reposToPrune.add(ownerRepo);
117789
+ taskTrees.push({
117790
+ dir,
117791
+ shape,
117792
+ ownerRepo,
117793
+ branch,
117794
+ verdict,
117795
+ prSignal: sig,
117796
+ idle,
117797
+ dest: join102(trash, name),
117798
+ willAct
117799
+ });
117800
+ }
117801
+ }
117525
117802
  return {
117526
117803
  roots,
117804
+ taskTreeRoots,
117527
117805
  trashDir: trash,
117528
117806
  orphans,
117529
117807
  registered,
117808
+ taskTrees,
117530
117809
  reposToPrune: [...reposToPrune],
117531
117810
  skipped
117532
117811
  };
@@ -117542,7 +117821,8 @@ function applyGc(plan, deps = {}) {
117542
117821
  }
117543
117822
  });
117544
117823
  const res = { quarantined: [], removed: [], branchesDeleted: [], pruned: [], errors: [] };
117545
- if (plan.orphans.length)
117824
+ const taskTreeActions = (plan.taskTrees ?? []).filter((t) => t.willAct);
117825
+ if (plan.orphans.length || taskTreeActions.length)
117546
117826
  mkdirp(plan.trashDir);
117547
117827
  for (const o of plan.orphans) {
117548
117828
  try {
@@ -117552,6 +117832,14 @@ function applyGc(plan, deps = {}) {
117552
117832
  res.errors.push(`quarantine ${o.dir}: ${e.message}`);
117553
117833
  }
117554
117834
  }
117835
+ for (const t of taskTreeActions) {
117836
+ try {
117837
+ move(t.dir, t.dest);
117838
+ res.quarantined.push(t.dir);
117839
+ } catch (e) {
117840
+ res.errors.push(`quarantine ${t.dir}: ${e.message}`);
117841
+ }
117842
+ }
117555
117843
  for (const r of plan.registered) {
117556
117844
  if (r.verdict !== "remove")
117557
117845
  continue;
@@ -117623,6 +117911,24 @@ function purgeTrash(paths) {
117623
117911
  function defaultRoots() {
117624
117912
  return [join102(homedir55(), "code")];
117625
117913
  }
117914
+ function defaultTaskTreeRoots() {
117915
+ const agentsDir = join102(homedir55(), ".switchroom", "agents");
117916
+ let names;
117917
+ try {
117918
+ names = readdirSync33(agentsDir);
117919
+ } catch {
117920
+ return [];
117921
+ }
117922
+ const roots = [];
117923
+ for (const name of names.sort()) {
117924
+ for (const sub of ["home/work", "home/workspace"]) {
117925
+ const r = join102(agentsDir, name, sub);
117926
+ if (existsSync100(r))
117927
+ roots.push(r);
117928
+ }
117929
+ }
117930
+ return roots;
117931
+ }
117626
117932
 
117627
117933
  // src/cli/worktree.ts
117628
117934
  function registerWorktreeCommand(program3) {
@@ -117756,10 +118062,13 @@ Skipped ${result.skipped.length} stale worktree(s) (kept for safety):`));
117756
118062
  }
117757
118063
  }
117758
118064
  });
117759
- worktree.command("gc").description(`Reclaim dev worktrees that outlived their PR.
118065
+ worktree.command("gc").description(`Reclaim dev worktrees that outlived their PR, and per-agent task trees.
117760
118066
  ` + `Dry-run by default; pass --yes to act. Quarantines orphaned worktree
117761
118067
  ` + `directories (move, not delete) and removes registered worktrees whose
117762
- ` + "PR is MERGED and whose tree is clean.").option("--root <dir>", "Scan root (repeatable). Default: ~/code", (val, acc) => [...acc, val], []).option("--yes", "Actually act (default is dry-run)").option("--json", "Output raw JSON").option("--purge-trash", "Hard-delete quarantined dirs older than --older-than").option("--older-than <days>", "Age threshold for --purge-trash (default 14)", "14").action((opts) => {
118068
+ ` + `PR is MERGED and whose tree is clean. Also sweeps per-agent home/work
118069
+ ` + `task trees (all shapes) that are clean + pushed + idle + merged/closed;
118070
+ ` + `dead-but-dirty/unpushed trees are surfaced and reclaimed only with
118071
+ ` + "--reclaim-dirty (reversible quarantine).").option("--root <dir>", "Dev-worktree scan root (repeatable). Default: ~/code", (val, acc) => [...acc, val], []).option("--task-root <dir>", "Task-tree scan root (repeatable). Default: every agent's home/work + home/workspace", (val, acc) => [...acc, val], []).option("--no-task-roots", "Skip the per-agent task-tree sweep entirely").option("--idle-days <days>", "Idle threshold for task trees (default 14)", "14").option("--reclaim-dirty", "Operator escape hatch: quarantine IDLE dirty/unpushed task trees (reversible)").option("--yes", "Actually act (default is dry-run)").option("--json", "Output raw JSON").option("--purge-trash", "Hard-delete quarantined dirs older than --older-than").option("--older-than <days>", "Age threshold for --purge-trash (default 14)", "14").action((opts) => {
117763
118072
  if (opts.purgeTrash) {
117764
118073
  const parsed = Number(opts.olderThan);
117765
118074
  const olderThan = isNaN(parsed) ? 14 : parsed;
@@ -117790,9 +118099,14 @@ Re-run with --yes to delete.`));
117790
118099
  return;
117791
118100
  }
117792
118101
  const roots = opts.root.length > 0 ? opts.root : defaultRoots();
118102
+ const taskTreeRoots = opts.taskRoots === false ? [] : opts.taskRoot.length > 0 ? opts.taskRoot : defaultTaskTreeRoots();
118103
+ const parsedIdle = Number(opts.idleDays);
118104
+ const idleDays = Number.isFinite(parsedIdle) && parsedIdle >= 0 ? parsedIdle : 14;
117793
118105
  const dateStamp = new Date().toISOString().slice(0, 10);
117794
- const plan = planGc(roots, { dateStamp });
118106
+ const plan = planGc(roots, { dateStamp, idleDays, escapeHatch: opts.reclaimDirty }, taskTreeRoots);
117795
118107
  const toRemove = plan.registered.filter((r) => r.verdict === "remove");
118108
+ const taskReap = plan.taskTrees.filter((t) => t.willAct);
118109
+ const taskEscape = plan.taskTrees.filter((t) => !t.willAct && t.idle && (t.verdict === "skip-dirty" || t.verdict === "skip-unpushed"));
117796
118110
  if (!opts.yes) {
117797
118111
  if (opts.json) {
117798
118112
  console.log(JSON.stringify({ dryRun: true, plan }));
@@ -117814,21 +118128,45 @@ Kept (${skips.length}):`));
117814
118128
  for (const r of skips)
117815
118129
  console.log(source_default.dim(` ${r.verdict.replace("skip-", "")}: ${r.path} [${r.branch}] (pr=${r.prSignal})`));
117816
118130
  }
118131
+ if (taskTreeRoots.length) {
118132
+ const label = opts.reclaimDirty ? "reclaim (reap + escape-hatch)" : "reap (clean + pushed + idle + merged/closed)";
118133
+ console.log(`
118134
+ Task trees to ${label}: ${source_default.bold(taskReap.length)}`);
118135
+ for (const t of taskReap)
118136
+ console.log(` ${t.dir} ${source_default.dim(`[${t.branch ?? "detached"}] ${t.shape} ${t.verdict} \u2192 ${t.dest}`)}`);
118137
+ if (taskEscape.length) {
118138
+ console.log(source_default.yellow(`
118139
+ Dead-but-dirty/unpushed task trees kept (${taskEscape.length}) \u2014 reclaim with --reclaim-dirty:`));
118140
+ for (const t of taskEscape)
118141
+ console.log(source_default.dim(` ${t.verdict.replace("skip-", "")}: ${t.dir} [${t.branch ?? "detached"}] ${t.shape}`));
118142
+ }
118143
+ const taskKept = plan.taskTrees.filter((t) => !t.willAct && !(t.idle && (t.verdict === "skip-dirty" || t.verdict === "skip-unpushed")) && t.verdict !== "skip-protected");
118144
+ if (taskKept.length) {
118145
+ console.log(source_default.dim(`
118146
+ Other task trees kept (${taskKept.length}):`));
118147
+ for (const t of taskKept)
118148
+ console.log(source_default.dim(` ${t.verdict.replace("skip-", "")}: ${t.dir} [${t.branch ?? "detached"}] (pr=${t.prSignal})`));
118149
+ }
118150
+ }
117817
118151
  if (plan.skipped.length) {
117818
118152
  console.log(source_default.dim(`
117819
118153
  Ignored non-switchroom dirs: ${plan.skipped.length}`));
117820
118154
  }
117821
118155
  console.log(source_default.dim(`
117822
- Re-run with --yes to act. Orphans are MOVED to the trash dir, not deleted.`));
118156
+ Re-run with --yes to act. Reclaimed trees are MOVED to the trash dir, not deleted.`));
117823
118157
  return;
117824
118158
  }
117825
118159
  const result = applyGc(plan);
117826
118160
  if (opts.json) {
117827
118161
  console.log(JSON.stringify(result));
117828
118162
  } else {
117829
- console.log(source_default.green(`Quarantined ${result.quarantined.length} orphan(s) \u2192 ${plan.trashDir}`));
118163
+ console.log(source_default.green(`Quarantined ${result.quarantined.length} dir(s) \u2192 ${plan.trashDir}`));
117830
118164
  console.log(source_default.green(`Removed ${result.removed.length} merged worktree(s); deleted ${result.branchesDeleted.length} branch(es).`));
117831
118165
  console.log(source_default.dim(`Pruned metadata in ${result.pruned.length} repo(s).`));
118166
+ if (taskEscape.length && !opts.reclaimDirty) {
118167
+ console.log(source_default.yellow(`
118168
+ ${taskEscape.length} dead-but-dirty/unpushed task tree(s) were kept. Reclaim reversibly with --reclaim-dirty.`));
118169
+ }
117832
118170
  for (const e of result.errors)
117833
118171
  console.warn(source_default.yellow(e));
117834
118172
  console.log(source_default.dim(`
@@ -120405,6 +120743,7 @@ var import_yaml22 = __toESM(require_dist(), 1);
120405
120743
  // src/config/overlay-writer.ts
120406
120744
  init_paths();
120407
120745
  import {
120746
+ chownSync as chownSync11,
120408
120747
  closeSync as closeSync18,
120409
120748
  existsSync as existsSync103,
120410
120749
  fsyncSync as fsyncSync8,
@@ -120481,6 +120820,21 @@ function withAgentLock(paths, fn) {
120481
120820
  } catch {}
120482
120821
  }
120483
120822
  }
120823
+ var overlayWriterRuntime = {
120824
+ geteuid: () => process.geteuid?.(),
120825
+ chown: (path10, uid, gid) => chownSync11(path10, uid, gid)
120826
+ };
120827
+ function alignStagedOwnerToDir(stagingPath, targetDir) {
120828
+ try {
120829
+ const euid = overlayWriterRuntime.geteuid();
120830
+ if (euid === undefined)
120831
+ return;
120832
+ const dirStat = statSync54(targetDir);
120833
+ if (dirStat.uid === euid)
120834
+ return;
120835
+ overlayWriterRuntime.chown(stagingPath, dirStat.uid, dirStat.gid);
120836
+ } catch {}
120837
+ }
120484
120838
  function writeOverlayEntry(agent, slug, yamlText, opts = {}) {
120485
120839
  const paths = overlayPathsFor(agent, opts);
120486
120840
  return withAgentLock(paths, () => {
@@ -120494,6 +120848,7 @@ function writeOverlayEntry(agent, slug, yamlText, opts = {}) {
120494
120848
  } finally {
120495
120849
  closeSync18(fd);
120496
120850
  }
120851
+ alignStagedOwnerToDir(stagingPath, paths.scheduleDir);
120497
120852
  renameSync27(stagingPath, finalPath);
120498
120853
  return finalPath;
120499
120854
  });
@@ -120511,6 +120866,7 @@ function writeSkillsOverlayEntry(agent, slug, yamlText, opts = {}) {
120511
120866
  } finally {
120512
120867
  closeSync18(fd);
120513
120868
  }
120869
+ alignStagedOwnerToDir(stagingPath, paths.skillsDir);
120514
120870
  renameSync27(stagingPath, finalPath);
120515
120871
  return finalPath;
120516
120872
  });
@@ -123985,7 +124341,7 @@ init_source();
123985
124341
  init_helpers();
123986
124342
  init_operator_uid();
123987
124343
  init_compose();
123988
- import { chownSync as chownSync11, existsSync as existsSync112, mkdirSync as mkdirSync65, writeFileSync as writeFileSync48, copyFileSync as copyFileSync15 } from "node:fs";
124344
+ import { chownSync as chownSync12, existsSync as existsSync112, mkdirSync as mkdirSync65, writeFileSync as writeFileSync48, copyFileSync as copyFileSync15 } from "node:fs";
123989
124345
  import { homedir as homedir62 } from "node:os";
123990
124346
  import { join as join115 } from "node:path";
123991
124347
  import { spawnSync as spawnSync27 } from "node:child_process";
@@ -124165,10 +124521,10 @@ async function doInstall2(opts, program3) {
124165
124521
  writeFileSync48(composePath, yaml, "utf8");
124166
124522
  try {
124167
124523
  if (typeof process.geteuid === "function" && process.geteuid() === 0) {
124168
- chownSync11(dir, operatorUid, operatorUid);
124169
- chownSync11(composePath, operatorUid, operatorUid);
124524
+ chownSync12(dir, operatorUid, operatorUid);
124525
+ chownSync12(composePath, operatorUid, operatorUid);
124170
124526
  if (bak)
124171
- chownSync11(bak, operatorUid, operatorUid);
124527
+ chownSync12(bak, operatorUid, operatorUid);
124172
124528
  }
124173
124529
  } catch {}
124174
124530
  console.log(source_default.green(` \u2713 Wrote ${composePath}`));