@bivy/bivy 0.17.3-staging.30 → 0.17.3-staging.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/bivy.mjs CHANGED
@@ -2440,6 +2440,7 @@ async function cmdApp(args = []) {
2440
2440
  bivy app scroll <x> <y> [--up|--down|--left|--right] [--steps <n>] [--app <app>]
2441
2441
  bivy app drag <x> <y> <to-x> <to-y> [--app <app>]
2442
2442
  bivy app move <x> <y> [--app <app>]
2443
+ bivy app menu ["File > Save"] [--app <app>] macOS: list the menu bar, or choose an item
2443
2444
 
2444
2445
  Manifest: {"version":1,"name":"My app","views":[
2445
2446
  {"kind":"web","name":"Website","source":{"kind":"service","port":3000}},
@@ -2469,6 +2470,8 @@ app (default: the one opened last). Keys: a character or enter, tab, escape,
2469
2470
  backspace, delete, arrows (up…), home, end, pageup, pagedown, f1–f12, with
2470
2471
  modifiers shift, ctrl, alt/option, cmd. On a Mac, the pointer and keyboard
2471
2472
  focus move to the app while it's used.
2473
+ "menu" (macOS) lists the app's menu bar with shortcuts; "menu 'File > Save'"
2474
+ chooses an item — more reliable than clicking, and prints a screenshot after.
2472
2475
  "present" says a change is ready for the user to look at: they get a card in
2473
2476
  the chat with the app at phone width (and before/after), and it opens the live
2474
2477
  preview. Use it when you finish a visible change, or when the user asks to see
@@ -2483,6 +2486,7 @@ Web previews require operator setup; see docs/apps.md.`);
2483
2486
  return;
2484
2487
  }
2485
2488
  if (INPUT_ACTIONS[action]) return appInput(action, rest);
2489
+ if (action === "menu") return appMenu(rest);
2486
2490
  if (!["publish", "list", "remove", "shot", "run", "present", "share"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
2487
2491
  // `run -- <command> [args…]`: publish a one-window desktop app without a manifest file.
2488
2492
  let runManifest;
@@ -2546,7 +2550,39 @@ Web previews require operator setup; see docs/apps.md.`);
2546
2550
  await appRequest(action === "run" ? "publish" : action, body);
2547
2551
  }
2548
2552
 
2549
- async function appRequest(action, body) {
2553
+ async function appMenu(rest) {
2554
+ const options = {};
2555
+ const positional = [];
2556
+ for (let i = 0; i < rest.length; i++) {
2557
+ if (rest[i] === "--app" || rest[i] === "--session") {
2558
+ const value = rest[++i];
2559
+ if (value === undefined || value.startsWith("--")) throw new Error(`${rest[i - 1]} requires a value.`);
2560
+ options[rest[i - 1]] = value;
2561
+ } else if (rest[i].startsWith("--")) throw new Error(`Unknown option ${rest[i]}. Run bivy app --help.`);
2562
+ else positional.push(rest[i]);
2563
+ }
2564
+ if (positional.length > 1) throw new Error('Usage: bivy app menu ["File > Save"] [--app <app>]');
2565
+ const sessionId = resolveAttachSessionId({ sessionFlag: options["--session"], env: process.env });
2566
+ if (!sessionId) throw new Error("Set --session <id> or run inside a Bivy agent session.");
2567
+ const body = { sessionId, ...(options["--app"] ? { target: options["--app"] } : {}) };
2568
+ if (positional[0]) body.path = positional[0].split(">").map((part) => part.trim());
2569
+ const result = await appRequest("menu", body, { print: false });
2570
+ if (!result.menus) { console.log(JSON.stringify(result, null, 2)); return; }
2571
+ // A tree an agent can read: one item per line, indented by level.
2572
+ const lines = [];
2573
+ const walk = (items, depth) => {
2574
+ for (const item of items) {
2575
+ if (item.separator) continue;
2576
+ const note = [item.shortcut, item.checked && "checked", item.enabled === false && "disabled"].filter(Boolean).join(", ");
2577
+ lines.push(`${" ".repeat(depth)}${item.title}${note ? ` (${note})` : ""}`);
2578
+ if (item.items) walk(item.items, depth + 1);
2579
+ }
2580
+ };
2581
+ for (const menu of result.menus) { lines.push(menu.title); walk(menu.items, 1); }
2582
+ console.log(lines.length ? `${lines.join("\n")}\n\nChoose one with: bivy app menu "<Menu> > <Item>"` : "This app has no menus.");
2583
+ }
2584
+
2585
+ async function appRequest(action, body, { print = true } = {}) {
2550
2586
  const config = loadConfig();
2551
2587
  if (!(await ensureNodeRunning(config))) throw new Error("Could not reach the Bivy node.");
2552
2588
  const token = await localDeviceToken(config);
@@ -2561,7 +2597,8 @@ async function appRequest(action, body) {
2561
2597
  console.error("Anyone with this link can use the app, including its live backend, until it expires or you revoke it (Apps → Revoke access).");
2562
2598
  return;
2563
2599
  }
2564
- console.log(JSON.stringify(result, null, 2));
2600
+ if (print) console.log(JSON.stringify(result, null, 2));
2601
+ return result;
2565
2602
  }
2566
2603
 
2567
2604
  // Computer use in desktop apps: each command's positional arguments, and the
@@ -2,6 +2,7 @@
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
3
  export const DISPLAY_SOCKET_PATH = "/__bivy/display";
4
4
  export const DISPLAY_STATS_PATH = "/__bivy/display-stats";
5
+ export const DISPLAY_MENU_PATH = "/__bivy/display-menu";
5
6
  export const NOVNC_PATH = "/__bivy/novnc/";
6
7
  /** The modifier apps paste with, as a keysym and noVNC key code: ⌘ on a Mac. */
7
8
  const PASTE_MODIFIER = { darwin: [0xffeb, "MetaLeft"] };
@@ -9,7 +10,8 @@ const PASTE_MODIFIER = { darwin: [0xffeb, "MetaLeft"] };
9
10
  * the app's display. It asks the display to match its size in device pixels
10
11
  * (`scale` is what the display was started at), scales down when the display
11
12
  * is larger, reconnects while the app starts, carries the clipboard both ways
12
- * on a tap, gives phones a keyboard, and reports stream latency to Bivy. */
13
+ * on a tap, gives phones a keyboard, offers the app's menu bar where the
14
+ * display can read it (macOS), and reports stream latency to Bivy. */
13
15
  export function displayViewer(nonce, name, scale, platform = process.platform) {
14
16
  const [modifier, modifierCode] = PASTE_MODIFIER[platform] ?? [0xffe3, "ControlLeft"];
15
17
  const title = name.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`);
@@ -20,11 +22,24 @@ export function displayViewer(nonce, name, scale, platform = process.platform) {
20
22
  button,#paste{font:inherit;font-size:14px;min-height:44px;padding:0 14px;border-radius:22px;border:1px solid GrayText;background:Canvas;color:CanvasText}
21
23
  #keys{display:none;min-width:44px;padding:0}@media (pointer:coarse){#keys{display:block}}
22
24
  #paste{display:flex;gap:8px;align-items:center;padding:4px 4px 4px 14px}#paste input{font:inherit;min-height:36px;border:0;background:transparent;color:inherit;width:12em}
23
- #typing{position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;border:0;padding:0}</style>
25
+ #typing{position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;border:0;padding:0}
26
+ #menu{position:fixed;left:12px;bottom:72px;width:min(22rem,calc(100vw - 24px));max-height:min(70vh,36rem);display:flex;flex-direction:column;background:Canvas;color:CanvasText;border:1px solid GrayText;border-radius:16px;box-shadow:0 8px 24px color-mix(in srgb,CanvasText 25%,transparent);overflow:hidden}
27
+ #menu header{display:flex;align-items:center;gap:4px;padding:4px;border-bottom:1px solid GrayText}
28
+ #menu header button{border:0;min-width:44px;padding:0;font-size:18px}
29
+ #menu h2{flex:1;margin:0;padding:0 8px;font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
30
+ #menu-status{margin:0;padding:12px 16px;color:GrayText;font-size:14px}#menu-status:empty{display:none}
31
+ #menu ul{list-style:none;margin:0;padding:4px 0;overflow:auto;overscroll-behavior:contain}
32
+ #menu li[role=separator]{border-top:1px solid GrayText;margin:4px 16px;opacity:.5}
33
+ #menu li button{display:flex;align-items:center;gap:8px;width:100%;border:0;border-radius:0;padding:0 16px;text-align:start}
34
+ #menu li button:hover:not(:disabled),#menu li button:focus-visible{background:Highlight;color:HighlightText;outline:none}
35
+ #menu li button:disabled{color:GrayText}
36
+ #menu .mark{width:1em;flex:none}#menu .label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#menu .keys{flex:none;opacity:.7;font-variant-numeric:tabular-nums}</style>
24
37
  <div id="screen"></div><p id="status" role="status">Starting the app…</p>
25
- <div id="tools"><button id="copy" type="button" hidden>Copy from app</button>
38
+ <div id="tools"><button id="menu-open" type="button" aria-haspopup="menu" aria-controls="menu" aria-expanded="false" hidden>Menu</button><button id="copy" type="button" hidden>Copy from app</button>
26
39
  <form id="paste" hidden><input id="paste-text" aria-label="Text to paste into the app" placeholder="Paste here"><button type="submit">Send</button></form>
27
40
  <button id="paste-open" type="button">Paste</button><button id="keys" type="button" aria-label="Show keyboard">⌨</button></div>
41
+ <div id="menu" role="dialog" aria-labelledby="menu-title" hidden><header><button id="menu-back" type="button" aria-label="Back">‹</button><h2 id="menu-title">Menu</h2><button id="menu-close" type="button" aria-label="Close menu">✕</button></header>
42
+ <p id="menu-status" role="status"></p><ul id="menu-items" role="menu" aria-labelledby="menu-title"></ul></div>
28
43
  <input id="typing" aria-label="Type into the app" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false">
29
44
  <script type="module" nonce="${nonce}">import RFB from "${NOVNC_PATH}core/rfb.js";
30
45
  const $=id=>document.getElementById(id),screen=$("screen"),status=$("status"),typing=$("typing"),SCALE=${scale === 2 ? 2 : 1};
@@ -57,7 +72,7 @@ function connect(){
57
72
  rfb._screenSize=()=>{const s=measure();return{w:Math.round(s.w*ask),h:Math.round(s.h*ask)};};
58
73
  rfb._requestRemoteResize=()=>{ask=SCALE;try{request();}finally{ask=1;}};
59
74
  rfb.resizeSession=true;rfb.scaleViewport=true;rfb.focusOnClick=true;
60
- rfb.addEventListener("connect",()=>{wait=500;say("");});
75
+ rfb.addEventListener("connect",()=>{wait=500;say("");hasMenu();});
61
76
  rfb.addEventListener("disconnect",()=>{say("Starting the app…");setTimeout(connect,wait);wait=Math.min(wait*2,5000);});
62
77
  // The app copied text: offer it; the browser only lets a tap write the clipboard.
63
78
  rfb.addEventListener("clipboard",e=>{remote=e.detail.text;$("copy").hidden=!remote;$("copy").textContent="Copy from app";});
@@ -75,6 +90,61 @@ $("paste-open").onclick=async()=>{
75
90
  $("paste").onsubmit=e=>{e.preventDefault();paste($("paste-text").value);$("paste-text").value="";$("paste").hidden=true;};
76
91
  $("paste-text").addEventListener("keydown",e=>{if(e.key==="Escape"){$("paste").hidden=true;rfb.focus();}});
77
92
 
93
+ // The app's menu bar, read when opened so enabled and checked states are
94
+ // current. Drill down through submenus; choosing an item closes the menu.
95
+ // Titles come from the app: shown as text only.
96
+ const MENU="${DISPLAY_MENU_PATH}",menu=$("menu"),items=$("menu-items"),menuStatus=$("menu-status");
97
+ let stack=[];
98
+ const hasMenu=()=>fetch(MENU,{method:"HEAD"}).then(r=>{$("menu-open").hidden=!r.ok;}).catch(()=>{});
99
+ const setOpen=open=>{menu.hidden=!open;$("tools").hidden=open;$("menu-open").setAttribute("aria-expanded",String(open));};
100
+ const closeMenu=()=>{setOpen(false);stack=[];rfb.focus();};
101
+ const cell=(cls,text)=>{const span=document.createElement("span");span.className=cls;span.textContent=text;return span;};
102
+ function show(){
103
+ const level=stack[stack.length-1];
104
+ $("menu-title").textContent=level.title;$("menu-back").hidden=stack.length<2;
105
+ items.replaceChildren(...level.items.map(item=>{
106
+ const li=document.createElement("li");
107
+ if(item.separator){li.setAttribute("role","separator");return li;}
108
+ li.setAttribute("role","none");
109
+ const button=document.createElement("button");
110
+ button.type="button";button.setAttribute("role",item.checked?"menuitemcheckbox":"menuitem");
111
+ if(item.checked)button.setAttribute("aria-checked","true");
112
+ button.disabled=item.enabled===false;
113
+ button.append(cell("mark",item.checked?"✓":""),cell("label",item.title));
114
+ if(item.items){button.setAttribute("aria-haspopup","menu");button.append(cell("keys","›"));button.onclick=()=>{stack.push({title:item.title,items:item.items,path:[...level.path,item.title]});show();};}
115
+ else{if(item.shortcut){button.append(cell("keys",item.shortcut));button.setAttribute("aria-keyshortcuts",item.shortcut);}button.onclick=()=>choose([...level.path,item.title]);}
116
+ li.append(button);return li;
117
+ }));
118
+ items.querySelector("button:not(:disabled)")?.focus();
119
+ }
120
+ async function choose(path){
121
+ menuStatus.textContent="Choosing "+path.join(" › ")+"…";
122
+ try{
123
+ const r=await fetch(MENU,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({path})});
124
+ if(!r.ok)throw new Error((await r.json().catch(()=>({}))).error||"The app didn’t take it.");
125
+ menuStatus.textContent="";closeMenu();
126
+ }catch(e){menuStatus.textContent=e.message;}
127
+ }
128
+ $("menu-open").onclick=async()=>{
129
+ stack=[];items.replaceChildren();setOpen(true);$("menu-back").hidden=true;$("menu-title").textContent="Menu";
130
+ menuStatus.textContent="Reading the app’s menus…";$("menu-close").focus();
131
+ try{
132
+ const r=await fetch(MENU);const body=await r.json();
133
+ if(!r.ok)throw new Error(body.error||"Couldn’t read the app’s menus.");
134
+ menuStatus.textContent=body.menus.length?"":"This app has no menus.";
135
+ stack=[{title:"Menu",items:body.menus.map(m=>({title:m.title,enabled:true,items:m.items})),path:[]}];show();
136
+ }catch(e){menuStatus.textContent=e.message;}
137
+ };
138
+ $("menu-close").onclick=closeMenu;
139
+ $("menu-back").onclick=()=>{stack.pop();show();};
140
+ menu.addEventListener("keydown",e=>{
141
+ if(e.key==="Escape"){e.preventDefault();if(stack.length>1){stack.pop();show();}else closeMenu();return;}
142
+ if(e.key!=="ArrowDown"&&e.key!=="ArrowUp")return;
143
+ const all=[...items.querySelectorAll("button:not(:disabled)")];if(!all.length)return;
144
+ e.preventDefault();const at=all.indexOf(document.activeElement);
145
+ all[(at+(e.key==="ArrowDown"?1:-1)+all.length)%all.length].focus();
146
+ });
147
+
78
148
  // Phones: a soft keyboard needs a text field; forward what's typed as keys.
79
149
  const SPECIAL={Backspace:0xff08,Enter:0xff0d,Tab:0xff09,Escape:0xff1b,ArrowLeft:0xff51,ArrowUp:0xff52,ArrowRight:0xff53,ArrowDown:0xff54};
80
150
  $("keys").onclick=()=>typing.focus();
@@ -9,7 +9,8 @@ import { readFileSync } from "node:fs";
9
9
  import { createRequire } from "node:module";
10
10
  import { WebSocketServer } from "ws";
11
11
  import { previewShell } from "./preview-shell.js";
12
- import { displayViewer, DISPLAY_SOCKET_PATH, DISPLAY_STATS_PATH, NOVNC_PATH } from "./display-viewer.js";
12
+ import { displayViewer, DISPLAY_MENU_PATH, DISPLAY_SOCKET_PATH, DISPLAY_STATS_PATH, NOVNC_PATH } from "./display-viewer.js";
13
+ import { pressMenu, readMenuPath, readMenus } from "./menu.js";
13
14
  import { inspectorScript } from "./inspector.js";
14
15
  const MIME = {
15
16
  ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
@@ -601,6 +602,10 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
601
602
  res.end();
602
603
  return;
603
604
  }
605
+ if (req.url === DISPLAY_MENU_PATH) {
606
+ await this.displayMenu(req, res, entry);
607
+ return;
608
+ }
604
609
  if (!["GET", "HEAD"].includes(req.method ?? "")) {
605
610
  res.writeHead(405, { Allow: "GET, HEAD" });
606
611
  res.end();
@@ -629,6 +634,55 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
629
634
  res.writeHead(200, { ...headers, "content-type": "text/html; charset=utf-8", "content-length": body.length });
630
635
  res.end(req.method === "HEAD" ? undefined : body);
631
636
  }
637
+ /** The app's menu bar for the viewer: HEAD says whether it has one, GET
638
+ * reads it, POST {path} chooses an item (same-origin, checked above). */
639
+ async displayMenu(req, res, entry) {
640
+ const control = entry.displayControl;
641
+ const json = (status, body) => { res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify(body)); };
642
+ if (req.method === "HEAD") {
643
+ res.writeHead(control ? 204 : 404);
644
+ res.end();
645
+ return;
646
+ }
647
+ if (!control) {
648
+ json(404, { error: "This app's menus aren't available." });
649
+ return;
650
+ }
651
+ try {
652
+ if (req.method === "GET") {
653
+ json(200, { menus: await readMenus(control) });
654
+ return;
655
+ }
656
+ if (req.method !== "POST") {
657
+ res.writeHead(405, { Allow: "GET, HEAD, POST" });
658
+ res.end();
659
+ return;
660
+ }
661
+ let body = "";
662
+ for await (const chunk of req) {
663
+ body += chunk.toString();
664
+ if (body.length > 4096) {
665
+ res.writeHead(413);
666
+ res.end();
667
+ return;
668
+ }
669
+ }
670
+ let path;
671
+ try {
672
+ path = readMenuPath(JSON.parse(body).path);
673
+ }
674
+ catch (error) {
675
+ json(400, { error: error.message });
676
+ return;
677
+ }
678
+ await pressMenu(control, path);
679
+ res.writeHead(204);
680
+ res.end();
681
+ }
682
+ catch (error) {
683
+ json(409, { error: error.message });
684
+ }
685
+ }
632
686
  /** Relays a viewer's WebSocket to the display's private VNC socket. */
633
687
  displaySocket(req, socket, head, entry) {
634
688
  this.displays.handleUpgrade(req, socket, head, (ws) => {
@@ -119,8 +119,9 @@ export class MacDisplayHost {
119
119
  throw new Error(missing);
120
120
  }
121
121
  const socket = path.join(state.dir, "vnc.sock");
122
+ const control = path.join(state.dir, "control.sock");
122
123
  const token = randomBytes(16).toString("hex");
123
- const child = spawn(binary, ["serve", socket, token, String(scale)], { stdio: ["ignore", "pipe", "pipe"] });
124
+ const child = spawn(binary, ["serve", socket, control, token, String(scale)], { stdio: ["ignore", "pipe", "pipe"] });
124
125
  state.child = child;
125
126
  const wm = { count: 0 };
126
127
  let errors = "";
@@ -141,7 +142,7 @@ export class MacDisplayHost {
141
142
  });
142
143
  if (!up)
143
144
  throw new Error(`Couldn't start a display for this app.${errors ? ` ${errors.trim().split("\n").at(-1)}` : ""}`);
144
- return { socket, wm, scale, env: { BIVY_MAC_DISPLAY: token }, launch: [binary, "run", "--"] };
145
+ return { socket, control, wm, scale, env: { BIVY_MAC_DISPLAY: token }, launch: [binary, "run", "--"] };
145
146
  }
146
147
  stop(id) {
147
148
  const state = this.displays.get(id);
@@ -7,7 +7,7 @@
7
7
  // screenshots work unchanged.
8
8
  //
9
9
  // macos-display check [--prompt] permissions, as JSON
10
- // macos-display serve <socket> <token> <scale>
10
+ // macos-display serve <socket> <control> <token> <scale>
11
11
  // macos-display run -- <command> [args…] starts the app (see below)
12
12
  //
13
13
  // The app is found by its environment: Bivy starts it through `run` with
@@ -18,6 +18,11 @@
18
18
  // ScreenCaptureKit (Screen Recording permission); clicks, keys and window
19
19
  // sizing go through Accessibility. Keys are sent to the app only; clicks land
20
20
  // only when the app's window is the one under the pointer.
21
+ //
22
+ // The control socket answers one JSON line per connection: {"menu":true}
23
+ // returns the app's menu bar, read through Accessibility; {"press":["File",
24
+ // "Save"]} presses the item at that path. The app's own full screen is
25
+ // switched off: the preview already sizes it to the viewer.
21
26
  import AppKit
22
27
  import ApplicationServices
23
28
  import CoreMedia
@@ -142,6 +147,103 @@ func axWindow(_ win: Win) -> AXUIElement? {
142
147
  } ?? axValue(app, kAXMainWindowAttribute)
143
148
  }
144
149
 
150
+ /// A listening Unix socket only this user can connect to.
151
+ func listening(_ path: String) -> Int32 {
152
+ unlink(path)
153
+ let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0)
154
+ var addr = sockaddr_un()
155
+ addr.sun_family = sa_family_t(AF_UNIX)
156
+ withUnsafeMutableBytes(of: &addr.sun_path) { buf in _ = path.withCString { strncpy(buf.baseAddress!.assumingMemoryBound(to: CChar.self), $0, buf.count - 1) } }
157
+ let old = umask(0o177)
158
+ let bound = withUnsafePointer(to: &addr) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size)) } }
159
+ umask(old)
160
+ guard bound == 0, chmod(path, 0o600) == 0, Darwin.listen(fd, 16) == 0 else { warn("Couldn't listen on \(path)"); exit(1) }
161
+ return fd
162
+ }
163
+
164
+ // MARK: - Menus
165
+
166
+ /// Menus deeper than this, or past this many items, are left out.
167
+ let MENU_DEPTH = 4, MENU_ITEMS = 3000
168
+ /// How a menu item's shortcut modifiers are written (AXMenuItemCmdModifiers
169
+ /// bits: 1 Shift, 2 Option, 4 Control, 8 no Command).
170
+ let SHORTCUT_MODIFIERS: [(bit: Int, symbol: String)] = [(4, "⌃"), (2, "⌥"), (1, "⇧")]
171
+
172
+ func shortcut(_ char: String?, _ modifiers: Int?) -> String? {
173
+ guard let char, !char.isEmpty, let modifiers else { return nil }
174
+ let symbols = SHORTCUT_MODIFIERS.filter { modifiers & $0.bit != 0 }.map(\.symbol).joined()
175
+ return symbols + (modifiers & 8 == 0 ? "⌘" : "") + char.uppercased()
176
+ }
177
+
178
+ /// The app's menu bar as data: titles, enabled, checked, shortcut, submenus.
179
+ /// The Apple menu is left out; the app menu (Settings, Quit) stays.
180
+ func readMenus(_ pid: pid_t) -> [[String: Any]] {
181
+ let app = AXUIElementCreateApplication(pid)
182
+ AXUIElementSetMessagingTimeout(app, 1)
183
+ guard let bar: AXUIElement = axValue(app, kAXMenuBarAttribute) else { return [] }
184
+ var budget = MENU_ITEMS
185
+ let keys = [kAXTitleAttribute, kAXEnabledAttribute, "AXMenuItemCmdChar", "AXMenuItemCmdModifiers", "AXMenuItemMarkChar", kAXChildrenAttribute] as CFArray
186
+ func items(_ menu: AXUIElement, depth: Int) -> [[String: Any]] {
187
+ var out: [[String: Any]] = []
188
+ for item in (axValue(menu, kAXChildrenAttribute) as [AXUIElement]?) ?? [] {
189
+ guard budget > 0 else { break }
190
+ budget -= 1
191
+ var values: CFArray?
192
+ guard AXUIElementCopyMultipleAttributeValues(item, keys, AXCopyMultipleAttributeOptions(), &values) == .success, let v = values as? [Any] else { continue }
193
+ let title = (v[0] as? String) ?? ""
194
+ if title.isEmpty {
195
+ if let last = out.last, last["separator"] == nil { out.append(["separator": true]) }
196
+ continue
197
+ }
198
+ var entry: [String: Any] = ["title": title, "enabled": (v[1] as? Bool) ?? false]
199
+ if let keys = shortcut(v[2] as? String, v[3] as? Int) { entry["shortcut"] = keys }
200
+ if let mark = v[4] as? String, !mark.isEmpty { entry["checked"] = true }
201
+ if depth < MENU_DEPTH, let submenu = (v[5] as? [AXUIElement])?.first {
202
+ let children = items(submenu, depth: depth + 1)
203
+ if !children.isEmpty { entry["items"] = children }
204
+ }
205
+ out.append(entry)
206
+ }
207
+ if out.last?["separator"] != nil { out.removeLast() }
208
+ return out
209
+ }
210
+ let tops: [AXUIElement] = axValue(bar, kAXChildrenAttribute) ?? []
211
+ return tops.dropFirst().compactMap { top in
212
+ guard let title: String = axValue(top, kAXTitleAttribute), !title.isEmpty, let menu = (axValue(top, kAXChildrenAttribute) as [AXUIElement]?)?.first else { return nil }
213
+ return ["title": title, "items": items(menu, depth: 1)]
214
+ }
215
+ }
216
+
217
+ /// Presses the menu item at a path of titles, like choosing it from the menu bar.
218
+ func pressMenu(_ pid: pid_t, _ path: [String]) -> String? {
219
+ let app = AXUIElementCreateApplication(pid)
220
+ AXUIElementSetMessagingTimeout(app, 1)
221
+ guard var node: AXUIElement = axValue(app, kAXMenuBarAttribute) else { return "The app has no menu bar." }
222
+ for (depth, title) in path.enumerated() {
223
+ // Below the bar, each level's items sit in a menu element.
224
+ let parent = depth == 0 ? node : ((axValue(node, kAXChildrenAttribute) as [AXUIElement]?)?.first ?? node)
225
+ let children: [AXUIElement] = axValue(parent, kAXChildrenAttribute) ?? []
226
+ guard let next = children.dropFirst(depth == 0 ? 1 : 0).first(where: { (axValue($0, kAXTitleAttribute) as String?) == title }) else {
227
+ return "No menu item \"\(path[0...depth].joined(separator: " > "))\"."
228
+ }
229
+ node = next
230
+ }
231
+ if (axValue(node, kAXEnabledAttribute) as Bool?) == false { return "\"\(path.joined(separator: " > "))\" is disabled right now." }
232
+ AXUIElementSetAttributeValue(app, kAXFrontmostAttribute as CFString, kCFBooleanTrue)
233
+ return AXUIElementPerformAction(node, kAXPressAction as CFString) == .success ? nil : "The app didn't accept the menu item."
234
+ }
235
+
236
+ /// Switches the app's own full screen off, window by window. True if any was.
237
+ func leaveFullScreen(_ pid: pid_t) -> Bool {
238
+ let app = AXUIElementCreateApplication(pid)
239
+ AXUIElementSetMessagingTimeout(app, 1)
240
+ var left = false
241
+ for window in (axValue(app, kAXWindowsAttribute) as [AXUIElement]?) ?? [] where (axValue(window, "AXFullScreen") as Bool?) == true {
242
+ left = AXUIElementSetAttributeValue(window, "AXFullScreen" as CFString, kCFBooleanFalse) == .success || left
243
+ }
244
+ return left
245
+ }
246
+
145
247
  // MARK: - Keys
