@bivy/bivy 0.17.2 → 0.17.3-staging.10

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
@@ -238,7 +288,7 @@ export function createRunTerminals(deps) {
238
288
  workspace,
239
289
  command: spec.command,
240
290
  args: spec.args,
241
- env: credentialEnv,
291
+ env: { ...credentialEnv, ...spec.env },
242
292
  cols: spec.cols,
243
293
  rows: spec.rows,
244
294
  clientId: spec.clientId,
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.17.2",
3
+ "version": "0.17.3-staging.10",
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.",
@@ -39,6 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@modelcontextprotocol/sdk": "1.30.0",
42
+ "@novnc/novnc": "1.7.0",
42
43
  "brace-expansion": "5.0.12",
43
44
  "express": "^5.2.1",
44
45
  "express-rate-limit": "^8.7.0",