@bivy/bivy 0.17.3-staging.9 → 0.18.0

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.
@@ -98,6 +98,44 @@ export function createRunTerminals(deps) {
98
98
  }
99
99
  return false;
100
100
  }
101
+ // One conversation, one writer. A run pinned to a session that is also open as
102
+ // a chat owns it while the PTY lives: the chat locks (and says so on every
103
+ // device), and when the PTY ends the chat reloads what the terminal wrote.
104
+ function bindRun(record, termId) {
105
+ record.tuiTermId = termId;
106
+ void deps.sessionTerminalsRecord(record.id, { termId }).catch(() => { });
107
+ deps.broadcastTuiState(record.id, true);
108
+ }
109
+ function releaseRun(record, termId) {
110
+ if (record.tuiTermId !== termId)
111
+ return;
112
+ record.tuiTermId = undefined;
113
+ void deps.sessionTerminalsForget(record.id).catch(() => { });
114
+ record.tuiRefreshing = true;
115
+ deps.broadcastTuiState(record.id, false);
116
+ void deps.refreshRecordAfterTui(record);
117
+ }
118
+ function adoptLiveRun(record) {
119
+ if (record.tuiTermId)
120
+ return;
121
+ for (const id of runTerminals) {
122
+ const pinned = terminals.meta(id)?.sessionId;
123
+ if (pinned && (pinned === record.id || pinned === record.sessionFile)) {
124
+ bindRun(record, id);
125
+ return;
126
+ }
127
+ }
128
+ }
129
+ /** Stop a run because its session is continuing as a chat. Tells every viewer
130
+ * where the session went first, so a terminal attached elsewhere (a laptop's
131
+ * `bivy run`) can offer to take it back instead of just printing "ended". */
132
+ function handRunToChat(termId, sessionId) {
133
+ deps.broadcast({ type: "terminal.closed", termId, reason: "chat", sessionId });
134
+ terminals.close(termId);
135
+ runTerminals.delete(termId);
136
+ runViewers.delete(termId);
137
+ deps.broadcast({ type: "terminal.closed", termId });
138
+ }
101
139
  async function runTerminalList() {
102
140
  const runs = terminals.list((meta) => meta.kind === "run").filter((t) => runTerminals.has(t.id));
103
141
  let saved = [];
@@ -218,6 +256,18 @@ export function createRunTerminals(deps) {
218
256
  emit({ type: "terminal.error", error: `Too many run terminals open (${runTerminals.size}/${deps.maxRunTerminals}). Close one (e.g. 'bivy kill') or raise BIVY_MAX_RUN_TERMINALS.` });
219
257
  return undefined;
220
258
  }
259
+ const owner = spec.sessionId && !spec.mux ? deps.findOpenSession(spec.sessionId) : undefined;
260
+ if (owner?.tuiTermId && runTerminals.has(owner.tuiTermId)) {
261
+ // Already open in a terminal: join it rather than start a second writer.
262
+ if (spec.clientId && (spec.cols !== undefined || spec.rows !== undefined))
263
+ terminals.setClientSize(owner.tuiTermId, spec.clientId, spec.cols || 80, spec.rows || 24);
264
+ emit({ type: "terminal.attached", termId: owner.tuiTermId, data: terminals.snapshot(owner.tuiTermId) ?? "" });
265
+ return owner.tuiTermId;
266
+ }
267
+ if (owner && deps.sessionBusy(owner)) {
268
+ emit({ type: "terminal.error", sessionId: owner.id, error: "This session is mid-turn in chat. Stop or finish the turn, then open it in the terminal." });
269
+ return undefined;
270
+ }
221
271
  const active = deps.getActiveSession();
222
272
  const workspace = spec.workspace || active?.session?.cwd || active?.worktree?.path || active?.workspace || deps.defaultWorkspace;
223
273
  // Agent-owned integrations keep their native login untouched. Only an
@@ -260,6 +310,17 @@ export function createRunTerminals(deps) {
260
310
  clearRunIdleNotify(id);
261
311
  deps.broadcast({ type: "terminal.exit", termId: id, code });
262
312
  deps.broadcast({ type: "terminal.closed", termId: id });
313
+ // A run that owned an open chat hands the conversation back to it; the
314
+ // chat keeps its own record, so none of the run bookkeeping below applies.
315
+ const bound = spec.sessionId ? deps.findOpenSession(spec.sessionId) : undefined;
316
+ if (bound?.tuiTermId === id) {
317
+ releaseRun(bound, id);
318
+ try {
319
+ deps.sessionListChanged();
320
+ }
321
+ catch { /* best-effort */ }
322
+ return;
323
+ }
263
324
  if (!spec.mux) {
264
325
  // The durable record of this run, in order of fidelity: the agent's
265
326
  // own session (pinned at launch), the session discovered in the
@@ -315,7 +376,9 @@ export function createRunTerminals(deps) {
315
376
  },
316
377
  });
317
378
  runTerminals.add(id);
318
- if (spec.sessionId && spec.agent && !spec.mux) {
379
+ if (owner)
380
+ bindRun(owner, id);
381
+ if (spec.sessionId && spec.agent && !spec.mux && !owner) {
319
382
  const runtimeId = TAKEOVER_RUNTIME_BY_AGENT[spec.agent] ?? spec.agent;
320
383
  try {
321
384
  deps.upsertSessionMetadata({ id: spec.sessionId, runtimeId, agentName: spec.agent, workspace, name: name || undefined, source: "cli", status: "working" });
@@ -366,6 +429,13 @@ export function createRunTerminals(deps) {
366
429
  : undefined;
367
430
  if (!entry)
368
431
  return { ok: false, status: 404, error: "No matching live run-terminal." };
432
+ // The chat already exists (the terminal was opened from it, or joined it):
433
+ // stopping the PTY is the whole handoff — its exit reloads the chat.
434
+ const owner = entry.meta.sessionId ? deps.findOpenSession(entry.meta.sessionId) : undefined;
435
+ if (owner?.tuiTermId === entry.id) {
436
+ handRunToChat(entry.id, owner.id);
437
+ return { ok: true, sessionId: owner.id, runtimeId: owner.runtimeId ?? "" };
438
+ }
369
439
  const agent = entry.meta.agent ?? "";
370
440
  const runtimeId = TAKEOVER_RUNTIME_BY_AGENT[agent];
371
441
  if (!runtimeId)
@@ -385,10 +455,7 @@ export function createRunTerminals(deps) {
385
455
  // second agent process until the next prompt. If preparation fails, the
386
456
  // terminal remains live and the user loses nothing.
387
457
  const record = await deps.createSession(workspace, pinned, { runtimeId, makeActive: true, source: "takeover" });
388
- terminals.close(entry.id);
389
- runTerminals.delete(entry.id);
390
- runViewers.delete(entry.id);
391
- deps.broadcast({ type: "terminal.closed", termId: entry.id });
458
+ handRunToChat(entry.id, record.id);
392
459
  const resumeCommand = RESUME_CLI_BY_AGENT[agent]?.(pinned);
393
460
  return { ok: true, sessionId: record.id, runtimeId, resumeCommand };
394
461
  }
@@ -473,84 +540,61 @@ export function createRunTerminals(deps) {
473
540
  return true;
474
541
  }
475
542
  case "terminal.open.tui": {
543
+ // "Open in terminal" for a chat session: its runtime's own interactive
544
+ // TUI, resuming the same conversation, as a daemon-owned run pinned to the
545
+ // session — so it outlives the device that opened it, every device can
546
+ // watch it, and the chat stays locked until it ends (single writer).
476
547
  void (async () => {
477
- const record = deps.resolveSession(msg.sessionId);
548
+ const record = typeof msg.sessionId === "string" && msg.sessionId ? deps.findOpenSession(msg.sessionId) : undefined;
478
549
  if (!record) {
479
550
  emit({ type: "terminal.error", error: "Session not found for TUI." });
480
551
  return;
481
552
  }
482
- if (record.tuiTermId) {
483
- const snapshot = terminals.snapshot(record.tuiTermId);
484
- if (snapshot != null) {
485
- owned.add(record.tuiTermId);
486
- if (typeof msg.cols !== "undefined" || typeof msg.rows !== "undefined")
487
- terminals.setClientSize(record.tuiTermId, clientId, Number(msg.cols) || 80, Number(msg.rows) || 24);
488
- emit({ type: "terminal.attached", termId: record.tuiTermId, data: snapshot });
489
- return;
490
- }
491
- }
492
- if (deps.sessionBusy(record)) {
553
+ const cols = Number(msg.cols) || undefined;
554
+ const rows = Number(msg.rows) || undefined;
555
+ const live = record.tuiTermId && runTerminals.has(record.tuiTermId) ? record.tuiTermId : undefined;
556
+ if (!live && deps.sessionBusy(record)) {
493
557
  emit({ type: "terminal.error", sessionId: record.id, error: "Finish or stop the current turn before opening the TUI." });
494
558
  return;
495
559
  }
496
- let spec;
497
- try {
498
- spec = record.session.interactiveTuiCommand ? await record.session.interactiveTuiCommand() : null;
499
- }
500
- catch (error) {
501
- emit({ type: "terminal.error", sessionId: record.id, error: error instanceof Error ? error.message : String(error) });
502
- return;
503
- }
504
- if (!spec) {
505
- emit({ type: "terminal.error", sessionId: record.id, error: "This runtime has no interactive TUI available on this node." });
506
- return;
507
- }
508
- const workspace = record.session.cwd || record.worktree?.path || record.workspace;
509
- try {
510
- const id = terminals.open({
511
- workspace,
512
- command: spec.command,
513
- args: spec.args,
514
- env: spec.env,
515
- cols: Number(msg.cols) || undefined,
516
- rows: Number(msg.rows) || undefined,
517
- clientId,
518
- onData: (data) => emit({ type: "terminal.output", termId: id, data }),
519
- onExit: (code) => {
520
- owned.delete(id);
521
- if (record.tuiTermId === id) {
522
- record.tuiTermId = undefined;
523
- void deps.sessionTerminalsForget(record.id).catch(() => { });
524
- record.tuiRefreshing = true;
525
- deps.broadcastTuiState(record.id, false);
526
- void deps.refreshRecordAfterTui(record);
527
- }
528
- emit({ type: "terminal.exit", termId: id, code });
529
- },
530
- });
531
- owned.add(id);
532
- record.tuiTermId = id;
533
- void deps.sessionTerminalsRecord(record.id, { termId: id }).catch(() => { });
534
- emit({ type: "terminal.opened", termId: id, workspace, sessionId: record.id, mode: "tui" });
535
- deps.broadcastTuiState(record.id, true);
536
- }
537
- catch (error) {
538
- emit({ type: "terminal.error", sessionId: record.id, error: error instanceof Error ? error.message : String(error) });
560
+ let spec = null;
561
+ if (!live) {
562
+ try {
563
+ spec = record.session.interactiveTuiCommand ? await record.session.interactiveTuiCommand() : null;
564
+ }
565
+ catch (error) {
566
+ emit({ type: "terminal.error", sessionId: record.id, error: error instanceof Error ? error.message : String(error) });
567
+ return;
568
+ }
569
+ if (!spec) {
570
+ emit({ type: "terminal.error", sessionId: record.id, error: "This runtime has no interactive TUI available on this node." });
571
+ return;
572
+ }
539
573
  }
574
+ const id = await openRunTerminal({
575
+ command: spec?.command ?? "",
576
+ args: spec?.args ?? [],
577
+ env: spec?.env,
578
+ name: record.session.getName() || undefined,
579
+ workspace: record.session.cwd || record.worktree?.path || record.workspace,
580
+ sessionId: record.id,
581
+ cols,
582
+ rows,
583
+ clientId,
584
+ }, emit);
585
+ if (id)
586
+ viewRun?.(id);
540
587
  })();
541
588
  return true;
542
589
  }
543
590
  case "terminal.close.tui": {
544
- const record = deps.resolveSession(msg.sessionId);
591
+ // "Use chat": hand the session's terminal back to its chat.
592
+ const record = typeof msg.sessionId === "string" && msg.sessionId ? deps.findOpenSession(msg.sessionId) : undefined;
545
593
  const termId = record?.tuiTermId;
546
- if (termId) {
547
- terminals.close(termId);
548
- owned.delete(termId);
549
- clearBellNotify(termId);
550
- }
551
- else if (record) {
594
+ if (record && termId && runTerminals.has(termId))
595
+ handRunToChat(termId, record.id);
596
+ else if (record)
552
597
  deps.broadcastTuiState(record.id, false);
553
- }
554
598
  return true;
555
599
  }
556
600
  case "terminal.attach": {
@@ -607,5 +651,5 @@ export function createRunTerminals(deps) {
607
651
  return false;
608
652
  }
609
653
  }
610
- return { runTerminalList, openRunTerminal, takeoverRunTerminal, handleTerminalMessage, addRunViewer, dropRunViewer, hasRunTerminal, hasLiveRunForSession };
654
+ return { runTerminalList, openRunTerminal, takeoverRunTerminal, handleTerminalMessage, addRunViewer, dropRunViewer, hasRunTerminal, hasLiveRunForSession, adoptLiveRun };
611
655
  }
@@ -38,5 +38,5 @@ export function createSessionNewDedupe(options = {}) {
38
38
  });
39
39
  return p;
40
40
  }
41
- return { run, size: () => inflight.size };
41
+ return { run, has: (requestId) => Boolean(requestId && inflight.has(requestId)), size: () => inflight.size };
42
42
  }
package/dist/terminal.js CHANGED
@@ -37,6 +37,14 @@ export function loadPty() {
37
37
  // and the relay closed the socket with "Rate limit exceeded" the moment a
38
38
  // phone/app attached to a live `bivy run` terminal.
39
39
  const OUTPUT_FLUSH_MS = 16;
40
+ /**
41
+ * Closing sends a hangup (node-pty's kill, what closing a terminal window
42
+ * sends). A shell hung up while it's still starting can miss it and live on,
43
+ * holding the PTY: a leaked shell on the machine, and a handle that keeps Node
44
+ * from exiting. Until it exits, these follow (Unix; ms after close): the hangup
45
+ * again, then a kill of its whole process group.
46
+ */
47
+ const CLOSE_FOLLOW_UPS = [[250, "SIGHUP"], [1000, "SIGKILL"]];
40
48
  /**
41
49
  * How much recent output to retain per terminal for scrollback replay on
42
50
  * reconnect. A phone that backgrounds the PWA (or drops its network) reattaches
@@ -385,6 +393,21 @@ export class TerminalManager {
385
393
  catch {
386
394
  // already gone
387
395
  }
396
+ if (process.platform === "win32")
397
+ return;
398
+ let exited = false;
399
+ void entry.exitPromise.then(() => { exited = true; });
400
+ for (const [delay, signal] of CLOSE_FOLLOW_UPS) {
401
+ setTimeout(() => {
402
+ if (exited)
403
+ return;
404
+ // The PTY's child leads its own process group: a kill takes its jobs too.
405
+ try {
406
+ process.kill(signal === "SIGKILL" ? -entry.proc.pid : entry.proc.pid, signal);
407
+ }
408
+ catch { /* already gone */ }
409
+ }, delay).unref();
410
+ }
388
411
  }
389
412
  has(id) {
390
413
  return this.terminals.has(id);
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.9",
3
+ "version": "0.18.0",
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.",