@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.
- package/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- 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
|
+
}
|
package/dist/approval.js
ADDED
|
@@ -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
|
+
});
|