@bivy/bivy 0.6.0 → 0.7.0-staging.100
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/README.md +15 -6
- package/bin/acp-shim.mjs +128 -14
- package/bin/agent-manifest.json +39 -0
- package/bin/bivy.mjs +99 -13
- package/bin/patch-pi-dependencies.mjs +22 -16
- package/dist/automation-checks.js +68 -0
- package/dist/bivy-login.js +13 -0
- package/dist/control-plane-tasks.js +39 -8
- package/dist/diagnostics.js +75 -0
- package/dist/github-tasks.js +27 -7
- package/dist/guard.js +51 -8
- package/dist/harness/egress.js +64 -1
- package/dist/harness/mcp-config.js +89 -6
- package/dist/harness/mcp-inject.js +31 -8
- package/dist/harness/net-proxy.js +28 -0
- package/dist/metadata.js +18 -0
- package/dist/policy/conditions.js +54 -3
- package/dist/policy/run-policy.js +2 -1
- package/dist/policy/session-reroute.js +52 -0
- package/dist/repo-workspace.js +19 -0
- package/dist/runtime/anthropic-preflight.js +41 -0
- package/dist/runtime/codex-sessions.js +10 -1
- package/dist/runtime/credential-store.js +35 -6
- package/dist/runtime/index.js +177 -20
- package/dist/runtime/oauth/model-oauth.js +5 -4
- package/dist/runtime/process.js +33 -9
- package/dist/runtime/protocol.js +64 -1
- package/dist/runtime/slash-commands.js +246 -0
- package/dist/server.js +789 -108
- package/dist/session/attachment-store.js +99 -11
- package/dist/session/event-log.js +75 -11
- package/dist/session/fork-dirty.js +41 -3
- package/dist/session/revert-file.js +44 -0
- package/dist/session/turn-watchdog.js +19 -0
- package/package.json +6 -3
package/dist/server.js
CHANGED
|
@@ -45,20 +45,22 @@ import { RelayConnector, loadRelayConfig } from "./relay-client.js";
|
|
|
45
45
|
import { readEphemeralTeardownConfig, shouldSelfTeardown, performSelfTeardown } from "./ephemeral-teardown.js";
|
|
46
46
|
import { buildSessionSnapshot, applySessionSnapshot } from "./session/snapshot.js";
|
|
47
47
|
import { createCheckpointBundle, applyCheckpointBundle, materializeCheckpoint } from "./session/checkpoint-pack.js";
|
|
48
|
+
import { configuredTurnTimeoutMs } from "./session/turn-watchdog.js";
|
|
49
|
+
import { runRequiredAutomationChecks } from "./automation-checks.js";
|
|
48
50
|
import { PolicyEngine } from "./policy/policy-engine.js";
|
|
49
51
|
import { TerminalManager } from "./terminal.js";
|
|
50
52
|
import { commandLaunch } from "./command-launch.js";
|
|
51
53
|
import { listMultiplexerSessions, attachCommand } from "./multiplexer.js";
|
|
52
54
|
import { createWorktree, removeWorktree, branchSlug, gitRepoRoot } from "./worktree.js";
|
|
53
55
|
import { HarnessManager } from "./harness/manager.js";
|
|
54
|
-
import { startEgressProxyIfEnabled } from "./harness/egress.js";
|
|
56
|
+
import { startEgressProxyIfEnabled, applySessionSandboxEgress, stopSessionEgress } from "./harness/egress.js";
|
|
55
57
|
import { initSharedDepCache, sharedDepCacheRoot } from "./harness/dep-cache.js";
|
|
56
58
|
import { evictToCap, dirSizeBytes } from "./harness/cache-evict.js";
|
|
57
59
|
import { checkDiskAdmission } from "./harness/disk-admission.js";
|
|
58
60
|
import { sandboxTier, setConfiguredSandboxTier, normalizeSandboxTier } from "./harness/sandbox.js";
|
|
59
61
|
import { setConfiguredAutoAttachToolImages } from "./harness/tool-image-attachments.js";
|
|
60
62
|
import { injectMcpProxyForSession, injectBivyToolsForSession } from "./harness/mcp-inject.js";
|
|
61
|
-
import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, fetchOrigin } from "./repo-workspace.js";
|
|
63
|
+
import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, resolveAdoptBaseRef, fetchOrigin } from "./repo-workspace.js";
|
|
62
64
|
import { configureGitAuth, writeGitCredentialEndpoint } from "./git-auth.js";
|
|
63
65
|
import { GitHubTaskPoller, resolveGitHubTaskConfig, buildTaskPrompt, buildResumePrompt, buildInteractiveResumePrompt, DEFAULT_ISSUE_INSTRUCTIONS, parseBivyDirectives, commitAll, pushBranch, mergeBaseIntoBranch, completeMerge, abortMerge, findOpenPullRequestForBranch, findPullRequestsForBranch, findMergedPullRequestForBranch, issueBranchName, getPullRequest, commentIssue, listOpenLabelledIssues, selectActionableIssues, getIssue, getIssueCommentBody, addLabel, removeLabel, announcePickup, } from "./github-tasks.js";
|
|
64
66
|
import { buildLinearTaskPrompt, getLinearIssue, linearBranchName } from "./linear-tasks.js";
|
|
@@ -73,6 +75,8 @@ import { thinkingTextFromContent } from "./session/transcript-merge.js";
|
|
|
73
75
|
import { normalizeMessages } from "./session/transcript-normal.js";
|
|
74
76
|
import { buildNativeImportSeedPrompt } from "./session/native-import.js";
|
|
75
77
|
import { EventLog } from "./session/event-log.js";
|
|
78
|
+
import { revertFile } from "./session/revert-file.js";
|
|
79
|
+
import { buildDiagnosticsReport, activationRecord } from "./diagnostics.js";
|
|
76
80
|
import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
|
|
77
81
|
import { planAttachment, isAttachPlanError, MAX_AGENT_ATTACHMENT_BYTES } from "./session/attach-to-chat.js";
|
|
78
82
|
import { extractInlineImageUrls, assistantTextForImageScan, fetchInlineImage, isFetchImageError, inlineImageDisplayName, } from "./session/inline-image-fetch.js";
|
|
@@ -497,11 +501,11 @@ const terminals = new TerminalManager();
|
|
|
497
501
|
// and fixed for that session's life; switching agents in the UI starts a new one.
|
|
498
502
|
let defaultRuntimeId = (process.env.BIVY_RUNTIME ?? "pi").toLowerCase();
|
|
499
503
|
const runtimeHost = new RuntimeHost({ credsDir, piDir, sessionsDir, attachToChat: attachToChatForSession });
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
// hits an exhausted-credits / rate-limit turn error swaps down the
|
|
503
|
-
// runtime's live setModel) and retries
|
|
504
|
-
//
|
|
504
|
+
// A built-in in-session model-fallback ruleset from BIVY_SESSION_MODEL_FALLBACK
|
|
505
|
+
// (docs/rulesets.md). Opt-in: set it to a comma-separated model list and a
|
|
506
|
+
// session that hits an exhausted-credits / rate-limit turn error swaps down the
|
|
507
|
+
// list (via the runtime's live setModel) and retries. Used only when the user
|
|
508
|
+
// hasn't authored their own session-scoped ruleset in the UI.
|
|
505
509
|
function sessionModelFallbackRuleset() {
|
|
506
510
|
const models = (process.env.BIVY_SESSION_MODEL_FALLBACK ?? "")
|
|
507
511
|
.split(",")
|
|
@@ -525,13 +529,27 @@ function sessionModelFallbackRuleset() {
|
|
|
525
529
|
],
|
|
526
530
|
};
|
|
527
531
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
532
|
+
/** The ruleset in-session recovery runs under right now: the user's active
|
|
533
|
+
* ruleset if it applies to sessions, else the env model-fallback ruleset, else
|
|
534
|
+
* undefined (→ built-in DEFAULT_RULESET). Read lazily on each turn error so UI
|
|
535
|
+
* edits take effect without a restart, mirroring activeQueueRuleset. */
|
|
536
|
+
function activeSessionRuleset() {
|
|
537
|
+
return activeRulesetFor(rulesetsDir, "session") ?? sessionModelFallbackRuleset();
|
|
538
|
+
}
|
|
539
|
+
// The in-session recovery effector's policy. Always available: an interactive
|
|
540
|
+
// session can wait out a provider usage/rate limit and resume when it resets
|
|
541
|
+
// (planResume), or swap models down a fallback chain (planReroute). Thin wrapper
|
|
542
|
+
// so a freshly-saved active ruleset is picked up on the next turn error.
|
|
543
|
+
const sessionRunPolicy = {
|
|
544
|
+
decide: (ctx) => createRunPolicy({ context: "session", ruleset: activeSessionRuleset() }).decide(ctx),
|
|
545
|
+
};
|
|
546
|
+
if (process.env.BIVY_SESSION_MODEL_FALLBACK) {
|
|
531
547
|
console.log(`[policy] in-session model reroute enabled: ${process.env.BIVY_SESSION_MODEL_FALLBACK}`);
|
|
532
548
|
}
|
|
533
549
|
let lastUpdateCheckAt = 0;
|
|
534
|
-
|
|
550
|
+
// The most recent "this node is behind" finding, so a client that connects after
|
|
551
|
+
// the check already ran still gets the banner (replayed on connect below).
|
|
552
|
+
let pendingBivyUpdate = null;
|
|
535
553
|
function runtimeSummary(rt) {
|
|
536
554
|
return runtimeHost.summary(rt);
|
|
537
555
|
}
|
|
@@ -621,11 +639,11 @@ function readJsonFile(file) {
|
|
|
621
639
|
return undefined;
|
|
622
640
|
}
|
|
623
641
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
642
|
+
// Poll npm for a newer release (throttled to every 6h). On finding one, remember
|
|
643
|
+
// it and push a dedicated `node.update` event so every connected app can show a
|
|
644
|
+
// banner with a one-tap "Update this node" button (see runBivyUpdate). Safe to
|
|
645
|
+
// call from anywhere — never throws, never interrupts a session.
|
|
646
|
+
async function checkBivyUpdate() {
|
|
629
647
|
const now = Date.now();
|
|
630
648
|
if (now - lastUpdateCheckAt < 6 * 60 * 60 * 1000)
|
|
631
649
|
return;
|
|
@@ -637,25 +655,48 @@ async function maybeNotifyBivyUpdate(record) {
|
|
|
637
655
|
const res = await fetch(updateRegistryUrl, { signal: AbortSignal.timeout(5000) });
|
|
638
656
|
if (!res.ok)
|
|
639
657
|
return;
|
|
640
|
-
const
|
|
641
|
-
if (!
|
|
642
|
-
return;
|
|
643
|
-
if (updateNoticeSentFor === latestVersion)
|
|
658
|
+
const latest = (await res.json()).version;
|
|
659
|
+
if (!latest || !isNewerVersion(latest, current))
|
|
644
660
|
return;
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
broadcast({
|
|
648
|
-
type: "session.notice",
|
|
649
|
-
sessionId: record.id,
|
|
650
|
-
level: "info",
|
|
651
|
-
message: `A newer Bivy version${label} is available. Run \`bivy update\` in your terminal to update.`,
|
|
652
|
-
action: "bivy update",
|
|
653
|
-
});
|
|
661
|
+
pendingBivyUpdate = { current, latest };
|
|
662
|
+
broadcast({ type: "node.update", current, latest });
|
|
654
663
|
}
|
|
655
664
|
catch {
|
|
656
665
|
// Best-effort update checks should never interrupt a session.
|
|
657
666
|
}
|
|
658
667
|
}
|
|
668
|
+
async function maybeNotifyBivyUpdate() {
|
|
669
|
+
// The daemon creates an initial session during startup before any UI is
|
|
670
|
+
// connected. Don't spend a check until someone can see the banner.
|
|
671
|
+
if (clients.size === 0 && !relay)
|
|
672
|
+
return;
|
|
673
|
+
await checkBivyUpdate();
|
|
674
|
+
}
|
|
675
|
+
// Run `bivy update` on this node, the same command a user would type. The CLI
|
|
676
|
+
// re-spawns itself detached, waits for any in-flight turn, updates, and restarts
|
|
677
|
+
// the service (logging to update.log), so we just fire-and-forget it here. The
|
|
678
|
+
// bin ships next to this server bundle in both the git checkout (src/server.ts)
|
|
679
|
+
// and the published package (dist/server.js), so repoRoot/bin/bivy.mjs resolves
|
|
680
|
+
// in both. Returns a friendly error instead of throwing when it can't be found
|
|
681
|
+
// (e.g. an unusual layout), so the banner can fall back to the manual command.
|
|
682
|
+
function runBivyUpdate() {
|
|
683
|
+
const script = path.join(repoRoot, "bin", "bivy.mjs");
|
|
684
|
+
if (!fs.existsSync(script)) {
|
|
685
|
+
return { ok: false, error: "Could not locate the bivy CLI on this node — run `bivy update` in a terminal." };
|
|
686
|
+
}
|
|
687
|
+
try {
|
|
688
|
+
const child = spawn(process.execPath, [script, "update"], {
|
|
689
|
+
detached: true,
|
|
690
|
+
stdio: "ignore",
|
|
691
|
+
env: process.env,
|
|
692
|
+
});
|
|
693
|
+
child.unref();
|
|
694
|
+
return { ok: true };
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
698
|
+
}
|
|
699
|
+
}
|
|
659
700
|
function runtimeInstallSpec(requested) {
|
|
660
701
|
let id = String(requested ?? "").trim().toLowerCase();
|
|
661
702
|
// Normalize a few historical aliases to their canonical runtime id.
|
|
@@ -921,6 +962,8 @@ function safeAttachmentName(value) {
|
|
|
921
962
|
* with its normal file tools. Any file type is supported;
|
|
922
963
|
* binary files arrive as base64 `data`.
|
|
923
964
|
*/
|
|
965
|
+
const MAX_PROMPT_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
966
|
+
const MAX_PROMPT_ATTACHMENTS_BYTES = 40 * 1024 * 1024;
|
|
924
967
|
function attachmentsFrom(value) {
|
|
925
968
|
if (!Array.isArray(value))
|
|
926
969
|
return { images: [], imageNotes: [], imageRefs: [], files: [] };
|
|
@@ -928,13 +971,24 @@ function attachmentsFrom(value) {
|
|
|
928
971
|
const imageNotes = [];
|
|
929
972
|
const imageRefs = [];
|
|
930
973
|
const files = [];
|
|
931
|
-
|
|
974
|
+
let totalBytes = 0;
|
|
975
|
+
if (value.length > 12)
|
|
976
|
+
throw new Error("A message can include at most 12 attachments");
|
|
977
|
+
for (const raw of value) {
|
|
932
978
|
if (!raw || typeof raw !== "object")
|
|
933
979
|
continue;
|
|
934
980
|
const attachment = raw;
|
|
935
981
|
const name = safeAttachmentName(attachment.name);
|
|
936
982
|
const size = Number(attachment.size || 0);
|
|
937
983
|
const mimeType = typeof attachment.mimeType === "string" && attachment.mimeType ? attachment.mimeType : undefined;
|
|
984
|
+
const encodedBytes = typeof attachment.data === "string" ? Math.floor(attachment.data.length * 3 / 4) : 0;
|
|
985
|
+
const textBytes = attachment.kind === "file" && typeof attachment.text === "string" ? Buffer.byteLength(attachment.text) : 0;
|
|
986
|
+
const actualBytes = encodedBytes || textBytes;
|
|
987
|
+
if (actualBytes > MAX_PROMPT_ATTACHMENT_BYTES)
|
|
988
|
+
throw new Error(`${name} exceeds the 10 MiB attachment limit`);
|
|
989
|
+
totalBytes += actualBytes;
|
|
990
|
+
if (totalBytes > MAX_PROMPT_ATTACHMENTS_BYTES)
|
|
991
|
+
throw new Error("Attachments exceed the 40 MiB per-message limit");
|
|
938
992
|
if (attachment.kind === "image" && typeof attachment.data === "string") {
|
|
939
993
|
const imgMime = mimeType ?? "image/png";
|
|
940
994
|
images.push({ type: "image", data: attachment.data, mimeType: imgMime });
|
|
@@ -2179,14 +2233,58 @@ function eventLogPath(sessionId) {
|
|
|
2179
2233
|
// whole history: overlay detail (reasoning + tool activity) AND the base transcript,
|
|
2180
2234
|
// the latter as bounded delta/reset records. Written on every event; read via
|
|
2181
2235
|
// eventLog.deriveHistory. `redactSecrets` scrubs credentials at the single flush
|
|
2182
|
-
// choke point before anything lands on the synced-to-PWA disk.
|
|
2183
|
-
|
|
2236
|
+
// choke point before anything lands on the synced-to-PWA disk. I/O/corruption
|
|
2237
|
+
// failures are never silently converted into empty history: keep a diagnostic,
|
|
2238
|
+
// log loudly, and notify the owning live session while pending appends remain
|
|
2239
|
+
// queued for retry.
|
|
2240
|
+
const eventLogIssues = new Map();
|
|
2241
|
+
const eventLog = new EventLog(eventLogDir, eventLogPath, redactSecrets, 500, (issue) => {
|
|
2242
|
+
eventLogIssues.set(issue.sessionId, { operation: issue.operation, message: issue.message, at: issue.at });
|
|
2243
|
+
console.error(`[event-log] ${issue.operation} failed for ${issue.sessionId}: ${issue.message}`);
|
|
2244
|
+
const record = openSessions.get(issue.sessionId);
|
|
2245
|
+
if (!record)
|
|
2246
|
+
return;
|
|
2247
|
+
const warning = `Session history storage problem (${issue.operation}): ${issue.message}`;
|
|
2248
|
+
if (record.warning === warning)
|
|
2249
|
+
return;
|
|
2250
|
+
record.warning = warning;
|
|
2251
|
+
broadcast({ type: "session.notice", sessionId: record.id, level: "error", message: warning });
|
|
2252
|
+
});
|
|
2184
2253
|
// Global content-addressed store for message attachments (images + files). Unlike
|
|
2185
2254
|
// the per-session `.bivy-attachments/` worktree copy (kept so the agent can open
|
|
2186
2255
|
// files with its tools), this is durable, session-independent, and re-findable:
|
|
2187
2256
|
// the transcript references blobs by hash, and clients rehydrate thumbnails by
|
|
2188
2257
|
// hash after a reload or on another device. See src/session/attachment-store.ts.
|
|
2189
|
-
const
|
|
2258
|
+
const positiveEnvNumber = (name, fallback) => {
|
|
2259
|
+
const value = Number(process.env[name]);
|
|
2260
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
2261
|
+
};
|
|
2262
|
+
const attachmentStore = new AttachmentStore(path.join(appDir, "attachments"), {
|
|
2263
|
+
maxFileBytes: positiveEnvNumber("BIVY_ATTACHMENT_MAX_FILE_BYTES", 25 * 1024 * 1024),
|
|
2264
|
+
maxStoreBytes: positiveEnvNumber("BIVY_ATTACHMENT_STORE_MAX_BYTES", 2 * 1024 * 1024 * 1024),
|
|
2265
|
+
retentionMs: positiveEnvNumber("BIVY_ATTACHMENT_RETENTION_MS", 30 * 24 * 60 * 60 * 1000),
|
|
2266
|
+
});
|
|
2267
|
+
let attachmentGcStats = attachmentStore.stats();
|
|
2268
|
+
function referencedAttachmentHashes() {
|
|
2269
|
+
// If transcript history is unreadable, collecting nothing would make its
|
|
2270
|
+
// still-referenced blobs look orphaned. Fail closed and skip destructive GC.
|
|
2271
|
+
if (!eventLog.health().ok)
|
|
2272
|
+
return null;
|
|
2273
|
+
const hashes = new Set();
|
|
2274
|
+
const ids = new Set(metadata.listSessions().map((session) => session.id));
|
|
2275
|
+
for (const record of new Set(openSessions.values()))
|
|
2276
|
+
ids.add(record.id);
|
|
2277
|
+
for (const id of ids) {
|
|
2278
|
+
for (const entry of eventLog.entries(id)) {
|
|
2279
|
+
if (entry.bivyKind === "attachment")
|
|
2280
|
+
for (const ref of entry.refs)
|
|
2281
|
+
hashes.add(ref.hash);
|
|
2282
|
+
else if (entry.bivyKind === "outbound-attachment" || entry.bivyKind === "inline-image")
|
|
2283
|
+
hashes.add(entry.ref.hash);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
return eventLog.health().ok ? hashes : null;
|
|
2287
|
+
}
|
|
2190
2288
|
// --- Warm session replication (docs/session-replication.md) -----------------
|
|
2191
2289
|
// A standby's replica repo lives under appDir/replicas/<id>: a self-contained git
|
|
2192
2290
|
// repo that receives checkpoint bundles and is checked out on promotion. Created
|
|
@@ -2536,6 +2634,14 @@ const RELAY_COMMANDS = {
|
|
|
2536
2634
|
ping(msg, ctx) {
|
|
2537
2635
|
ctx.reply({ type: "pong", requestId: typeof msg.requestId === "string" ? msg.requestId : undefined });
|
|
2538
2636
|
},
|
|
2637
|
+
// Kick off `bivy update` on this node from the app's version-mismatch banner
|
|
2638
|
+
// (see runBivyUpdate). The node restarts itself when the update lands, so the
|
|
2639
|
+
// client just sees the socket reconnect on the new build; a failure to even
|
|
2640
|
+
// start reports back so the banner can show the manual command.
|
|
2641
|
+
"node.update"(_msg, ctx) {
|
|
2642
|
+
const result = runBivyUpdate();
|
|
2643
|
+
ctx.reply({ type: "node.update.result", ok: result.ok, error: result.error });
|
|
2644
|
+
},
|
|
2539
2645
|
// Fetch a stored attachment's bytes by content hash. The relay client (a phone
|
|
2540
2646
|
// not on the LAN) can't reach the GET /api/attachment endpoint, so it fetches
|
|
2541
2647
|
// over the encrypted tunnel instead; the relay framing chunks the base64 payload
|
|
@@ -2601,6 +2707,30 @@ const RELAY_COMMANDS = {
|
|
|
2601
2707
|
ctx.reply({ type: "session.error", sessionId: record.id, error: error instanceof Error ? error.message : String(error) });
|
|
2602
2708
|
}
|
|
2603
2709
|
},
|
|
2710
|
+
async "session.revert_file"(msg, ctx) {
|
|
2711
|
+
// C3d — revert ONE changed file to its pre-turn content without rewinding the
|
|
2712
|
+
// whole turn. `content` is the file's pre-turn text (or null when the turn
|
|
2713
|
+
// added it). Path-confined to the session's worktree by revertFile.
|
|
2714
|
+
const record = resolveSession(msg.sessionId);
|
|
2715
|
+
const relPath = String(msg.path ?? "").trim();
|
|
2716
|
+
if (!record || !relPath)
|
|
2717
|
+
return;
|
|
2718
|
+
if (sessionBusy(record)) {
|
|
2719
|
+
ctx.reply({ type: "session.error", sessionId: record.id, error: "Stop the current turn before reverting a file." });
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
const content = typeof msg.content === "string" ? msg.content : null;
|
|
2723
|
+
const result = revertFile(harnessDirFor(record), relPath, content);
|
|
2724
|
+
if (!result.ok) {
|
|
2725
|
+
ctx.reply({ type: "session.error", sessionId: record.id, error: `Could not revert ${relPath}: ${result.error ?? "unknown error"}` });
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
// Recompute the turn's diff against the (unchanged) baseline so the review
|
|
2729
|
+
// surface drops the reverted file immediately.
|
|
2730
|
+
const event = { type: "session.file_reverted", sessionId: record.id, path: relPath, status: result.status };
|
|
2731
|
+
ctx.reply(event);
|
|
2732
|
+
ctx.broadcast(event);
|
|
2733
|
+
},
|
|
2604
2734
|
async "session.pr.refresh"(msg, ctx) {
|
|
2605
2735
|
// Force a refresh regardless of live/attached state — resume the session if
|
|
2606
2736
|
// the node dropped it from memory, so a finished/detached session can still
|
|
@@ -2966,6 +3096,7 @@ const RELAY_COMMANDS = {
|
|
|
2966
3096
|
},
|
|
2967
3097
|
async "models.list"(msg) {
|
|
2968
3098
|
const requestedSessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId : undefined;
|
|
3099
|
+
const wantedRuntimeId = typeof msg.runtimeId === "string" && msg.runtimeId ? msg.runtimeId : undefined;
|
|
2969
3100
|
let record;
|
|
2970
3101
|
try {
|
|
2971
3102
|
record = requestedSessionId ? await resolveOrResumeSession(requestedSessionId, msg.path) : active;
|
|
@@ -2978,7 +3109,12 @@ const RELAY_COMMANDS = {
|
|
|
2978
3109
|
relay?.sendEvent({ type: "session.error", sessionId: requestedSessionId, error: "Session not found" });
|
|
2979
3110
|
return;
|
|
2980
3111
|
}
|
|
2981
|
-
|
|
3112
|
+
// On a draft (no session id), a runtime hint from the composer takes
|
|
3113
|
+
// precedence so an agent switch previews *that* agent's models even if a
|
|
3114
|
+
// stale `active` on another runtime lingers on the node.
|
|
3115
|
+
if (!requestedSessionId && wantedRuntimeId && record?.runtimeId !== wantedRuntimeId)
|
|
3116
|
+
record = null;
|
|
3117
|
+
record ??= await sessionForModelQuery(wantedRuntimeId);
|
|
2982
3118
|
const session = record.session;
|
|
2983
3119
|
const current = session.getCurrentModel();
|
|
2984
3120
|
const models = await publicModelsList(session, current);
|
|
@@ -2990,6 +3126,17 @@ const RELAY_COMMANDS = {
|
|
|
2990
3126
|
// (e.g. Claude) — the "Claude shows Codex models" bug.
|
|
2991
3127
|
relay?.sendEvent({ type: "models.list", sessionId: record.id, runtimeId: record.runtimeId, current: current ? publicModel(current, current) : null, models, thinking });
|
|
2992
3128
|
},
|
|
3129
|
+
"models.prefetch"(msg) {
|
|
3130
|
+
// The composer's agent picker opened: warm the scratch session for each
|
|
3131
|
+
// offered agent in the background so the first switch to any of them answers
|
|
3132
|
+
// instantly. Fire-and-forget — no reply; the follow-up models.list carries
|
|
3133
|
+
// the result. Ignore anything but a bounded string[] of runtime ids.
|
|
3134
|
+
const ids = Array.isArray(msg.runtimeIds)
|
|
3135
|
+
? msg.runtimeIds.filter((id) => typeof id === "string" && !!id).slice(0, 16)
|
|
3136
|
+
: [];
|
|
3137
|
+
if (ids.length)
|
|
3138
|
+
prefetchModels(ids);
|
|
3139
|
+
},
|
|
2993
3140
|
async "model.select"(msg) {
|
|
2994
3141
|
const requestedSessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId : undefined;
|
|
2995
3142
|
let record;
|
|
@@ -3407,7 +3554,10 @@ const RELAY_COMMANDS = {
|
|
|
3407
3554
|
record.lastPrompt = agentPrompt;
|
|
3408
3555
|
record.lastPromptOptions = promptOptionsFor(record, msg.streamingBehavior, images);
|
|
3409
3556
|
record.reroute?.beginTurn();
|
|
3410
|
-
|
|
3557
|
+
// The user is driving this turn manually — supersede any pending auto-resume
|
|
3558
|
+
// that was scheduled after a prior limit so it can't re-fire on top of them.
|
|
3559
|
+
clearSessionResume(record.id);
|
|
3560
|
+
await promptWithWatchdog(record, agentPrompt, record.lastPromptOptions);
|
|
3411
3561
|
}).catch((error) => {
|
|
3412
3562
|
// Mirror the HTTP path (see the /prompt route): a rejected turn after
|
|
3413
3563
|
// the runtime marked the session working emits no agent_end, so without
|
|
@@ -3442,6 +3592,7 @@ const RELAY_COMMANDS = {
|
|
|
3442
3592
|
source: rec.source,
|
|
3443
3593
|
title: rec.session.getName(),
|
|
3444
3594
|
model: rec.session.getCurrentModel()?.name,
|
|
3595
|
+
sandbox: rec.sandbox,
|
|
3445
3596
|
};
|
|
3446
3597
|
let dirtyPatch;
|
|
3447
3598
|
if (rec.worktree) {
|
|
@@ -3450,6 +3601,14 @@ const RELAY_COMMANDS = {
|
|
|
3450
3601
|
}
|
|
3451
3602
|
catch { /* best effort — omit dirty state */ }
|
|
3452
3603
|
}
|
|
3604
|
+
// Publish the source branch so a cross-node fork's COMMITTED work travels
|
|
3605
|
+
// via origin (the destination adopts `origin/<branch>`; see
|
|
3606
|
+
// resolveAdoptBaseRef). Uncommitted work rides the dirtyPatch above. Only
|
|
3607
|
+
// for a genuine cross-node fork — a same-node cross-agent fork adopts the
|
|
3608
|
+
// LOCAL branch and needs no push. Best-effort: a no-token/offline node just
|
|
3609
|
+
// falls back to the default base downstream.
|
|
3610
|
+
if (msg.crossNode === true)
|
|
3611
|
+
await pushForkSourceBranch(rec);
|
|
3453
3612
|
// Refresh the account model-auth vault so the destination node can pull
|
|
3454
3613
|
// this session's model credentials during import (fork credential-move,
|
|
3455
3614
|
// docs/session-fork-plan.md). Best-effort: local-only nodes just skip it.
|
|
@@ -3530,6 +3689,7 @@ const RELAY_COMMANDS = {
|
|
|
3530
3689
|
source: rec.source,
|
|
3531
3690
|
title: rec.session.getName(),
|
|
3532
3691
|
model: rec.session.getCurrentModel()?.name,
|
|
3692
|
+
sandbox: rec.sandbox,
|
|
3533
3693
|
};
|
|
3534
3694
|
// Carry uncommitted work: capture from the SOURCE worktree; standUpFork
|
|
3535
3695
|
// re-applies it into the fork's fresh worktree. Local git ops only.
|
|
@@ -4481,16 +4641,26 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
|
|
|
4481
4641
|
// evidence trail — the branch/PR references and a bounded summary only,
|
|
4482
4642
|
// never file lists or error details (those stay in `message`/`extra`,
|
|
4483
4643
|
// which are broadcast to the live session but never sent to onEvidence).
|
|
4484
|
-
const kind = stage === "pr_opened" ? "pull_request"
|
|
4644
|
+
const kind = stage === "pr_opened" ? "pull_request"
|
|
4645
|
+
: stage === "started" || stage === "pushed" ? "branch"
|
|
4646
|
+
: stage === "failed" || stage === "checks_failed" || stage === "no_changes" ? "completed"
|
|
4647
|
+
: undefined;
|
|
4485
4648
|
if (kind) {
|
|
4649
|
+
const summary = stage === "pr_opened" ? "Pull request opened."
|
|
4650
|
+
: stage === "started" ? "Working branch and session created."
|
|
4651
|
+
: stage === "pushed" ? "Changes pushed; no pull request is open."
|
|
4652
|
+
: stage === "no_changes" ? "Run completed with no file changes."
|
|
4653
|
+
: stage === "checks_failed" ? "Deterministic validation checks failed."
|
|
4654
|
+
: "Execution failed. Detailed diagnostics remain on the node.";
|
|
4486
4655
|
void overrides.onEvidence?.({
|
|
4487
4656
|
output: { sessionId: record.id, branch, prUrl: typeof extra.prUrl === "string" ? extra.prUrl : undefined },
|
|
4488
4657
|
events: [{
|
|
4489
4658
|
at: new Date().toISOString(),
|
|
4490
4659
|
kind,
|
|
4491
|
-
summary
|
|
4660
|
+
summary,
|
|
4492
4661
|
ref: branch,
|
|
4493
4662
|
url: typeof extra.prUrl === "string" ? extra.prUrl : undefined,
|
|
4663
|
+
...(stage === "checks_failed" || stage === "failed" ? { status: "failed" } : {}),
|
|
4494
4664
|
}],
|
|
4495
4665
|
});
|
|
4496
4666
|
}
|
|
@@ -4503,7 +4673,7 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
|
|
|
4503
4673
|
// which now adopts the existing remote branch rather than colliding.
|
|
4504
4674
|
const existing = findIssueSession(source);
|
|
4505
4675
|
if (existing?.worktree && fs.existsSync(existing.worktree.path)) {
|
|
4506
|
-
return runIssueFollowUp(cfg, issue, existing, emit);
|
|
4676
|
+
return runIssueFollowUp(cfg, issue, existing, emit, overrides);
|
|
4507
4677
|
}
|
|
4508
4678
|
// Idempotency guard against the duplicate-PR regression: if this issue's
|
|
4509
4679
|
// deterministic branch already produced a *merged* pull request, the change has
|
|
@@ -4585,8 +4755,8 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
|
|
|
4585
4755
|
try {
|
|
4586
4756
|
emit(record, "started", `Started work on ${cfg.owner}/${cfg.repo}#${issue.number}.`);
|
|
4587
4757
|
await runSessionTurn(record, buildTaskPrompt(issue, nodeGithubIssuePrompt()));
|
|
4588
|
-
emit(record, "agent_done", `Agent finished issue #${issue.number};
|
|
4589
|
-
await reportIssueOutcome(cfg, issue, record, emit, { followUp: false });
|
|
4758
|
+
emit(record, "agent_done", `Agent finished issue #${issue.number}; running deterministic checks.`);
|
|
4759
|
+
await reportIssueOutcome(cfg, issue, record, emit, { followUp: false, onEvidence: overrides.onEvidence });
|
|
4590
4760
|
}
|
|
4591
4761
|
catch (error) {
|
|
4592
4762
|
emit(record, "failed", `GitHub issue #${issue.number} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -4599,7 +4769,7 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
|
|
|
4599
4769
|
* the new comment as another turn in the same worktree, then report the outcome
|
|
4600
4770
|
* the same way a fresh pickup does.
|
|
4601
4771
|
*/
|
|
4602
|
-
async function runIssueFollowUp(cfg, issue, record, emit) {
|
|
4772
|
+
async function runIssueFollowUp(cfg, issue, record, emit, overrides = {}) {
|
|
4603
4773
|
const wt = record.worktree;
|
|
4604
4774
|
if (!wt)
|
|
4605
4775
|
throw new Error("issue session has no worktree");
|
|
@@ -4628,8 +4798,8 @@ async function runIssueFollowUp(cfg, issue, record, emit) {
|
|
|
4628
4798
|
}
|
|
4629
4799
|
}
|
|
4630
4800
|
await runSessionTurn(record, buildFollowUpPrompt(issue));
|
|
4631
|
-
emit(record, "agent_done", `Agent handled the follow-up on issue #${issue.number};
|
|
4632
|
-
await reportIssueOutcome(cfg, issue, record, emit, { followUp: true });
|
|
4801
|
+
emit(record, "agent_done", `Agent handled the follow-up on issue #${issue.number}; running deterministic checks.`);
|
|
4802
|
+
await reportIssueOutcome(cfg, issue, record, emit, { followUp: true, onEvidence: overrides.onEvidence });
|
|
4633
4803
|
}
|
|
4634
4804
|
catch (error) {
|
|
4635
4805
|
emit(record, "failed", `GitHub issue #${issue.number} follow-up failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -4655,6 +4825,26 @@ async function reportIssueOutcome(cfg, issue, record, emit, opts) {
|
|
|
4655
4825
|
const wt = record.worktree;
|
|
4656
4826
|
if (!wt)
|
|
4657
4827
|
throw new Error("issue session has no worktree");
|
|
4828
|
+
// Customer success is not `agent_end`. Run the repository's declared standard
|
|
4829
|
+
// checks under local time/output bounds and report only privacy-safe metadata
|
|
4830
|
+
// (name/hash/status/exit), never command text or output, to the control plane.
|
|
4831
|
+
const checks = runRequiredAutomationChecks(wt.path);
|
|
4832
|
+
if (checks.length > 0) {
|
|
4833
|
+
const failed = checks.filter((check) => check.status === "failed");
|
|
4834
|
+
await opts.onEvidence?.({
|
|
4835
|
+
checks,
|
|
4836
|
+
events: [{
|
|
4837
|
+
at: new Date().toISOString(),
|
|
4838
|
+
kind: "completed",
|
|
4839
|
+
summary: failed.length ? `${failed.length} deterministic check(s) failed.` : `${checks.length} deterministic check(s) passed.`,
|
|
4840
|
+
status: failed.length ? "failed" : "passed",
|
|
4841
|
+
}],
|
|
4842
|
+
});
|
|
4843
|
+
if (failed.length) {
|
|
4844
|
+
emit(record, "checks_failed", `${failed.map((check) => check.name).join(", ")} failed; the run needs review.`);
|
|
4845
|
+
throw new Error(`Required checks failed: ${failed.map((check) => check.name).join(", ")}`);
|
|
4846
|
+
}
|
|
4847
|
+
}
|
|
4658
4848
|
const commitMessage = opts.followUp ? `Follow-up on #${issue.number}` : `${issue.title} (#${issue.number})`;
|
|
4659
4849
|
await commitAll(wt.path, commitMessage);
|
|
4660
4850
|
await fetchOrigin(wt.path);
|
|
@@ -4740,7 +4930,7 @@ async function runSessionTurn(record, prompt) {
|
|
|
4740
4930
|
}
|
|
4741
4931
|
});
|
|
4742
4932
|
});
|
|
4743
|
-
await record
|
|
4933
|
+
await promptWithWatchdog(record, prompt);
|
|
4744
4934
|
await finished;
|
|
4745
4935
|
}
|
|
4746
4936
|
/** Set + persist + broadcast a session's display name (used by issue pickup). */
|
|
@@ -5050,6 +5240,47 @@ async function resolveTokenForRepo(owner, repo) {
|
|
|
5050
5240
|
}
|
|
5051
5241
|
return (await resolveGitHubToken()) ?? (await hostedMintToken());
|
|
5052
5242
|
}
|
|
5243
|
+
/** The session source a Linear-issue pickup advertises, keyed by the issue's
|
|
5244
|
+
* provider-native id so the control plane can correlate a re-dispatch to it
|
|
5245
|
+
* (findSessionByExternalId → "linear:<externalId>"). The Linear analogue of the
|
|
5246
|
+
* GitHub `issue:owner/repo#N` source. */
|
|
5247
|
+
function linearSessionSource(externalId) {
|
|
5248
|
+
return `linear:${externalId}`;
|
|
5249
|
+
}
|
|
5250
|
+
/**
|
|
5251
|
+
* Case B for a queued follow-up the control plane correlated to an existing
|
|
5252
|
+
* session (`targetKind === "existing_session"`): if that session is still live on
|
|
5253
|
+
* this node, continue it as a normal chat — run `prompt` as a follow-up turn and
|
|
5254
|
+
* re-publish its branch/PR — so a channel reply lands in the same thread. The
|
|
5255
|
+
* provider-agnostic analogue of the GitHub issue follow-up (`runIssueFollowUp`);
|
|
5256
|
+
* used by both the Linear and the generic (Slack) pickup paths. When the session
|
|
5257
|
+
* isn't live here (its machine was torn down), best-effort restore its snapshot so
|
|
5258
|
+
* the caller's fresh pickup continues its branch/transcript instead of cold-
|
|
5259
|
+
* starting, and return false so the caller falls through. Returns true only when
|
|
5260
|
+
* it fully handled the item.
|
|
5261
|
+
*/
|
|
5262
|
+
async function continueCorrelatedSession(item, prompt, report) {
|
|
5263
|
+
if (item.targetKind !== "existing_session" || !item.targetSessionId)
|
|
5264
|
+
return false;
|
|
5265
|
+
const record = openSessions.get(item.targetSessionId);
|
|
5266
|
+
if (!record) {
|
|
5267
|
+
await restoreSessionFromSnapshot(item.targetSessionId).catch((e) => console.warn(`[case-b] snapshot restore for ${item.targetSessionId} failed:`, e.message));
|
|
5268
|
+
return false;
|
|
5269
|
+
}
|
|
5270
|
+
const branch = record.worktree?.branch;
|
|
5271
|
+
await runSessionTurn(record, prompt);
|
|
5272
|
+
if (record.worktree) {
|
|
5273
|
+
await maybePushWorktreeBranch(record);
|
|
5274
|
+
await maybeDetectPullRequest(record);
|
|
5275
|
+
}
|
|
5276
|
+
await report({
|
|
5277
|
+
output: { sessionId: record.id, branch, prUrl: record.prUrl },
|
|
5278
|
+
events: record.prUrl
|
|
5279
|
+
? [{ at: new Date().toISOString(), kind: "pull_request", summary: "Pull request updated.", ref: branch, url: record.prUrl }]
|
|
5280
|
+
: undefined,
|
|
5281
|
+
});
|
|
5282
|
+
return true;
|
|
5283
|
+
}
|
|
5053
5284
|
async function runWorkItem(item, report) {
|
|
5054
5285
|
if ((item.source === "schedule" || item.source === "manual") && item.body?.startsWith("bivy-room-v1:")) {
|
|
5055
5286
|
const [, nodeId, ...payload] = item.body.split(":");
|
|
@@ -5128,6 +5359,10 @@ async function runWorkItem(item, report) {
|
|
|
5128
5359
|
const parsed = parseRepo(repoSlug);
|
|
5129
5360
|
if (!parsed)
|
|
5130
5361
|
throw new Error(`Linear work item has an invalid repo "${repoSlug}"`);
|
|
5362
|
+
// Case B: a re-dispatch the control plane correlated to an existing session
|
|
5363
|
+
// continues it as a normal chat instead of starting cold (mirrors GitHub).
|
|
5364
|
+
if (await continueCorrelatedSession(item, buildLinearTaskPrompt(issue), report))
|
|
5365
|
+
return;
|
|
5131
5366
|
const githubToken = await resolveGitHubToken();
|
|
5132
5367
|
if (!githubToken)
|
|
5133
5368
|
throw new Error("no GitHub token available to clone the Linear issue repository");
|
|
@@ -5138,7 +5373,7 @@ async function runWorkItem(item, report) {
|
|
|
5138
5373
|
const record = await createSession(repoDir, undefined, {
|
|
5139
5374
|
worktree: { branch, base },
|
|
5140
5375
|
makeActive: false,
|
|
5141
|
-
source:
|
|
5376
|
+
source: linearSessionSource(item.externalId),
|
|
5142
5377
|
runtimeId: item.runtimeId || nodeConfiguredDefaultAgent(),
|
|
5143
5378
|
sandbox: normalizeSandboxTier(item.sandbox),
|
|
5144
5379
|
approvalMode: approvalModeFrom(item.approvalMode),
|
|
@@ -5164,6 +5399,13 @@ async function runWorkItem(item, report) {
|
|
|
5164
5399
|
const parsedRepo = item.repo ? parseRepo(item.repo) : undefined;
|
|
5165
5400
|
if (item.repo && !parsedRepo)
|
|
5166
5401
|
throw new Error(`work item ${item.id} has an invalid repo "${item.repo}"`);
|
|
5402
|
+
const request = item.body ? `${item.title}\n\n${item.body}` : item.title;
|
|
5403
|
+
// Case B (provider-agnostic): a follow-up the control plane correlated to an
|
|
5404
|
+
// existing session continues it as a normal chat. Reached by Slack the moment a
|
|
5405
|
+
// reply carries a thread identity the control plane can correlate; a one-shot
|
|
5406
|
+
// slash command has none, so it simply falls through to a fresh session.
|
|
5407
|
+
if (await continueCorrelatedSession(item, request, report))
|
|
5408
|
+
return;
|
|
5167
5409
|
const sessionOpts = {
|
|
5168
5410
|
makeActive: false,
|
|
5169
5411
|
title: item.title,
|
|
@@ -5184,7 +5426,6 @@ async function runWorkItem(item, report) {
|
|
|
5184
5426
|
}
|
|
5185
5427
|
catch { }
|
|
5186
5428
|
}
|
|
5187
|
-
const request = item.body ? `${item.title}\n\n${item.body}` : item.title;
|
|
5188
5429
|
const prompt = parsedRepo || record.worktree
|
|
5189
5430
|
? [
|
|
5190
5431
|
request,
|
|
@@ -5711,6 +5952,48 @@ async function applyRequestedModel(record, model) {
|
|
|
5711
5952
|
broadcast({ type: "session.error", sessionId: record.id, error: error instanceof Error ? error.message : "Selected model is not available on this node." });
|
|
5712
5953
|
}
|
|
5713
5954
|
}
|
|
5955
|
+
// Serialize clone + worktree work per repo directory. Two forks (or a fork and
|
|
5956
|
+
// a GitHub pickup) hitting the same shared clone concurrently race on
|
|
5957
|
+
// `git worktree add`/`remove` and the `.bivy/worktrees` dir — the loser used to
|
|
5958
|
+
// see "already exists"/"already checked out" or, worse, `createWorktree`'s
|
|
5959
|
+
// adopt-path `rmSync` clearing a sibling's tree. A lightweight per-key async
|
|
5960
|
+
// mutex removes the race without a filesystem lock.
|
|
5961
|
+
const repoWorktreeLocks = new Map();
|
|
5962
|
+
async function withRepoLock(key, fn) {
|
|
5963
|
+
const prev = repoWorktreeLocks.get(key) ?? Promise.resolve();
|
|
5964
|
+
// Chain the map's tail on the PREVIOUS holder settling (never rejecting), so a
|
|
5965
|
+
// failing fork doesn't poison the next waiter's gate. Each caller still awaits
|
|
5966
|
+
// its own `run` and gets its own result/exception. Bounded by repo count.
|
|
5967
|
+
const gate = prev.then(() => { }, () => { });
|
|
5968
|
+
const run = gate.then(fn);
|
|
5969
|
+
repoWorktreeLocks.set(key, run.then(() => { }, () => { }));
|
|
5970
|
+
return run;
|
|
5971
|
+
}
|
|
5972
|
+
/**
|
|
5973
|
+
* Best-effort push of a fork SOURCE's branch to origin before the bundle leaves
|
|
5974
|
+
* the node, so a cross-node fork's committed work travels via origin (the
|
|
5975
|
+
* destination bases its adopted worktree on `origin/<branch>` — see
|
|
5976
|
+
* `resolveAdoptBaseRef`). Guarded by a token + repo backing; a failure just
|
|
5977
|
+
* means the destination falls back to the default base and the dirty patch.
|
|
5978
|
+
*/
|
|
5979
|
+
async function pushForkSourceBranch(rec) {
|
|
5980
|
+
const parts = repoSessionParts(rec);
|
|
5981
|
+
if (!parts)
|
|
5982
|
+
return;
|
|
5983
|
+
const { wt, parsed } = parts;
|
|
5984
|
+
try {
|
|
5985
|
+
const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
|
|
5986
|
+
if (!token)
|
|
5987
|
+
return;
|
|
5988
|
+
const cfg = { token, owner: parsed.owner, repo: parsed.repo, repoDir: wt.repoRoot, label: "bivy", claimLabel: "bivy:in-progress", pollMs: 60_000 };
|
|
5989
|
+
await pushBranch(cfg, wt.path, wt.branch);
|
|
5990
|
+
rec.branchPushed = true;
|
|
5991
|
+
}
|
|
5992
|
+
catch {
|
|
5993
|
+
// offline / no rights / protected branch — committed work may not reach a
|
|
5994
|
+
// cross-node destination, but the fork still proceeds from the best base.
|
|
5995
|
+
}
|
|
5996
|
+
}
|
|
5714
5997
|
/**
|
|
5715
5998
|
* Stand a forked session up on THIS node from a `ForkBundle`: credential-move,
|
|
5716
5999
|
* (optional) prerequisite detection, repo/worktree reconstruction, transcript
|
|
@@ -5721,8 +6004,10 @@ async function applyRequestedModel(record, model) {
|
|
|
5721
6004
|
*/
|
|
5722
6005
|
async function standUpFork(opts) {
|
|
5723
6006
|
const { bundle, targetRuntimeId } = opts;
|
|
5724
|
-
const targetRuntime = getRuntime(targetRuntimeId);
|
|
5725
6007
|
const fallback = opts.fallback ?? { workspace: defaultWorkspace, cwd: defaultWorkspace };
|
|
6008
|
+
// Carry the source's sandbox tier so a sandboxed session forks into a
|
|
6009
|
+
// sandboxed one, rather than defaulting to this node's tier (fork.ts).
|
|
6010
|
+
const forkSandbox = normalizeSandboxTier(bundle.record.sandbox);
|
|
5726
6011
|
// Credential-move: if the chosen model's provider isn't logged in on this node,
|
|
5727
6012
|
// pull the account model-auth vault (a login done on another node carries over),
|
|
5728
6013
|
// then re-check. Best-effort — a local-only node just skips it.
|
|
@@ -5734,41 +6019,64 @@ async function standUpFork(opts) {
|
|
|
5734
6019
|
modelConfigured = await providerConfigured();
|
|
5735
6020
|
}
|
|
5736
6021
|
// Prerequisite detection. A missing AGENT is a hard blocker — stop before any
|
|
5737
|
-
// clone/worktree work. Skipped for a same-node local fork.
|
|
6022
|
+
// clone/worktree work. Skipped for a same-node local fork. Read the agent's
|
|
6023
|
+
// availability + display name from the runtime REGISTRY (which never throws)
|
|
6024
|
+
// rather than resolving the runtime up front: `getRuntime` throws for a
|
|
6025
|
+
// known-but-not-installed agent, which — called eagerly — surfaced a raw
|
|
6026
|
+
// "not available" string with an empty `missing[]` instead of this friendly
|
|
6027
|
+
// install checklist. An unknown id (no registry entry) is treated as
|
|
6028
|
+
// unavailable so it, too, degrades to the checklist rather than a getRuntime throw.
|
|
5738
6029
|
const agentInfo = listRuntimes().find((r) => r.id === targetRuntimeId);
|
|
5739
|
-
const agentAvailable = agentInfo ? agentInfo.status === "available" :
|
|
6030
|
+
const agentAvailable = agentInfo ? agentInfo.status === "available" : false;
|
|
6031
|
+
const agentDisplayName = agentInfo?.displayName ?? targetRuntimeId;
|
|
5740
6032
|
const prereqInput = {
|
|
5741
|
-
agent: { id: targetRuntimeId, displayName:
|
|
6033
|
+
agent: { id: targetRuntimeId, displayName: agentDisplayName, available: agentAvailable },
|
|
5742
6034
|
...(modelProvider ? { model: { provider: modelProvider, configured: Boolean(modelConfigured) } } : {}),
|
|
5743
6035
|
};
|
|
5744
6036
|
if (opts.detectPrereqs) {
|
|
5745
6037
|
const early = evaluateForkPrereqs(prereqInput);
|
|
5746
6038
|
if (blockingForkPrereqs(early).length > 0) {
|
|
5747
|
-
return { ok: false, error: `${
|
|
6039
|
+
return { ok: false, error: `${agentDisplayName} is not installed on the destination node.`, missing: missingForkPrereqs(early) };
|
|
5748
6040
|
}
|
|
5749
6041
|
}
|
|
6042
|
+
// Safe now: the agent is available (or this is a same-node local fork whose
|
|
6043
|
+
// agent is self-evidently present). The per-session sandbox tier bakes into
|
|
6044
|
+
// the runtime's launch flags.
|
|
6045
|
+
const targetRuntime = getRuntime(targetRuntimeId, forkSandbox);
|
|
5750
6046
|
// Reconstruct repo + worktree when the source was repo-backed.
|
|
5751
6047
|
let workspace = fallback.workspace;
|
|
5752
6048
|
let cwd = fallback.cwd;
|
|
5753
6049
|
let repoReachable;
|
|
5754
6050
|
let worktree;
|
|
6051
|
+
let dirtyWarning;
|
|
5755
6052
|
const parsed = bundle.record.repoSlug ? parseRepo(bundle.record.repoSlug) : undefined;
|
|
5756
6053
|
if (parsed) {
|
|
5757
6054
|
const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
|
|
5758
6055
|
repoReachable = Boolean(token);
|
|
5759
6056
|
const repoDir = await cloneOrUpdateRepo({ owner: parsed.owner, repo: parsed.repo, token, root: reposRoot });
|
|
5760
6057
|
const srcBranch = bundle.record.branch;
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
6058
|
+
// Serialize clone-adjacent worktree ops on this repo so concurrent forks /
|
|
6059
|
+
// pickups don't race on `git worktree add` or clobber each other's trees.
|
|
6060
|
+
const wt = await withRepoLock(repoDir, async () => {
|
|
6061
|
+
if (opts.worktree === "fresh") {
|
|
6062
|
+
// Same-node fork: cut a NEW branch from the source's LOCAL branch (which
|
|
6063
|
+
// holds its latest, possibly-unpushed commits) or the repo default.
|
|
6064
|
+
const forkBranch = `${srcBranch ?? "fork"}-fork-${randomBytes(4).toString("hex")}`;
|
|
6065
|
+
return createWorktree({ repoDir, id: forkBranch, branch: forkBranch, base: srcBranch ?? await resolveDefaultBaseRef(repoDir) });
|
|
6066
|
+
}
|
|
6067
|
+
// Cross-node adopt: the source branch has no LOCAL ref here. Base the
|
|
6068
|
+
// adopted branch on the pushed `origin/<branch>` so committed work travels
|
|
6069
|
+
// (was: undefined → the destination's DEFAULT branch, silently dropping
|
|
6070
|
+
// every commit). Give the worktree DIR a unique suffix so a same-branch
|
|
6071
|
+
// adopt never reuses — or, via createWorktree's stale-dir cleanup, deletes
|
|
6072
|
+
// — another live session's tree.
|
|
6073
|
+
const dirId = `${srcBranch ?? "fork"}-${randomBytes(4).toString("hex")}`;
|
|
6074
|
+
const base = srcBranch ? await resolveAdoptBaseRef(repoDir, srcBranch) : await resolveDefaultBaseRef(repoDir);
|
|
6075
|
+
return createWorktree({ repoDir, id: dirId, branch: srcBranch, base });
|
|
6076
|
+
});
|
|
6077
|
+
const applied = applyDirtyPatch(wt.path, bundle.dirtyPatch);
|
|
6078
|
+
if (applied.warning)
|
|
6079
|
+
dirtyWarning = applied.warning;
|
|
5772
6080
|
workspace = repoDir;
|
|
5773
6081
|
cwd = wt.path;
|
|
5774
6082
|
worktree = wt;
|
|
@@ -5782,8 +6090,10 @@ async function standUpFork(opts) {
|
|
|
5782
6090
|
const forkRepoRoot = await gitRepoRoot(cwd);
|
|
5783
6091
|
if (forkRepoRoot) {
|
|
5784
6092
|
const forkBranch = `bivy/fork-${randomBytes(6).toString("hex")}`;
|
|
5785
|
-
const wt = await createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch });
|
|
5786
|
-
applyDirtyPatch(wt.path, bundle.dirtyPatch);
|
|
6093
|
+
const wt = await withRepoLock(forkRepoRoot, () => createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch }));
|
|
6094
|
+
const applied = applyDirtyPatch(wt.path, bundle.dirtyPatch);
|
|
6095
|
+
if (applied.warning)
|
|
6096
|
+
dirtyWarning = applied.warning;
|
|
5787
6097
|
workspace = forkRepoRoot;
|
|
5788
6098
|
cwd = wt.path;
|
|
5789
6099
|
worktree = wt;
|
|
@@ -5793,8 +6103,8 @@ async function standUpFork(opts) {
|
|
|
5793
6103
|
// transcript (full) or a fresh session the caller seeds with plan.seedPrompt.
|
|
5794
6104
|
const plan = await materializeFork({ bundle, targetRuntime, ctx: { workspace, cwd }, seed: { transcriptUrl: opts.transcriptUrl } });
|
|
5795
6105
|
const record = plan.kind === "resume"
|
|
5796
|
-
? await createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, makeActive: false })
|
|
5797
|
-
: await createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, makeActive: false });
|
|
6106
|
+
? await createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false })
|
|
6107
|
+
: await createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false });
|
|
5798
6108
|
// Mark the new session as a fork of its source, so the run card can show
|
|
5799
6109
|
// "Forked from …" and the lineage survives a reload (persisted below). Just
|
|
5800
6110
|
// the parent's session id — an identifier, not content, so it's safe to
|
|
@@ -5813,6 +6123,10 @@ async function standUpFork(opts) {
|
|
|
5813
6123
|
}
|
|
5814
6124
|
if (bundle.record.title && !record.session.getName())
|
|
5815
6125
|
record.session.setName(bundle.record.title);
|
|
6126
|
+
// Surface a non-fatal note when the source's uncommitted changes didn't apply
|
|
6127
|
+
// cleanly, so the fork isn't silently missing work-in-progress.
|
|
6128
|
+
if (dirtyWarning)
|
|
6129
|
+
broadcast({ type: "session.notice", sessionId: record.id, message: dirtyWarning });
|
|
5816
6130
|
await applyRequestedModel(record, opts.model ?? nodeDefaultModel() ?? undefined);
|
|
5817
6131
|
persistSessionMetadata(record);
|
|
5818
6132
|
scheduleAdvertise();
|
|
@@ -6324,6 +6638,14 @@ async function sweepDiskGuardrails() {
|
|
|
6324
6638
|
await cleanupOldWorktrees();
|
|
6325
6639
|
evictSharedDepCacheIfNeeded();
|
|
6326
6640
|
warnOversizedWorktrees();
|
|
6641
|
+
const attachmentRefs = referencedAttachmentHashes();
|
|
6642
|
+
if (attachmentRefs)
|
|
6643
|
+
attachmentGcStats = attachmentStore.gc(attachmentRefs);
|
|
6644
|
+
else
|
|
6645
|
+
console.warn("[attachments] skipping garbage collection because event-log references are not healthy");
|
|
6646
|
+
if ((attachmentGcStats.overCapBytes ?? 0) > 0) {
|
|
6647
|
+
console.warn(`[attachments] store remains ${attachmentGcStats.overCapBytes} bytes over cap because referenced history is retained`);
|
|
6648
|
+
}
|
|
6327
6649
|
}
|
|
6328
6650
|
/**
|
|
6329
6651
|
* Prune "ghost" sessions: metadata rows for a path-based runtime (pi) whose
|
|
@@ -6465,6 +6787,9 @@ function closeSessionRecord(record, reason = "closed") {
|
|
|
6465
6787
|
sessionEvents.clear(record.id);
|
|
6466
6788
|
record.session.dispose();
|
|
6467
6789
|
harness.detach(record.id);
|
|
6790
|
+
// Tear down this session's own egress proxy, if it started one (read-only /
|
|
6791
|
+
// workflow network policy). No-op for the default path.
|
|
6792
|
+
void stopSessionEgress(record.id);
|
|
6468
6793
|
record.mcpRestore?.();
|
|
6469
6794
|
openSessions.delete(record.id);
|
|
6470
6795
|
if (record.sessionFile)
|
|
@@ -6552,6 +6877,20 @@ const idleCloseTimer = setInterval(() => { closeIdleSessions(); pruneGhostSessio
|
|
|
6552
6877
|
idleCloseTimer.unref?.();
|
|
6553
6878
|
const worktreeCleanupTimer = setInterval(() => void sweepDiskGuardrails(), worktreeCleanupSweepMs);
|
|
6554
6879
|
worktreeCleanupTimer.unref?.();
|
|
6880
|
+
// In-session auto-resume tunables (see the resume helpers below). setTimeout
|
|
6881
|
+
// can't be trusted past ~24.8 days and we don't want one timer owning a
|
|
6882
|
+
// multi-hour wait a restart would drop, so each timer is capped and the periodic
|
|
6883
|
+
// sweep re-arms the remainder from the persisted resumeAt.
|
|
6884
|
+
const SESSION_RESUME_MAX_TIMER_MS = 30 * 60_000;
|
|
6885
|
+
const SESSION_RESUME_SWEEP_MS = 60_000;
|
|
6886
|
+
/** Slack around "due": a capped timer may fire a touch early — drive only when
|
|
6887
|
+
* within this of the target, else re-arm. */
|
|
6888
|
+
const SESSION_RESUME_TICK_MS = 15_000;
|
|
6889
|
+
const sessionResumeTimers = new Map();
|
|
6890
|
+
// Fire due auto-resumes (a usage/rate limit that has since reset) and re-arm the
|
|
6891
|
+
// tail of long waits whose in-process timer was capped or lost to a restart.
|
|
6892
|
+
const sessionResumeTimer = setInterval(() => sessionResumeSweep(), SESSION_RESUME_SWEEP_MS);
|
|
6893
|
+
sessionResumeTimer.unref?.();
|
|
6555
6894
|
// --- server-side ephemeral teardown ----------------------------------------
|
|
6556
6895
|
// On a disposable machine (bootstrap set BIVY_EPHEMERAL=1) the daemon ends the
|
|
6557
6896
|
// machine ITSELF once it goes idle, so teardown no longer needs the launching
|
|
@@ -6699,6 +7038,64 @@ setTimeout(() => void sweepDiskGuardrails(), 30_000).unref?.();
|
|
|
6699
7038
|
// One sweep shortly after boot clears ghosts left by a previous run before any
|
|
6700
7039
|
// client paints its sidebar; the idle timer keeps it clean thereafter.
|
|
6701
7040
|
setTimeout(pruneGhostSessions, 10_000).unref?.();
|
|
7041
|
+
const turnTimeoutMs = configuredTurnTimeoutMs();
|
|
7042
|
+
if (turnTimeoutMs > 0)
|
|
7043
|
+
console.log(`[turn-watchdog] armed: timeout=${turnTimeoutMs}ms`);
|
|
7044
|
+
else
|
|
7045
|
+
console.warn("[turn-watchdog] disabled by BIVY_TURN_TIMEOUT_MS=0");
|
|
7046
|
+
function turnTimeoutMessage() {
|
|
7047
|
+
return `Agent turn timed out after ${Math.round(turnTimeoutMs / 60_000)} minutes and was stopped.`;
|
|
7048
|
+
}
|
|
7049
|
+
function clearTurnWatchdog(record) {
|
|
7050
|
+
if (record.turnWatchdog)
|
|
7051
|
+
clearTimeout(record.turnWatchdog);
|
|
7052
|
+
record.turnWatchdog = undefined;
|
|
7053
|
+
record.turnTimeoutSignal = undefined;
|
|
7054
|
+
record.turnTimeoutResolve = undefined;
|
|
7055
|
+
}
|
|
7056
|
+
function armTurnWatchdog(record) {
|
|
7057
|
+
clearTurnWatchdog(record);
|
|
7058
|
+
record.turnTimedOut = false;
|
|
7059
|
+
if (turnTimeoutMs <= 0)
|
|
7060
|
+
return;
|
|
7061
|
+
record.turnTimeoutSignal = new Promise((resolve) => { record.turnTimeoutResolve = resolve; });
|
|
7062
|
+
record.turnWatchdog = setTimeout(() => {
|
|
7063
|
+
record.turnWatchdog = undefined;
|
|
7064
|
+
record.turnTimedOut = true;
|
|
7065
|
+
record.lastFailureAt = Date.now();
|
|
7066
|
+
const message = turnTimeoutMessage();
|
|
7067
|
+
record.turnTimeoutResolve?.();
|
|
7068
|
+
record.turnTimeoutResolve = undefined;
|
|
7069
|
+
// Clear/persist first so the session and an ephemeral runner cannot remain
|
|
7070
|
+
// pinned in a false working state if the runtime's abort path fails to emit
|
|
7071
|
+
// agent_end. abort() is still invoked to kill the underlying process group.
|
|
7072
|
+
clearSessionWorking(record);
|
|
7073
|
+
metadata.touchSession(record.id, "failed");
|
|
7074
|
+
broadcast({ type: "session.outcome", sessionId: record.id, status: "timed_out", completedAt: new Date().toISOString(), error: message });
|
|
7075
|
+
broadcast({ type: "session.error", sessionId: record.id, error: message });
|
|
7076
|
+
void record.session.abort().catch((error) => {
|
|
7077
|
+
console.error(`[turn-watchdog] abort failed for ${record.id}:`, error);
|
|
7078
|
+
}).finally(() => evaluateEphemeralTeardown());
|
|
7079
|
+
}, turnTimeoutMs);
|
|
7080
|
+
record.turnWatchdog.unref?.();
|
|
7081
|
+
}
|
|
7082
|
+
async function promptWithWatchdog(record, prompt, options) {
|
|
7083
|
+
armTurnWatchdog(record);
|
|
7084
|
+
const timeoutSignal = record.turnTimeoutSignal;
|
|
7085
|
+
try {
|
|
7086
|
+
await Promise.race([
|
|
7087
|
+
record.session.prompt(prompt, options),
|
|
7088
|
+
...(timeoutSignal ? [timeoutSignal.then(() => { throw new Error(turnTimeoutMessage()); })] : []),
|
|
7089
|
+
]);
|
|
7090
|
+
}
|
|
7091
|
+
catch (error) {
|
|
7092
|
+
// The timeout callback already cleared/persisted the session. For an ordinary
|
|
7093
|
+
// prompt failure, disarm here and let the caller publish its actionable error.
|
|
7094
|
+
if (!record.turnTimedOut)
|
|
7095
|
+
clearTurnWatchdog(record);
|
|
7096
|
+
throw error;
|
|
7097
|
+
}
|
|
7098
|
+
}
|
|
6702
7099
|
function markSessionWorking(record, activity) {
|
|
6703
7100
|
touchSession(record);
|
|
6704
7101
|
const wasWorking = record.isWorking;
|
|
@@ -6712,6 +7109,7 @@ function markSessionWorking(record, activity) {
|
|
|
6712
7109
|
scheduleAdvertise(); // idle → working transition
|
|
6713
7110
|
}
|
|
6714
7111
|
function clearSessionWorking(record) {
|
|
7112
|
+
clearTurnWatchdog(record);
|
|
6715
7113
|
touchSession(record);
|
|
6716
7114
|
record.isWorking = false;
|
|
6717
7115
|
record.lastActivity = undefined;
|
|
@@ -6742,6 +7140,125 @@ async function refreshSessionUsage(record) {
|
|
|
6742
7140
|
// Usage reporting must never affect the session it's reporting on.
|
|
6743
7141
|
}
|
|
6744
7142
|
}
|
|
7143
|
+
// ── In-session auto-resume after a usage/rate limit ─────────────────────────
|
|
7144
|
+
// When a turn ends because a provider window is exhausted ("you've hit your
|
|
7145
|
+
// weekly limit · resets 12am (UTC)") and the session's ruleset says retry, we
|
|
7146
|
+
// wait out the window and re-send the same prompt when it resets — instead of
|
|
7147
|
+
// leaving a dead error bubble. Durable: the due time is persisted (metadata
|
|
7148
|
+
// resumeAt) so a daemon restart re-arms it (sessionResumeSweep); an in-process
|
|
7149
|
+
// timer fires it promptly while the daemon is up. (Tunables + timer map are
|
|
7150
|
+
// declared up by the timer cluster so the sweep interval can reference them.)
|
|
7151
|
+
/** The authoritative reset time for the limit a session just hit: the soonest
|
|
7152
|
+
* future reset among its most-utilized usage windows (the binding one), from
|
|
7153
|
+
* the last snapshot the runtime reported. Essential for a multi-day "weekly"
|
|
7154
|
+
* window, whose error text states only a time-of-day. Undefined when unknown. */
|
|
7155
|
+
function limitResetHint(record, nowMs) {
|
|
7156
|
+
const windows = record.usage?.plan?.windows ?? [];
|
|
7157
|
+
let best;
|
|
7158
|
+
for (const w of windows) {
|
|
7159
|
+
if (!w.resetsAt)
|
|
7160
|
+
continue;
|
|
7161
|
+
const at = Date.parse(w.resetsAt);
|
|
7162
|
+
if (!Number.isFinite(at) || at <= nowMs)
|
|
7163
|
+
continue;
|
|
7164
|
+
const util = w.utilizationPct ?? 0;
|
|
7165
|
+
// Prefer the most-utilized window (the one being hit); tie-break on soonest reset.
|
|
7166
|
+
if (!best || util > best.util || (util === best.util && at < best.at))
|
|
7167
|
+
best = { at, util };
|
|
7168
|
+
}
|
|
7169
|
+
return best ? new Date(best.at).toISOString() : undefined;
|
|
7170
|
+
}
|
|
7171
|
+
/** Cancel a pending in-process resume timer (leaves the durable marker alone). */
|
|
7172
|
+
function cancelSessionResumeTimer(id) {
|
|
7173
|
+
const timer = sessionResumeTimers.get(id);
|
|
7174
|
+
if (timer) {
|
|
7175
|
+
clearTimeout(timer);
|
|
7176
|
+
sessionResumeTimers.delete(id);
|
|
7177
|
+
}
|
|
7178
|
+
}
|
|
7179
|
+
/** Clear both the durable resume marker and any armed timer — the session moved
|
|
7180
|
+
* on (a new user turn, or the resume itself started). */
|
|
7181
|
+
function clearSessionResume(id) {
|
|
7182
|
+
cancelSessionResumeTimer(id);
|
|
7183
|
+
metadata.setResumeAt(id, null);
|
|
7184
|
+
}
|
|
7185
|
+
function armSessionResumeTimer(id, dueMs) {
|
|
7186
|
+
cancelSessionResumeTimer(id);
|
|
7187
|
+
const delay = Math.min(Math.max(0, dueMs - Date.now()), SESSION_RESUME_MAX_TIMER_MS);
|
|
7188
|
+
const timer = setTimeout(() => {
|
|
7189
|
+
sessionResumeTimers.delete(id);
|
|
7190
|
+
void driveSessionResume(id);
|
|
7191
|
+
}, delay);
|
|
7192
|
+
timer.unref?.();
|
|
7193
|
+
sessionResumeTimers.set(id, timer);
|
|
7194
|
+
}
|
|
7195
|
+
/** Persist + arm an auto-resume decided by the session policy. Synchronous so
|
|
7196
|
+
* the caller can atomically suppress the turn's error toast. */
|
|
7197
|
+
function scheduleSessionResume(record, plan) {
|
|
7198
|
+
metadata.setResumeAt(record.id, plan.resumeAt);
|
|
7199
|
+
const when = Date.parse(plan.resumeAt);
|
|
7200
|
+
const cond = plan.condition.replace(/_/g, " ");
|
|
7201
|
+
broadcast({
|
|
7202
|
+
type: "session.notice",
|
|
7203
|
+
sessionId: record.id,
|
|
7204
|
+
level: "info",
|
|
7205
|
+
message: `Hit a ${cond} limit — I'll resume this automatically when it resets (${plan.resumeAt}).`,
|
|
7206
|
+
});
|
|
7207
|
+
armSessionResumeTimer(record.id, Number.isFinite(when) ? when : Date.now());
|
|
7208
|
+
}
|
|
7209
|
+
/** Fire a due auto-resume: re-open the session if needed and re-send the turn's
|
|
7210
|
+
* last prompt. Clears the durable marker BEFORE driving so a crash mid-resume
|
|
7211
|
+
* can't loop. Best-effort — never throws into a timer/sweep. */
|
|
7212
|
+
async function driveSessionResume(id) {
|
|
7213
|
+
const meta = metadata.getSession(id);
|
|
7214
|
+
if (!meta?.resumeAt)
|
|
7215
|
+
return; // cancelled or already resumed
|
|
7216
|
+
const due = Date.parse(meta.resumeAt);
|
|
7217
|
+
if (Number.isFinite(due) && due - Date.now() > SESSION_RESUME_TICK_MS) {
|
|
7218
|
+
// A capped timer fired before the real due time — re-arm for the remainder.
|
|
7219
|
+
armSessionResumeTimer(id, due);
|
|
7220
|
+
return;
|
|
7221
|
+
}
|
|
7222
|
+
clearSessionResume(id);
|
|
7223
|
+
try {
|
|
7224
|
+
const live = openSessions.get(id);
|
|
7225
|
+
if (live?.isWorking)
|
|
7226
|
+
return; // a user turn is already running — don't pile on
|
|
7227
|
+
const record = live ?? (await resolveOrResumeSession(id, meta.path));
|
|
7228
|
+
if (!record)
|
|
7229
|
+
return; // transcript gone / unresolvable
|
|
7230
|
+
if (record.isWorking)
|
|
7231
|
+
return;
|
|
7232
|
+
// In-memory lastPrompt is the exact user turn to retry; after a restart it's
|
|
7233
|
+
// gone, so fall back to the generic interrupted-turn continuation nudge.
|
|
7234
|
+
const prompt = record.lastPrompt ?? buildInteractiveResumePrompt();
|
|
7235
|
+
console.log(`[resume] auto-resuming session ${id} — provider limit has reset`);
|
|
7236
|
+
broadcast({ type: "session.notice", sessionId: id, level: "info", message: "The limit has reset — resuming now." });
|
|
7237
|
+
await promptWithWatchdog(record, prompt, record.lastPromptOptions);
|
|
7238
|
+
}
|
|
7239
|
+
catch (error) {
|
|
7240
|
+
console.warn(`[resume] auto-resume after a provider limit failed for ${id}`, error);
|
|
7241
|
+
}
|
|
7242
|
+
}
|
|
7243
|
+
/** Re-arm (or immediately fire) durable auto-resume markers. Runs once at boot
|
|
7244
|
+
* and on an interval, so a wait survives a restart and a capped timer's tail
|
|
7245
|
+
* still fires. */
|
|
7246
|
+
function sessionResumeSweep() {
|
|
7247
|
+
const now = Date.now();
|
|
7248
|
+
for (const meta of metadata.sessionsWithResumeAt()) {
|
|
7249
|
+
const due = Date.parse(meta.resumeAt);
|
|
7250
|
+
if (!Number.isFinite(due)) {
|
|
7251
|
+
metadata.setResumeAt(meta.id, null);
|
|
7252
|
+
continue;
|
|
7253
|
+
}
|
|
7254
|
+
if (sessionResumeTimers.has(meta.id))
|
|
7255
|
+
continue; // already armed this run
|
|
7256
|
+
if (due <= now + SESSION_RESUME_TICK_MS)
|
|
7257
|
+
void driveSessionResume(meta.id);
|
|
7258
|
+
else
|
|
7259
|
+
armSessionResumeTimer(meta.id, due);
|
|
7260
|
+
}
|
|
7261
|
+
}
|
|
6745
7262
|
/**
|
|
6746
7263
|
* Turn a raw provider/runtime error string into something a human can read.
|
|
6747
7264
|
* Model APIs commonly return `<status> {json}` (e.g. `400 {"error":{"message":
|
|
@@ -6815,9 +7332,12 @@ function maybeSignalAuthRequired(record, errorText) {
|
|
|
6815
7332
|
}
|
|
6816
7333
|
function attachSessionListeners(record) {
|
|
6817
7334
|
record.unsubscribe?.();
|
|
6818
|
-
// In-session
|
|
6819
|
-
//
|
|
6820
|
-
|
|
7335
|
+
// In-session recovery controller — waits out a usage/rate limit and resumes
|
|
7336
|
+
// (planResume), or swaps models down a fallback chain (planReroute). One per
|
|
7337
|
+
// session; its per-turn budget resets on each user prompt. The policy reads
|
|
7338
|
+
// the active session ruleset lazily, so it's inert until one authorizes a
|
|
7339
|
+
// retry/reroute for the failing condition.
|
|
7340
|
+
if (!record.reroute) {
|
|
6821
7341
|
record.reroute = new SessionRerouteController({
|
|
6822
7342
|
policy: sessionRunPolicy,
|
|
6823
7343
|
onNotice: (n) => broadcast({ type: "session.notice", sessionId: record.id, level: n.level, message: n.message }),
|
|
@@ -6937,7 +7457,18 @@ function attachSessionListeners(record) {
|
|
|
6937
7457
|
// credential or a 4xx from the API) otherwise vanished: working cleared,
|
|
6938
7458
|
// no reply, no signal. Surface it as a session-scoped error so the client
|
|
6939
7459
|
// can show it *inline in that chat*, and notify instead of "done".
|
|
6940
|
-
|
|
7460
|
+
// A terminal turn error reaches us two ways. pi-ai puts it on the last
|
|
7461
|
+
// assistant message (stopReason:"error" → terminalTurnError), and the
|
|
7462
|
+
// server owns surfacing it. Claude Code instead throws inside the SDK
|
|
7463
|
+
// query: it emits its OWN session.error to the client AND carries the raw
|
|
7464
|
+
// text on agent_end.error (e.g. "you've hit your weekly limit · resets 12am
|
|
7465
|
+
// (UTC)"). We read that too — but only to DRIVE recovery, since the runtime
|
|
7466
|
+
// already surfaced it; re-broadcasting would double the error bubble.
|
|
7467
|
+
const messageError = terminalTurnError(event);
|
|
7468
|
+
const agentEndError = typeof event.error === "string"
|
|
7469
|
+
? humanizeAgentError(event.error)
|
|
7470
|
+
: undefined;
|
|
7471
|
+
const turnError = messageError ?? (agentEndError?.trim() ? agentEndError : undefined);
|
|
6941
7472
|
// Before surfacing a turn error, see if the session's run policy can recover
|
|
6942
7473
|
// it in place by swapping to a fallback model and retrying the same prompt.
|
|
6943
7474
|
// planReroute is synchronous, so we can atomically suppress the error toast
|
|
@@ -6945,23 +7476,40 @@ function attachSessionListeners(record) {
|
|
|
6945
7476
|
const reroutePlan = turnError && record.lastPrompt !== undefined
|
|
6946
7477
|
? record.reroute?.planReroute(turnError, record.session.getCurrentModel()?.name) ?? null
|
|
6947
7478
|
: null;
|
|
7479
|
+
// If a reroute doesn't apply, a usage/rate limit that gave a reset time can
|
|
7480
|
+
// instead be waited out and resumed when the window clears (planResume is
|
|
7481
|
+
// synchronous too, so this stays atomic with suppressing the error toast).
|
|
7482
|
+
const resumePlan = !reroutePlan && turnError && record.lastPrompt !== undefined
|
|
7483
|
+
? record.reroute?.planResume(turnError, record.session.getCurrentModel()?.name, {
|
|
7484
|
+
resetsAtHint: limitResetHint(record, Date.now()),
|
|
7485
|
+
}) ?? null
|
|
7486
|
+
: null;
|
|
6948
7487
|
if (reroutePlan) {
|
|
6949
7488
|
void record.reroute.applyReroute(reroutePlan, {
|
|
6950
7489
|
getCurrentModelName: () => record.session.getCurrentModel()?.name,
|
|
6951
7490
|
setModel: (p, i) => record.session.setModel(p, i),
|
|
6952
7491
|
reprompt: async () => {
|
|
6953
|
-
await
|
|
7492
|
+
await promptWithWatchdog(record, record.lastPrompt, record.lastPromptOptions);
|
|
6954
7493
|
},
|
|
6955
7494
|
});
|
|
6956
7495
|
}
|
|
6957
|
-
else if (
|
|
7496
|
+
else if (resumePlan) {
|
|
7497
|
+
// Charge the attempt budget so a limit that re-fires after the reset can
|
|
7498
|
+
// eventually exhaust (→ surface) instead of looping, then park the turn
|
|
7499
|
+
// as a scheduled resume rather than a dead error.
|
|
7500
|
+
record.reroute.noteResumeApplied();
|
|
7501
|
+
scheduleSessionResume(record, resumePlan);
|
|
7502
|
+
}
|
|
7503
|
+
else if (messageError) {
|
|
7504
|
+
// Only the server-owned (pi-ai) path surfaces here; a Claude Code error
|
|
7505
|
+
// the runtime already broadcast falls through to avoid a duplicate bubble.
|
|
6958
7506
|
record.lastFailureAt = Date.now();
|
|
6959
7507
|
metadata.touchSession(record.id, "failed");
|
|
6960
7508
|
scheduleAdvertise();
|
|
6961
|
-
broadcast({ type: "session.error", sessionId: record.id, error:
|
|
7509
|
+
broadcast({ type: "session.error", sessionId: record.id, error: messageError });
|
|
6962
7510
|
// If the terminal error is an auth failure (expired key/token → 4xx),
|
|
6963
7511
|
// also raise the sign-in sheet for the failing provider.
|
|
6964
|
-
maybeSignalAuthRequired(record,
|
|
7512
|
+
maybeSignalAuthRequired(record, messageError);
|
|
6965
7513
|
void sendNotificationHint({
|
|
6966
7514
|
kind: "session_error",
|
|
6967
7515
|
sessionId: record.id,
|
|
@@ -7458,7 +8006,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
|
|
|
7458
8006
|
if (makeActive)
|
|
7459
8007
|
active = existing;
|
|
7460
8008
|
broadcast({ type: "session.created", sessionId: existing.id, name: existing.session.getName(), workspace: existing.workspace, sessionFile: existing.sessionFile, source: existing.source, branch: existing.worktree?.branch, prUrl: existing.prUrl, runtimeId: existing.runtimeId, agentName: getRuntime(existing.runtimeId).displayName, bivySession: bivySessionEnvelope(existing), capabilities: capabilitiesWithCommands(existing.runtimeId, existing.session) });
|
|
7461
|
-
void maybeNotifyBivyUpdate(
|
|
8009
|
+
void maybeNotifyBivyUpdate();
|
|
7462
8010
|
return existing;
|
|
7463
8011
|
}
|
|
7464
8012
|
// Pick the agent for this session (fixed for its life). Resuming a tagged
|
|
@@ -7585,6 +8133,12 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
|
|
|
7585
8133
|
// session legitimately starts "active now".
|
|
7586
8134
|
const resumedLastActive = requestedSessionFile ? metaLastActiveMs(storedMeta) : undefined;
|
|
7587
8135
|
const record = { id: sessionId, session, runtimeId: rt.id, sandbox: sessionSandbox, approvalMode: opts.approvalMode, workspace: sessionWorkspace, sessionFile: session.sessionFile, agentServiceAddress: attachedAddress ?? rt.agentServiceAddress, worktree, source, prUrl: storedMeta?.prUrl, prs: storedMeta?.prs, lastTouchedAt: resumedLastActive ?? Date.now(), warning: modelFallbackMessage, ephemeral: opts.ephemeral };
|
|
8136
|
+
// Apply this session's sandbox network policy as a per-session egress proxy
|
|
8137
|
+
// (its own proxy/decider, never the node-global one). Opt-in via BIVY_SANDBOX_NET:
|
|
8138
|
+
// a read-only session then actually blocks outbound network even for a CLI agent
|
|
8139
|
+
// whose own sandbox doesn't (opencode/aider/goose). No-op otherwise. Fire-and-
|
|
8140
|
+
// forget — a slow proxy listen never delays session creation.
|
|
8141
|
+
void applySessionSandboxEgress(record.id, sessionSandbox, (event) => broadcast({ type: "node.egress", event }));
|
|
7588
8142
|
// Stage 2 slice 4: a re-attached session recovers its still-running TUI
|
|
7589
8143
|
// terminal link (the PTY survives a detach) from the session→terminal registry.
|
|
7590
8144
|
if (attached) {
|
|
@@ -7647,7 +8201,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
|
|
|
7647
8201
|
if (makeActive)
|
|
7648
8202
|
active = record;
|
|
7649
8203
|
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) });
|
|
7650
|
-
void maybeNotifyBivyUpdate(
|
|
8204
|
+
void maybeNotifyBivyUpdate();
|
|
7651
8205
|
scheduleAdvertise();
|
|
7652
8206
|
return record;
|
|
7653
8207
|
}
|
|
@@ -7757,32 +8311,75 @@ async function resolveOrResumeSession(sessionId, sessionPath) {
|
|
|
7757
8311
|
// races the runtime.select that switches the default agent, pin the pill to the
|
|
7758
8312
|
// *previous* runtime (the reported agent-switching bug). Mirror how session.new/
|
|
7759
8313
|
// session.open already refuse to touch `active` for remote clients: reuse a
|
|
7760
|
-
//
|
|
7761
|
-
//
|
|
7762
|
-
|
|
7763
|
-
|
|
7764
|
-
|
|
7765
|
-
|
|
8314
|
+
// non-active scratch session per runtime instead of spawning a fresh runtime
|
|
8315
|
+
// process on every picker read.
|
|
8316
|
+
//
|
|
8317
|
+
// Keyed by runtime id, not a single slot: switching agents (Claude → Codex →
|
|
8318
|
+
// Claude) used to evict and re-spawn the one scratch on every switch — the
|
|
8319
|
+
// "switching agent takes a long time before models appear" bug. A map keeps one
|
|
8320
|
+
// warm scratch per runtime so a switch back to an agent already viewed this
|
|
8321
|
+
// session answers from the live session with no re-spawn, and `prefetchModels`
|
|
8322
|
+
// can warm several ahead of the first pick.
|
|
8323
|
+
const modelQueryScratch = new Map();
|
|
8324
|
+
const modelQueryScratchPending = new Map();
|
|
8325
|
+
async function sessionForModelQuery(runtimeId) {
|
|
8326
|
+
const wanted = resolveRuntimeId(runtimeId);
|
|
8327
|
+
// A live active session answers for itself — but only when it IS the runtime
|
|
8328
|
+
// being queried, so a prefetch/draft read for a *different* agent doesn't get
|
|
8329
|
+
// the active session's (wrong-runtime) model list.
|
|
8330
|
+
if (active && active.runtimeId === wanted)
|
|
7766
8331
|
return active;
|
|
7767
|
-
const
|
|
7768
|
-
if (
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
|
|
7774
|
-
|
|
7775
|
-
//
|
|
7776
|
-
//
|
|
7777
|
-
//
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
.
|
|
7784
|
-
|
|
7785
|
-
return
|
|
8332
|
+
const cached = modelQueryScratch.get(wanted);
|
|
8333
|
+
if (cached && openSessions.has(cached.id) && cached.runtimeId === wanted && !sessionBusy(cached)) {
|
|
8334
|
+
touchSession(cached);
|
|
8335
|
+
return cached;
|
|
8336
|
+
}
|
|
8337
|
+
// De-dupe concurrent picker reads per runtime. Without this, a WS models.list
|
|
8338
|
+
// and an HTTP GET /api/models fired together on page load both miss the reuse
|
|
8339
|
+
// guard above (the scratch assignment only lands after createSession resolves
|
|
8340
|
+
// ~0.3s later) and each stand up a session, leaving two empty rows a fraction
|
|
8341
|
+
// of a second apart. Collapse concurrent builds onto one promise per runtime,
|
|
8342
|
+
// mirroring resumingSessions.
|
|
8343
|
+
const inflight = modelQueryScratchPending.get(wanted);
|
|
8344
|
+
if (inflight)
|
|
8345
|
+
return inflight;
|
|
8346
|
+
const build = createSession(defaultWorkspace, undefined, { makeActive: false, ephemeral: true, runtimeId: wanted })
|
|
8347
|
+
.then((rec) => { modelQueryScratch.set(wanted, rec); return rec; })
|
|
8348
|
+
.finally(() => { modelQueryScratchPending.delete(wanted); });
|
|
8349
|
+
modelQueryScratchPending.set(wanted, build);
|
|
8350
|
+
return build;
|
|
8351
|
+
}
|
|
8352
|
+
/**
|
|
8353
|
+
* Warm the model-query scratch for one or more runtimes in the background so the
|
|
8354
|
+
* first agent switch to any of them answers instantly instead of paying the
|
|
8355
|
+
* runtime spin-up on the critical path. Fired when the agent picker opens (see
|
|
8356
|
+
* the `models.prefetch` command). Best-effort and de-duped: a runtime already
|
|
8357
|
+
* warm (or being warmed) is a no-op, and a spin-up failure is swallowed — the
|
|
8358
|
+
* normal models.list path will surface any real error when the user picks it.
|
|
8359
|
+
*/
|
|
8360
|
+
function prefetchModels(runtimeIds) {
|
|
8361
|
+
const wanted = [];
|
|
8362
|
+
for (const id of runtimeIds) {
|
|
8363
|
+
let resolved;
|
|
8364
|
+
try {
|
|
8365
|
+
resolved = resolveRuntimeId(id);
|
|
8366
|
+
}
|
|
8367
|
+
catch {
|
|
8368
|
+
continue; // unknown/uninstalled agent — nothing to warm
|
|
8369
|
+
}
|
|
8370
|
+
if (wanted.includes(resolved))
|
|
8371
|
+
continue;
|
|
8372
|
+
const cached = modelQueryScratch.get(resolved);
|
|
8373
|
+
if (cached && openSessions.has(cached.id) && !sessionBusy(cached))
|
|
8374
|
+
continue;
|
|
8375
|
+
if (modelQueryScratchPending.has(resolved))
|
|
8376
|
+
continue;
|
|
8377
|
+
wanted.push(resolved);
|
|
8378
|
+
}
|
|
8379
|
+
// Warm serially, not in a burst: spinning up every agent subprocess at once
|
|
8380
|
+
// would spike a small node's memory/CPU right as the user is interacting. Each
|
|
8381
|
+
// build is cached (and de-duped) so this cost is paid at most once per runtime.
|
|
8382
|
+
void wanted.reduce((chain, id) => chain.then(() => sessionForModelQuery(id).then(() => undefined, () => undefined)), Promise.resolve());
|
|
7786
8383
|
}
|
|
7787
8384
|
async function createRepoSession(parsed, opts = {}) {
|
|
7788
8385
|
const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
|
|
@@ -8334,6 +8931,9 @@ app.delete("/api/devices/:id", (req, res) => {
|
|
|
8334
8931
|
res.json({ ok: true, devices: pairingStore.listDevices() });
|
|
8335
8932
|
});
|
|
8336
8933
|
app.get("/api/node/info", (_req, res) => {
|
|
8934
|
+
const selectedRuntimeId = active?.runtimeId ?? defaultRuntimeId;
|
|
8935
|
+
const runtimeInfo = runtimeList(selectedRuntimeId).find((runtime) => runtime.id === selectedRuntimeId);
|
|
8936
|
+
const structuredControls = runtimeInfo?.protectionLevel === "native-sandbox" || runtimeInfo?.protectionLevel === "tool-controls";
|
|
8337
8937
|
res.json({
|
|
8338
8938
|
nodeId: identity.nodeId,
|
|
8339
8939
|
name: identity.name,
|
|
@@ -8344,15 +8944,31 @@ app.get("/api/node/info", (_req, res) => {
|
|
|
8344
8944
|
guardrails: {
|
|
8345
8945
|
mode: approvalMode,
|
|
8346
8946
|
defaultAllow: approvalMode === "autonomous" || approvalMode === "never",
|
|
8347
|
-
|
|
8348
|
-
|
|
8349
|
-
|
|
8947
|
+
enforcementLevel: runtimeInfo?.protectionLevel ?? "user-permissions",
|
|
8948
|
+
protection: runtimeInfo?.protectionLabel ?? "Runs as your user",
|
|
8949
|
+
workspaceBoundary: structuredControls
|
|
8950
|
+
? "Structured file tools are checked against the active workspace; shell commands are not an OS isolation boundary."
|
|
8951
|
+
: "Not guaranteed by Bivy for this runtime. Run it in a container/VM when isolation is required.",
|
|
8952
|
+
denyList: structuredControls
|
|
8953
|
+
? "Known catastrophic shell commands are heuristically blocked; this catches accidents, not adversarial bypasses."
|
|
8954
|
+
: "No universal Bivy command interception is available for this runtime.",
|
|
8955
|
+
strictApprovalOptIn: "Set approval mode to risky or always for prompt-heavy review where this runtime exposes tool controls.",
|
|
8350
8956
|
},
|
|
8351
|
-
runtime: runtimeSummary(getRuntime(
|
|
8957
|
+
runtime: { ...runtimeSummary(getRuntime(selectedRuntimeId)), ...runtimeInfo },
|
|
8352
8958
|
defaultRuntimeId,
|
|
8353
8959
|
sandbox: sandboxInfo(),
|
|
8354
8960
|
});
|
|
8355
8961
|
});
|
|
8962
|
+
// One-tap "Update this node" from the app's version-mismatch banner, for
|
|
8963
|
+
// direct/LAN clients (the relay path uses the RELAY_COMMANDS "node.update"
|
|
8964
|
+
// handler). Both call the same runBivyUpdate.
|
|
8965
|
+
app.post("/api/node/update", (_req, res) => {
|
|
8966
|
+
const result = runBivyUpdate();
|
|
8967
|
+
if (result.ok)
|
|
8968
|
+
res.json({ ok: true });
|
|
8969
|
+
else
|
|
8970
|
+
res.status(500).json({ ok: false, error: result.error });
|
|
8971
|
+
});
|
|
8356
8972
|
// Build collectNodeStats() options, resolving the optional session so the panel
|
|
8357
8973
|
// can attribute a session-scoped tier (its live agent process + workspace size).
|
|
8358
8974
|
function nodeStatsOptsFor(sessionId) {
|
|
@@ -8386,8 +9002,38 @@ function sandboxInfo() {
|
|
|
8386
9002
|
tier: sandboxTier(),
|
|
8387
9003
|
};
|
|
8388
9004
|
}
|
|
9005
|
+
// Redacted diagnostics bundle (B4d) — a shareable support export with no secrets,
|
|
9006
|
+
// prompts, transcripts, diffs, or repo content: versions, health counters, a
|
|
9007
|
+
// whitelisted set of config flags, and the activation stage record.
|
|
9008
|
+
app.get("/api/diagnostics", (_req, res) => {
|
|
9009
|
+
const relayConfig = loadRelayConfig(appDir);
|
|
9010
|
+
const selectedRuntimeId = active?.runtimeId ?? defaultRuntimeId;
|
|
9011
|
+
const runtimeInfo = runtimeList(selectedRuntimeId).find((runtime) => runtime.id === selectedRuntimeId);
|
|
9012
|
+
const report = buildDiagnosticsReport({
|
|
9013
|
+
version: currentVersion() ?? undefined,
|
|
9014
|
+
platform: process.platform,
|
|
9015
|
+
nodeVersion: process.version,
|
|
9016
|
+
relayConfigured: Boolean(relayConfig),
|
|
9017
|
+
health: {
|
|
9018
|
+
sessionsOpen: new Set(openSessions.values()).size,
|
|
9019
|
+
sessionsIndexed: metadata.listSessions().length,
|
|
9020
|
+
enforcementLevel: runtimeInfo?.protectionLevel ?? "user-permissions",
|
|
9021
|
+
approvalMode,
|
|
9022
|
+
relayConnected: Boolean(relay?.connected),
|
|
9023
|
+
},
|
|
9024
|
+
env: process.env,
|
|
9025
|
+
// The node knows it is online and which runtime is selectable; the client's
|
|
9026
|
+
// setup readiness fills the rest. This baseline still records the golden path.
|
|
9027
|
+
activation: activationRecord({ nodeOnline: true, runtimeReady: Boolean(runtimeInfo) }),
|
|
9028
|
+
generatedAt: new Date().toISOString(),
|
|
9029
|
+
});
|
|
9030
|
+
res.json(report);
|
|
9031
|
+
});
|
|
8389
9032
|
app.get("/api/status", (_req, res) => {
|
|
8390
9033
|
const relayConfig = loadRelayConfig(appDir);
|
|
9034
|
+
const selectedRuntimeId = active?.runtimeId ?? defaultRuntimeId;
|
|
9035
|
+
const runtimeInfo = runtimeList(selectedRuntimeId).find((runtime) => runtime.id === selectedRuntimeId);
|
|
9036
|
+
const workspaceBoundary = runtimeInfo?.protectionLevel === "native-sandbox" || runtimeInfo?.protectionLevel === "tool-controls";
|
|
8391
9037
|
res.json({
|
|
8392
9038
|
ok: true,
|
|
8393
9039
|
nodeId: identity.nodeId,
|
|
@@ -8402,7 +9048,9 @@ app.get("/api/status", (_req, res) => {
|
|
|
8402
9048
|
approvalMode,
|
|
8403
9049
|
guardrails: {
|
|
8404
9050
|
autonomousDefault: approvalMode === "autonomous",
|
|
8405
|
-
workspaceBoundary
|
|
9051
|
+
workspaceBoundary,
|
|
9052
|
+
enforcementLevel: runtimeInfo?.protectionLevel ?? "user-permissions",
|
|
9053
|
+
protection: runtimeInfo?.protectionLabel ?? "Runs as your user",
|
|
8406
9054
|
strictApprovalOptIn: true,
|
|
8407
9055
|
},
|
|
8408
9056
|
relay: {
|
|
@@ -8424,6 +9072,9 @@ app.get("/api/status", (_req, res) => {
|
|
|
8424
9072
|
},
|
|
8425
9073
|
devices: { paired: pairingStore.listDevices().length, localTokens: identity.listDevices().length },
|
|
8426
9074
|
approvals: { pending: approvals.list().filter((a) => a.status === "pending").length, recent: metadata.listApprovals(20) },
|
|
9075
|
+
eventLog: { ...eventLog.diskUsage(), ...eventLog.health(), affectedSessions: eventLogIssues.size },
|
|
9076
|
+
attachments: attachmentGcStats,
|
|
9077
|
+
turnWatchdog: { enabled: turnTimeoutMs > 0, timeoutMs: turnTimeoutMs },
|
|
8427
9078
|
updatedAt: new Date().toISOString(),
|
|
8428
9079
|
});
|
|
8429
9080
|
});
|
|
@@ -8619,10 +9270,13 @@ app.get("/api/models", async (req, res, next) => {
|
|
|
8619
9270
|
try {
|
|
8620
9271
|
const requestedSessionId = typeof req.query.sessionId === "string" && req.query.sessionId ? req.query.sessionId : undefined;
|
|
8621
9272
|
const requestedPath = typeof req.query.path === "string" ? req.query.path : undefined;
|
|
9273
|
+
const wantedRuntimeId = typeof req.query.runtimeId === "string" && req.query.runtimeId ? req.query.runtimeId : undefined;
|
|
8622
9274
|
let record = requestedSessionId ? await resolveOrResumeSession(requestedSessionId, requestedPath) : active;
|
|
8623
9275
|
if (requestedSessionId && !record)
|
|
8624
9276
|
return res.status(404).json({ error: "Session not found" });
|
|
8625
|
-
record
|
|
9277
|
+
if (!requestedSessionId && wantedRuntimeId && record?.runtimeId !== wantedRuntimeId)
|
|
9278
|
+
record = undefined;
|
|
9279
|
+
record ??= await sessionForModelQuery(wantedRuntimeId);
|
|
8626
9280
|
const session = record.session;
|
|
8627
9281
|
const current = session.getCurrentModel();
|
|
8628
9282
|
const models = await publicModelsList(session, current);
|
|
@@ -8633,6 +9287,17 @@ app.get("/api/models", async (req, res, next) => {
|
|
|
8633
9287
|
next(error);
|
|
8634
9288
|
}
|
|
8635
9289
|
});
|
|
9290
|
+
// Warm the per-runtime model-query scratch ahead of the first agent switch (see
|
|
9291
|
+
// prefetchModels). Fire-and-forget: returns immediately while the runtimes spin
|
|
9292
|
+
// up in the background, so the picker never blocks on it.
|
|
9293
|
+
app.post("/api/models/prefetch", (req, res) => {
|
|
9294
|
+
const ids = Array.isArray(req.body?.runtimeIds)
|
|
9295
|
+
? req.body.runtimeIds.filter((id) => typeof id === "string" && !!id).slice(0, 16)
|
|
9296
|
+
: [];
|
|
9297
|
+
if (ids.length)
|
|
9298
|
+
prefetchModels(ids);
|
|
9299
|
+
res.json({ ok: true });
|
|
9300
|
+
});
|
|
8636
9301
|
app.post("/api/models/select", async (req, res, next) => {
|
|
8637
9302
|
try {
|
|
8638
9303
|
const requestedSessionId = typeof req.body?.sessionId === "string" && req.body.sessionId ? req.body.sessionId : undefined;
|
|
@@ -9934,7 +10599,7 @@ app.post("/api/session/prompt", async (req, res, next) => {
|
|
|
9934
10599
|
broadcast({ type: "session.user_message", sessionId: record.id, text: promptText, clientMessageId: req.body?.clientMessageId });
|
|
9935
10600
|
void maybeNameSession(record, promptText);
|
|
9936
10601
|
harnessBeginTurn(record);
|
|
9937
|
-
await
|
|
10602
|
+
await promptWithWatchdog(record, agentPrompt, promptOptionsFor(record, req.body?.streamingBehavior, images));
|
|
9938
10603
|
}).catch((error) => {
|
|
9939
10604
|
clearSessionWorking(record);
|
|
9940
10605
|
broadcast({ type: "session.error", sessionId: record.id, error: String(error?.stack ?? error) });
|
|
@@ -10169,6 +10834,14 @@ const server = app.listen(port, host, async () => {
|
|
|
10169
10834
|
// Recover interactive sessions a restart interrupted mid-turn (auto-continue, or
|
|
10170
10835
|
// flag for a one-tap manual Resume) per the node's sessionResumeMode setting.
|
|
10171
10836
|
void reconcileInterruptedSessions().catch((error) => console.warn("[resume] interrupted-session reconciliation failed", error));
|
|
10837
|
+
// Re-arm (or fire) durable auto-resume markers a limit-hit turn left behind,
|
|
10838
|
+
// so a session waiting out a usage/rate window still resumes after a restart.
|
|
10839
|
+
try {
|
|
10840
|
+
sessionResumeSweep();
|
|
10841
|
+
}
|
|
10842
|
+
catch (error) {
|
|
10843
|
+
console.warn("[resume] auto-resume sweep failed at boot", error);
|
|
10844
|
+
}
|
|
10172
10845
|
// Universal Agent Harness — network effect boundary (opt-in via
|
|
10173
10846
|
// BIVY_EGRESS_PROXY). Governs/logs outbound traffic of CLI agents, which
|
|
10174
10847
|
// inherit the proxy env from process.ts.
|
|
@@ -10218,6 +10891,14 @@ wss.on("connection", (socket, req) => {
|
|
|
10218
10891
|
// sharing a PTY size it to their min (see TerminalManager.setClientSize).
|
|
10219
10892
|
const clientTerminalId = `sock-${randomUUID()}`;
|
|
10220
10893
|
socket.send(JSON.stringify({ type: "hello", activeSessionId: active?.id, activeSession: active ? { id: active.id, isStreaming: sessionBusy(active), lastActivity: active.lastActivity, workingStartedAt: active.workingStartedAt } : null }));
|
|
10894
|
+
// Authoritative version status on every connect: `latest` set means this node
|
|
10895
|
+
// is behind (banner shows); absent means up to date (banner + any "Updating…"
|
|
10896
|
+
// state clear — this is how the banner disappears after an update lands and
|
|
10897
|
+
// the socket reconnects on the new build). Then (re)run the throttled check so
|
|
10898
|
+
// a freshly-opened app surfaces a newly-available update without waiting for a
|
|
10899
|
+
// session turn.
|
|
10900
|
+
socket.send(JSON.stringify({ type: "node.update", current: currentVersion() ?? "", latest: pendingBivyUpdate?.latest }));
|
|
10901
|
+
void checkBivyUpdate();
|
|
10221
10902
|
socket.on("message", (raw) => {
|
|
10222
10903
|
let msg;
|
|
10223
10904
|
try {
|