@bivy/bivy 0.21.2-staging.1 → 0.21.2-staging.11

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 (127) hide show
  1. package/bin/archive-install.mjs +150 -0
  2. package/bin/bivy.mjs +81 -11
  3. package/bin/cli-commands.mjs +5 -4
  4. package/bin/guides/add-an-agent.md +39 -0
  5. package/bin/guides/talk-to-the-user.md +13 -0
  6. package/bin/prune-sessions.mjs +46 -14
  7. package/dist/agent-cli.js +91 -63
  8. package/dist/agents/claude-code/runtime.js +19 -10
  9. package/dist/apps/styles.css +6 -50
  10. package/dist/automation-reseal.js +2 -0
  11. package/dist/node-config.js +18 -0
  12. package/dist/node-update.js +26 -1
  13. package/dist/plugin-cli.js +3 -4
  14. package/dist/plugin-sdk/manifest.js +19 -11
  15. package/dist/redact.js +23 -1
  16. package/dist/runtime/cli-parsers.js +18 -1
  17. package/dist/runtime/index.js +71 -41
  18. package/dist/secrets-cli.js +24 -1
  19. package/dist/secrets.js +38 -0
  20. package/dist/server.js +51 -36
  21. package/dist/session/agent-questions.js +42 -2
  22. package/dist/worktree.js +17 -0
  23. package/package.json +1 -1
  24. package/web/assets/AutomationsView-DdynRz7Z.js +51 -0
  25. package/web/assets/{ChangesCard-BQ3lwCYn.js → ChangesCard-BKMC_psO.js} +3 -3
  26. package/web/assets/{LibraryView-CqY8xTAh.js → LibraryView-DP15D9C2.js} +1 -1
  27. package/web/assets/ProviderConnect-CTmnEedT.js +1 -0
  28. package/web/assets/SessionList-B3ypZDqf.js +1 -0
  29. package/web/assets/{Settings-Bpd2LYcT.js → Settings-V4Kt_5_L.js} +8 -8
  30. package/web/assets/{Sheet-B0HjJdGp.js → Sheet-DTaiHzIs.js} +1 -1
  31. package/web/assets/{Terminal-DTbs_kzR.js → Terminal-BLmaxri5.js} +5 -5
  32. package/web/assets/{VoiceSettings-5stXm8Rh.js → VoiceSettings-BHsSLcee.js} +1 -1
  33. package/web/assets/{abnfDiagram-VCTEODGH-BzxGthCK.js → abnfDiagram-VCTEODGH-QbtUqhRb.js} +1 -1
  34. package/web/assets/architecture-7GRP2DOG-DiaeDSoh.js +1 -0
  35. package/web/assets/{architectureDiagram-5GKGNRK7-BUKaHpyZ.js → architectureDiagram-5GKGNRK7-Cl6WlvIW.js} +1 -1
  36. package/web/assets/{blockDiagram-I7D4REHJ-BBCtk8Uh.js → blockDiagram-I7D4REHJ-C88c1PEQ.js} +1 -1
  37. package/web/assets/{c4Diagram-7LVT6UL2-DY29eJER.js → c4Diagram-7LVT6UL2-zDGGT6qM.js} +1 -1
  38. package/web/assets/channel-BPU-KLWx.js +1 -0
  39. package/web/assets/{chunk-4HAMMTFA-Dwwu-TeK.js → chunk-4HAMMTFA-CQnMoxAB.js} +1 -1
  40. package/web/assets/{chunk-75Z2AOVW-CIRWwn6Y.js → chunk-75Z2AOVW-Cs6qWmdz.js} +1 -1
  41. package/web/assets/{chunk-DU6HZSFF-BjC0SliR.js → chunk-DU6HZSFF-DzJRFHiz.js} +1 -1
  42. package/web/assets/{chunk-F27PBJKO-wa_cUFjb.js → chunk-F27PBJKO-OZCska6x.js} +1 -1
  43. package/web/assets/{chunk-GMAD6QVW-ldP8bvnz.js → chunk-GMAD6QVW-B_8pNlzK.js} +1 -1
  44. package/web/assets/{chunk-GVQU2GXP-BiIt8KQk.js → chunk-GVQU2GXP-4y2rTA33.js} +1 -1
  45. package/web/assets/{chunk-IMKFNOWR-Dy7EkDid.js → chunk-IMKFNOWR-BKGXOlEy.js} +1 -1
  46. package/web/assets/{chunk-L3NEJ4N5-DrtR_CiV.js → chunk-L3NEJ4N5-Brxn-p9g.js} +1 -1
  47. package/web/assets/{chunk-OSK3NFVY-Dnvo5XKB.js → chunk-OSK3NFVY-DwpuG7o9.js} +1 -1
  48. package/web/assets/{chunk-P2QGCYS3-CdZdfs9H.js → chunk-P2QGCYS3-CCpNvmac.js} +1 -1
  49. package/web/assets/{chunk-POPQ4Y6H-DUdouJMc.js → chunk-POPQ4Y6H-CfTwiW4G.js} +1 -1
  50. package/web/assets/{chunk-PWAF6VOD-BMGGIPTG.js → chunk-PWAF6VOD-91WR941x.js} +1 -1
  51. package/web/assets/{chunk-SHT3W25Y-Mb0aPPgh.js → chunk-SHT3W25Y-Ddcd2kTs.js} +1 -1
  52. package/web/assets/{chunk-SVP7TREG-C5eicczL.js → chunk-SVP7TREG-DibOBVM1.js} +1 -1
  53. package/web/assets/{chunk-TICWLB2K-CoKD47HN.js → chunk-TICWLB2K-DKZ7iFXW.js} +1 -1
  54. package/web/assets/classDiagram-ZZMXUADV-CCh6R8Ty.js +1 -0
  55. package/web/assets/classDiagram-v2-VYDZK3BY-CCh6R8Ty.js +1 -0
  56. package/web/assets/{cynefin-OW5HDTMX-BTzJNWu1.js → cynefin-OW5HDTMX-C3vikVDV.js} +1 -1
  57. package/web/assets/{cynefinDiagram-5FMLGOSQ-Dd7g6wFx.js → cynefinDiagram-5FMLGOSQ-BD720KJ2.js} +1 -1
  58. package/web/assets/{dagre-GXQ25YYZ-D3zVJkwr.js → dagre-GXQ25YYZ-D_Yh2THu.js} +1 -1
  59. package/web/assets/{diagram-S7CK7UJ4-S9vzG5Iu.js → diagram-S7CK7UJ4-DM39wEUv.js} +1 -1
  60. package/web/assets/{diagram-UQ7AKVKN-Csc1xBzG.js → diagram-UQ7AKVKN-CUw8UoIl.js} +1 -1
  61. package/web/assets/{diagram-VSXAHHWV-Bigb1Thn.js → diagram-VSXAHHWV-mcdz_Uo6.js} +1 -1
  62. package/web/assets/{diagram-VX7I27RA-D-FXM6TU.js → diagram-VX7I27RA-BaNPXU0X.js} +1 -1
  63. package/web/assets/{diagram-Z3DM3KII-D8hJhbd2.js → diagram-Z3DM3KII-DfkwhJNu.js} +1 -1
  64. package/web/assets/{ebnfDiagram-PWID7BFC-DXvgoAAx.js → ebnfDiagram-PWID7BFC-DVWHyztc.js} +1 -1
  65. package/web/assets/{erDiagram-RLTQ6QDP-ZBHJikuC.js → erDiagram-RLTQ6QDP-HHGMhQu-.js} +1 -1
  66. package/web/assets/eventmodeling-NTZA5JFV-Ds3fojiM.js +1 -0
  67. package/web/assets/flowDiagram-HODETNUW-DU8X798l.js +1 -0
  68. package/web/assets/{ganttDiagram-EL5Y4UJY-CouBpzDS.js → ganttDiagram-EL5Y4UJY-wpA-VyQv.js} +1 -1
  69. package/web/assets/{gitGraph-4MIJSDKK-D_EH-2tK.js → gitGraph-4MIJSDKK-aKMK4MF-.js} +1 -1
  70. package/web/assets/{gitGraphDiagram-WWUBYQGX-CiYi0gV_.js → gitGraphDiagram-WWUBYQGX-DTlc_fKF.js} +1 -1
  71. package/web/assets/{index-BqTUAMUo.js → index-CUeorpuq.js} +2 -2
  72. package/web/assets/index-PhGoZUBH.css +1 -0
  73. package/web/assets/{info-A6RAGUB7-CqxR-vqg.js → info-A6RAGUB7-Cv7R3QMt.js} +1 -1
  74. package/web/assets/{infoDiagram-27XIBGKW-CKjwiYqh.js → infoDiagram-27XIBGKW-DQ8M1hlm.js} +1 -1
  75. package/web/assets/{ishikawaDiagram-5VMMS53U-OKhyRTlP.js → ishikawaDiagram-5VMMS53U-CTph8eqY.js} +1 -1
  76. package/web/assets/{journeyDiagram-3NMN7TZE-DjpB5OfX.js → journeyDiagram-3NMN7TZE-Drb7u1Nc.js} +1 -1
  77. package/web/assets/{kanban-definition-UXKFOSKX-B8Xzxar1.js → kanban-definition-UXKFOSKX-kvg2Aspv.js} +1 -1
  78. package/web/assets/{mermaid-parser.core-DiDXDDZp.js → mermaid-parser.core-CdLewUsw.js} +3 -3
  79. package/web/assets/{mermaid.core-BcCoKrIv.js → mermaid.core-CT3K8cBa.js} +4 -4
  80. package/web/assets/{mindmap-definition-YA3MSWOX-B37CHXfT.js → mindmap-definition-YA3MSWOX-BjmfoV0m.js} +1 -1
  81. package/web/assets/mount-B3Wovsbl.js +36 -0
  82. package/web/assets/{packet-AYTQ26CC-DADMJ2Yt.js → packet-AYTQ26CC-DGvzP9jp.js} +1 -1
  83. package/web/assets/{pegDiagram-XKGWAZYB-BwXOeSnI.js → pegDiagram-XKGWAZYB-BiC2EyGL.js} +1 -1
  84. package/web/assets/{pie-WAS4IAKB-D9hNG31t.js → pie-WAS4IAKB-q7EN-Jqj.js} +1 -1
  85. package/web/assets/{pieDiagram-E7YTZNPT-BjQhU1Mo.js → pieDiagram-E7YTZNPT-czADSo8s.js} +1 -1
  86. package/web/assets/{quadrantDiagram-AXDQQJYC-BUmmIEMt.js → quadrantDiagram-AXDQQJYC-3gtQRACG.js} +1 -1
  87. package/web/assets/{radar-RG4KPBEZ-DW58Z5Mj.js → radar-RG4KPBEZ-DCdM3lNO.js} +1 -1
  88. package/web/assets/{railroad-74A4TZTK-Bxb0GBL6.js → railroad-74A4TZTK-CqRLEVSm.js} +1 -1
  89. package/web/assets/railroad-abnf-HS5TGJTU-BUseZVfJ.js +1 -0
  90. package/web/assets/railroad-ebnf-LZEXJU2U-Bvq_27zd.js +1 -0
  91. package/web/assets/railroad-peg-WCYAUIDC-RMMi8Jbw.js +1 -0
  92. package/web/assets/{railroadDiagram-O6MQD6OU-CqOaqCxM.js → railroadDiagram-O6MQD6OU-CLZ30gwQ.js} +1 -1
  93. package/web/assets/{requirementDiagram-BXWQKSXE-B2Mihs8T.js → requirementDiagram-BXWQKSXE-lpSX7i6e.js} +1 -1
  94. package/web/assets/{sankeyDiagram-P5KCCOFB-DI2cV18A.js → sankeyDiagram-P5KCCOFB-Ce2dosbC.js} +1 -1
  95. package/web/assets/{sequenceDiagram-WJ2MYXX4-Ct6KM5nc.js → sequenceDiagram-WJ2MYXX4-CuEkX8By.js} +1 -1
  96. package/web/assets/{settingsRoute-DiYTWwf-.js → settingsRoute-AXRXVGb_.js} +1 -1
  97. package/web/assets/{stateDiagram-D77RDMKH-0z6WQwlg.js → stateDiagram-D77RDMKH-DexcaE4u.js} +1 -1
  98. package/web/assets/stateDiagram-v2-MP3YSRHH-C8nB07ri.js +1 -0
  99. package/web/assets/{swimlanes-42K2YHIH-mLnDFml4.js → swimlanes-42K2YHIH-BCXmgqmT.js} +1 -1
  100. package/web/assets/swimlanesDiagram-VR7AAH4N-B2JbsdGO.js +8 -0
  101. package/web/assets/{timeline-definition-24CTP7MA-CSZZvZUD.js → timeline-definition-24CTP7MA-_FwHQA_5.js} +1 -1
  102. package/web/assets/{treeView-Q6P3EWNA-nkM4Mhz8.js → treeView-Q6P3EWNA-BBOBdfSv.js} +1 -1
  103. package/web/assets/{treemap-WGGIJYW6-D0q8GQM0.js → treemap-WGGIJYW6-CSsG-a9v.js} +1 -1
  104. package/web/assets/useStore-BGuS31a3.js +16 -0
  105. package/web/assets/{vennDiagram-4TSXK5OY-DMh3_TDy.js → vennDiagram-4TSXK5OY-BWHgnsaP.js} +1 -1
  106. package/web/assets/{wardley-WFR3VGLG-C1n_QCAB.js → wardley-WFR3VGLG-YhQuPDiS.js} +1 -1
  107. package/web/assets/{wardleyDiagram-VM6X3IG4-C6ap89Zw.js → wardleyDiagram-VM6X3IG4-8OaNtyFJ.js} +1 -1
  108. package/web/assets/{xychartDiagram-S5SC5T6Z-B27MAKKy.js → xychartDiagram-S5SC5T6Z-D-_svy0O.js} +1 -1
  109. package/web/index.html +2 -2
  110. package/web/sw.js +1 -1
  111. package/web/assets/AutomationsView-chlf1Cpr.js +0 -51
  112. package/web/assets/ProviderConnect-BC-EWUjN.js +0 -1
  113. package/web/assets/SessionList-BG0cvQfe.js +0 -1
  114. package/web/assets/architecture-7GRP2DOG-jzSX7bLG.js +0 -1
  115. package/web/assets/channel-C1pvpPyo.js +0 -1
  116. package/web/assets/classDiagram-ZZMXUADV-LoptuGLH.js +0 -1
  117. package/web/assets/classDiagram-v2-VYDZK3BY-LoptuGLH.js +0 -1
  118. package/web/assets/eventmodeling-NTZA5JFV-QziTZg_i.js +0 -1
  119. package/web/assets/flowDiagram-HODETNUW-hMB4P6zf.js +0 -1
  120. package/web/assets/index-CtJDDeFG.css +0 -1
  121. package/web/assets/mount-CyPeuIDQ.js +0 -36
  122. package/web/assets/railroad-abnf-HS5TGJTU-D71MxTg_.js +0 -1
  123. package/web/assets/railroad-ebnf-LZEXJU2U-DuU1fM8N.js +0 -1
  124. package/web/assets/railroad-peg-WCYAUIDC-B0-xZffZ.js +0 -1
  125. package/web/assets/stateDiagram-v2-MP3YSRHH-CrCsCpWo.js +0 -1
  126. package/web/assets/swimlanesDiagram-VR7AAH4N-CRl9QwhL.js +0 -8
  127. package/web/assets/useStore-0Cz83n1l.js +0 -16
