@bivy/bivy 0.15.0-staging.9 → 0.15.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.
@@ -22,7 +22,7 @@
22
22
  "hidden": false,
23
23
  "supportTier": "supported",
24
24
  "certification": "release-tested",
25
- "testedVersion": "1.18.23",
25
+ "testedVersion": "1.18.25",
26
26
  "headlessFlags": [
27
27
  "run",
28
28
  "-s"
package/bin/bivy.mjs CHANGED
@@ -3543,11 +3543,55 @@ function terminalQr(text) {
3543
3543
 
3544
3544
  // --- commands ---------------------------------------------------------------
3545
3545
 
3546
+ async function cmdTokenSetup() {
3547
+ // A token-bearing installer command is an explicit "add this machine" flow,
3548
+ // not a request to walk through first-run preferences. In particular, do not
3549
+ // consume the account token after presenting the agent and login wizards:
3550
+ // those prompts made the Connect a Machine command unusable on headless
3551
+ // machines and made it look as if the token had been ignored.
3552
+ if (!(await ensureDeps())) process.exit(1);
3553
+
3554
+ const config = loadConfig();
3555
+ if (!config.workspace || config.workspace === repoRoot) {
3556
+ config.workspace = firstSetupWorkspace(config);
3557
+ if (!fs.existsSync(config.workspace)) fs.mkdirSync(config.workspace, { recursive: true });
3558
+ }
3559
+ const explicitPort = Number(process.env.PORT);
3560
+ config.port = explicitPort || await findAvailablePort(Number(config.port) || 4317, nodeBindHost());
3561
+ config.env = { ...config.env, BIVY_RUNTIME: config.env?.BIVY_RUNTIME || "pi" };
3562
+ saveConfig(config);
3563
+
3564
+ const code = await run(nodeBin, [...nodeScriptArgs(relaySetupEntry)], {
3565
+ cwd: repoRoot,
3566
+ env: startEnv(config),
3567
+ });
3568
+ if (code !== 0) {
3569
+ console.error(c.red("Machine enrollment failed; the token was not accepted."));
3570
+ process.exitCode = code || 1;
3571
+ return;
3572
+ }
3573
+
3574
+ console.log(c.dim("\nInstalling the background service so the node keeps running…"));
3575
+ const started = await installService(config);
3576
+ if (!started) {
3577
+ console.log(`Finish with ${c.cyan("bivy service install")} or start it with ${c.cyan("bivy start")}.`);
3578
+ return;
3579
+ }
3580
+ console.log(c.bold(c.green("\n✓ Machine added and running. No interactive setup was needed.")));
3581
+ }
3582
+
3546
3583
  async function cmdSetup(args = []) {
3547
3584
  if (args.includes("-h") || args.includes("--help")) {
3548
3585
  console.log("Usage: bivy setup\n\nFirst-run wizard: agent choice, model login, remote access + sign-in, and background service. Workspace and port get safe defaults. Re-run later to change the default agent or remote access.");
3549
3586
  return;
3550
3587
  }
3588
+ // install.sh supplies these only for the Connect a Machine command. Keep
3589
+ // this branch before creating a readline prompter: there may be no TTY.
3590
+ if (process.env.BIVY_SESSION_TOKEN || process.env.BIVY_NODE_CLAIM_CODE) {
3591
+ await cmdTokenSetup();
3592
+ return;
3593
+ }
3594
+
3551
3595
  console.log(c.bold("\n Bivy — node setup\n"));
3552
3596
  if (process.getuid?.() === 0) {
3553
3597
  console.log(c.yellow("You are running setup as root. For a real node, create a normal user (e.g. 'bivy') and install there."));
@@ -4,7 +4,7 @@ import { defineAgentIntegration } from "../definition.js";
4
4
  import { withExactCapabilitySurface } from "../../runtime/types.js";
5
5
  import { createCredentialStore } from "../../runtime/credentials.js";
6
6
  import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled, } from "./runtime.js";
7
- export const CLAUDE_TESTED_VERSION = "0.3.246";
7
+ export const CLAUDE_TESTED_VERSION = "0.3.251";
8
8
  const CLAUDE_CAPABILITIES = withExactCapabilitySurface({
9
9
  toolInterception: true,
10
10
  modelSelection: true,
@@ -9,7 +9,7 @@ import { codexCredentialPreflight } from "../../runtime/codex-preflight.js";
9
9
  import { deleteCodexSession, discoverNativeCodexSessions, loadCodexTranscript, writeCodexRollout, exportCodexRollout, importCodexRollout, } from "../../runtime/codex-sessions.js";
10
10
  import { ProtocolRuntime } from "../../runtime/protocol.js";
11
11
  import { codexSlashCommands } from "../../runtime/slash-commands.js";
12
- export const CODEX_TESTED_VERSION = "0.150.0";
12
+ export const CODEX_TESTED_VERSION = "0.151.0";
13
13
  const CODEX_AVAILABLE_CACHE = new Map();
14
14
  function codexCommand() {
15
15
  return process.env.BIVY_CODEX_BIN?.trim() || "codex";
@@ -0,0 +1,42 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { withExactCapabilitySurface } from "../../runtime/types.js";
4
+ /**
5
+ * The ONE capability table for Pi, shared by the real `PiRuntime` and the
6
+ * `LazyPiRuntime` facade the registry hands out before the SDK is loaded.
7
+ *
8
+ * This module deliberately imports nothing from the pi SDK so the lazy facade
9
+ * can use it without defeating its purpose. Keeping a single source of truth is
10
+ * a correctness requirement, not tidiness: the daemon reads flags such as
11
+ * `sessionRefIsPath` off whichever runtime object the registry returns — the
12
+ * facade — *before* any session is opened. When the facade's copy of this table
13
+ * drifted from the real one and lost `sessionRefIsPath`, the daemon treated every
14
+ * pi resume ref as an opaque id, stripped the transcript path to its basename,
15
+ * and pi created a brand-new empty session for every message sent to a closed
16
+ * chat (the "sending to a closed session creates a new one" regression).
17
+ */
18
+ export const PI_CAPABILITIES = withExactCapabilitySurface({
19
+ toolInterception: true,
20
+ modelSelection: true,
21
+ packages: true,
22
+ resume: true,
23
+ fork: false,
24
+ // pi resumes by session-file path (under the node's sessions dir), so the
25
+ // daemon applies its path-traversal guard to these refs.
26
+ sessionRefIsPath: true,
27
+ // pi's TUI ships with the node, so the chat<->TUI hand-off is always offered.
28
+ interactiveTui: true,
29
+ usageReporting: true,
30
+ // pi can find a prior on-disk session by cwd + start time, so a `bivy run`
31
+ // terminal with no pinned sessionId can still be continued as a governed chat.
32
+ sessionDiscovery: true,
33
+ // pi transcripts are structured messages that round-trip through the session
34
+ // store, so a pi->pi fork is full fidelity (see exportForFork/importForFork).
35
+ forkTransport: true,
36
+ // pi can also stand up a session from portable {role,text} history, so a fork
37
+ // FROM another agent INTO pi is a true replay, not a seeded summary.
38
+ forkHistoryImport: true,
39
+ // The pi-coding-agent SDK implements both explicitly: prompting mid-turn
40
+ // with no streamingBehavior hint throws, forcing every caller to choose.
41
+ streamingBehaviors: ["steer", "followUp"],
42
+ });
@@ -4,19 +4,8 @@ import { spawnSync } from "node:child_process";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { defineAgentIntegration } from "../definition.js";
7
- import { withExactCapabilitySurface } from "../../runtime/types.js";
8
- export const PI_TESTED_VERSION = "0.84.3";
9
- const PI_CAPABILITIES = withExactCapabilitySurface({
10
- toolInterception: true,
11
- modelSelection: true,
12
- packages: true,
13
- resume: true,
14
- fork: false,
15
- interactiveTui: true,
16
- usageReporting: true,
17
- sessionDiscovery: true,
18
- streamingBehaviors: ["steer", "followUp"],
19
- });
7
+ import { PI_CAPABILITIES } from "./capabilities.js";
8
+ export const PI_TESTED_VERSION = "0.84.4";
20
9
  export function piAgentDir() {
21
10
  return process.env.PI_CODING_AGENT_DIR?.trim() || path.join(os.homedir(), ".pi", "agent");
22
11
  }
@@ -15,7 +15,7 @@ import { toModelInfo as sharedToModelInfo } from "../../runtime/normalize.js";
15
15
  import { provisionPiAuthJson } from "../../runtime/credential-provisioning.js";
16
16
  import { isNativeOAuthProvider } from "../../runtime/oauth/model-oauth-providers.js";
17
17
  import { bivySessionEnv } from "../../runtime/session-env.js";
18
- import { withExactCapabilitySurface } from "../../runtime/types.js";
18
+ import { PI_CAPABILITIES } from "./capabilities.js";
19
19
  import { mapToolCall, mapToolResult } from "../../runtime/tool-call-map.js";
20
20
  import { BackgroundShellTracker, createBackgroundAwareBashOperations } from "./background-shell.js";
21
21
  /**
@@ -406,28 +406,9 @@ export class PiRuntime {
406
406
  options;
407
407
  id = "pi";
408
408
  displayName = "Pi";
409
- capabilities = withExactCapabilitySurface({
410
- toolInterception: true,
411
- modelSelection: true,
412
- packages: true,
413
- resume: true,
414
- fork: false,
415
- // pi resumes by session-file path (under the node's sessions dir), so the
416
- // daemon applies its path-traversal guard to these refs.
417
- sessionRefIsPath: true,
418
- // pi's TUI ships with the node, so the chat<->TUI hand-off is always offered.
419
- interactiveTui: true,
420
- usageReporting: true,
421
- // pi transcripts are structured messages that round-trip through the session
422
- // store, so a pi->pi fork is full fidelity (see exportForFork/importForFork).
423
- forkTransport: true,
424
- // pi can also stand up a session from portable {role,text} history, so a fork
425
- // FROM another agent INTO pi is a true replay, not a seeded summary.
426
- forkHistoryImport: true,
427
- // The pi-coding-agent SDK implements both explicitly: prompting mid-turn
428
- // with no streamingBehavior hint throws, forcing every caller to choose.
429
- streamingBehaviors: ["steer", "followUp"],
430
- });
409
+ // Shared with LazyPiRuntime (the facade the registry actually hands out) —
410
+ // see capabilities.ts for why the two must never drift.
411
+ capabilities = PI_CAPABILITIES;
431
412
  constructor(options) {
432
413
  this.options = options;
433
414
  }
@@ -43,7 +43,7 @@ export const AGENT_PROFILES = {
43
43
  // Approve/Deny + session/load resume + a real model picker), the same bar Pi,
44
44
  // Claude Code, and Codex clear. See `acp` below for the version fallback.
45
45
  supportTier: "supported",
46
- testedVersion: "1.18.23",
46
+ testedVersion: "1.18.25",
47
47
  blurb: "The most widely used open-source coding harness (OpenCode CLI).",
48
48
  // `opencode run -s <id> "<prompt>"` continues a prior session by its own id
49
49
  // (`-s, --session session id to continue`, per `opencode run --help`).
@@ -3,9 +3,9 @@
3
3
  export const CERTIFICATION_MATRIX = {
4
4
  schemaVersion: 1,
5
5
  agents: [
6
- { id: "claude-code-sdk", status: "active", executionMode: "protocol", pinnedVersion: "0.3.246", capabilities: ["toolInterception", "modelSelection", "resume"] },
7
- { id: "codex-approvals", status: "active", executionMode: "protocol", pinnedVersion: "0.150.0", capabilities: ["toolInterception", "modelSelection", "resume"] },
8
- { id: "pi", status: "active", executionMode: "protocol", pinnedVersion: "0.84.3", capabilities: ["toolInterception", "modelSelection", "resume"] },
9
- { id: "opencode", status: "active", executionMode: "protocol", pinnedVersion: "1.18.23", capabilities: ["toolInterception", "modelSelection", "resume"] },
6
+ { id: "claude-code-sdk", status: "active", executionMode: "protocol", pinnedVersion: "0.3.251", capabilities: ["toolInterception", "modelSelection", "resume"] },
7
+ { id: "codex-approvals", status: "active", executionMode: "protocol", pinnedVersion: "0.151.0", capabilities: ["toolInterception", "modelSelection", "resume"] },
8
+ { id: "pi", status: "active", executionMode: "protocol", pinnedVersion: "0.84.4", capabilities: ["toolInterception", "modelSelection", "resume"] },
9
+ { id: "opencode", status: "active", executionMode: "protocol", pinnedVersion: "1.18.25", capabilities: ["toolInterception", "modelSelection", "resume"] },
10
10
  ]
11
11
  };
@@ -386,7 +386,17 @@ export class ControlPlaneTaskPoller {
386
386
  ],
387
387
  });
388
388
  if (decision.action === "reroute") {
389
- current = { ...current, runtimeId: decision.routing.runtimeId, model: decision.routing.model };
389
+ // A candidate changes only the routing dimensions it names. Preserve
390
+ // the existing runtime/model for partial fallback routes (for example
391
+ // a model-only fallback must not silently reset the selected agent).
392
+ current = {
393
+ ...current,
394
+ ...(decision.routing.runtimeId !== undefined ? { runtimeId: decision.routing.runtimeId } : {}),
395
+ ...(decision.routing.model !== undefined ? { model: decision.routing.model } : {}),
396
+ // Account routing is not yet represented by WorkItem. Do not claim
397
+ // that an account fallback was applied until the queue wire shape
398
+ // and credential resolver support it end-to-end.
399
+ };
390
400
  rerouteCount = decision.rerouteCount;
391
401
  await report({ routingReason: `fallback: ${decision.ref}` });
392
402
  }
package/dist/metadata.js CHANGED
@@ -103,7 +103,14 @@ export class MetadataStore {
103
103
  const prev = this.data.sessions[input.id];
104
104
  const createdAt = input.createdAt ?? prev?.createdAt ?? nowIso();
105
105
  const updatedAt = input.updatedAt ?? nowIso();
106
- this.data.sessions[input.id] = { ...prev, ...input, createdAt, updatedAt };
106
+ // Runtime adapters do not all expose the same resume fields. In particular,
107
+ // an adapter can temporarily have no sessionFile while it is being rebuilt.
108
+ // Do not let that partial snapshot erase the durable resume ref (or any
109
+ // other field) already recorded for the session. The old object spread did
110
+ // exactly that: `{ path: undefined }` replaced the Pi transcript path, so
111
+ // reopening the saved row fell through to a fresh session.
112
+ const defined = Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
113
+ this.data.sessions[input.id] = { ...prev, ...defined, createdAt, updatedAt };
107
114
  this.save();
108
115
  }
109
116
  touchSession(id, status) {
@@ -94,8 +94,25 @@ export const DEFAULT_RULESET = {
94
94
  * human-readable errors — never throws.
95
95
  */
96
96
  export function validateRuleset(value) {
97
- if (Check(RulesetSchema, value))
98
- return { ok: true, ruleset: value, errors: [] };
97
+ if (Check(RulesetSchema, value)) {
98
+ const ruleset = value;
99
+ const errors = [];
100
+ for (let ruleIndex = 0; ruleIndex < ruleset.rules.length; ruleIndex += 1) {
101
+ const rule = ruleset.rules[ruleIndex];
102
+ if (rule.action !== "reroute" || !rule.chain)
103
+ continue;
104
+ for (let candidateIndex = 0; candidateIndex < rule.chain.length; candidateIndex += 1) {
105
+ const candidate = rule.chain[candidateIndex];
106
+ const hasRoute = [candidate.runtimeId, candidate.model, candidate.account]
107
+ .some((field) => typeof field === "string" && field.trim().length > 0);
108
+ if (!hasRoute)
109
+ errors.push(`/rules/${ruleIndex}/chain/${candidateIndex}: fallback candidate must specify runtimeId, model, or account`);
110
+ }
111
+ }
112
+ if (errors.length)
113
+ return { ok: false, errors };
114
+ return { ok: true, ruleset, errors: [] };
115
+ }
99
116
  const errors = [...Errors(RulesetSchema, value)]
100
117
  .slice(0, 20)
101
118
  .map((e) => `${e.path || "/"}: ${e.message}`);
@@ -77,3 +77,12 @@ export function authProviderForSession(runtimeId, modelProvider) {
77
77
  return provider;
78
78
  return undefined;
79
79
  }
80
+ /** Convert a provider failure into the safe wire shape consumed by clients.
81
+ * Raw provider text can contain stack frames, paths, or tokens and must never
82
+ * be used as the user-facing auth error. */
83
+ export function classifyModelAuthError(raw, runtimeId, modelProvider) {
84
+ if (!isModelAuthError(raw))
85
+ return null;
86
+ const provider = authProviderForSession(runtimeId, modelProvider);
87
+ return provider ? { kind: "model_auth", provider } : null;
88
+ }
package/dist/server.js CHANGED
@@ -34,7 +34,7 @@ import { InMemoryLocationRegistry } from "./runtime/location-registry.js";
34
34
  import { ControlPlaneSessionLocationRegistry, LayeredSessionLocationRegistry } from "./runtime/control-plane-location.js";
35
35
  import { attachAdoptedSessions, classifyAttachFailure } from "./runtime/adoption.js";
36
36
  import { createCredentialStore, testProviderCredential } from "./runtime/credentials.js";
37
- import { isModelAuthError, authProviderForSession } from "./runtime/auth-errors.js";
37
+ import { isModelAuthError, authProviderForSession, classifyModelAuthError } from "./runtime/auth-errors.js";
38
38
  import { createCredentialVault, migrateVaultDir } from "./runtime/credential-store.js";
39
39
  import { probeAnthropicAccess } from "./runtime/anthropic-preflight.js";
40
40
  import { createSessionNamer, fallbackSessionName } from "./session/session-namer.js";
@@ -930,6 +930,7 @@ function harnessDirFor(record) {
930
930
  function harnessBeginTurn(record) {
931
931
  const dir = harnessDirFor(record);
932
932
  record.harnessTurnReady = undefined;
933
+ record.harnessTurnFinished = false;
933
934
  if (!dir)
934
935
  return;
935
936
  const previous = record.workspaceState === "dirty" ? "dirty" : "clean";
@@ -954,6 +955,14 @@ function harnessBeginTurn(record) {
954
955
  })();
955
956
  }
956
957
  /** After a turn, snapshot again and broadcast the structured diff it produced. */
958
+ function finishHarnessTurn(record) {
959
+ if (record.harnessTurnFinished)
960
+ return;
961
+ record.harnessTurnFinished = true;
962
+ void harnessEndTurn(record).finally(() => {
963
+ void replication.onTurnComplete(record.id);
964
+ });
965
+ }
957
966
  async function harnessEndTurn(record) {
958
967
  const previous = record.workspaceState === "dirty" ? "dirty" : "clean";
959
968
  try {
@@ -1643,11 +1652,14 @@ function stampSessionEvent(payload) {
1643
1652
  return payload;
1644
1653
  }
1645
1654
  // Collapse the burst of assistant `message_update`s (one per agent stdout line,
1646
- // each carrying the FULL text so far) into ~1 fan-out per tick. See
1647
- // session-event-coalescer.ts for the rationale (kills the O(n^2) re-serialize).
1655
+ // each carrying the FULL text so far) to a human-smooth 4 fps. A 16 ms window
1656
+ // still allowed up to 62 cumulative snapshots/second, making a long answer
1657
+ // quadratic and surprisingly expensive over a phone's relay connection. The
1658
+ // latest snapshot supersedes every skipped one, and non-update events flush it
1659
+ // immediately, so 250 ms changes neither final content nor event ordering.
1648
1660
  // The coalescer's emit stamps the surviving (latest) update, so only ~one seq is
1649
- // spent per tick — the ring holds turn-scale events, not every stdout line.
1650
- const SESSION_UPDATE_COALESCE_MS = 16;
1661
+ // spent per window — the ring holds turn-scale events, not every stdout line.
1662
+ const SESSION_UPDATE_COALESCE_MS = 250;
1651
1663
  const sessionEvents = new SessionEventCoalescer({
1652
1664
  coalesceMs: SESSION_UPDATE_COALESCE_MS,
1653
1665
  emit: (payload) => broadcastCoalesced(stampSessionEvent(payload)),
@@ -2842,7 +2854,7 @@ const RELAY_COMMANDS = {
2842
2854
  // this the relay client (PWA) is stranded on "Working…" forever with
2843
2855
  // only a session.error toast. Clear working so a terminal state reaches it.
2844
2856
  clearSessionWorking(record);
2845
- broadcast({ type: "session.error", sessionId: record.id, error: actionableAgentError(record.runtimeId, error) });
2857
+ broadcast({ type: "session.error", sessionId: record.id, error: sessionAgentError(record, error) });
2846
2858
  });
2847
2859
  },
2848
2860
  async "session.new"(msg) {
@@ -4580,11 +4592,58 @@ async function reconcileInterruptedSessions() {
4580
4592
  }
4581
4593
  }
4582
4594
  }
4595
+ /** Run the legacy direct GitHub queue through the same failure policy as the
4596
+ * hosted queue. There is no control-plane run/evidence endpoint on this path,
4597
+ * so the policy is applied locally and terminal failures retain the poller's
4598
+ * existing logging behavior. */
4599
+ async function runDirectGitHubIssueWithPolicy(cfg, issue) {
4600
+ let ruleset;
4601
+ try {
4602
+ // Repository policy has precedence, matching the hosted queue. An invalid
4603
+ // project policy must not disable the direct poller; fall back to the
4604
+ // node-global policy and leave the validation error visible in the log.
4605
+ ruleset = loadProjectPolicy(cfg.repoDir)?.ruleset;
4606
+ }
4607
+ catch (error) {
4608
+ console.warn(`[policy] ${error instanceof Error ? error.message : String(error)}`);
4609
+ }
4610
+ const policy = createRunPolicy({
4611
+ context: "queue",
4612
+ ruleset: ruleset ?? activeRulesetFor(rulesetsDir, "queue"),
4613
+ });
4614
+ let attempt = 1;
4615
+ let rerouteCount = 0;
4616
+ let routing = {};
4617
+ for (;;) {
4618
+ try {
4619
+ await runIssueTask(cfg, issue, { ...routing });
4620
+ return;
4621
+ }
4622
+ catch (error) {
4623
+ const decision = policy.decide({ routing, error, attempt, rerouteCount });
4624
+ if (decision.action !== "retry" && decision.action !== "reroute")
4625
+ throw error;
4626
+ attempt += 1;
4627
+ if (decision.action === "reroute") {
4628
+ routing = {
4629
+ ...(routing.runtimeId !== undefined ? { runtimeId: routing.runtimeId } : {}),
4630
+ ...(routing.model !== undefined ? { model: routing.model } : {}),
4631
+ ...(decision.routing.runtimeId !== undefined ? { runtimeId: decision.routing.runtimeId } : {}),
4632
+ ...(decision.routing.model !== undefined ? { model: decision.routing.model } : {}),
4633
+ };
4634
+ rerouteCount = decision.rerouteCount;
4635
+ }
4636
+ console.warn(`[github-tasks] issue #${issue.number} ${decision.action} (${decision.condition}): ${decision.summary}`);
4637
+ if (decision.delayMs > 0)
4638
+ await new Promise((resolve) => setTimeout(resolve, decision.delayMs));
4639
+ }
4640
+ }
4641
+ }
4583
4642
  async function startGitHubTasksIfConfigured() {
4584
4643
  const cfg = await resolveGitHubTaskConfig();
4585
4644
  if (!cfg)
4586
4645
  return;
4587
- githubPoller = new GitHubTaskPoller(cfg, (issue) => runIssueTask(cfg, issue), nodeGithubMaxConcurrent);
4646
+ githubPoller = new GitHubTaskPoller(cfg, (issue) => runDirectGitHubIssueWithPolicy(cfg, issue), nodeGithubMaxConcurrent);
4588
4647
  githubPoller.start();
4589
4648
  }
4590
4649
  let controlPlanePoller;
@@ -5959,6 +6018,7 @@ async function sessionListRows() {
5959
6018
  agent: meta?.runtimeId ?? s.agent,
5960
6019
  agentName: meta?.agentName ?? s.agentName,
5961
6020
  source: rec?.source ?? meta?.source,
6021
+ bivyCreated: Boolean(meta),
5962
6022
  forkedFrom: rec?.forkedFrom ?? meta?.forkedFrom,
5963
6023
  branch: rec?.worktree?.branch ?? meta?.branch,
5964
6024
  sandbox: rec?.sandbox ?? normalizeSandboxTier(meta?.sandbox),
@@ -7175,6 +7235,11 @@ function actionableAgentError(runtimeId, error) {
7175
7235
  }
7176
7236
  return raw;
7177
7237
  }
7238
+ function sessionAgentError(record, error) {
7239
+ const raw = humanizeAgentError(error instanceof Error ? error.message : String(error));
7240
+ return classifyModelAuthError(raw, record.runtimeId, record.session.getCurrentModel()?.provider)
7241
+ ?? actionableAgentError(record.runtimeId, error);
7242
+ }
7178
7243
  /**
7179
7244
  * A turn that ended in a *terminal* model/provider failure the runtime would
7180
7245
  * otherwise swallow. `agent_end` carries the turn's messages and whether the
@@ -7231,7 +7296,7 @@ function attachSessionListeners(record) {
7231
7296
  policy: sessionRunPolicy,
7232
7297
  onNotice: (n) => broadcast({ type: "session.notice", sessionId: record.id, level: n.level, message: n.message }),
7233
7298
  onModelChanged: () => broadcast({ type: "model.updated", sessionId: record.id, model: publicModel(record.session.getCurrentModel(), record.session.getCurrentModel()) }),
7234
- onFailed: (message) => broadcast({ type: "session.error", sessionId: record.id, error: message }),
7299
+ onFailed: (message) => broadcast({ type: "session.error", sessionId: record.id, error: sessionAgentError(record, message) }),
7235
7300
  });
7236
7301
  }
7237
7302
  record.unsubscribe = record.session.subscribe((event) => {
@@ -7328,9 +7393,14 @@ function attachSessionListeners(record) {
7328
7393
  transcripts.resolveInlineImages(record);
7329
7394
  }
7330
7395
  // Durably persist the throttled sidecars at the turn boundary so a crash
7331
- // loses at most the in-flight turn's UI detail, not the whole turn.
7332
- if (event.type === "turn_end")
7396
+ // loses at most the in-flight turn's UI detail, not the whole turn. A
7397
+ // runtime may keep its agent process alive and never emit agent_end (Pi),
7398
+ // so turn_end is also authoritative for the interactive working state.
7399
+ if (event.type === "turn_end") {
7333
7400
  eventLog.flush(record.id);
7401
+ clearSessionWorking(record);
7402
+ finishHarnessTurn(record);
7403
+ }
7334
7404
  // AskUserQuestion is intercepted and answered by the daemon's guardian /
7335
7405
  // QuestionManager (see guardianInterceptor), which broadcasts
7336
7406
  // session.question(.resolved) from its own listeners — runtimes no longer
@@ -7365,14 +7435,9 @@ function attachSessionListeners(record) {
7365
7435
  transcripts.clearLiveIntermediate(record.id);
7366
7436
  clearSessionWorking(record);
7367
7437
  void refreshSessionUsage(record);
7368
- // Snapshot the worktree and broadcast the structured diff this turn made —
7369
- // universal edit review + rewind target, for every runtime.
7370
- // Warm-replicate this turn to the standby AFTER the checkpoint is committed
7371
- // (so the shipped frame carries this turn's transcript AND its checkpoint).
7372
- // Gated on session sync — inert by default.
7373
- void harnessEndTurn(record).finally(() => {
7374
- void replication.onTurnComplete(record.id);
7375
- });
7438
+ // Snapshot/diff at whichever completion boundary arrived first. Pi keeps
7439
+ // its process alive after turn_end; other runtimes also emit agent_end.
7440
+ finishHarnessTurn(record);
7376
7441
  // A turn that ended in a terminal model/provider error (e.g. an expired
7377
7442
  // credential or a 4xx from the API) otherwise vanished: working cleared,
7378
7443
  // no reply, no signal. Surface it as a session-scoped error so the client
@@ -7434,7 +7499,7 @@ function attachSessionListeners(record) {
7434
7499
  scheduleAdvertise();
7435
7500
  broadcast({ type: "session.failed", sessionId: record.id, failedAt: record.lastFailureAt });
7436
7501
  if (messageError)
7437
- broadcast({ type: "session.error", sessionId: record.id, error: actionableAgentError(record.runtimeId, messageError) });
7502
+ broadcast({ type: "session.error", sessionId: record.id, error: sessionAgentError(record, messageError) });
7438
7503
  // If the terminal error is an auth failure (expired key/token → 4xx),
7439
7504
  // also raise the sign-in sheet for the failing provider.
7440
7505
  maybeSignalAuthRequired(record, turnError);
@@ -8058,7 +8123,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
8058
8123
  void refreshSessionUsage(record);
8059
8124
  if (makeActive)
8060
8125
  active = record;
8061
- broadcast({ type: "session.created", sessionId, name: record.session.getName(), workspace: sessionWorkspace, sessionFile: record.sessionFile, source: record.source, branch: worktree?.branch, prUrl: record.prUrl, runtimeId: rt.id, agentName: rt.displayName, modelFallbackMessage, bivySession: bivySessionEnvelope(record), capabilities: capabilitiesWithCommands(rt.id, record.session) });
8126
+ broadcast({ type: "session.created", sessionId, name: record.session.getName(), workspace: sessionWorkspace, sessionFile: record.sessionFile, source: record.source, bivyCreated: true, branch: worktree?.branch, prUrl: record.prUrl, runtimeId: rt.id, agentName: rt.displayName, modelFallbackMessage, bivySession: bivySessionEnvelope(record), capabilities: capabilitiesWithCommands(rt.id, record.session) });
8062
8127
  void maybeNotifyBivyUpdate();
8063
8128
  scheduleAdvertise();
8064
8129
  return record;
@@ -8075,6 +8140,12 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
8075
8140
  * of the same session onto one promise fixes both.
8076
8141
  */
8077
8142
  const resumingSessions = new Map();
8143
+ // Keep an id index as well as the resolved runtime ref. A prompt can arrive
8144
+ // while session.open is still resolving and may not carry the session's path;
8145
+ // without this index it cannot discover the in-flight open from metadata and
8146
+ // is incorrectly reported as "Session not found". Both commands must join the
8147
+ // same resume operation by the identity the client actually supplied.
8148
+ const resumingSessionsById = new Map();
8078
8149
  /**
8079
8150
  * Resolve the session a client command targets, resuming it from durable
8080
8151
  * metadata when it isn't currently held in memory. A session survives being
@@ -8097,6 +8168,9 @@ async function resolveOrResumeSession(sessionId, sessionPath) {
8097
8168
  const open = openSessions.get(id);
8098
8169
  if (open)
8099
8170
  return open;
8171
+ const byId = resumingSessionsById.get(id);
8172
+ if (byId)
8173
+ return byId;
8100
8174
  const pathRef = typeof sessionPath === "string" && sessionPath.trim() ? sessionPath.trim() : undefined;
8101
8175
  const meta = metadata.getSession(id) ?? (pathRef ? metadata.getSession(pathRef) : undefined);
8102
8176
  // A run that only kept its terminal log has nothing to resume — it opens as a
@@ -8152,15 +8226,23 @@ async function resolveOrResumeSession(sessionId, sessionPath) {
8152
8226
  }
8153
8227
  }
8154
8228
  const inflight = resumingSessions.get(key);
8155
- if (inflight)
8229
+ if (inflight) {
8230
+ resumingSessionsById.set(id, inflight);
8156
8231
  return inflight;
8232
+ }
8157
8233
  const resume = createSession(defaultWorkspace, ref, { runtimeId, makeActive: false });
8158
8234
  resumingSessions.set(key, resume);
8235
+ resumingSessionsById.set(id, resume);
8159
8236
  try {
8160
8237
  return await resume;
8161
8238
  }
8162
8239
  finally {
8163
8240
  resumingSessions.delete(key);
8241
+ // Only delete our promise. A future implementation may replace an entry
8242
+ // after a failed resume, and must not have its newer attempt removed by an
8243
+ // older finally block.
8244
+ if (resumingSessionsById.get(id) === resume)
8245
+ resumingSessionsById.delete(id);
8164
8246
  }
8165
8247
  }
8166
8248
  // Enumerating a runtime's models needs a live session (the model registry lives
@@ -9836,6 +9918,7 @@ app.get("/api/sessions", async (_req, res, next) => {
9836
9918
  agent: meta?.runtimeId ?? s.agent,
9837
9919
  agentName: meta?.agentName ?? s.agentName,
9838
9920
  source: rec?.source ?? meta?.source,
9921
+ bivyCreated: Boolean(meta),
9839
9922
  forkedFrom: rec?.forkedFrom ?? meta?.forkedFrom,
9840
9923
  branch: rec?.worktree?.branch ?? meta?.branch,
9841
9924
  prUrl: rec?.prUrl ?? meta?.prUrl,
@@ -9857,7 +9940,18 @@ app.get("/api/sessions", async (_req, res, next) => {
9857
9940
  });
9858
9941
  app.post("/api/sessions/open", async (req, res, next) => {
9859
9942
  try {
9860
- const session = await createSession(defaultWorkspace, String(req.body?.path ?? ""), { runtimeId: agentFrom(req.body ?? {}) });
9943
+ // Direct transports use this endpoint for both opening a saved session and
9944
+ // the legacy path-only open flow. The old path-only implementation ignored
9945
+ // sessionId, so a direct client sending into a closed chat opened a brand-new
9946
+ // session instead of reopening the selected conversation.
9947
+ const requestedId = typeof req.body?.sessionId === "string" && req.body.sessionId.trim()
9948
+ ? req.body.sessionId.trim()
9949
+ : undefined;
9950
+ const session = requestedId
9951
+ ? await resolveOrResumeSession(requestedId, req.body?.path)
9952
+ : await createSession(defaultWorkspace, String(req.body?.path ?? ""), { runtimeId: agentFrom(req.body ?? {}) });
9953
+ if (!session)
9954
+ return res.status(404).json({ error: "Session not found" });
9861
9955
  res.json({
9862
9956
  id: session.id,
9863
9957
  workspace: session.workspace,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.15.0-staging.9",
3
+ "version": "0.15.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.",
@@ -50,9 +50,9 @@
50
50
  "zod": "^4.0.0"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@anthropic-ai/claude-agent-sdk": "0.3.246",
54
- "@earendil-works/pi-ai": "0.84.3",
55
- "@earendil-works/pi-coding-agent": "0.84.3",
53
+ "@anthropic-ai/claude-agent-sdk": "0.3.251",
54
+ "@earendil-works/pi-ai": "0.84.4",
55
+ "@earendil-works/pi-coding-agent": "0.84.4",
56
56
  "node-pty": "^1.1.0"
57
57
  },
58
58
  "overrides": {