@bivy/bivy 0.9.1-staging.124 → 0.9.1-staging.126

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/dist/server.js CHANGED
@@ -46,6 +46,7 @@ import { readEphemeralTeardownConfig, shouldSelfTeardown, performSelfTeardown }
46
46
  import { buildSessionSnapshot, applySessionSnapshot } from "./session/snapshot.js";
47
47
  import { createCheckpointBundle, applyCheckpointBundle, materializeCheckpoint } from "./session/checkpoint-pack.js";
48
48
  import { configuredTurnTimeoutMs } from "./session/turn-watchdog.js";
49
+ import { forceAbortTurn } from "./session/abort-recovery.js";
49
50
  import { runRequiredAutomationChecks } from "./automation-checks.js";
50
51
  import { PolicyEngine } from "./policy/policy-engine.js";
51
52
  import { TerminalManager } from "./terminal.js";
@@ -2798,8 +2799,28 @@ const RELAY_COMMANDS = {
2798
2799
  ctx.broadcast({ type: "session.renamed", sessionId: rec.id, sessionFile: rec.sessionFile, name: newName });
2799
2800
  scheduleAdvertise();
2800
2801
  },
2801
- async abort(msg) {
2802
- await resolveSession(msg.sessionId)?.session.abort();
2802
+ abort(msg, ctx) {
2803
+ const record = resolveSession(msg.sessionId);
2804
+ if (!record || !sessionBusy(record))
2805
+ return;
2806
+ // A wedged runtime may never resolve abort() or emit agent_end. Settle the
2807
+ // daemon and client first, then make the SDK abort best-effort. The synthetic
2808
+ // agent_end also closes running tool cards and drains visible follow-ups.
2809
+ forceAbortTurn({
2810
+ settle: () => {
2811
+ questionManager.cancelForSession(record.id);
2812
+ approvals.cancelForSession(record.id);
2813
+ clearSessionWorking(record, "idle");
2814
+ },
2815
+ notifySettled: () => ctx.broadcast({
2816
+ type: "session.event",
2817
+ sessionId: record.id,
2818
+ event: { type: "agent_end", aborted: true },
2819
+ }),
2820
+ abort: () => record.session.abort(),
2821
+ onAbortError: (error) => console.warn(`[session-abort] runtime abort failed for ${record.id}:`, error),
2822
+ });
2823
+ record.abortRecovery = recoverRecordAfterAbort(record);
2803
2824
  },
2804
2825
  async "session.command.invoke"(msg, ctx) {
2805
2826
  // Invoke a protocol-mode agent command (AgentCommand.mode === "protocol")
@@ -3549,6 +3570,7 @@ const RELAY_COMMANDS = {
3549
3570
  broadcast({ type: "session.error", sessionId: requestedSessionId, error: error instanceof Error ? error.message : String(error) });
3550
3571
  return;
3551
3572
  }
3573
+ await record.abortRecovery;
3552
3574
  record.remoteActive = true;
3553
3575
  if (record.tuiTermId || record.tuiRefreshing) {
3554
3576
  broadcast({ type: "session.error", sessionId: record.id, error: record.tuiRefreshing ? "This session is returning from the terminal. Try again in a moment." : "This session is open in the terminal (TUI). Close the TUI to chat here." });
@@ -7169,7 +7191,7 @@ function armTurnWatchdog(record) {
7169
7191
  // Clear/persist first so the session and an ephemeral runner cannot remain
7170
7192
  // pinned in a false working state if the runtime's abort path fails to emit
7171
7193
  // agent_end. abort() is still invoked to kill the underlying process group.
7172
- clearSessionWorking(record);
7194
+ clearSessionWorking(record, "idle");
7173
7195
  metadata.touchSession(record.id, "failed");
7174
7196
  broadcast({ type: "session.outcome", sessionId: record.id, status: "timed_out", completedAt: new Date().toISOString(), error: message });
7175
7197
  broadcast({ type: "session.error", sessionId: record.id, error: message });
@@ -7208,7 +7230,7 @@ function markSessionWorking(record, activity) {
7208
7230
  if (!wasWorking)
7209
7231
  scheduleAdvertise(); // idle → working transition
7210
7232
  }
7211
- function clearSessionWorking(record) {
7233
+ function clearSessionWorking(record, forcedStatus) {
7212
7234
  clearTurnWatchdog(record);
7213
7235
  touchSession(record);
7214
7236
  record.isWorking = false;
@@ -7217,7 +7239,10 @@ function clearSessionWorking(record) {
7217
7239
  // A completed turn clears any pending manual-resume offer: the session has now
7218
7240
  // moved on (whether it was the resume itself or an unrelated new message).
7219
7241
  metadata.setResumePending(record.id, false);
7220
- persistSessionMetadata(record, sessionStatus(record));
7242
+ // A stuck runtime can keep its own isStreaming bit true forever. Recovery
7243
+ // callers explicitly force idle so that stale SDK state is not persisted as
7244
+ // a permanently-working session after Bivy has settled the turn.
7245
+ persistSessionMetadata(record, forcedStatus ?? sessionStatus(record));
7221
7246
  scheduleAdvertise(); // working → idle transition
7222
7247
  }
7223
7248
  /**
@@ -8117,6 +8142,68 @@ async function tryAttachLiveRemote(sessionId, options) {
8117
8142
  return { result: undefined, gone, error };
8118
8143
  }
8119
8144
  }
8145
+ /** Re-open a runtime that still reports streaming shortly after a manual Stop.
8146
+ * The client is settled immediately by the abort handler; prompts arriving from
8147
+ * its follow-up queue await this promise and therefore cannot be steered into the
8148
+ * stale in-process Pi turn. */
8149
+ async function recoverRecordAfterAbort(record) {
8150
+ const oldSession = record.session;
8151
+ try {
8152
+ await new Promise((resolve) => setTimeout(resolve, 250));
8153
+ if (record.session !== oldSession || !oldSession.isStreaming)
8154
+ return;
8155
+ if (!record.sessionFile) {
8156
+ broadcast({ type: "session.error", sessionId: record.id, error: "The stopped agent did not settle. Reopen this chat to continue." });
8157
+ return;
8158
+ }
8159
+ const rt = await ensureRuntimeAvailable(record.runtimeId, record.sandbox);
8160
+ const workspace = record.worktree?.path || oldSession.cwd || record.workspace;
8161
+ const runtimeSessionOptions = {
8162
+ workspace,
8163
+ toolProvider: integrations.toolProvider({ current: record.id }),
8164
+ ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}),
8165
+ };
8166
+ const { session, warning } = await runtimeHost.openSession(rt, {
8167
+ ...runtimeSessionOptions,
8168
+ sessionFile: record.sessionFile,
8169
+ });
8170
+ // The original abort may have completed while openSession was in flight.
8171
+ if (record.session !== oldSession || !oldSession.isStreaming) {
8172
+ session.dispose();
8173
+ return;
8174
+ }
8175
+ if (session.id !== record.id) {
8176
+ session.dispose();
8177
+ throw new Error(`runtime reopened session as ${session.id} instead of ${record.id}`);
8178
+ }
8179
+ record.unsubscribe?.();
8180
+ record.unsubscribe = undefined;
8181
+ record.session = session;
8182
+ record.sessionFile = session.sessionFile ?? record.sessionFile;
8183
+ record.workspace = session.cwd || workspace;
8184
+ record.warning = warning;
8185
+ oldSession.dispose();
8186
+ attachSessionListeners(record);
8187
+ persistSessionMetadata(record, "idle");
8188
+ broadcast(buildHistoryEvent({
8189
+ sessionId: record.id,
8190
+ workspace: record.workspace,
8191
+ source: record.source,
8192
+ runtimeId: record.runtimeId,
8193
+ isStreaming: false,
8194
+ messages: conversationMessages(record),
8195
+ }));
8196
+ if (warning)
8197
+ broadcast({ type: "session.warning", sessionId: record.id, warning });
8198
+ console.warn(`[session-abort] reopened stuck runtime for ${record.id}`);
8199
+ }
8200
+ catch (error) {
8201
+ broadcast({ type: "session.error", sessionId: record.id, error: `Could not recover the stopped agent: ${error instanceof Error ? error.message : String(error)}` });
8202
+ }
8203
+ finally {
8204
+ record.abortRecovery = undefined;
8205
+ }
8206
+ }
8120
8207
  async function createSession(workspace = defaultWorkspace, sessionFile, opts = {}) {
8121
8208
  const makeActive = opts.makeActive !== false;
8122
8209
  // Normalize the resume ref into the token the owning runtime actually expects.
@@ -10803,6 +10890,7 @@ app.post("/api/session/prompt", async (req, res, next) => {
10803
10890
  if (!record) {
10804
10891
  record = await createWorkspaceSession(requestedWorkspace ?? defaultWorkspace, { title: titleText, runtimeId: agentFrom(req.body ?? {}) });
10805
10892
  }
10893
+ await record.abortRecovery;
10806
10894
  if (record.tuiTermId || record.tuiRefreshing) {
10807
10895
  return res.status(409).json({ error: record.tuiRefreshing ? "This session is returning from the terminal. Try again in a moment." : "This session is open in the terminal (TUI). Close the TUI to chat here." });
10808
10896
  }
@@ -0,0 +1,16 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ /**
4
+ * Settle Bivy's view of a turn before asking the runtime to abort it.
5
+ *
6
+ * Runtime aborts are deliberately best-effort: an in-process SDK can be wedged
7
+ * in the very operation abort is meant to interrupt. Stop must therefore not
8
+ * await that SDK before the client is allowed to leave its working state.
9
+ */
10
+ export function forceAbortTurn(options) {
11
+ options.settle();
12
+ options.notifySettled();
13
+ void Promise.resolve()
14
+ .then(options.abort)
15
+ .catch((error) => options.onAbortError?.(error));
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.9.1-staging.124",
3
+ "version": "0.9.1-staging.126",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",