@@ -0,0 +1,150 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ /**
4
+ * Install and update from the self-contained GitHub release archives
5
+ * (scripts/release-archives.mjs). Layout under the install root
6
+ * (default ~/.local/share/bivy):
7
+ *
8
+ * versions/<version>-<target>/ one extracted archive each
9
+ * current -> versions/… the active one, swapped atomically
10
+ *
11
+ * The launcher (current/bin/bivy) passes its unresolved `current` path as
12
+ * BIVY_ARCHIVE_ROOT, so a service unit or shim written through it keeps
13
+ * working after `current` moves to a new version.
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import crypto from "node:crypto";
18
+ import { spawnSync } from "node:child_process";
19
+
20
+ export const RELEASES_URL = "https://github.com/bivysh/bivy/releases";
21
+ export const ARCHIVE_INFO = "bivy-archive.json";
22
+ /** Versions kept on disk: the active one and the one before it, for rollback. */
23
+ const KEEP = 2;
24
+
25
+ /** "linux-x64" etc.: the archive target for this machine, or undefined. */
26
+ export function archiveTarget(platform = process.platform, arch = process.arch) {
27
+ return ["linux", "darwin"].includes(platform) && ["x64", "arm64"].includes(arch) ? `${platform}-${arch}` : undefined;
28
+ }
29
+
30
+ /** Where a channel's manifest and archives live. `latest` is the newest stable release. */
31
+ export function channelUrls(channel, base = process.env.BIVY_RELEASES_URL || RELEASES_URL) {
32
+ const dir = channel === "latest" ? `${base}/latest/download` : `${base}/download/${encodeURIComponent(channel)}`;
33
+ return {
34
+ manifest: `${dir}/manifest.json`,
35
+ // Stable archives are fetched from their own tag, so a release published
36
+ // mid-update can't swap the file under a manifest read a moment earlier.
37
+ archive: (manifest, file) => (channel === "latest" ? `${base}/download/v${manifest.version}/${file}` : `${dir}/${file}`),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * The archive install this CLI runs from, or null. `archiveRoot` is the
43
+ * launcher's BIVY_ARCHIVE_ROOT; it only counts when it really is the install
44
+ * holding `appRoot`, so a value inherited into some other checkout is ignored.
45
+ */
46
+ export function archiveInstall(archiveRoot, appRoot) {
47
+ if (!archiveRoot) return null;
48
+ try {
49
+ if (fs.realpathSync(path.join(archiveRoot, "app")) !== fs.realpathSync(appRoot)) return null;
50
+ const info = JSON.parse(fs.readFileSync(path.join(archiveRoot, ARCHIVE_INFO), "utf8"));
51
+ // Run as current/bin/bivy or as versions/<v>/bin/bivy, the install root is
52
+ // the same, and `current` is the path worth writing down.
53
+ const parent = path.dirname(archiveRoot);
54
+ const installRoot = path.basename(archiveRoot) === "current" ? parent : path.basename(parent) === "versions" ? path.dirname(parent) : undefined;
55
+ // An archive unpacked anywhere else runs, but has no install to update.
56
+ const current = path.join(installRoot ?? "", "current");
57
+ if (!installRoot || !fs.existsSync(current)) return null;
58
+ // Run through `current`, write `current`; a specific version (say, a new one
59
+ // being checked before the swap) stays itself.
60
+ const root = fs.realpathSync(archiveRoot) === fs.realpathSync(current) ? current : archiveRoot;
61
+ return { root, installRoot, version: info.version, target: info.target };
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export async function fetchManifest(url, fetchImpl = fetch) {
68
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(15_000) });
69
+ if (!res.ok) throw new Error(`Could not read the release manifest (${res.status} from ${url})`);
70
+ const manifest = await res.json();
71
+ if (typeof manifest?.version !== "string" || !manifest.archives) throw new Error(`${url} is not a Bivy release manifest`);
72
+ return manifest;
73
+ }
74
+
75
+ /**
76
+ * Fetch a large file with curl. Node's fetch can abort the process on an
77
+ * assertion when a server closes the connection while the body is paused
78
+ * for backpressure; curl is on every machine install.sh runs on.
79
+ */
80
+ export function curlDownload(url, dest) {
81
+ const res = spawnSync("curl", ["-fsSL", "--retry", "2", "-o", dest, url], { encoding: "utf8" });
82
+ if (res.status !== 0) throw new Error(`Could not download ${url}: ${(res.stderr || "").trim() || `curl exit ${res.status}`}`);
83
+ }
84
+
85
+ /**
86
+ * Download, verify and extract one archive into versions/. Returns its
87
+ * directory. Nothing outside versions/ changes, so a failure here leaves the
88
+ * running install untouched.
89
+ */
90
+ export async function stageArchive({ installRoot, manifest, target, archiveUrl, download = curlDownload }) {
91
+ const entry = manifest.archives[target];
92
+ if (!entry) throw new Error(`Bivy ${manifest.version} has no archive for ${target}`);
93
+ const versions = path.join(installRoot, "versions");
94
+ fs.mkdirSync(versions, { recursive: true });
95
+ const work = fs.mkdtempSync(path.join(versions, ".stage-"));
96
+ try {
97
+ const tarball = path.join(work, entry.file);
98
+ await download(archiveUrl(manifest, entry.file), tarball);
99
+ const sha256 = crypto.createHash("sha256").update(fs.readFileSync(tarball)).digest("hex");
100
+ if (sha256 !== entry.sha256) throw new Error(`${entry.file} failed verification: sha256 ${sha256}, the manifest says ${entry.sha256}`);
101
+
102
+ const tar = spawnSync("tar", ["-xzf", tarball, "-C", work], { encoding: "utf8" });
103
+ if (tar.status !== 0) throw new Error(`Could not extract ${entry.file}: ${tar.stderr}`);
104
+ const extracted = path.join(work, entry.file.replace(/\.tar\.gz$/, ""));
105
+ const info = JSON.parse(fs.readFileSync(path.join(extracted, ARCHIVE_INFO), "utf8"));
106
+ if (info.version !== manifest.version || info.target !== target) throw new Error(`${entry.file} holds ${info.version} ${info.target}, not ${manifest.version} ${target}`);
107
+
108
+ const dest = path.join(versions, `${manifest.version}-${target}`);
109
+ // Re-staging a version (a reinstall) must not replace the directory `current`
110
+ // points at while it runs; move it aside first and drop it afterwards.
111
+ const aside = fs.existsSync(dest) ? `${dest}.old-${process.pid}` : undefined;
112
+ if (aside) fs.renameSync(dest, aside);
113
+ fs.renameSync(extracted, dest);
114
+ if (aside) fs.rmSync(aside, { recursive: true, force: true });
115
+ return dest;
116
+ } finally {
117
+ fs.rmSync(work, { recursive: true, force: true });
118
+ }
119
+ }
120
+
121
+ /** The version directory `current` points at, or undefined. */
122
+ export function activeVersion(installRoot) {
123
+ try {
124
+ return path.resolve(installRoot, fs.readlinkSync(path.join(installRoot, "current")));
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ }
129
+
130
+ /** Point `current` at a version directory atomically. Returns what it pointed at before. */
131
+ export function activate(installRoot, versionDir) {
132
+ const previous = activeVersion(installRoot);
133
+ const link = path.join(installRoot, "current");
134
+ const next = `${link}.next-${process.pid}`;
135
+ fs.rmSync(next, { force: true });
136
+ fs.symlinkSync(path.relative(installRoot, versionDir), next);
137
+ fs.renameSync(next, link);
138
+ return previous;
139
+ }
140
+
141
+ /** Remove version directories other than the newest KEEP, never the active one. */
142
+ export function prune(installRoot, keep = []) {
143
+ const versions = path.join(installRoot, "versions");
144
+ const active = activeVersion(installRoot);
145
+ const kept = new Set([...new Set([active, ...keep].filter(Boolean).map((p) => path.resolve(p)))].slice(0, KEEP));
146
+ for (const name of fs.readdirSync(versions)) {
147
+ const dir = path.join(versions, name);
148
+ if (!kept.has(dir)) fs.rmSync(dir, { recursive: true, force: true });
149
+ }
150
+ }
package/bin/bivy.mjs CHANGED
@@ -45,11 +45,16 @@ import { parseTailscaleStatus, serveConflict, serveFailureHint, serveOffArgs, se
45
45
  import { resolveAttachSessionId } from "./attach-session-id.mjs";
46
46
  import { detectInstallKind as classifyInstallKind, managedInstall, npmGlobalPrefix } from "./install-kind.mjs";
47
47
  import { hasConfiguredService as configuredServiceExists } from "./service-state.mjs";
48
+ import { activate, activeVersion, archiveInstall, archiveTarget, channelUrls, fetchManifest, prune, stageArchive } from "./archive-install.mjs";
48
49
  import { COMMANDS, EXIT, cliError, completionWords, describeCli, describeCommand, describeTools, renderHelp, resolveCommand, subcommandTable, suggestCommands, toolArgv, wantsJson } from "./cli-commands.mjs";
49
50
 
50
- const selfScript = fileURLToPath(import.meta.url);
51
- const __dirname = path.dirname(selfScript);
52
- const repoRoot = path.resolve(__dirname, "..");
51
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
52
+ // A GitHub release archive install runs through its launcher, which names the
53
+ // install's `current` path. Paths written to disk (service unit, shims) use it,
54
+ // so they stay valid when an update points `current` at a new version.
55
+ const archive = archiveInstall(process.env.BIVY_ARCHIVE_ROOT, path.resolve(__dirname, ".."));
56
+ const repoRoot = archive ? path.join(archive.root, "app") : path.resolve(__dirname, "..");
57
+ const selfScript = path.join(repoRoot, "bin", "bivy.mjs");
53
58
  // Mutable node state (config, relay E2E keys, sessions, logs) must live in a
54
59
  // stable, user-owned directory that survives reinstalls. Pick it by install type:
55
60
  // - BIVY_DATA_DIR set -> honor it (explicit override)
@@ -77,10 +82,11 @@ process.env.BIVY_DATA_DIR = appDir;
77
82
  // - "git" dev checkout (has .git)
78
83
  // - "npx" ephemeral `npx bivy` run (repoRoot under an npm _npx cache)
79
84
  // - "npm-global" `npm i -g @bivy/bivy` (repoRoot is below node_modules/@bivy)
85
+ // - "archive" a GitHub release archive under ~/.local/share/bivy (install.sh)
80
86
  // - "packaged" install.sh tarball tree (user-owned, self-preserving)
81
87
  // - "managed" a system package (the AUR's `bivy`), updated by its manager
82
88
  function detectInstallKind() {
83
- return classifyInstallKind(repoRoot);
89
+ return archive ? "archive" : classifyInstallKind(repoRoot);
84
90
  }
85
91
  const cliConfigPath = path.join(appDir, "cli.json");
86
92
  const canonicalConfigPath = path.join(appDir, "config.yaml");
@@ -185,7 +191,7 @@ const mcpProxyEntry = path.join(repoRoot, packaged ? "dist/harness/mcp-proxy-cli
185
191
  const mcpServeEntry = path.join(repoRoot, packaged ? "dist/harness/mcp-serve-cli.js" : "src/harness/mcp-serve-cli.ts");
186
192
  const qrEntry = path.join(repoRoot, "public", "qr.js");
187
193
  const tsxCli = packaged ? "" : path.join(repoRoot, "node_modules", "tsx", "dist", "cli.mjs");
188
- const nodeBin = process.execPath;
194
+ const nodeBin = archive ? path.join(archive.root, "runtime", "bin", "node") : process.execPath;
189
195
  const nodeScriptArgs = (entry) => (tsxCli ? [tsxCli, entry] : [entry]);
190
196
 
191
197
  // Resolve the baked-in hosted endpoints (app/relay/client URLs). Imported lazily
@@ -230,7 +236,7 @@ function commandPath(extraPath = "") {
230
236
  process.env.PATH || "",
231
237
  // npm global executables normally live beside the Node binary (including
232
238
  // nvm/fnm installs). Services need this even without shell initialization.
233
- path.dirname(process.execPath),
239
+ path.dirname(nodeBin),
234
240
  ].filter(Boolean);
235
241
  return [...new Set(parts.flatMap((part) => String(part).split(path.delimiter)).filter(Boolean))].join(path.delimiter);
236
242
  }
@@ -2679,6 +2685,7 @@ finish: Bivy already pushes when a turn ends while the user is away. --json prin
2679
2685
  // Blocks until the user answers (printing the answer), or with --async returns
2680
2686
  // an id to read later with `bivy ask status|wait <id>`.
2681
2687
  const ASK_USAGE = `Usage: bivy ask "<question>" [--option <label>]… [--multi] [--header <label>] [--timeout 10m] [--async] [--json]
2688
+ bivy ask "<question>" --secret <NAME> [--url <https://…>] [--timeout 10m] [--json]
2682
2689
  bivy ask status <id> [--json]
2683
2690
  bivy ask wait <id> [--timeout 10m] [--json]`;
2684
2691
 
@@ -2691,6 +2698,11 @@ Ask the user a question in the chat and wait for the answer. Their devices get a
2691
2698
  several with --multi) or write their own; without options they type an answer.
2692
2699
  --header is the short label on the card.
2693
2700
 
2701
+ --secret NAME asks for an API key, token or password instead: the card takes a
2702
+ hidden value, the node stores it in its vault as NAME, and this prints only
2703
+ secret://NAME, never the value. --url is where the user finds it. Use it with
2704
+ 'bivy secrets exec --env NAME -- <command>'.
2705
+
2694
2706
  Prints the answer and exits 0. Exits 1 if the user dismissed the question and 5
2695
2707
  if --timeout (default 10m, up to 24h) passes first; the card closes then.
2696
2708
  --async returns the question's id at once; 'bivy ask wait <id>' blocks for the
@@ -2700,7 +2712,7 @@ answer later and 'bivy ask status <id>' checks without waiting.
2700
2712
  }
2701
2713
  const json = wantsJson(args);
2702
2714
  const fail = (error) => cliError(error, { json, paint: c.red });
2703
- const parsed = parseSessionArgs(args, ["--session", "--option", "--header", "--timeout"]);
2715
+ const parsed = parseSessionArgs(args, ["--session", "--option", "--header", "--timeout", "--secret", "--url"]);
2704
2716
  if (parsed.error) return fail({ code: "usage", message: parsed.error, exit: EXIT.usage });
2705
2717
  const sessionId = resolveAttachSessionId({ sessionFlag: parsed.one("--session"), env: process.env });
2706
2718
  if (!sessionId) return fail({ code: "no_session", message: "No session id.", hint: "Run inside an agent session ($BIVY_SESSION_ID) or pass --session <id>.", next: "bivy sessions --json", exit: EXIT.usage });
@@ -2723,6 +2735,7 @@ answer later and 'bivy ask status <id>' checks without waiting.
2723
2735
  };
2724
2736
  const answerText = (result) => Object.values(result.answers ?? {}).join("\n");
2725
2737
  const finish = (result) => {
2738
+ if (result.status === "answered" && !answerText(result)) return fail({ code: "failed", message: "The secret was not stored on this node.", next: "bivy secrets doctor", exit: EXIT.failed });
2726
2739
  const out = { id: result.id, status: result.status, ...(result.status === "answered" ? { answer: answerText(result) } : {}) };
2727
2740
  if (result.status === "answered") { console.log(json ? JSON.stringify(out) : out.answer); return; }
2728
2741
  if (result.status === "pending") { console.log(json ? JSON.stringify(out) : `Still waiting for an answer. Check again with: bivy ask wait ${result.id}`); process.exit(EXIT.timeout); }
@@ -2749,8 +2762,10 @@ answer later and 'bivy ask status <id>' checks without waiting.
2749
2762
  if (!question) return fail({ code: "usage", message: ASK_USAGE, exit: EXIT.usage });
2750
2763
  const options = parsed.values["--option"] ?? [];
2751
2764
  if (options.length === 1) return fail({ code: "usage", message: "Offer at least two --option values, or none for a free-text answer.", exit: EXIT.usage });
2765
+ const secretName = parsed.one("--secret");
2766
+ if (parsed.one("--url") !== undefined && secretName === undefined) return fail({ code: "usage", message: "--url goes with --secret.", exit: EXIT.usage });
2752
2767
  const started = await call("", {
2753
- questions: [{ question, header: parsed.one("--header"), options, multiSelect: parsed.has("--multi") }],
2768
+ questions: [{ question, header: parsed.one("--header"), options, multiSelect: parsed.has("--multi"), ...(secretName !== undefined ? { secret: { name: secretName, url: parsed.one("--url") } } : {}) }],
2754
2769
  timeoutSeconds: timeout,
2755
2770
  });
2756
2771
  if (parsed.has("--async")) {
@@ -4065,6 +4080,7 @@ function plistContent(config) {
4065
4080
  PORT: String(config.port),
4066
4081
  BIVY_WORKSPACE: config.workspace,
4067
4082
  BIVY_DATA_DIR: appDir,
4083
+ ...(archive ? { BIVY_ARCHIVE_ROOT: archive.root } : {}),
4068
4084
  ...config.env,
4069
4085
  PATH: commandPath(config.env?.PATH),
4070
4086
  })
@@ -4109,6 +4125,7 @@ function systemdContent(config) {
4109
4125
  PORT: String(config.port),
4110
4126
  BIVY_WORKSPACE: config.workspace,
4111
4127
  BIVY_DATA_DIR: appDir,
4128
+ ...(archive ? { BIVY_ARCHIVE_ROOT: archive.root } : {}),
4112
4129
  ...config.env,
4113
4130
  PATH: commandPath(config.env?.PATH),
4114
4131
  })
@@ -4301,7 +4318,9 @@ async function restartServiceReconciled(config) {
4301
4318
  // A typed config edit may change workspace, port, or environment without
4302
4319
  // touching the old unit. Reinstall on content drift so `bivy restart` really
4303
4320
  // applies the canonical file instead of reviving stale baked-in values.
4304
- if (portChanged || configChanged) return await installService(config);
4321
+ // installService only *starts* the unit (`enable --now`, `launchctl load`),
4322
+ // which leaves an already-running node on its old code; restart it as well.
4323
+ if (portChanged || configChanged) return (await installService(config)) && restartService();
4305
4324
  return restartService();
4306
4325
  }
4307
4326
 
@@ -5515,6 +5534,49 @@ async function cmdUpdate(args = []) {
5515
5534
  await runUpdate(args);
5516
5535
  }
5517
5536
 
5537
+ /**
5538
+ * Update a GitHub release archive install: stage the channel's archive beside
5539
+ * the running one, point `current` at it, restart, and point `current` back if
5540
+ * the node doesn't come up. The version before stays on disk for that.
5541
+ */
5542
+ async function updateArchiveInstall(channel, { skipWait, force }) {
5543
+ const urls = channelUrls(channel);
5544
+ const target = archiveTarget();
5545
+ console.log(c.dim(`Checking ${urls.manifest} (channel: ${channel})…`));
5546
+ const manifest = await fetchManifest(urls.manifest);
5547
+ if (manifest.version === archive.version && !force) {
5548
+ console.log(c.green(`Bivy ${archive.version} is the newest on ${channel}.`));
5549
+ return;
5550
+ }
5551
+ console.log(c.dim(`Downloading Bivy ${manifest.version} for ${target}…`));
5552
+ const staged = await stageArchive({ installRoot: archive.installRoot, manifest, target, archiveUrl: urls.archive });
5553
+ const probe = runQuiet(path.join(staged, "bin", "bivy"), ["--version"]);
5554
+ if (probe.code !== 0 || probe.stdout.trim() !== manifest.version) {
5555
+ throw new Error(`The downloaded Bivy ${manifest.version} does not run here (${(probe.stderr || probe.stdout).trim() || `exit ${probe.code}`}); kept ${archive.version}.`);
5556
+ }
5557
+ const config = loadConfig();
5558
+ await waitForIdleSessions(config, { skip: skipWait });
5559
+ const previous = activate(archive.installRoot, staged);
5560
+ if (!hasConfiguredService(config)) {
5561
+ prune(archive.installRoot, [staged, previous]);
5562
+ console.log(c.green(`Updated to ${manifest.version}. Run 'bivy start' to use it.`));
5563
+ return;
5564
+ }
5565
+ if ((await restartServiceReconciled(config)) && (await verifyNodeCameUp(config))) {
5566
+ prune(archive.installRoot, [staged, previous]);
5567
+ console.log(c.green(`Updated ${archive.version} → ${manifest.version} and restarted the background service.`));
5568
+ return;
5569
+ }
5570
+ if (previous && previous !== activeVersion(archive.installRoot)) {
5571
+ activate(archive.installRoot, previous);
5572
+ await restartServiceReconciled(config);
5573
+ console.log(c.red(`⚠ Bivy ${manifest.version} did not come up, so ${archive.version} is back in place.`));
5574
+ } else {
5575
+ reportNodeDidNotStart();
5576
+ }
5577
+ process.exitCode = 1;
5578
+ }
5579
+
5518
5580
  async function runUpdate(args = []) {
5519
5581
  const skipWait = args.includes("--force") || args.includes("--no-wait");
5520
5582
  const kind = detectInstallKind();
@@ -5564,6 +5626,12 @@ async function runUpdate(args = []) {
5564
5626
  return;
5565
5627
  }
5566
5628
 
5629
+ if (kind === "archive") {
5630
+ await updateArchiveInstall(channel, { skipWait, force: args.includes("--force") });
5631
+ console.log(c.dim(`=== bivy update finished ${new Date().toISOString()} ===`));
5632
+ return;
5633
+ }
5634
+
5567
5635
  if (kind === "packaged") {
5568
5636
  console.log(c.dim(`Updating packaged Bivy install (channel: ${channel})…`));
5569
5637
  // The actual restart happens inside install.sh, which shells out to
@@ -6747,8 +6815,10 @@ Unlike 'bivy run', these commands operate on governed background Runs with check
6747
6815
  // secrets-cli.ts only recognizes "--help"/"help" as the FIRST argument, so
6748
6816
  // a subcommand-position --help (e.g. 'bivy secrets list --help') would
6749
6817
  // otherwise be ignored and the live action would run anyway (#113).
6750
- const forwardArgs = (args.includes("-h") || args.includes("--help")) ? ["--help"] : args;
6751
- process.exit(await run(nodeBin, [...nodeScriptArgs(secretsEntry), ...forwardArgs], { cwd: repoRoot, env: process.env }));
6818
+ // Words after `--` belong to `secrets exec`'s command, not to us.
6819
+ const own = args.includes("--") ? args.slice(0, args.indexOf("--")) : args;
6820
+ const forwardArgs = (own.includes("-h") || own.includes("--help")) ? ["--help"] : args;
6821
+ process.exit(await run(nodeBin, [...nodeScriptArgs(secretsEntry), ...forwardArgs], { cwd: own[0] === "exec" ? process.cwd() : repoRoot, env: process.env }));
6752
6822
  break;
6753
6823
  }
6754
6824
  case "voice": {
@@ -46,8 +46,9 @@ export const COMMANDS = [
46
46
  { name: "attach", group: "session", scope: "session", json: true, usage: 'attach <file> [--caption "…"] [--artifact]', summary: "Show a local file or image to the user in the chat" },
47
47
  { name: "notify", group: "session", scope: "session", json: true, usage: "notify [--urgent]", summary: "Push to the user's phone so they come back (nothing posted in the chat)",
48
48
  tools: [{ name: "notify_user", description: "Push to the user's phone so they come back to this session, e.g. when you are blocked on them while still working. The push names the session only and nothing is posted in the chat, so say what you need in your reply. Not needed when you finish: Bivy already pushes when a turn ends while they are away. Sent only when nobody has the app open, at most one a minute per session.", input: { urgent: { type: "boolean", description: "Push even while the user has the app open." } }, argv: ["notify", { when: "urgent", flag: "--urgent" }] }] },
49
- { name: "ask", group: "session", scope: "session", json: true, usage: 'ask "<question>" [--option A --option B] [--async]', summary: "Ask the user a question and wait for the answer", subcommands: ["status", "wait"],
50
- tools: [{ name: "ask_user", description: "Ask the user a question in the chat and wait for their answer (their phone gets a push). Offer 2-8 options, or none for a free-text answer; they can always write their own. Returns {status, answer}: status is answered, dismissed (decide yourself) or expired.", input: { question: { type: "string", description: "The question.", required: true }, options: { type: "array", items: { type: "string" }, description: "Choices to pick from (2-8)." }, multiple: { type: "boolean", description: "Allow several choices." }, header: { type: "string", description: "A short label for the card." }, timeout_seconds: { type: "number", description: "How long to wait (default 600)." } }, argv: ["ask", { arg: "question" }, { flag: "--option", from: "options" }, { when: "multiple", flag: "--multi" }, { flag: "--header", from: "header" }, { flag: "--timeout", from: "timeout_seconds" }] }] },
49
+ { name: "ask", group: "session", scope: "session", json: true, usage: 'ask "<question>" [--option A --option B] [--secret NAME] [--async]', summary: "Ask the user a question (or for a secret) and wait for the answer", subcommands: ["status", "wait"],
50
+ tools: [{ name: "ask_user", description: "Ask the user a question in the chat and wait for their answer (their phone gets a push). Offer 2-8 options, or none for a free-text answer; they can always write their own. Returns {status, answer}: status is answered, dismissed (decide yourself) or expired.", input: { question: { type: "string", description: "The question.", required: true }, options: { type: "array", items: { type: "string" }, description: "Choices to pick from (2-8)." }, multiple: { type: "boolean", description: "Allow several choices." }, header: { type: "string", description: "A short label for the card." }, timeout_seconds: { type: "number", description: "How long to wait (default 600)." } }, argv: ["ask", { arg: "question" }, { flag: "--option", from: "options" }, { when: "multiple", flag: "--multi" }, { flag: "--header", from: "header" }, { flag: "--timeout", from: "timeout_seconds" }] },
51
+ { name: "request_secret", description: "Ask the user for an API key, token or password. Their card takes a hidden value that is stored in this machine's vault as `name`; you get back only secret://<name>, never the value. Use it with `bivy secrets exec --env <name> -- <command>`. Never ask for a secret with ask_user. Returns {status, answer}.", input: { name: { type: "string", description: "Vault name, env-var style, e.g. STRIPE_API_KEY.", required: true }, question: { type: "string", description: "What to paste and why, e.g. \"Paste your Stripe secret key so I can create the products.\"", required: true }, url: { type: "string", description: "https:// page where the user finds or creates it." }, timeout_seconds: { type: "number", description: "How long to wait (default 600)." } }, argv: ["ask", { arg: "question" }, { flag: "--secret", from: "name" }, { flag: "--url", from: "url" }, { flag: "--timeout", from: "timeout_seconds" }] }] },
51
52
  { name: "title", group: "session", scope: "session", json: true, usage: 'title "<title>"', summary: "Rename this session",
52
53
  tools: [{ name: "set_session_title", description: "Rename this session in the user's session list. Use a short title (under 60 characters) that says what the work is, when the first message made a poor title or the work changed direction.", input: { title: { type: "string", description: "The new title.", required: true } }, argv: ["title", { arg: "title" }] }] },
53
54
  { name: "app", group: "session", scope: "session", json: true, usage: "app <publish|shot|present|share|run|…>", summary: "Live previews: publish a web/terminal/desktop app, screenshot it, present it", subcommands: ["publish", "list", "remove", "shot", "present", "share", "notes", "run", "click", "type", "key", "scroll", "drag", "move", "menu"],
@@ -114,7 +115,7 @@ export const COMMANDS = [
114
115
  { name: "logout", aliases: ["signout"], group: "accounts", scope: "account", usage: "logout", summary: "Sign this machine out" },
115
116
  { name: "provider", aliases: ["model"], group: "accounts", scope: "node", usage: "provider login [provider]", summary: "Sign into a model provider", subcommands: ["login"] },
116
117
  { name: "auth", aliases: ["credentials", "creds"], group: "accounts", scope: "node", usage: "auth <list|add|remove|import|sync|…>", summary: "Model credentials: import local logins, add, sync", subcommands: ["list", "add", "remove", "sync", "preset", "ingest", "config", "import"] },
117
- { name: "secrets", aliases: ["secret"], group: "accounts", scope: "node", usage: "secrets <list|set|ref|delete|doctor|resolve>", summary: "Store tokens in the node vault", subcommands: ["list", "set", "ref", "delete", "doctor", "resolve"] },
118
+ { name: "secrets", aliases: ["secret"], group: "accounts", scope: "node", usage: "secrets <list|set|ref|delete|doctor|resolve|exec>", summary: "Store tokens in the node vault, and run a command with them", subcommands: ["list", "set", "ref", "delete", "doctor", "resolve", "exec"] },
118
119
  { name: "github:connect", aliases: ["connect-repo"], group: "accounts", scope: "node", usage: "github:connect [owner/repo]", summary: "Connect GitHub" },
119
120
  { name: "github:app-create", group: "accounts", scope: "node", usage: "github:app-create [--org <org>]", summary: "One-click: create and connect a GitHub App" },
120
121
  { name: "github:app-connect", group: "accounts", scope: "node", usage: "github:app-connect --app-id <id> --key <path.pem>", summary: "Connect an existing GitHub App" },
@@ -129,7 +130,7 @@ export const COMMANDS = [
129
130
 
130
131
  // Help and discovery
131
132
  { name: "guide", group: "help", scope: "node", json: true, usage: "guide [topic] [--json]", summary: "Short playbooks for agents: show the user, talk to them, long work, more hands, automate",
132
- tools: [{ name: "bivy_guide", description: "Short playbooks for working in Bivy. Without a topic, lists them.", input: { topic: { type: "string", description: "show-the-user, talk-to-the-user, long-work, more-hands or automate." } }, argv: ["guide", { arg: "topic" }] }] },
133
+ tools: [{ name: "bivy_guide", description: "Short playbooks for working in Bivy. Without a topic, lists them.", input: { topic: { type: "string", description: "show-the-user, talk-to-the-user, long-work, more-hands, automate or add-an-agent." } }, argv: ["guide", { arg: "topic" }] }] },
133
134
  { name: "help", aliases: ["-h", "--help"], group: "help", scope: "node", json: true, usage: "help [command] [--json]", summary: "Show this help, one command's row, or every command as JSON" },
134
135
  { name: "version", aliases: ["--version", "-v"], group: "help", scope: "node", usage: "version", summary: "Print the installed Bivy version" },
135
136
 
@@ -0,0 +1,39 @@
1
+ # Add an agent: any ACP agent or CLI
2
+
3
+ Summary: Make another coding agent available in Bivy, writing a small adapter when its CLI needs one.
4
+
5
+ An agent with an ACP mode needs only its command:
6
+
7
+ bivy agent add gemini-acp --command gemini --args '["--experimental-acp"]'
8
+
9
+ Any other CLI that takes a prompt on stdin (or as its last argument with
10
+ `--prompt-mode argv`):
11
+
12
+ bivy agent add review-bot --transport process --command review-bot --args '["run"]'
13
+
14
+ It is in the picker at once. Check it with one real turn, which prints the answer:
15
+
16
+ bivy exec -a gemini-acp "Say hello"
17
+
18
+ When the CLI's output needs translating, write an adapter: a program Bivy runs
19
+ once per turn with the prompt on stdin. It prints one JSON event per line:
20
+
21
+ {"type":"session.started","sessionId":"abc"} # passed back as {id} to resume
22
+ {"type":"message.reasoning","text":"…"}
23
+ {"type":"tool.call","toolCallId":"t1","name":"bash","input":{"command":"ls"}}
24
+ {"type":"tool.result","toolCallId":"t1","result":"…"}
25
+ {"type":"message.delta","text":"The answer…"}
26
+ {"type":"usage","tokens":{"input_tokens":10,"output_tokens":20}}
27
+ {"type":"session.done"} # or {"type":"session.error","error":"…"}
28
+
29
+ Start from examples/agents/jsonl-adapter/adapter.mjs in the Bivy repo, then:
30
+
31
+ bivy agent add my-agent --transport process --parser bivy-protocol \
32
+ --command "$PWD/adapter.mjs" --resume-args '["--resume","{id}"]'
33
+ bivy exec -a my-agent "List the files here"
34
+
35
+ These events report what the agent did; they cannot ask before a tool runs. For
36
+ approvals, the adapter must speak ACP (`--transport acp`).
37
+
38
+ The entry lives under `agents:` in the node's config.yaml (`bivy agent list`,
39
+ `bivy agent remove <id>`). The full format is in docs/add-an-agent.md.
@@ -30,6 +30,19 @@ dismissed it (use your judgment), exit 5 that `--timeout` (default 10m) passed.
30
30
  For long waits use `--async`, keep working, and later `bivy ask wait <id>`.
31
31
  Ask only when you can't reasonably decide yourself.
32
32
 
33
+ ## You need a key or password: ask for a secret
34
+
35
+ bivy ask "Paste your Stripe secret key so I can create the products" \
36
+ --secret STRIPE_API_KEY --url https://dashboard.stripe.com/apikeys
37
+ bivy secrets exec --env STRIPE_API_KEY -- stripe products list
38
+
39
+ The card takes a hidden value and stores it in this machine's vault; you get
40
+ back `secret://STRIPE_API_KEY`, never the value. Use it through
41
+ `bivy secrets exec`, which puts it in the command's environment. It stays
42
+ stored for later sessions, so try `bivy secrets exec` first and ask only when
43
+ it says the secret is missing. Never ask for a secret with a plain `bivy ask`
44
+ or in chat, and never print it: the answer would land in the transcript.
45
+
33
46
  ## Name the session: title
34
47
 
35
48
  bivy title "Fix login redirect loop"
@@ -68,23 +68,55 @@ export function linkSessionWorktrees({ sessions, staleSessions, staleWorktrees,
68
68
  return { sessions: sorted, worktrees: [...removeWorktrees] };
69
69
  }
70
70
 
71
- // Removal set for sessions. Live ("working") sessions are never touched. The
72
- // newest `keep` NON-EMPTY sessions (across every agent) survive; empty/untitled
73
- // sessions are always beyond the keep window. An item is removed only when it is
74
- // BOTH beyond the keep window AND older than `ageMs` — the same safe intersection
75
- // as the file-based selectStale, so keep-only and age-only both behave sensibly.
76
- // `keep`/`ageMs` of null disable that half of the test.
71
+ // Delegated child sessions (sub-agents, `delegatedFrom`) belong to the session
72
+ // that started them, as the sidebar nests them. Groups sessions into families
73
+ // keyed by the top-most listed ancestor; a child whose parent isn't listed is
74
+ // its own root.
75
+ function sessionFamilies(sessions) {
76
+ const byId = new Map(sessions.map((s) => [s.id, s]));
77
+ const rootOf = (s) => {
78
+ const seen = new Set([s.id]);
79
+ let cur = s;
80
+ for (;;) {
81
+ const parent = byId.get(cur.delegatedFrom?.sessionId);
82
+ if (!parent || seen.has(parent.id)) return cur.id;
83
+ seen.add(parent.id);
84
+ cur = parent;
85
+ }
86
+ };
87
+ const families = new Map();
88
+ for (const s of sessions) {
89
+ const root = rootOf(s);
90
+ families.set(root, [...(families.get(root) ?? []), s]);
91
+ }
92
+ return [...families.values()];
93
+ }
94
+
95
+ // Removal set for sessions. A parent and its sub-agent sessions are judged as one
96
+ // unit: the family's activity is its newest member's, it is live if any member is
97
+ // working, and it is empty only if every member is. So sub-agents never take
98
+ // keep slots from real sessions, and a recent child keeps its parent (and vice
99
+ // versa). Live families are never touched. The newest `keep` NON-EMPTY families
100
+ // survive; empty ones are always beyond the keep window. A family is removed only
101
+ // when it is BOTH beyond the keep window AND older than `ageMs` — the same safe
102
+ // intersection as the file-based selectStale. `keep`/`ageMs` of null disable that
103
+ // half of the test.
77
104
  export function selectStaleSessions(sessions, keep, ageMs, now) {
78
- const eligible = sessions.filter((s) => s.status !== "working");
79
- const nonEmptyDesc = eligible
80
- .filter((s) => !isEmptySession(s))
81
- .sort((a, b) => sessionActivityMs(b) - sessionActivityMs(a));
82
- const rank = new Map(nonEmptyDesc.map((s, i) => [s.id, i]));
105
+ const eligible = sessionFamilies(sessions)
106
+ .filter((members) => !members.some((s) => s.status === "working"))
107
+ .map((members) => ({
108
+ members,
109
+ activity: Math.max(...members.map(sessionActivityMs)),
110
+ empty: members.every(isEmptySession),
111
+ }));
112
+ const nonEmptyDesc = eligible.filter((f) => !f.empty).sort((a, b) => b.activity - a.activity);
113
+ const rank = new Map(nonEmptyDesc.map((f, i) => [f, i]));
83
114
  return eligible
84
- .filter((s) => {
85
- const beyondKeep = keep === null ? true : (isEmptySession(s) ? true : (rank.get(s.id) ?? Infinity) >= keep);
86
- const olderThan = ageMs === null ? true : now - sessionActivityMs(s) >= ageMs;
115
+ .filter((f) => {
116
+ const beyondKeep = keep === null ? true : (f.empty ? true : rank.get(f) >= keep);
117
+ const olderThan = ageMs === null ? true : now - f.activity >= ageMs;
87
118
  return beyondKeep && olderThan;
88
119
  })
120
+ .flatMap((f) => f.members)
89
121
  .sort((a, b) => sessionActivityMs(b) - sessionActivityMs(a));
90
122
  }