switchroom 0.20.7 → 0.20.9

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.
Files changed (52) hide show
  1. package/dist/agent-scheduler/index.js +111 -14
  2. package/dist/auth-broker/index.js +113 -30
  3. package/dist/cli/autoaccept-poll.js +5 -3
  4. package/dist/cli/drive-write-pretool.mjs +5 -3
  5. package/dist/cli/ms-365-write-pretool.mjs +5 -3
  6. package/dist/cli/notion-write-pretool.mjs +67 -6
  7. package/dist/cli/switchroom.js +389 -31
  8. package/dist/host-control/main.js +69 -8
  9. package/dist/vault/approvals/kernel-server.js +68 -7
  10. package/dist/vault/broker/server.js +68 -7
  11. package/package.json +1 -1
  12. package/profiles/default/CLAUDE.md.hbs +12 -13
  13. package/telegram-plugin/ask-user.ts +6 -7
  14. package/telegram-plugin/bridge/ipc-client.ts +17 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +5 -2
  16. package/telegram-plugin/dist/gateway/gateway.js +410 -178
  17. package/telegram-plugin/dist/server.js +5 -2
  18. package/telegram-plugin/gateway/auth-broker-client.ts +1 -1
  19. package/telegram-plugin/gateway/auth-command.ts +4 -2
  20. package/telegram-plugin/gateway/boot-reason.ts +61 -0
  21. package/telegram-plugin/gateway/checklist-fallback.ts +8 -1
  22. package/telegram-plugin/gateway/cron-session.ts +66 -0
  23. package/telegram-plugin/gateway/gateway.ts +36 -34
  24. package/telegram-plugin/gateway/narrative-lane.ts +21 -1
  25. package/telegram-plugin/gateway/outbound-send-path.ts +9 -1
  26. package/telegram-plugin/gateway/represent-delivery-guard.ts +33 -2
  27. package/telegram-plugin/gateway/stream-render.ts +11 -2
  28. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +21 -1
  29. package/telegram-plugin/gateway/subagent-handback-marker.ts +94 -0
  30. package/telegram-plugin/gateway/throttle-tier-wiring.ts +93 -18
  31. package/telegram-plugin/render/emphasis-guard.ts +92 -12
  32. package/telegram-plugin/render/line-start-guard.ts +27 -2
  33. package/telegram-plugin/sticker-aliases.ts +12 -14
  34. package/telegram-plugin/tests/ask-user.test.ts +15 -0
  35. package/telegram-plugin/tests/boot-card-reason.test.ts +88 -0
  36. package/telegram-plugin/tests/checklist-fallback.test.ts +21 -0
  37. package/telegram-plugin/tests/cron-bridge-drain-spool-ack.test.ts +150 -0
  38. package/telegram-plugin/tests/handback-tasknotif-dedup.test.ts +248 -0
  39. package/telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts +70 -0
  40. package/telegram-plugin/tests/narrative-lane-golden.test.ts +86 -0
  41. package/telegram-plugin/tests/queued-card-surface.test.ts +66 -0
  42. package/telegram-plugin/tests/render/emphasis-guard.test.ts +105 -6
  43. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +123 -36
  44. package/telegram-plugin/tests/reply-quote-wire.test.ts +47 -0
  45. package/telegram-plugin/tests/represent-guard.test.ts +45 -0
  46. package/telegram-plugin/tests/sticker-aliases.test.ts +43 -0
  47. package/telegram-plugin/tests/throttle-tier-probe-only.test.ts +216 -0
  48. package/telegram-plugin/tests/throttle-tier-route-429-wiring.test.ts +92 -0
  49. package/telegram-plugin/tests/throttle-tier-route-429.test.ts +71 -0
  50. package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
  51. package/telegram-plugin/throttle-tier.ts +59 -0
  52. package/telegram-plugin/turn-flush-safety.ts +79 -0