146
248
 
147
249
  /// US-layout key codes for the characters a keysym can name.
@@ -275,6 +377,13 @@ final class Host: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendabl
275
377
  var wanted: (ids: [CGWindowID], region: CGRect)?
276
378
  var captured: (ids: [CGWindowID], region: CGRect)?
277
379
  var acceptor: DispatchSourceRead?
380
+ /// Menus and full screen are Accessibility round trips to the app: off the
381
+ /// frame queue, one at a time.
382
+ let ax = DispatchQueue(label: "sh.bivy.macos-display.ax")
383
+ var control: DispatchSourceRead?
384
+ /// The app's processes, including ones whose windows aren't on screen now
385
+ /// (a full-screen window in another Space).
386
+ var apps = Set<pid_t>()
278
387
  /// The last capture error, and when: retried after a pause, reported once.
279
388
  var failure = (message: "", at: 0.0)
280
389
 
@@ -300,6 +409,7 @@ final class Host: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendabl
300
409
  let all = onScreenWindows()
301
410
  known = known.filter { entry in all.contains { $0.pid == entry.key } }
302
411
  ours = all.filter { !SYSTEM_LAYERS.contains($0.layer) && owns($0.pid) }
412
+ apps.formUnion(ours.map(\.pid))
303
413
  let normal = ours.filter { $0.layer == 0 }
