@bivy/bivy 0.0.0 → 0.1.0-staging.2

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 (146) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +265 -5
  3. package/bin/acp-shim.mjs +298 -0
  4. package/bin/agent-manifest.json +277 -0
  5. package/bin/bivy.mjs +4100 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/prune-sessions.mjs +52 -0
  9. package/bin/sessions-list.mjs +27 -0
  10. package/bin/shim-path.mjs +126 -0
  11. package/bin/uninstall-paths.mjs +48 -0
  12. package/dist/approval.js +87 -0
  13. package/dist/attach.js +248 -0
  14. package/dist/auth.js +258 -0
  15. package/dist/bivy-login.js +180 -0
  16. package/dist/browser-open.js +50 -0
  17. package/dist/control-plane-tasks.js +236 -0
  18. package/dist/data-dir.js +25 -0
  19. package/dist/device-registry.js +201 -0
  20. package/dist/e2e.js +70 -0
  21. package/dist/ephemeral-exec.js +109 -0
  22. package/dist/exec.js +209 -0
  23. package/dist/git-auth.js +155 -0
  24. package/dist/github-app-auth.js +107 -0
  25. package/dist/github-app-connect.js +235 -0
  26. package/dist/github-app-manifest.js +82 -0
  27. package/dist/github-app-sync-cli.js +93 -0
  28. package/dist/github-app-vault.js +106 -0
  29. package/dist/github-apps.js +121 -0
  30. package/dist/github-connect-repo.js +74 -0
  31. package/dist/github-device-auth.js +109 -0
  32. package/dist/github-tasks.js +650 -0
  33. package/dist/guard.js +109 -0
  34. package/dist/harness/cache-evict.js +88 -0
  35. package/dist/harness/checkpoint.js +0 -0
  36. package/dist/harness/cow-clone.js +84 -0
  37. package/dist/harness/dep-cache.js +78 -0
  38. package/dist/harness/disk-admission.js +46 -0
  39. package/dist/harness/egress.js +30 -0
  40. package/dist/harness/manager.js +97 -0
  41. package/dist/harness/mcp-config-formats.js +164 -0
  42. package/dist/harness/mcp-config.js +111 -0
  43. package/dist/harness/mcp-inject.js +134 -0
  44. package/dist/harness/mcp-proxy-cli.js +88 -0
  45. package/dist/harness/mcp-proxy.js +150 -0
  46. package/dist/harness/net-proxy.js +120 -0
  47. package/dist/harness/sandbox.js +96 -0
  48. package/dist/history-sync.js +26 -0
  49. package/dist/hosted-endpoints.d.mts +14 -0
  50. package/dist/hosted-endpoints.mjs +35 -0
  51. package/dist/identity.js +153 -0
  52. package/dist/integrations/index.js +4 -0
  53. package/dist/integrations/manager.js +279 -0
  54. package/dist/integrations/oauth.js +78 -0
  55. package/dist/integrations/registry.js +239 -0
  56. package/dist/integrations/store.js +54 -0
  57. package/dist/integrations/types.js +1 -0
  58. package/dist/linear-tasks.js +49 -0
  59. package/dist/metadata.js +226 -0
  60. package/dist/multiplexer.js +79 -0
  61. package/dist/native-pi.js +38 -0
  62. package/dist/node-stats.js +237 -0
  63. package/dist/pairing-crypto.js +105 -0
  64. package/dist/policy/conditions.js +103 -0
  65. package/dist/policy/policy-engine.js +20 -0
  66. package/dist/policy/risk.js +18 -0
  67. package/dist/policy/ruleset.js +113 -0
  68. package/dist/policy/run-policy.js +108 -0
  69. package/dist/policy/session-reroute.js +96 -0
  70. package/dist/pty-runner.py +95 -0
  71. package/dist/question.js +146 -0
  72. package/dist/redact.js +97 -0
  73. package/dist/relay-attach.js +345 -0
  74. package/dist/relay-chunk.js +73 -0
  75. package/dist/relay-cli-crypto.js +70 -0
  76. package/dist/relay-client.js +344 -0
  77. package/dist/relay-setup.js +262 -0
  78. package/dist/repo-workspace.js +208 -0
  79. package/dist/runtime/adoption.js +45 -0
  80. package/dist/runtime/agent-service-bin.js +149 -0
  81. package/dist/runtime/agent-service.js +439 -0
  82. package/dist/runtime/ansi.js +27 -0
  83. package/dist/runtime/anthropic-preflight.js +80 -0
  84. package/dist/runtime/claude-code.js +1364 -0
  85. package/dist/runtime/cli-parsers.js +647 -0
  86. package/dist/runtime/codex-auth.js +168 -0
  87. package/dist/runtime/codex-preflight.js +60 -0
  88. package/dist/runtime/codex-sessions.js +229 -0
  89. package/dist/runtime/control-plane-location.js +74 -0
  90. package/dist/runtime/credential-ingest.js +122 -0
  91. package/dist/runtime/credential-provisioning.js +79 -0
  92. package/dist/runtime/credential-store.js +435 -0
  93. package/dist/runtime/credentials.js +153 -0
  94. package/dist/runtime/host.js +153 -0
  95. package/dist/runtime/index.js +1548 -0
  96. package/dist/runtime/local-model-store.js +194 -0
  97. package/dist/runtime/location-registry.js +28 -0
  98. package/dist/runtime/model-catalog.js +97 -0
  99. package/dist/runtime/model-namer.js +85 -0
  100. package/dist/runtime/native-process-scan.js +102 -0
  101. package/dist/runtime/native-session-discovery.js +103 -0
  102. package/dist/runtime/normalize.js +75 -0
  103. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  104. package/dist/runtime/oauth/model-oauth.js +324 -0
  105. package/dist/runtime/opencode-preflight.js +55 -0
  106. package/dist/runtime/pi-auth.js +82 -0
  107. package/dist/runtime/pi-oauth.js +52 -0
  108. package/dist/runtime/pi-session-discovery.js +42 -0
  109. package/dist/runtime/pi.js +518 -0
  110. package/dist/runtime/process.js +499 -0
  111. package/dist/runtime/protocol.js +630 -0
  112. package/dist/runtime/remote.js +541 -0
  113. package/dist/runtime/rpc-protocol.js +56 -0
  114. package/dist/runtime/ruleset-store.js +117 -0
  115. package/dist/runtime/session-location.js +50 -0
  116. package/dist/runtime/types.js +17 -0
  117. package/dist/secrets-cli.js +134 -0
  118. package/dist/secrets.js +264 -0
  119. package/dist/server.js +9411 -0
  120. package/dist/session/bivy-session.js +1 -0
  121. package/dist/session/checkpoint-pack.js +133 -0
  122. package/dist/session/event-log.js +340 -0
  123. package/dist/session/fork-dirty.js +73 -0
  124. package/dist/session/fork-prereqs.js +61 -0
  125. package/dist/session/fork.js +57 -0
  126. package/dist/session/native-import.js +56 -0
  127. package/dist/session/reconnect.js +168 -0
  128. package/dist/session/replication-service.js +236 -0
  129. package/dist/session/replication.js +106 -0
  130. package/dist/session/replicator.js +140 -0
  131. package/dist/session/session-new-dedupe.js +42 -0
  132. package/dist/session/sibling-client.js +201 -0
  133. package/dist/session/transcript-merge.js +131 -0
  134. package/dist/session/transcript-normal.js +130 -0
  135. package/dist/session/workspace-context.js +1 -0
  136. package/dist/session-event-coalescer.js +50 -0
  137. package/dist/session-identity.js +34 -0
  138. package/dist/session-ref.js +65 -0
  139. package/dist/stt-cli.js +131 -0
  140. package/dist/stt.js +168 -0
  141. package/dist/terminal.js +409 -0
  142. package/dist/wire-format.js +67 -0
  143. package/dist/worktree-provision.js +118 -0
  144. package/dist/worktree.js +117 -0
  145. package/package.json +40 -6
  146. package/public/qr.js +464 -0