@@ -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.9", COMMIT_SHA = "63c4c44b";
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, source) {
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, source });
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, source: "schedule" });
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, "schedule");
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, source: "skills" });
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, "skills");
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",
@@ -35931,7 +36014,8 @@ var init_protocol2 = __esm(() => {
35931
36014
  v: exports_external.literal(PROTOCOL_VERSION),
35932
36015
  op: exports_external.literal("mark-throttled"),
35933
36016
  id: exports_external.string().min(1),
35934
- until: exports_external.number().int().positive()
36017
+ until: exports_external.number().int().positive(),
36018
+ probeOnly: exports_external.boolean().optional()
35935
36019
  });
35936
36020
  RefreshAccountRequestSchema = exports_external.object({
35937
36021
  v: exports_external.literal(PROTOCOL_VERSION),
@@ -36327,12 +36411,13 @@ class AuthBrokerClient {
36327
36411
  const data = await this.send(req);
36328
36412
  return data;
36329
36413
  }
36330
- async markThrottled(until) {
36414
+ async markThrottled(until, probeOnly = false) {
36331
36415
  const data = await this.send({
36332
36416
  v: PROTOCOL_VERSION,
36333
36417
  id: randomUUID(),
36334
36418
  op: "mark-throttled",
36335
- until
36419
+ until,
36420
+ ...probeOnly ? { probeOnly: true } : {}
36336
36421
  });
36337
36422
  return data;
36338
36423
  }
@@ -46933,7 +47018,6 @@ var ManifestSchema, WARN_ONLY_COMPONENTS;
46933
47018
  var init_manifest = __esm(() => {
46934
47019
  init_zod();
46935
47020
  ManifestSchema = exports_external.object({
46936
- switchroom_version: exports_external.string().min(1),
46937
47021
  tested_at: exports_external.string().min(1),
46938
47022
  runtime: exports_external.object({
46939
47023
  bun: exports_external.string().min(1),
@@ -65426,7 +65510,7 @@ async function checkManifestDrift(probers) {
65426
65510
  {
65427
65511
  name: "dependency manifest",
65428
65512
  status: "ok",
65429
- detail: `all versions match (manifest ${manifest.switchroom_version})`
65513
+ detail: `all versions match (switchroom ${SWITCHROOM_VERSION})`
65430
65514
  }
65431
65515
  ];
65432
65516
  }
@@ -65812,6 +65896,7 @@ var init_doctor = __esm(() => {
65812
65896
  init_accounts();
65813
65897
  init_schema();
65814
65898
  init_manifest();
65899
+ init_resolve_version();
65815
65900
  init_hindsight2();
65816
65901
  init_hindsight();
65817
65902
  init_host_capabilities();
@@ -85383,6 +85468,7 @@ Scaffolding agent: ${name}
85383
85468
  }
85384
85469
  } catch (err) {
85385
85470
  console.error(source_default.red(`Failed to restart ${n}: ${err.message}`));
85471
+ process.exitCode = 1;
85386
85472
  }
85387
85473
  }
85388
85474
  if (sawAbort) {
@@ -113945,6 +114031,16 @@ async function executeRollout(steps, target, deps, execOpts = {}) {
113945
114031
  timedOut: true
113946
114032
  });
113947
114033
  }
114034
+ if (restartRes.status !== 0) {
114035
+ const gotAfterExit = deps.probeVersion(step.agent);
114036
+ deps.log(` \u2717 ${step.agent} \u2192 restart exited ${restartRes.status} ` + `(probe says ${gotAfterExit ?? "<unreachable>"}) \u2014 STOPPING`);
114037
+ emit(isCanary ? { phase: "canary-fail", target, agent: step.agent, n: agentIndex, m: totalAgents } : { phase: "agent-done", target, agent: step.agent, n: agentIndex, m: totalAgents });
114038
+ return fail4({
114039
+ failedStep: "restart-agent",
114040
+ failedAgent: step.agent,
114041
+ got: gotAfterExit
114042
+ });
114043
+ }
113948
114044
  const got = deps.probeVersion(step.agent);
113949
114045
  if (got === null || normalizeVersion(got) !== targetNorm) {
113950
114046
  deps.log(` \u2717 ${step.agent} \u2192 ${got ?? "<unreachable>"} (expected ${target}) \u2014 STOPPING`);
@@ -114635,6 +114731,7 @@ function registerRestartCommand(program3) {
114635
114731
  }
114636
114732
  } catch (err) {
114637
114733
  console.error(source_default.red(` ${name}: restart failed: ${err.message}`));
114734
+ process.exitCode = 1;
114638
114735
  }
114639
114736
  }
114640
114737
  console.log();
@@ -114645,6 +114742,7 @@ function registerRestartCommand(program3) {
114645
114742
  // src/cli/versions.ts
114646
114743
  init_source();
114647
114744
  init_manifest();
114745
+ init_resolve_version();
114648
114746
  function formatRow(component, declared, installed, isDrift, warnOnly) {
114649
114747
  const installedStr = installed ?? source_default.red("(not installed)");
114650
114748
  if (!isDrift) {
@@ -114674,7 +114772,7 @@ function registerVersionsCommand(program3) {
114674
114772
  const driftMap = new Map(report.drift.map((d) => [d.component, d]));
114675
114773
  console.log(source_default.bold(`
114676
114774
  Dependency manifest`));
114677
- console.log(source_default.gray(` switchroom ${manifest.switchroom_version} \u00b7 tested ${manifest.tested_at}`));
114775
+ console.log(source_default.gray(` switchroom ${SWITCHROOM_VERSION} \u00b7 tested ${manifest.tested_at}`));
114678
114776
  console.log();
114679
114777
  const bunDrift = driftMap.get("bun");
114680
114778
  console.log(formatRow("bun", manifest.runtime.bun, bunDrift ? bunDrift.installed : manifest.runtime.bun, !!bunDrift, false));
@@ -117367,6 +117465,40 @@ function isEphemeralPath(path10) {
117367
117465
  }
117368
117466
  return false;
117369
117467
  }
117468
+ function pushStateFrom(opts) {
117469
+ if (opts.detached)
117470
+ return "detached";
117471
+ if (!opts.upstream)
117472
+ return "no-upstream";
117473
+ if (opts.aheadCount < 0)
117474
+ return "error";
117475
+ if (opts.aheadCount > 0)
117476
+ return "unpushed";
117477
+ return "pushed";
117478
+ }
117479
+ function isStablePerRepoBranch(branch) {
117480
+ return !!branch && /^agent\/[^/]+\/main$/.test(branch);
117481
+ }
117482
+ function classifyTaskTree(i2) {
117483
+ if (i2.isRegistryClaimed || i2.isStablePerRepoTree || i2.isEphemeralPath) {
117484
+ return "skip-protected";
117485
+ }
117486
+ if (i2.inUse === "unavailable")
117487
+ return "skip-probe-unavailable";
117488
+ if (i2.inUse === "in-use")
117489
+ return "skip-in-use";
117490
+ if (!i2.clean)
117491
+ return "skip-dirty";
117492
+ if (i2.pushed !== "pushed")
117493
+ return "skip-unpushed";
117494
+ if (!i2.idle)
117495
+ return "skip-active";
117496
+ if (i2.prSignal === "error")
117497
+ return "skip-unknown";
117498
+ if (i2.prSignal !== "merged" && i2.prSignal !== "closed")
117499
+ return "skip-unmerged";
117500
+ return "reap";
117501
+ }
117370
117502
  var defaultExec3 = (file, args, cwd) => execFileSync31(file, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).toString();
117371
117503
  function defaultPrSignal(repo, branch, exec) {
117372
117504
  try {
@@ -117399,10 +117531,28 @@ function defaultPrSignal(repo, branch, exec) {
117399
117531
  return "error";
117400
117532
  }
117401
117533
  }
117534
+ function defaultNewestTrackedMtimeMs(dir, exec) {
117535
+ let out;
117536
+ try {
117537
+ out = exec("git", ["-C", dir, "ls-files", "-z"]);
117538
+ } catch {
117539
+ return Date.now();
117540
+ }
117541
+ const files = out.split("\x00").filter(Boolean);
117542
+ let newest = 0;
117543
+ for (const f of files) {
117544
+ try {
117545
+ const m = statSync52(join102(dir, f)).mtimeMs;
117546
+ if (m > newest)
117547
+ newest = m;
117548
+ } catch {}
117549
+ }
117550
+ return newest || Date.now();
117551
+ }
117402
117552
  function trashRoot() {
117403
117553
  return resolve58(process.env.SWITCHROOM_WORKTREE_TRASH ?? join102(homedir55(), ".switchroom", "worktree-gc-trash"));
117404
117554
  }
117405
- function planGc(roots, deps = {}) {
117555
+ function planGc(roots, deps = {}, taskTreeRoots = []) {
117406
117556
  const exists = deps.existsSync ?? existsSync100;
117407
117557
  const readDir = deps.readDir ?? ((p) => readdirSync33(p));
117408
117558
  const readFile4 = deps.readFile ?? ((p) => readFileSync90(p, "utf8"));
@@ -117411,6 +117561,11 @@ function planGc(roots, deps = {}) {
117411
117561
  const prSignal = deps.prSignal ?? ((repo, branch) => defaultPrSignal(repo, branch, exec));
117412
117562
  const stamp = deps.dateStamp ?? "undated";
117413
117563
  const trash = join102(trashRoot(), stamp);
117564
+ const probeInUse = deps.probeInUse ?? probePathInUse;
117565
+ const newestMtime = deps.newestTrackedMtimeMs ?? ((dir) => defaultNewestTrackedMtimeMs(dir, exec));
117566
+ const idleDays = deps.idleDays ?? 14;
117567
+ const nowMs = deps.nowMs ?? Date.now();
117568
+ const escapeHatch = deps.escapeHatch ?? false;
117414
117569
  let claimed;
117415
117570
  try {
117416
117571
  claimed = new Set(listRecords().map((r) => resolve58(r.path)));
@@ -117522,11 +117677,137 @@ function planGc(roots, deps = {}) {
117522
117677
  registered.push({ path: wt.path, branch: wt.branch, verdict, prSignal: sig, repo });
117523
117678
  }
117524
117679
  }
117680
+ const taskTrees = [];
117681
+ for (const root of taskTreeRoots) {
117682
+ if (!exists(root))
117683
+ continue;
117684
+ let entries;
117685
+ try {
117686
+ entries = readDir(root);
117687
+ } catch {
117688
+ continue;
117689
+ }
117690
+ for (const name of entries) {
117691
+ const dir = join102(root, name);
117692
+ if (isEphemeralPath(dir))
117693
+ continue;
117694
+ const dotGit = join102(dir, ".git");
117695
+ if (!exists(dotGit))
117696
+ continue;
117697
+ let shape;
117698
+ try {
117699
+ shape = stat3(dotGit).isDirectory() ? "clone" : "worktree";
117700
+ } catch {
117701
+ continue;
117702
+ }
117703
+ let remoteOk = false;
117704
+ try {
117705
+ remoteOk = isSwitchroomRemote(exec("git", ["-C", dir, "remote", "get-url", "origin"]));
117706
+ } catch {
117707
+ remoteOk = false;
117708
+ }
117709
+ if (!remoteOk) {
117710
+ skipped.push({ dir, reason: "not a switchroom task tree" });
117711
+ continue;
117712
+ }
117713
+ let ownerRepo = null;
117714
+ if (shape === "worktree") {
117715
+ try {
117716
+ const ptr = parseGitdirPointer(readFile4(dotGit));
117717
+ if (ptr)
117718
+ ownerRepo = repoRootFromWorktreeGitdir(ptr);
117719
+ } catch {
117720
+ ownerRepo = null;
117721
+ }
117722
+ }
117723
+ let branch = null;
117724
+ let detached = false;
117725
+ try {
117726
+ const b = exec("git", ["-C", dir, "rev-parse", "--abbrev-ref", "HEAD"]).trim();
117727
+ if (b === "HEAD")
117728
+ detached = true;
117729
+ else
117730
+ branch = b;
117731
+ } catch {
117732
+ detached = true;
117733
+ }
117734
+ const claimedWt = claimed.has(resolve58(dir));
117735
+ const stableTree = isStablePerRepoBranch(branch);
117736
+ const ephemeral = isEphemeralPath(dir);
117737
+ const protectedTree = claimedWt || stableTree || ephemeral;
117738
+ let clean3 = false;
117739
+ try {
117740
+ clean3 = isEffectivelyClean(exec("git", ["-C", dir, "status", "--porcelain"]).split(`
117741
+ `));
117742
+ } catch {
117743
+ clean3 = false;
117744
+ }
117745
+ let upstream = null;
117746
+ let aheadCount = 0;
117747
+ if (!detached) {
117748
+ try {
117749
+ upstream = exec("git", ["-C", dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]).trim() || null;
117750
+ } catch {
117751
+ upstream = null;
117752
+ }
117753
+ if (upstream) {
117754
+ try {
117755
+ aheadCount = parseInt(exec("git", ["-C", dir, "rev-list", "--count", "@{upstream}..HEAD"]).trim(), 10);
117756
+ if (!Number.isFinite(aheadCount))
117757
+ aheadCount = -1;
117758
+ } catch {
117759
+ aheadCount = -1;
117760
+ }
117761
+ }
117762
+ }
117763
+ const pushed = pushStateFrom({ detached, upstream, aheadCount });
117764
+ let idle = false;
117765
+ try {
117766
+ idle = (nowMs - newestMtime(dir)) / 86400000 >= idleDays;
117767
+ } catch {
117768
+ idle = false;
117769
+ }
117770
+ let inUse = "unavailable";
117771
+ let sig = "error";
117772
+ if (!protectedTree) {
117773
+ inUse = probeInUse(dir);
117774
+ if (inUse === "free" && clean3 && pushed === "pushed" && idle && branch) {
117775
+ sig = prSignal(dir, branch);
117776
+ }
117777
+ }
117778
+ const verdict = classifyTaskTree({
117779
+ isRegistryClaimed: claimedWt,
117780
+ isStablePerRepoTree: stableTree,
117781
+ isEphemeralPath: ephemeral,
117782
+ clean: clean3,
117783
+ pushed,
117784
+ prSignal: sig,
117785
+ idle,
117786
+ inUse
117787
+ });
117788
+ const willAct = verdict === "reap" || escapeHatch && idle && (verdict === "skip-dirty" || verdict === "skip-unpushed");
117789
+ if (willAct && ownerRepo)
117790
+ reposToPrune.add(ownerRepo);
117791
+ taskTrees.push({
117792
+ dir,
117793
+ shape,
117794
+ ownerRepo,
117795
+ branch,
117796
+ verdict,
117797
+ prSignal: sig,
117798
+ idle,
117799
+ dest: join102(trash, name),
117800
+ willAct
117801
+ });
117802
+ }
117803
+ }
117525
117804
  return {
117526
117805
  roots,
117806
+ taskTreeRoots,
117527
117807
  trashDir: trash,
117528
117808
  orphans,
117529
117809
  registered,
117810
+ taskTrees,
117530
117811
  reposToPrune: [...reposToPrune],
117531
117812
  skipped
117532
117813
  };
@@ -117542,7 +117823,8 @@ function applyGc(plan, deps = {}) {
117542
117823
  }
117543
117824
  });
117544
117825
  const res = { quarantined: [], removed: [], branchesDeleted: [], pruned: [], errors: [] };
117545
- if (plan.orphans.length)
117826
+ const taskTreeActions = (plan.taskTrees ?? []).filter((t) => t.willAct);
117827
+ if (plan.orphans.length || taskTreeActions.length)
117546
117828
  mkdirp(plan.trashDir);
117547
117829
  for (const o of plan.orphans) {
117548
117830
  try {
@@ -117552,6 +117834,14 @@ function applyGc(plan, deps = {}) {
117552
117834
  res.errors.push(`quarantine ${o.dir}: ${e.message}`);
117553
117835
  }
117554
117836
  }
117837
+ for (const t of taskTreeActions) {
117838
+ try {
117839
+ move(t.dir, t.dest);
117840
+ res.quarantined.push(t.dir);
117841
+ } catch (e) {
117842
+ res.errors.push(`quarantine ${t.dir}: ${e.message}`);
117843
+ }
117844
+ }
117555
117845
  for (const r of plan.registered) {
117556
117846
  if (r.verdict !== "remove")
117557
117847
  continue;
@@ -117623,6 +117913,24 @@ function purgeTrash(paths) {
117623
117913
  function defaultRoots() {
117624
117914
  return [join102(homedir55(), "code")];
117625
117915
  }
117916
+ function defaultTaskTreeRoots() {
117917
+ const agentsDir = join102(homedir55(), ".switchroom", "agents");
117918
+ let names;
117919
+ try {
117920
+ names = readdirSync33(agentsDir);
117921
+ } catch {
117922
+ return [];
117923
+ }
117924
+ const roots = [];
117925
+ for (const name of names.sort()) {
117926
+ for (const sub of ["home/work", "home/workspace"]) {
117927
+ const r = join102(agentsDir, name, sub);
117928
+ if (existsSync100(r))
117929
+ roots.push(r);
117930
+ }
117931
+ }
117932
+ return roots;
117933
+ }
117626
117934
 
117627
117935
  // src/cli/worktree.ts
117628
117936
  function registerWorktreeCommand(program3) {
@@ -117756,10 +118064,13 @@ Skipped ${result.skipped.length} stale worktree(s) (kept for safety):`));
117756
118064
  }
117757
118065
  }
117758
118066
  });
117759
- worktree.command("gc").description(`Reclaim dev worktrees that outlived their PR.
118067
+ worktree.command("gc").description(`Reclaim dev worktrees that outlived their PR, and per-agent task trees.
117760
118068
  ` + `Dry-run by default; pass --yes to act. Quarantines orphaned worktree
117761
118069
  ` + `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) => {
118070
+ ` + `PR is MERGED and whose tree is clean. Also sweeps per-agent home/work
118071
+ ` + `task trees (all shapes) that are clean + pushed + idle + merged/closed;
118072
+ ` + `dead-but-dirty/unpushed trees are surfaced and reclaimed only with
118073
+ ` + "--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
118074
  if (opts.purgeTrash) {
117764
118075
  const parsed = Number(opts.olderThan);
117765
118076
  const olderThan = isNaN(parsed) ? 14 : parsed;
@@ -117790,9 +118101,14 @@ Re-run with --yes to delete.`));
117790
118101
  return;
117791
118102
  }
117792
118103
  const roots = opts.root.length > 0 ? opts.root : defaultRoots();
118104
+ const taskTreeRoots = opts.taskRoots === false ? [] : opts.taskRoot.length > 0 ? opts.taskRoot : defaultTaskTreeRoots();
118105
+ const parsedIdle = Number(opts.idleDays);
118106
+ const idleDays = Number.isFinite(parsedIdle) && parsedIdle >= 0 ? parsedIdle : 14;
117793
118107
  const dateStamp = new Date().toISOString().slice(0, 10);
117794
- const plan = planGc(roots, { dateStamp });
118108
+ const plan = planGc(roots, { dateStamp, idleDays, escapeHatch: opts.reclaimDirty }, taskTreeRoots);
117795
118109
  const toRemove = plan.registered.filter((r) => r.verdict === "remove");
118110
+ const taskReap = plan.taskTrees.filter((t) => t.willAct);
118111
+ const taskEscape = plan.taskTrees.filter((t) => !t.willAct && t.idle && (t.verdict === "skip-dirty" || t.verdict === "skip-unpushed"));
117796
118112
  if (!opts.yes) {
117797
118113
  if (opts.json) {
117798
118114
  console.log(JSON.stringify({ dryRun: true, plan }));
@@ -117814,21 +118130,45 @@ Kept (${skips.length}):`));
117814
118130
  for (const r of skips)
117815
118131
  console.log(source_default.dim(` ${r.verdict.replace("skip-", "")}: ${r.path} [${r.branch}] (pr=${r.prSignal})`));
117816
118132
  }
118133
+ if (taskTreeRoots.length) {
118134
+ const label = opts.reclaimDirty ? "reclaim (reap + escape-hatch)" : "reap (clean + pushed + idle + merged/closed)";
118135
+ console.log(`
118136
+ Task trees to ${label}: ${source_default.bold(taskReap.length)}`);
118137
+ for (const t of taskReap)
118138
+ console.log(` ${t.dir} ${source_default.dim(`[${t.branch ?? "detached"}] ${t.shape} ${t.verdict} \u2192 ${t.dest}`)}`);
118139
+ if (taskEscape.length) {
118140
+ console.log(source_default.yellow(`
118141
+ Dead-but-dirty/unpushed task trees kept (${taskEscape.length}) \u2014 reclaim with --reclaim-dirty:`));
118142
+ for (const t of taskEscape)
118143
+ console.log(source_default.dim(` ${t.verdict.replace("skip-", "")}: ${t.dir} [${t.branch ?? "detached"}] ${t.shape}`));
118144
+ }
118145
+ const taskKept = plan.taskTrees.filter((t) => !t.willAct && !(t.idle && (t.verdict === "skip-dirty" || t.verdict === "skip-unpushed")) && t.verdict !== "skip-protected");
118146
+ if (taskKept.length) {
118147
+ console.log(source_default.dim(`
118148
+ Other task trees kept (${taskKept.length}):`));
118149
+ for (const t of taskKept)
118150
+ console.log(source_default.dim(` ${t.verdict.replace("skip-", "")}: ${t.dir} [${t.branch ?? "detached"}] (pr=${t.prSignal})`));
118151
+ }
118152
+ }
117817
118153
  if (plan.skipped.length) {
117818
118154
  console.log(source_default.dim(`
117819
118155
  Ignored non-switchroom dirs: ${plan.skipped.length}`));
117820
118156
  }
117821
118157
  console.log(source_default.dim(`
117822
- Re-run with --yes to act. Orphans are MOVED to the trash dir, not deleted.`));
118158
+ Re-run with --yes to act. Reclaimed trees are MOVED to the trash dir, not deleted.`));
117823
118159
  return;
117824
118160
  }
117825
118161
  const result = applyGc(plan);
117826
118162
  if (opts.json) {
117827
118163
  console.log(JSON.stringify(result));
117828
118164
  } else {
117829
- console.log(source_default.green(`Quarantined ${result.quarantined.length} orphan(s) \u2192 ${plan.trashDir}`));
118165
+ console.log(source_default.green(`Quarantined ${result.quarantined.length} dir(s) \u2192 ${plan.trashDir}`));
117830
118166
  console.log(source_default.green(`Removed ${result.removed.length} merged worktree(s); deleted ${result.branchesDeleted.length} branch(es).`));
117831
118167
  console.log(source_default.dim(`Pruned metadata in ${result.pruned.length} repo(s).`));
118168
+ if (taskEscape.length && !opts.reclaimDirty) {
118169
+ console.log(source_default.yellow(`
118170
+ ${taskEscape.length} dead-but-dirty/unpushed task tree(s) were kept. Reclaim reversibly with --reclaim-dirty.`));
118171
+ }
117832
118172
  for (const e of result.errors)
117833
118173
  console.warn(source_default.yellow(e));
117834
118174
  console.log(source_default.dim(`
@@ -120405,6 +120745,7 @@ var import_yaml22 = __toESM(require_dist(), 1);
120405
120745
  // src/config/overlay-writer.ts
120406
120746
  init_paths();
120407
120747
  import {
120748
+ chownSync as chownSync11,
120408
120749
  closeSync as closeSync18,
120409
120750
  existsSync as existsSync103,
120410
120751
  fsyncSync as fsyncSync8,
@@ -120481,6 +120822,21 @@ function withAgentLock(paths, fn) {
120481
120822
  } catch {}
120482
120823
  }
120483
120824
  }
120825
+ var overlayWriterRuntime = {
120826
+ geteuid: () => process.geteuid?.(),
120827
+ chown: (path10, uid, gid) => chownSync11(path10, uid, gid)
120828
+ };
120829
+ function alignStagedOwnerToDir(stagingPath, targetDir) {
120830
+ try {
120831
+ const euid = overlayWriterRuntime.geteuid();
120832
+ if (euid === undefined)
120833
+ return;
120834
+ const dirStat = statSync54(targetDir);
120835
+ if (dirStat.uid === euid)
120836
+ return;
120837
+ overlayWriterRuntime.chown(stagingPath, dirStat.uid, dirStat.gid);
120838
+ } catch {}
120839
+ }
120484
120840
  function writeOverlayEntry(agent, slug, yamlText, opts = {}) {
120485
120841
  const paths = overlayPathsFor(agent, opts);
120486
120842
  return withAgentLock(paths, () => {
@@ -120494,6 +120850,7 @@ function writeOverlayEntry(agent, slug, yamlText, opts = {}) {
120494
120850
  } finally {
120495
120851
  closeSync18(fd);
120496
120852
  }
120853
+ alignStagedOwnerToDir(stagingPath, paths.scheduleDir);
120497
120854
  renameSync27(stagingPath, finalPath);
120498
120855
  return finalPath;
120499
120856
  });
@@ -120511,6 +120868,7 @@ function writeSkillsOverlayEntry(agent, slug, yamlText, opts = {}) {
120511
120868
  } finally {
120512
120869
  closeSync18(fd);
120513
120870
  }
120871
+ alignStagedOwnerToDir(stagingPath, paths.skillsDir);
120514
120872
  renameSync27(stagingPath, finalPath);
120515
120873
  return finalPath;
120516
120874
  });
@@ -123985,7 +124343,7 @@ init_source();
123985
124343
  init_helpers();
123986
124344
  init_operator_uid();
123987
124345
  init_compose();
123988
- import { chownSync as chownSync11, existsSync as existsSync112, mkdirSync as mkdirSync65, writeFileSync as writeFileSync48, copyFileSync as copyFileSync15 } from "node:fs";
124346
+ import { chownSync as chownSync12, existsSync as existsSync112, mkdirSync as mkdirSync65, writeFileSync as writeFileSync48, copyFileSync as copyFileSync15 } from "node:fs";
123989
124347
  import { homedir as homedir62 } from "node:os";
123990
124348
  import { join as join115 } from "node:path";
123991
124349
  import { spawnSync as spawnSync27 } from "node:child_process";
@@ -124165,10 +124523,10 @@ async function doInstall2(opts, program3) {
124165
124523
  writeFileSync48(composePath, yaml, "utf8");
124166
124524
  try {
124167
124525
  if (typeof process.geteuid === "function" && process.geteuid() === 0) {
124168
- chownSync11(dir, operatorUid, operatorUid);
124169
- chownSync11(composePath, operatorUid, operatorUid);
124526
+ chownSync12(dir, operatorUid, operatorUid);
124527
+ chownSync12(composePath, operatorUid, operatorUid);
124170
124528
  if (bak)
124171
- chownSync11(bak, operatorUid, operatorUid);
124529
+ chownSync12(bak, operatorUid, operatorUid);
124172
124530
  }
124173
124531
  } catch {}
124174
124532
  console.log(source_default.green(` \u2713 Wrote ${composePath}`));