@bivy/bivy 0.15.0-staging.9 → 0.16.0-staging.1
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/bin/agent-manifest.json +1 -1
- package/bin/bivy.mjs +44 -0
- package/dist/agents/claude-code/integration.js +1 -1
- package/dist/agents/codex/integration.js +1 -1
- package/dist/agents/pi/capabilities.js +42 -0
- package/dist/agents/pi/integration.js +2 -13
- package/dist/agents/pi/runtime.js +4 -23
- package/dist/agents/profiles.js +1 -1
- package/dist/certification/generated.js +4 -4
- package/dist/control-plane-tasks.js +11 -1
- package/dist/metadata.js +8 -1
- package/dist/policy/ruleset.js +19 -2
- package/dist/runtime/auth-errors.js +9 -0
- package/dist/server.js +116 -22
- package/package.json +4 -4
package/bin/agent-manifest.json
CHANGED
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.
|
|
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.
|
|
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 {
|
|
8
|
-
export const PI_TESTED_VERSION = "0.84.
|
|
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 {
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
}
|
package/dist/agents/profiles.js
CHANGED
|
@@ -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.
|
|
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.
|
|
7
|
-
{ id: "codex-approvals", status: "active", executionMode: "protocol", pinnedVersion: "0.
|
|
8
|
-
{ id: "pi", status: "active", executionMode: "protocol", pinnedVersion: "0.84.
|
|
9
|
-
{ id: "opencode", status: "active", executionMode: "protocol", pinnedVersion: "1.18.
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/policy/ruleset.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
1647
|
-
//
|
|
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
|
|
1650
|
-
const SESSION_UPDATE_COALESCE_MS =
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
7369
|
-
//
|
|
7370
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.16.0-staging.1",
|
|
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.
|
|
54
|
-
"@earendil-works/pi-ai": "0.84.
|
|
55
|
-
"@earendil-works/pi-coding-agent": "0.84.
|
|
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": {
|