304
414
  if normal.count != count { count = normal.count; say("windows \(count)") }
305
415
  main = normal.max { $0.bounds.width * $0.bounds.height < $1.bounds.width * $1.bounds.height }
@@ -400,15 +510,7 @@ final class Host: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendabl
400
510
  // MARK: Serving viewers
401
511
 
402
512
  func listen(path: String) {
403
- unlink(path)
404
- let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0)
405
- var addr = sockaddr_un()
406
- addr.sun_family = sa_family_t(AF_UNIX)
407
- withUnsafeMutableBytes(of: &addr.sun_path) { buf in _ = path.withCString { strncpy(buf.baseAddress!.assumingMemoryBound(to: CChar.self), $0, buf.count - 1) } }
408
- let old = umask(0o177)
409
- let bound = withUnsafePointer(to: &addr) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size)) } }
410
- umask(old)
411
- guard bound == 0, chmod(path, 0o600) == 0, Darwin.listen(fd, 16) == 0 else { warn("Couldn't listen on \(path)"); exit(1) }
513
+ let fd = listening(path)
412
514
  let accept = DispatchSource.makeReadSource(fileDescriptor: fd, queue: q)
413
515
  accept.setEventHandler { [self] in
414
516
  let peer = Darwin.accept(fd, nil, nil)
@@ -429,6 +531,50 @@ final class Host: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendabl
429
531
  acceptor = accept
430
532
  }