@@ -0,0 +1,126 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Managed shell-rc PATH block for `bivy shim`.
4
+ //
5
+ // Installing a shim into `~/.local/bin` only helps if that directory wins over
6
+ // the real binary on the user's interactive PATH. It usually does NOT: version
7
+ // managers (mise/asdf/nvm/pyenv) inject their bin dirs via shell-init hooks that
8
+ // run *during* interactive startup and prepend to PATH — after any static
9
+ // `export PATH="$HOME/.local/bin:$PATH"` the user may have set. So the real
10
+ // agent keeps winning and the shim never fires (see the original report).
11
+ //
12
+ // The reliable fix is a small, clearly-marked block that Bivy manages at the END
13
+ // of the user's rc file (`.zshrc`/`.bashrc`) — i.e. AFTER the version-manager
14
+ // init — that force-moves the shim dir(s) to the front of PATH. This module is
15
+ // the pure, unit-tested core: render the block, and idempotently upsert/remove
16
+ // it inside arbitrary rc-file text. `bin/bivy.mjs` does the file IO.
17
+ //
18
+ // The block is written to be valid in both bash and zsh (the shells whose rc we
19
+ // touch). It deliberately avoids `for d in $PATH` word-splitting — which behaves
20
+ // differently in zsh — and instead rewrites PATH with parameter substitution.
21
+
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+
25
+ export const MANAGED_BLOCK_START = "# >>> bivy shim path >>>";
26
+ export const MANAGED_BLOCK_END = "# <<< bivy shim path <<<";
27
+
28
+ // Render a directory as a shell expression for the rc file. Directories under
29
+ // $HOME are emitted as "$HOME/…" so the block stays portable across machines
30
+ // that sync dotfiles; anything else is an absolute, double-quoted literal. In
31
+ // both forms the value is double-quoted, so spaces are handled and the leading
32
+ // `$HOME` still expands.
33
+ export function shimDirToRcExpr(dir, home = os.homedir()) {
34
+ const resolved = path.resolve(dir);
35
+ const h = path.resolve(home);
36
+ const esc = (s) => s.replace(/(["\\$`])/g, "\\$1");
37
+ if (resolved === h) return '"$HOME"';
38
+ if (resolved.startsWith(h + path.sep)) {
39
+ return `"$HOME${esc(resolved.slice(h.length))}"`;
40
+ }
41
+ return `"${esc(resolved)}"`;
42
+ }
43
+
44
+ // Render the managed block for one or more shim directories. Later-listed dirs
45
+ // are prepended first so the FIRST directory in `dirs` ends up frontmost on
46
+ // PATH (highest priority).
47
+ export function renderManagedBlock(dirs, { home = os.homedir() } = {}) {
48
+ const unique = [...new Set(dirs.map((d) => path.resolve(d)))];
49
+ const exprs = unique
50
+ .slice()
51
+ .reverse()
52
+ .map((d) => shimDirToRcExpr(d, home))
53
+ .join(" ");
54
+ return [
55
+ MANAGED_BLOCK_START,
56
+ "# Keeps Bivy's agent-shim dir(s) ahead of version-manager bins so that a",
57
+ "# shimmed agent resolves to its Bivy shim, not the real binary. Managed by",
58
+ "# `bivy shim`: regenerated on install, removed when the last shim is gone.",
59
+ "# Do not edit — your changes will be overwritten. (Valid in bash and zsh.)",
60
+ `for __bivy_dir in ${exprs}; do`,
61
+ ' [ -d "$__bivy_dir" ] || continue',
62
+ ' case ":$PATH:" in',
63
+ ' *":$__bivy_dir:"*)',
64
+ ' PATH=":$PATH:"',
65
+ ' PATH="${PATH//:$__bivy_dir:/:}"',
66
+ ' PATH="${PATH#:}"; PATH="${PATH%:}" ;;',
67
+ " esac",
68
+ ' PATH="$__bivy_dir${PATH:+:$PATH}"',
69
+ "done",
70
+ "unset __bivy_dir",
71
+ "export PATH",
72
+ MANAGED_BLOCK_END,
73
+ ].join("\n");
74
+ }
75
+
76
+ // True if `content` already contains a managed block.
77
+ export function hasManagedBlock(content) {
78
+ return typeof content === "string" && content.includes(MANAGED_BLOCK_START);
79
+ }
80
+
81
+ // Return `content` with any managed block removed. Line-based so it tolerates
82
+ // leading/trailing whitespace on the marker lines, and collapses the blank-line
83
+ // gap the block leaves behind so repeated upsert/remove cycles don't accrete
84
+ // blank lines.
85
+ export function removeManagedBlock(content) {
86
+ if (typeof content !== "string" || content === "") return content ?? "";
87
+ const lines = content.split("\n");
88
+ const out = [];
89
+ let inBlock = false;
90
+ for (const line of lines) {
91
+ const trimmed = line.trim();
92
+ if (!inBlock && trimmed === MANAGED_BLOCK_START) {
93
+ inBlock = true;
94
+ continue;
95
+ }
96
+ if (inBlock) {
97
+ if (trimmed === MANAGED_BLOCK_END) inBlock = false;
98
+ continue;
99
+ }
100
+ out.push(line);
101
+ }
102
+ // Collapse 2+ trailing blank lines (left where the block used to be) to a
103
+ // single trailing newline.
104
+ return out.join("\n").replace(/\n{2,}$/, "\n");
105
+ }
106
+
107
+ // Idempotently place `block` at the END of `content` (after version-manager
108
+ // init). Any existing managed block is removed first, so re-running never
109
+ // duplicates it and always re-anchors the block last.
110
+ export function upsertManagedBlock(content, block) {
111
+ const cleaned = removeManagedBlock(content || "").replace(/\s*$/, "");
112
+ if (cleaned === "") return `${block}\n`;
113
+ return `${cleaned}\n\n${block}\n`;
114
+ }
115
+
116
+ // Resolve which rc file to manage for a given login shell. Returns
117
+ // `{ file, shell }` for shells whose rc we understand (zsh/bash), or `null`
118
+ // when we can't safely auto-manage (caller should print manual instructions).
119
+ export function rcFileForShell(shellPath, home = os.homedir()) {
120
+ const shell = path.basename(String(shellPath || "")).toLowerCase();
121
+ if (shell.includes("zsh")) return { file: path.join(home, ".zshrc"), shell: "zsh" };
122
+ // Interactive bash terminals source ~/.bashrc (login shells chain to it from
123
+ // ~/.bash_profile on most setups); that's where the interactive PATH lives.
124
+ if (shell.includes("bash")) return { file: path.join(home, ".bashrc"), shell: "bash" };
125
+ return null;
126
+ }
@@ -0,0 +1,48 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Pure(ish) filesystem helper for `bivy uninstall`, extracted so it can be
4
+ // unit-tested without executing the CLI (bin/bivy.mjs runs main() on import).
5
+ //
6
+ // Background: `bivy uninstall --keep-sessions` used to move the raw pi
7
+ // transcripts aside to a one-off `~/bivy-sessions-<timestamp>` folder that
8
+ // nothing ever restored, and deleted the durable session index
9
+ // (`.bivy/metadata.json`) regardless of the flag — so both the CLI and the
10
+ // React app showed nothing after a fresh reinstall (issue #461).
11
+ // `removeExcept` instead deletes everything under the app's state dir except
12
+ // the paths the caller says to keep, leaving them exactly where they already
13
+ // are — the path a rerun of a dev checkout, or install.sh's own `.bivy`
14
+ // carry-forward on a packaged reinstall, already looks for them.
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ /**
19
+ * Delete everything under `root` except the given absolute paths, pruning
20
+ * (but not removing) the directory chain needed to reach them. With no `keep`
21
+ * entries this is equivalent to `rm -rf root`.
22
+ */
23
+ export function removeExcept(root, keep) {
24
+ if (!keep.length) {
25
+ fs.rmSync(root, { recursive: true, force: true });
26
+ return;
27
+ }
28
+ const keepResolved = keep.map((p) => path.resolve(p));
29
+ const isAncestorOfKept = (dir) => keepResolved.some((k) => k === dir || k.startsWith(dir + path.sep));
30
+ const walk = (dir) => {
31
+ let ents;
32
+ try {
33
+ ents = fs.readdirSync(dir, { withFileTypes: true });
34
+ } catch {
35
+ return;
36
+ }
37
+ for (const ent of ents) {
38
+ const full = path.resolve(path.join(dir, ent.name));
39
+ if (keepResolved.includes(full)) continue;
40
+ if (ent.isDirectory() && isAncestorOfKept(full)) {
41
+ walk(full);
42
+ continue;
43
+ }
44
+ fs.rmSync(full, { recursive: true, force: true });
45
+ }
46
+ };
47
+ walk(root);
48
+ }
@@ -0,0 +1,87 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { randomUUID } from "node:crypto";
4
+ /** Cap on retained resolved/expired approvals so a long-lived daemon can't grow
5
+ * history (and every /api/approvals + session-list scan) without bound. */
6
+ const MAX_HISTORY = 200;
7
+ export class ApprovalManager {
8
+ pending = new Map();
9
+ history = [];
10
+ listeners = new Set();
11
+ /** Append to history, trimming to the most recent MAX_HISTORY entries. */
12
+ record(request) {
13
+ this.history.push(request);
14
+ if (this.history.length > MAX_HISTORY) {
15
+ this.history.splice(0, this.history.length - MAX_HISTORY);
16
+ }
17
+ }
18
+ onRequest(listener) {
19
+ this.listeners.add(listener);
20
+ return () => this.listeners.delete(listener);
21
+ }
22
+ list() {
23
+ return [...this.history, ...[...this.pending.values()].map((p) => p.request)];
24
+ }
25
+ async request(input) {
26
+ const request = {
27
+ id: randomUUID(),
28
+ sessionId: input.sessionId,
29
+ toolName: input.toolName,
30
+ input: input.toolInput,
31
+ reason: input.reason,
32
+ risk: input.risk,
33
+ workspace: input.workspace,
34
+ repo: input.repo,
35
+ branch: input.branch,
36
+ createdAt: Date.now(),
37
+ status: "pending",
38
+ };
39
+ const approved = await new Promise((resolve) => {
40
+ const timeout = setTimeout(() => {
41
+ const pending = this.pending.get(request.id);
42
+ if (!pending)
43
+ return;
44
+ pending.request.status = "expired";
45
+ this.pending.delete(request.id);
46
+ this.record(pending.request);
47
+ resolve(false);
48
+ }, input.timeoutMs ?? 5 * 60_000);
49
+ this.pending.set(request.id, { request, resolve, timeout });
50
+ for (const listener of this.listeners)
51
+ listener(request);
52
+ });
53
+ return approved;
54
+ }
55
+ resolve(id, approved) {
56
+ const pending = this.pending.get(id);
57
+ if (!pending)
58
+ return false;
59
+ clearTimeout(pending.timeout);
60
+ pending.request.status = approved ? "approved" : "rejected";
61
+ this.pending.delete(id);
62
+ this.record(pending.request);
63
+ pending.resolve(approved);
64
+ return true;
65
+ }
66
+ /** Cancel (deny) any pending approvals for a session being torn down, so a
67
+ * killed/closed session's ApprovalCard doesn't linger on connected clients
68
+ * until its 5-minute timeout. Mirrors QuestionManager.cancelForSession. */
69
+ cancelForSession(sessionId) {
70
+ for (const [id, pending] of this.pending) {
71
+ if (pending.request.sessionId === sessionId)
72
+ this.resolve(id, false);
73
+ }
74
+ }
75
+ resolveAll(approved) {
76
+ const ids = [...this.pending.keys()];
77
+ const resolved = [];
78
+ for (const id of ids) {
79
+ const pending = this.pending.get(id);
80
+ if (!pending)
81
+ continue;
82
+ this.resolve(id, approved);
83
+ resolved.push(pending.request);
84
+ }
85
+ return resolved;
86
+ }
87
+ }
package/dist/attach.js ADDED
@@ -0,0 +1,248 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { StringDecoder } from "node:string_decoder";
4
+ import { WebSocket } from "ws";
5
+ /**
6
+ * Raw-TTY bridge for `bivy run` and `bivy resume`.
7
+ *
8
+ * The agent's PTY lives in the node daemon; this process is just a client that
9
+ * binds the local terminal to it: local keystrokes become `terminal.input`, the
10
+ * daemon's output is written raw to stdout, and window resizes are forwarded.
11
+ * Because the PTY is daemon-owned, detaching here (Ctrl-\ twice, or losing the
12
+ * connection) leaves the session running so you can rejoin it later from another
13
+ * terminal (`bivy resume`), a phone, or the web app.
14
+ *
15
+ * Modes:
16
+ * --run <specJson> open a fresh run-terminal for a resolved agent command
17
+ * --attach <termId> bind to an existing run-terminal (replays scrollback)
18
+ */
19
+ const c = {
20
+ dim: (s) => `\x1b[2m${s}\x1b[22m`,
21
+ bold: (s) => `\x1b[1m${s}\x1b[22m`,
22
+ red: (s) => `\x1b[31m${s}\x1b[39m`,
23
+ green: (s) => `\x1b[32m${s}\x1b[39m`,
24
+ cyan: (s) => `\x1b[36m${s}\x1b[39m`,
25
+ };
26
+ /**
27
+ * Undo the terminal modes a full-screen TUI (Claude Code, Codex, vim) turns on:
28
+ * leave the alternate screen, re-show the cursor, and switch off bracketed
29
+ * paste, focus reporting, mouse tracking, application cursor/keypad keys and
30
+ * any kitty keyboard-protocol flags.
31
+ *
32
+ * Needed because the daemon-side PTY can die without the TUI restoring the
33
+ * terminal itself: a "continue as chat" takeover kills the agent's PTY, and
34
+ * TerminalManager.close() deliberately drops its final output — the very chunk
35
+ * that would have carried the TUI's own restore sequences. The same applies to
36
+ * a Ctrl-\ detach, where the TUI keeps running remotely and never resets the
37
+ * local terminal. Without this, the user's original terminal is left in those
38
+ * modes and every mouse move, focus change or paste smears escape garbage over
39
+ * the shell prompt. Each sequence is a no-op when its mode was never enabled.
40
+ */
41
+ const TTY_MODE_RESET = "\x1b[?1049l" + // leave the alternate screen
42
+ "\x1b[?2004l" + // bracketed paste off
43
+ "\x1b[?1004l" + // focus reporting off
44
+ "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l" + // mouse tracking off
45
+ "\x1b[<u" + // pop kitty keyboard-protocol flags (no-op if never pushed)
46
+ "\x1b[?1l\x1b>" + // cursor keys + keypad back to normal mode
47
+ "\x1b[?25h" + // cursor visible
48
+ "\x1b[0m"; // reset colors/attributes
49
+ function parseArgs(argv) {
50
+ let url = process.env.BIVY_URL || `http://localhost:${process.env.PORT || "4317"}`;
51
+ let token = process.env.BIVY_DEVICE_TOKEN || undefined;
52
+ let mode = "attach";
53
+ let run;
54
+ let termId;
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const arg = argv[i];
57
+ if (arg === "--url" && argv[i + 1])
58
+ url = argv[++i];
59
+ else if (arg.startsWith("--url="))
60
+ url = arg.slice("--url=".length);
61
+ else if (arg === "--token" && argv[i + 1])
62
+ token = argv[++i];
63
+ else if (arg.startsWith("--token="))
64
+ token = arg.slice("--token=".length);
65
+ else if (arg === "--run" && argv[i + 1]) {
66
+ mode = "run";
67
+ run = JSON.parse(argv[++i]);
68
+ }
69
+ else if (arg === "--attach" && argv[i + 1]) {
70
+ mode = "attach";
71
+ termId = argv[++i];
72
+ }
73
+ }
74
+ return { url: url.replace(/\/+$/, ""), token, mode, run, termId };
75
+ }
76
+ function wsUrl(baseUrl, token) {
77
+ const u = new URL(baseUrl);
78
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
79
+ u.pathname = "/ws";
80
+ u.search = token ? `?access_token=${encodeURIComponent(token)}` : "";
81
+ return u.toString();
82
+ }
83
+ function termSize() {
84
+ return { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 };
85
+ }
86
+ /**
87
+ * Bind the local TTY to a daemon-owned terminal until it exits or the user
88
+ * detaches. `open` sends the message that starts/attaches the terminal once the
89
+ * socket is ready; the returned promise resolves with the process exit code.
90
+ */
91
+ function bridge(args, open) {
92
+ return new Promise((resolve) => {
93
+ const socket = new WebSocket(wsUrl(args.url, args.token));
94
+ let termId = args.termId;
95
+ let bound = false;
96
+ let detachArmed = 0;
97
+ let settled = false;
98
+ const inputDecoder = new StringDecoder("utf8");
99
+ const stdin = process.stdin;
100
+ const wasRaw = stdin.isTTY ? stdin.isRaw : false;
101
+ let modesReset = false;
102
+ // Reset TUI terminal modes exactly once, and only if a terminal was ever
103
+ // bound (before that, no TUI output has touched this terminal). Callers on
104
+ // the message paths invoke this BEFORE printing their exit message so the
105
+ // message lands on the primary screen, not the alternate screen the reset
106
+ // just left (where it would vanish).
107
+ const resetModes = () => {
108
+ if (modesReset || !bound)
109
+ return;
110
+ modesReset = true;
111
+ process.stdout.write(TTY_MODE_RESET);
112
+ };
113
+ const restore = () => {
114
+ resetModes();
115
+ try {
116
+ if (stdin.isTTY)
117
+ stdin.setRawMode(wasRaw);
118
+ }
119
+ catch { }
120
+ stdin.pause();
121
+ stdin.removeListener("data", onInput);
122
+ process.stdout.removeListener("resize", onResize);
123
+ };
124
+ const finish = (code) => {
125
+ if (settled)
126
+ return;
127
+ settled = true;
128
+ restore();
129
+ try {
130
+ socket.close();
131
+ }
132
+ catch { }
133
+ resolve(code);
134
+ };
135
+ const send = (payload) => {
136
+ if (socket.readyState === WebSocket.OPEN)
137
+ socket.send(JSON.stringify(payload));
138
+ };
139
+ const onInput = (data) => {
140
+ // Ctrl-\ (0x1c) twice in a row detaches, leaving the daemon session alive.
141
+ if (data.length === 1 && data[0] === 0x1c) {
142
+ const now = Date.now();
143
+ if (now - detachArmed < 1500) {
144
+ resetModes();
145
+ process.stdout.write(c.dim("\r\n[detached — session still running; `bivy resume` to return]\r\n"));
146
+ finish(0);
147
+ return;
148
+ }
149
+ detachArmed = now;
150
+ return;
151
+ }
152
+ const text = inputDecoder.write(data);
153
+ if (termId && text)
154
+ send({ kind: "terminal.input", termId, data: text });
155
+ };
156
+ const onResize = () => {
157
+ if (!termId)
158
+ return;
159
+ const { cols, rows } = termSize();
160
+ send({ kind: "terminal.resize", termId, cols, rows });
161
+ };
162
+ const beginTty = () => {
163
+ if (bound)
164
+ return;
165
+ bound = true;
166
+ if (stdin.isTTY)
167
+ stdin.setRawMode(true);
168
+ stdin.resume();
169
+ stdin.on("data", onInput);
170
+ process.stdout.on("resize", onResize);
171
+ };
172
+ socket.on("open", () => open(socket));
173
+ socket.on("error", (err) => {
174
+ process.stderr.write(c.red(`\r\nConnection error: ${err instanceof Error ? err.message : String(err)}\r\n`));
175
+ finish(1);
176
+ });
177
+ socket.on("close", () => finish(settled ? 0 : 1));
178
+ socket.on("message", (raw) => {
179
+ let msg;
180
+ try {
181
+ msg = JSON.parse(raw.toString());
182
+ }
183
+ catch {
184
+ return;
185
+ }
186
+ const type = msg.type;
187
+ if (type === "terminal.opened" && !termId) {
188
+ termId = String(msg.termId);
189
+ beginTty();
190
+ onResize();
191
+ }
192
+ else if (type === "terminal.attached" && (!termId || msg.termId === termId)) {
193
+ termId = String(msg.termId);
194
+ if (typeof msg.data === "string")
195
+ process.stdout.write(msg.data);
196
+ beginTty();
197
+ onResize();
198
+ }
199
+ else if (type === "terminal.gone" && msg.termId === termId) {
200
+ resetModes();
201
+ process.stderr.write(c.red(`\r\nTerminal ${termId} is no longer running.\r\n`));
202
+ finish(1);
203
+ }
204
+ else if (type === "terminal.output" && msg.termId === termId) {
205
+ if (typeof msg.data === "string")
206
+ process.stdout.write(msg.data);
207
+ }
208
+ else if ((type === "terminal.exit" || type === "terminal.closed") && msg.termId === termId) {
209
+ const code = typeof msg.code === "number" ? msg.code : 0;
210
+ resetModes();
211
+ process.stdout.write(c.dim(`\r\n[session ended]\r\n`));
212
+ finish(code);
213
+ }
214
+ else if (type === "terminal.error") {
215
+ process.stderr.write(c.red(`\r\n${String(msg.error || "Terminal error")}\r\n`));
216
+ if (!bound)
217
+ finish(1);
218
+ }
219
+ });
220
+ });
221
+ }
222
+ async function main() {
223
+ const args = parseArgs(process.argv.slice(2));
224
+ if (args.mode === "run") {
225
+ const spec = args.run;
226
+ const { cols, rows } = termSize();
227
+ process.stdout.write(c.dim(`Starting ${c.cyan(spec.name || spec.label || spec.agent || spec.command)} — Ctrl-\\ Ctrl-\\ to detach\r\n`));
228
+ const code = await bridge(args, (ws) => ws.send(JSON.stringify({ kind: "terminal.open.run", agent: spec.agent, label: spec.label, name: spec.name, model: spec.model, command: spec.command, args: spec.args ?? [], workspace: spec.workspace, sessionId: spec.sessionId, cols, rows })));
229
+ process.exit(code);
230
+ }
231
+ // attach
232
+ if (!args.termId) {
233
+ process.stderr.write(c.dim("Nothing to attach to. Start one with `bivy run <agent>`, or pick one with `bivy resume`.\n"));
234
+ process.exit(1);
235
+ }
236
+ await attachById(args, args.termId);
237
+ }
238
+ // Bind the local TTY to an existing run-terminal by id (replays scrollback).
239
+ async function attachById(args, termId) {
240
+ const { cols, rows } = termSize();
241
+ process.stdout.write(c.dim(`Attaching to ${c.cyan(termId)} — Ctrl-\\ Ctrl-\\ to detach\r\n`));
242
+ const code = await bridge({ ...args, termId }, (ws) => ws.send(JSON.stringify({ kind: "terminal.attach", termId, cols, rows })));
243
+ process.exit(code);
244
+ }
245
+ main().catch((error) => {
246
+ process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
247
+ process.exit(1);
248
+ });