431
533
 
534
+ /// The control socket: one request line, one JSON answer, then closed.
535
+ func serveControl(path: String) {
536
+ let fd = listening(path)
537
+ let accept = DispatchSource.makeReadSource(fileDescriptor: fd, queue: ax)
538
+ accept.setEventHandler { [self] in
539
+ let peer = Darwin.accept(fd, nil, nil)
540
+ guard peer >= 0 else { return }
541
+ defer { Darwin.close(peer) }
542
+ var wait = timeval(tv_sec: 5, tv_usec: 0), on: Int32 = 1
543
+ setsockopt(peer, SOL_SOCKET, SO_RCVTIMEO, &wait, socklen_t(MemoryLayout<timeval>.size))
544
+ setsockopt(peer, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
545
+ var line: [UInt8] = []
546
+ var byte: UInt8 = 0
547
+ while line.count < 16384, Darwin.read(peer, &byte, 1) == 1, byte != 10 { line.append(byte) }
548
+ let answer = respond(to: (try? JSONSerialization.jsonObject(with: Data(line))) as? [String: Any] ?? [:])
549
+ let data = ((try? JSONSerialization.data(withJSONObject: answer)) ?? Data()) + Data([10])
550
+ _ = data.withUnsafeBytes { Darwin.write(peer, $0.baseAddress, $0.count) }
551
+ }
552
+ accept.resume()
553
+ control = accept
554
+ // Full screen is checked here too, so it never holds up frames.
555
+ let timer = DispatchSource.makeTimerSource(queue: ax)
556
+ timer.schedule(deadline: .now() + 1, repeating: .seconds(1))
557
+ timer.setEventHandler { [self] in
558
+ let pids = q.sync { apps = apps.filter { kill($0, 0) == 0 }; return apps }
559
+ guard pids.contains(where: leaveFullScreen) else { return }
560
+ // Once it has animated back, fit it to the viewer again.
561
+ q.asyncAfter(deadline: .now() + 1) { self.fitted = nil }
562
+ }
563
+ timer.resume()
564
+ fullScreenTimer = timer
565
+ }
566
+ var fullScreenTimer: DispatchSourceTimer?
567
+
568
+ func respond(to request: [String: Any]) -> [String: Any] {
569
+ guard let pid = q.sync(execute: { main?.pid }) else { return ["error": "The app has no window yet."] }
570
+ if request["menu"] as? Bool == true { return ["menus": readMenus(pid)] }
571
+ if let path = request["press"] as? [String], (1...8).contains(path.count) {
572
+ if let error = pressMenu(pid, path) { return ["error": error] }
573
+ return ["ok": true]
574
+ }
575
+ return ["error": "Unknown request."]
576
+ }
577
+
432
578
  func drop(_ client: Client) {
433
579
  guard let index = clients.firstIndex(where: { $0 === client }) else { return }
434
580
  clients.remove(at: index)
@@ -711,9 +857,10 @@ case "run" where args.count > 3 && args[2] == "--":
711
857
  run(Array(args[3...]))
712
858
  case "check":
713
859
  say(permissions(prompt: args.contains("--prompt")))
714
- case "serve" where args.count == 5:
715
- let host = Host(token: args[3], scale: CGFloat(Double(args[4]) == 2 ? 2 : 1))
860
+ case "serve" where args.count == 6:
861
+ let host = Host(token: args[4], scale: CGFloat(Double(args[5]) == 2 ? 2 : 1))
716
862
  host.q.sync { host.listen(path: args[2]) }
863
+ host.serveControl(path: args[3])
717
864
  let timer = DispatchSource.makeTimerSource(queue: host.q)
718
865
  timer.schedule(deadline: .now(), repeating: .milliseconds(200))
719
866
  timer.setEventHandler { host.tick() }
@@ -721,6 +868,6 @@ case "serve" where args.count == 5:
721
868
  say("ready")
722
869
  RunLoop.main.run()
723
870
  default:
724
- warn("Usage: macos-display check [--prompt] | serve <socket> <token> <scale> | run -- <command> [args…]")
871
+ warn("Usage: macos-display check [--prompt] | serve <socket> <control> <token> <scale> | run -- <command> [args…]")
725
872
  exit(2)
726
873
  }
@@ -0,0 +1,76 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import net from "node:net";
4
+ const MAX_ITEMS = 3000;
5
+ const MAX_DEPTH = 4;
6
+ const TIMEOUT_MS = 5_000;
7
+ /** A path of titles, top menu first: "File > Export…" or ["File", "Export…"]. */
8
+ export function readMenuPath(input) {
9
+ const path = typeof input === "string" ? input.split(">").map((part) => part.trim()) : input;
10
+ if (!Array.isArray(path) || !path.length || path.length > 8 || path.some((part) => typeof part !== "string" || !part || part.length > 200)) {
11
+ throw new Error("A menu item is a path of titles, like \"File > Save\".");
12
+ }
13
+ return path;
14
+ }
15
+ /** Keeps what the display says to known fields and bounded sizes: the menu
16
+ * comes from the app, so it is data to show, never markup. */
17
+ function clean(items, depth, budget) {
18
+ if (!Array.isArray(items))
19
+ return [];
20
+ const out = [];
21
+ for (const raw of items) {
22
+ if (budget.left-- <= 0)
23
+ break;
24
+ const item = raw;
25
+ if (item?.separator === true) {
26
+ out.push({ separator: true });
27
+ continue;
28
+ }
29
+ if (typeof item?.title !== "string" || !item.title)
30
+ continue;
31
+ const entry = { title: item.title.slice(0, 200), enabled: item.enabled === true };
32
+ if (typeof item.shortcut === "string" && item.shortcut.length <= 20)
33
+ entry.shortcut = item.shortcut;
34
+ if (item.checked === true)
35
+ entry.checked = true;
36
+ const children = depth < MAX_DEPTH ? clean(item.items, depth + 1, budget) : [];
37
+ if (children.length)
38
+ entry.items = children;
39
+ out.push(entry);
40
+ }
41
+ return out;
42
+ }
43
+ /** One request to a display's control socket, one JSON answer. */
44
+ function ask(control, request) {
45
+ return new Promise((resolve, reject) => {
46
+ const socket = net.connect(control);
47
+ let data = "";
48
+ socket.setTimeout(TIMEOUT_MS, () => socket.destroy(new Error("The app didn't answer in time.")));
49
+ socket.on("data", (chunk) => { data += chunk; if (data.length > 4 * 1024 * 1024)
50
+ socket.destroy(new Error("The app's menu is too large.")); });
51
+ socket.on("error", (error) => reject(error.code ? new Error("The app isn't running.") : error));
52
+ socket.on("end", () => {
53
+ try {
54
+ const answer = JSON.parse(data);
55
+ if (typeof answer.error === "string")
56
+ reject(new Error(answer.error.slice(0, 300)));
57
+ else
58
+ resolve(answer);
59
+ }
60
+ catch {
61
+ reject(new Error("The app's display gave an unreadable answer."));
62
+ }
63
+ });
64
+ socket.end(`${JSON.stringify(request)}\n`);
65
+ });
66
+ }
67
+ /** The app's menu bar, read now, so enabled and checked states are current. */
68
+ export async function readMenus(control) {
69
+ const answer = await ask(control, { menu: true });
70
+ const budget = { left: MAX_ITEMS };
71
+ return (Array.isArray(answer.menus) ? answer.menus : []).flatMap((menu) => typeof menu?.title === "string" && menu.title ? [{ title: menu.title.slice(0, 200), items: clean(menu.items, 1, budget) }] : []);
72
+ }
73
+ /** Chooses the menu item at `path`, as if picked from the menu bar. */
74
+ export async function pressMenu(control, path) {
75
+ await ask(control, { press: path });
76
+ }
@@ -8,6 +8,7 @@ import { takeShots } from "./screenshot.js";
8
8
  import { approximate, composite, readStrokes } from "./annotate.js";
9
9
  import { captureFrame, encodePng, sendInput } from "./rfb.js";
10
10
  import { inputEvents, readAction } from "./input.js";
11
+ import { pressMenu, readMenuPath, readMenus } from "./menu.js";
11
12
  import { pngSize } from "./review.js";
12
13
  const NO_REVIEWS = { publish: (review) => review, expire: () => { } };
13
14
  const NO_DISPLAYS = { unavailable: () => "Desktop app views aren't available on this machine.", ensure: () => Promise.reject(new Error("Desktop app views aren't available on this machine.")), stop: () => { } };
@@ -373,12 +374,31 @@ export class AppService {
373
374
  const { width, height } = await sendInput(entry.display, inputEvents(action));
374
375
  if (!this.screenshots.enabled())
375
376
  return { width, height, message: "Done. Agent screenshots are off on this machine, so there's no picture of the result: bivy config set sessions.appScreenshots true" };
377
+ return { width, height, shot: await this.after(entry, sessionId), message: "Done. The shot is the app now." };
378
+ }
379
+ /** A desktop app's menu bar: listed, or one item chosen by its path of
380
+ * titles ("File > Save"). Choosing returns the app afterwards, like `act`. */
381
+ async menu(sessionId, target, path) {
382
+ const item = path === undefined ? undefined : readMenuPath(path);
383
+ const entry = this.pickView(sessionId, target, true);
384
+ if (!entry.display)
385
+ await this.windowShown(entry);
386
+ if (!entry.displayControl)
387
+ throw new Error("This machine's desktop apps don't expose their menus (macOS only). Use keyboard shortcuts: bivy app key.");
388
+ if (!item)
389
+ return { menus: await readMenus(entry.displayControl) };
390
+ await pressMenu(entry.displayControl, item);
391
+ if (!this.screenshots.enabled())
392
+ return { message: "Chosen. Agent screenshots are off on this machine, so there's no picture of the result." };
393
+ return { shot: await this.after(entry, sessionId), message: "Chosen. The shot is the app now." };
394
+ }
395
+ /** The app a moment after an action, as a screenshot. */
396
+ async after(entry, sessionId) {
376
397
  await new Promise((r) => setTimeout(r, AFTER_INPUT_MS));
377
398
  const outDir = path.join(os.tmpdir(), "bivy-shots", sessionId.replace(/[^A-Za-z0-9_-]/g, "_"), String(Date.now()));
378
399
  const run = this.shooting.catch(() => { }).then(() => this.screenshots.take([entry], { widths: [], themes: [], path: "/" }, outDir));
379
400
  this.shooting = run;
380
- const [shot] = await run;
381
- return { width, height, shot, message: "Done. The shot is the app now." };
401
+ return (await run)[0];
382
402
  }
383
403
  /** Draw on the preview: the user's marks on a picture of what they saw.
384
404
  * The picture is the Compare screenshot they drew on, a desktop app's
@@ -462,6 +482,7 @@ export class AppService {
462
482
  throw new Error("This view has no program.");
463
483
  return this.displays.ensure(entry.view.id, entry.app.name, entry.displayScale).then((display) => {
464
484
  entry.display = display.socket;
485
+ entry.displayControl = display.control;
465
486
  const [command, ...args] = [...display.launch ?? [], target.command, ...target.args];
466
487
  return { command: command, args, workspace: target.workspace, env: display.env, name: `${entry.app.name} · ${entry.view.name}` };
467
488
  });
@@ -518,6 +539,7 @@ export class AppService {
518
539
  const entry = this.registry.getView(id);
519
540
  if (entry) {
520
541
  entry.display = undefined;
542
+ entry.displayControl = undefined;
521
543
  entry.displayScale = undefined;
522
544
  }
523
545
  const server = this.servers.get(id);
@@ -23,6 +23,7 @@ export function createAppCommands(service, workspaceFor, published = () => { })
23
23
  "apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined, msg.direct === true, typeof msg.scale === "number" ? msg.scale : undefined, typeof msg.path === "string" ? msg.path : undefined),
24
24
  "apps.shot": (msg) => service.shot(String(msg.sessionId), typeof msg.appId === "string" ? msg.appId : undefined, { widths: msg.widths, themes: msg.themes, path: typeof msg.path === "string" ? msg.path : undefined }),
25
25
  "apps.input": (msg) => service.act(String(msg.sessionId), typeof msg.target === "string" ? msg.target : undefined, msg.action),
26
+ "apps.menu": (msg) => service.menu(String(msg.sessionId), typeof msg.target === "string" ? msg.target : undefined, msg.path),
26
27
  "apps.present": (msg) => service.present(String(msg.sessionId), { target: typeof msg.target === "string" ? msg.target : undefined, path: typeof msg.path === "string" ? msg.path : undefined, note: typeof msg.note === "string" ? msg.note : undefined }),
27
28
  "apps.showMe": (msg) => service.present(String(msg.sessionId), { target: typeof msg.appId === "string" ? msg.appId : undefined, trigger: "asked" }),
28
29
  "apps.mute": (msg) => service.mute(String(msg.sessionId)),
@@ -3,7 +3,7 @@
3
3
  /** Direct-mode adapters for operations whose canonical implementation is the
4
4
  * client command registry. Relay and HTTP now differ only in framing. */
5
5
  export const CLIENT_COMMAND_ROUTES = [
6
- ...["list", "publish", "offers", "adopt", "open", "logs", "shot", "input", "present", "showMe", "mute", "reviewMode", "annotate", "clearNotes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
6
+ ...["list", "publish", "offers", "adopt", "open", "logs", "shot", "input", "menu", "present", "showMe", "mute", "reviewMode", "annotate", "clearNotes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
7
7
  { method: "post", path: "/api/artifacts/list", kind: "artifacts.list" },
8
8
  { method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
9
9
  { method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
@@ -16,6 +16,7 @@ export const CLIENT_COMMAND_SCHEMAS = {
16
16
  "apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()), scale: Type.Optional(Type.Integer({ minimum: 1, maximum: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
17
17
  "apps.shot": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()), widths: Type.Optional(Type.Array(Type.Integer(), { maxItems: 4 })), themes: Type.Optional(Type.Array(Type.Union([Type.Literal("light"), Type.Literal("dark")]), { maxItems: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
18
18
  "apps.input": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })), action: Type.Object({ kind: Type.String({ maxLength: 20 }) }) }),
19
+ "apps.menu": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })), path: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 200 }), { minItems: 1, maxItems: 8 })) }),
19
20
  "apps.present": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })), path: Type.Optional(Type.String({ maxLength: 2048 })), note: Type.Optional(Type.String({ maxLength: 2000 })) }),
20
21
  "apps.showMe": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()) }),
21
22
  "apps.mute": Type.Object({ ...request, ...session }),
package/dist/worktree.js CHANGED
@@ -4,6 +4,7 @@ import { execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
+ import { gitNonInteractiveEnv } from "./git-auth.js";
7
8
  import { cowProvisionDeps, provisionDepsByInstall } from "./worktree-provision.js";
8
9
  const exec = promisify(execFile);
9
10
  /** Slugify arbitrary text into a safe git branch / directory segment. */
@@ -86,7 +87,7 @@ export async function createWorktree(opts) {
86
87
  const localExists = await refExists(repoRoot, `refs/heads/${branch}`);
87
88
  const remoteExists = !localExists && await refExists(repoRoot, `refs/remotes/origin/${branch}`);
88
89
  if (localExists) {
89
- await exec("git", ["-C", repoRoot, "worktree", "add", wtPath, branch]);
90
+ await worktreeAdd(repoRoot, [wtPath, branch]);
90
91
  }
91
92
  else {
92
93
  if (remoteExists)
@@ -98,10 +99,10 @@ export async function createWorktree(opts) {
98
99
  // Empty repo (e.g. a freshly created GitHub repo with no commits): HEAD is
99
100
  // unborn, so it can't seed a worktree. Start the branch as an orphan —
100
101
  // the session's first commit becomes the repo's root commit.
101
- await exec("git", ["-C", repoRoot, "worktree", "add", "--orphan", "-b", branch, wtPath]);
102
+ await worktreeAdd(repoRoot, ["--orphan", "-b", branch, wtPath]);
102
103
  }
103
104
  else {
104
- await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, wtPath, base]);
105
+ await worktreeAdd(repoRoot, ["-b", branch, wtPath, base]);
105
106
  }
106
107
  }
107
108
  // Opportunistically reuse a sibling worktree's installed deps (node_modules,
@@ -120,6 +121,42 @@ export async function createWorktree(opts) {
120
121
  }
121
122
  return { path: wtPath, branch, repoRoot };
122
123
  }
124
+ /**
125
+ * `git worktree add`, non-interactively. Managed repos are partial clones
126
+ * (`--filter=blob:none`), so the checkout lazily fetches blobs from origin and
127
+ * needs credentials. The daemon has no TTY: without GIT_TERMINAL_PROMPT=0 a
128
+ * credential miss falls through to git's terminal prompt, which fails with the
129
+ * cryptic "could not read Username …: Device not configured" (macOS ENXIO).
130
+ */
131
+ async function worktreeAdd(repoRoot, args) {
132
+ try {
133
+ await exec("git", ["-C", repoRoot, "worktree", "add", ...args], { env: gitNonInteractiveEnv() });
134
+ }
135
+ catch (error) {
136
+ const stderr = String(error.stderr ?? "");
137
+ const friendly = describeWorktreeAddError(stderr);
138
+ if (friendly)
139
+ throw new Error(friendly, { cause: error });
140
+ throw error;
141
+ }
142
+ }
143
+ /**
144
+ * Turn a `git worktree add` failure caused by the lazy blob fetch into a message
145
+ * a person can act on, with git's output kept below as details. Returns
146
+ * undefined for any other failure (the raw error is already specific).
147
+ */
148
+ export function describeWorktreeAddError(stderr) {
149
+ const detail = stderr.trim();
150
+ const auth = /could not read (Username|Password)|terminal prompts disabled|Authentication failed|Invalid username or (password|token)|Permission to .* denied/i.test(detail);
151
+ const promisor = /from promisor remote/i.test(detail);
152
+ if (auth) {
153
+ return `Couldn't create the session's worktree: git had to download repository files from GitHub, but no GitHub credentials were available on this machine. Reconnect GitHub for this machine in Bivy, then start the session again.\n\n${detail}`;
154
+ }
155
+ if (promisor) {
156
+ return `Couldn't create the session's worktree: git had to download repository files from the remote and the download failed. Check this machine's network connection, then start the session again.\n\n${detail}`;
157
+ }
158
+ return undefined;
159
+ }
123
160
  /** True if `ref` (a fully-qualified ref) resolves in the repo. */
124
161
  async function refExists(repoRoot, ref) {
125
162
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.17.3-staging.30",
3
+ "version": "0.17.3-staging.32",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",