@bivy/bivy 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/acp-shim.mjs +128 -14
- package/bin/agent-manifest.json +3 -2
- package/bin/bivy.mjs +36 -4
- package/dist/github-device-auth.js +16 -0
- package/dist/harness/mcp-config.js +89 -6
- package/dist/harness/mcp-inject.js +31 -8
- package/dist/metadata.js +32 -0
- package/dist/policy/conditions.js +54 -3
- package/dist/policy/run-policy.js +2 -1
- package/dist/policy/session-reroute.js +66 -0
- package/dist/repo-workspace.js +19 -0
- package/dist/runtime/index.js +127 -18
- package/dist/runtime/protocol.js +15 -0
- package/dist/secrets.js +6 -2
- package/dist/server.js +372 -23
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -8,7 +8,7 @@ import { randomUUID, randomBytes, timingSafeEqual, createHash } from "node:crypt
|
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import express from "express";
|
|
10
10
|
import { WebSocketServer, WebSocket } from "ws";
|
|
11
|
-
import { listRuntimes, catalogRuntimes, cliInstallSpec, isCliAgentId } from "./runtime/index.js";
|
|
11
|
+
import { listRuntimes, catalogRuntimes, cliInstallSpec, invalidateCliProbeCache, isCliAgentId } from "./runtime/index.js";
|
|
12
12
|
import { createRunPolicy } from "./policy/run-policy.js";
|
|
13
13
|
import { DEFAULT_BACKOFF } from "./policy/ruleset.js";
|
|
14
14
|
import { SessionRerouteController } from "./policy/session-reroute.js";
|
|
@@ -60,7 +60,7 @@ import { checkDiskAdmission } from "./harness/disk-admission.js";
|
|
|
60
60
|
import { sandboxTier, setConfiguredSandboxTier, normalizeSandboxTier } from "./harness/sandbox.js";
|
|
61
61
|
import { setConfiguredAutoAttachToolImages } from "./harness/tool-image-attachments.js";
|
|
62
62
|
import { injectMcpProxyForSession, injectBivyToolsForSession } from "./harness/mcp-inject.js";
|
|
63
|
-
import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, resolveAdoptBaseRef, fetchOrigin } from "./repo-workspace.js";
|
|
63
|
+
import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, ghCliInstalled, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, resolveAdoptBaseRef, fetchOrigin } from "./repo-workspace.js";
|
|
64
64
|
import { configureGitAuth, writeGitCredentialEndpoint } from "./git-auth.js";
|
|
65
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";
|
|
66
66
|
import { buildLinearTaskPrompt, getLinearIssue, linearBranchName } from "./linear-tasks.js";
|
|
@@ -84,6 +84,7 @@ import { ReplicationService } from "./session/replication-service.js";
|
|
|
84
84
|
import { createSessionNewDedupe } from "./session/session-new-dedupe.js";
|
|
85
85
|
import { evaluateForkPrereqs, blockingForkPrereqs, missingForkPrereqs } from "./session/fork-prereqs.js";
|
|
86
86
|
import { SecretVault, resolveSecret } from "./secrets.js";
|
|
87
|
+
import { deviceFlowClientId, requestDeviceCode, pollAccessTokenOnce, REPO_CONNECT_SCOPE } from "./github-device-auth.js";
|
|
87
88
|
import { InstallationTokenCache, createAppJwt, resolveInstallationId } from "./github-app-auth.js";
|
|
88
89
|
import { loadGitHubAppConfigs, orderAppsForOwner, listGitHubApps, removeGitHubApp, upsertGitHubApp, privateKeyIdFor, } from "./github-apps.js";
|
|
89
90
|
import { buildAppManifest, convertManifest, renderManifestForm } from "./github-app-manifest.js";
|
|
@@ -501,11 +502,11 @@ const terminals = new TerminalManager();
|
|
|
501
502
|
// and fixed for that session's life; switching agents in the UI starts a new one.
|
|
502
503
|
let defaultRuntimeId = (process.env.BIVY_RUNTIME ?? "pi").toLowerCase();
|
|
503
504
|
const runtimeHost = new RuntimeHost({ credsDir, piDir, sessionsDir, attachToChat: attachToChatForSession });
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
// hits an exhausted-credits / rate-limit turn error swaps down the
|
|
507
|
-
// runtime's live setModel) and retries
|
|
508
|
-
//
|
|
505
|
+
// A built-in in-session model-fallback ruleset from BIVY_SESSION_MODEL_FALLBACK
|
|
506
|
+
// (docs/rulesets.md). Opt-in: set it to a comma-separated model list and a
|
|
507
|
+
// session that hits an exhausted-credits / rate-limit turn error swaps down the
|
|
508
|
+
// list (via the runtime's live setModel) and retries. Used only when the user
|
|
509
|
+
// hasn't authored their own session-scoped ruleset in the UI.
|
|
509
510
|
function sessionModelFallbackRuleset() {
|
|
510
511
|
const models = (process.env.BIVY_SESSION_MODEL_FALLBACK ?? "")
|
|
511
512
|
.split(",")
|
|
@@ -529,9 +530,21 @@ function sessionModelFallbackRuleset() {
|
|
|
529
530
|
],
|
|
530
531
|
};
|
|
531
532
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
533
|
+
/** The ruleset in-session recovery runs under right now: the user's active
|
|
534
|
+
* ruleset if it applies to sessions, else the env model-fallback ruleset, else
|
|
535
|
+
* undefined (→ built-in DEFAULT_RULESET). Read lazily on each turn error so UI
|
|
536
|
+
* edits take effect without a restart, mirroring activeQueueRuleset. */
|
|
537
|
+
function activeSessionRuleset() {
|
|
538
|
+
return activeRulesetFor(rulesetsDir, "session") ?? sessionModelFallbackRuleset();
|
|
539
|
+
}
|
|
540
|
+
// The in-session recovery effector's policy. Always available: an interactive
|
|
541
|
+
// session can wait out a provider usage/rate limit and resume when it resets
|
|
542
|
+
// (planResume), or swap models down a fallback chain (planReroute). Thin wrapper
|
|
543
|
+
// so a freshly-saved active ruleset is picked up on the next turn error.
|
|
544
|
+
const sessionRunPolicy = {
|
|
545
|
+
decide: (ctx) => createRunPolicy({ context: "session", ruleset: activeSessionRuleset() }).decide(ctx),
|
|
546
|
+
};
|
|
547
|
+
if (process.env.BIVY_SESSION_MODEL_FALLBACK) {
|
|
535
548
|
console.log(`[policy] in-session model reroute enabled: ${process.env.BIVY_SESSION_MODEL_FALLBACK}`);
|
|
536
549
|
}
|
|
537
550
|
let lastUpdateCheckAt = 0;
|
|
@@ -2812,6 +2825,15 @@ const RELAY_COMMANDS = {
|
|
|
2812
2825
|
async "repos.list"() {
|
|
2813
2826
|
relay?.sendEvent({ type: "repos.list", ...(await listAccessibleRepos()) });
|
|
2814
2827
|
},
|
|
2828
|
+
// Web-driven "Connect GitHub" for the repo picker: start the node's device
|
|
2829
|
+
// flow, then poll it on GitHub's interval. Both answer with the same
|
|
2830
|
+
// `github.connect.status` event so the client has one shape to handle.
|
|
2831
|
+
async "github.connect.start"() {
|
|
2832
|
+
relay?.sendEvent({ type: "github.connect.status", ...(await startGithubConnect()) });
|
|
2833
|
+
},
|
|
2834
|
+
async "github.connect.poll"() {
|
|
2835
|
+
relay?.sendEvent({ type: "github.connect.status", ...(await pollGithubConnect()) });
|
|
2836
|
+
},
|
|
2815
2837
|
// Branches for the repo the composer's repo pill just picked, so the branch
|
|
2816
2838
|
// pill next to it can offer a specific remote branch to clone/base a new
|
|
2817
2839
|
// session from instead of always the repo's default. See listRepoBranches.
|
|
@@ -3205,6 +3227,9 @@ const RELAY_COMMANDS = {
|
|
|
3205
3227
|
const before = runtimeList().find((runtime) => runtime.id === spec.id);
|
|
3206
3228
|
if (before?.status !== "available")
|
|
3207
3229
|
await runInstallCommand(spec);
|
|
3230
|
+
// The just-installed binary changes what the CLI probes would report, so drop
|
|
3231
|
+
// their (process-lifetime) cache and let the catalog below re-probe it.
|
|
3232
|
+
invalidateCliProbeCache();
|
|
3208
3233
|
const activeAgent = active?.runtimeId ?? defaultRuntimeId;
|
|
3209
3234
|
const runtimes = runtimeList(activeAgent);
|
|
3210
3235
|
relay?.sendEvent({ type: "runtime.install.done", id: spec.id, runtimes });
|
|
@@ -3542,6 +3567,9 @@ const RELAY_COMMANDS = {
|
|
|
3542
3567
|
record.lastPrompt = agentPrompt;
|
|
3543
3568
|
record.lastPromptOptions = promptOptionsFor(record, msg.streamingBehavior, images);
|
|
3544
3569
|
record.reroute?.beginTurn();
|
|
3570
|
+
// The user is driving this turn manually — supersede any pending auto-resume
|
|
3571
|
+
// that was scheduled after a prior limit so it can't re-fire on top of them.
|
|
3572
|
+
clearSessionResume(record.id);
|
|
3545
3573
|
await promptWithWatchdog(record, agentPrompt, record.lastPromptOptions);
|
|
3546
3574
|
}).catch((error) => {
|
|
3547
3575
|
// Mirror the HTTP path (see the /prompt route): a rejected turn after
|
|
@@ -4330,6 +4358,12 @@ function startModelAuthWatcher() {
|
|
|
4330
4358
|
let sessionAdvertiseTarget;
|
|
4331
4359
|
let advertiseTimer;
|
|
4332
4360
|
let advertiseResyncTimer;
|
|
4361
|
+
// Only one replace-all session advert may be in flight. If an older snapshot
|
|
4362
|
+
// (still containing a just-deleted/pruned session) completes after a newer one,
|
|
4363
|
+
// the control plane resurrects that row. Changes arriving during a request set
|
|
4364
|
+
// this flag and are sent immediately after it completes, in order.
|
|
4365
|
+
let advertiseRunning = false;
|
|
4366
|
+
let advertiseAgain = false;
|
|
4333
4367
|
// How often the node re-affirms it's online to the control plane. Kept well
|
|
4334
4368
|
// under the control plane's NODE_ONLINE_TTL_MS (90s) so a missed beat or two
|
|
4335
4369
|
// doesn't flap a healthy node's status.
|
|
@@ -4560,11 +4594,17 @@ async function advertiseSessions() {
|
|
|
4560
4594
|
: [];
|
|
4561
4595
|
return {
|
|
4562
4596
|
sessionId: s.id,
|
|
4563
|
-
|
|
4564
|
-
|
|
4597
|
+
// Failures (including exhausted credits/rate limits) are outcomes to
|
|
4598
|
+
// review, not blocking questions that keep saying "Needs your response".
|
|
4599
|
+
// Only a still-pending approval/question owns that status.
|
|
4600
|
+
status: pendingApproval ? "needs_action" : (record ? (sessionBusy(record) ? "working" : "idle") : "saved"),
|
|
4601
|
+
needsAction: pendingApproval,
|
|
4565
4602
|
source: record?.source || meta?.source,
|
|
4566
4603
|
titleEnc: name ? relay.sealString(name) : undefined,
|
|
4567
4604
|
branch: record?.worktree?.branch || meta?.branch,
|
|
4605
|
+
// This is activity time, not advert receive time. A daemon restart/full
|
|
4606
|
+
// resync must not make every historical row appear freshly updated.
|
|
4607
|
+
updatedAt: isoFrom(record?.lastTouchedAt ?? meta?.lastActivityAt ?? meta?.updatedAt ?? s.modified),
|
|
4568
4608
|
agentServiceAddress,
|
|
4569
4609
|
githubIssueUrl: record?.githubIssueUrl,
|
|
4570
4610
|
prUrl: record?.prUrl,
|
|
@@ -4582,16 +4622,39 @@ async function advertiseSessions() {
|
|
|
4582
4622
|
// best effort; the periodic resync and the next change will retry
|
|
4583
4623
|
}
|
|
4584
4624
|
}
|
|
4585
|
-
/** Debounced advertise — many session events collapse into one
|
|
4625
|
+
/** Debounced, serialized advertise — many session events collapse into one
|
|
4626
|
+
* POST, and replace-all snapshots can never complete out of order. */
|
|
4586
4627
|
function scheduleAdvertise() {
|
|
4587
|
-
if (!sessionAdvertiseTarget
|
|
4628
|
+
if (!sessionAdvertiseTarget)
|
|
4629
|
+
return;
|
|
4630
|
+
if (advertiseRunning) {
|
|
4631
|
+
advertiseAgain = true;
|
|
4632
|
+
return;
|
|
4633
|
+
}
|
|
4634
|
+
if (advertiseTimer)
|
|
4588
4635
|
return;
|
|
4589
4636
|
advertiseTimer = setTimeout(() => {
|
|
4590
4637
|
advertiseTimer = undefined;
|
|
4591
|
-
void
|
|
4638
|
+
void drainSessionAdverts();
|
|
4592
4639
|
}, 1000);
|
|
4593
4640
|
advertiseTimer.unref?.();
|
|
4594
4641
|
}
|
|
4642
|
+
async function drainSessionAdverts() {
|
|
4643
|
+
if (advertiseRunning) {
|
|
4644
|
+
advertiseAgain = true;
|
|
4645
|
+
return;
|
|
4646
|
+
}
|
|
4647
|
+
advertiseRunning = true;
|
|
4648
|
+
try {
|
|
4649
|
+
do {
|
|
4650
|
+
advertiseAgain = false;
|
|
4651
|
+
await advertiseSessions();
|
|
4652
|
+
} while (advertiseAgain);
|
|
4653
|
+
}
|
|
4654
|
+
finally {
|
|
4655
|
+
advertiseRunning = false;
|
|
4656
|
+
}
|
|
4657
|
+
}
|
|
4595
4658
|
let githubPoller;
|
|
4596
4659
|
/**
|
|
4597
4660
|
* Serialize all work for one issue (keyed by its `issue:owner/repo#N` source).
|
|
@@ -6862,6 +6925,28 @@ const idleCloseTimer = setInterval(() => { closeIdleSessions(); pruneGhostSessio
|
|
|
6862
6925
|
idleCloseTimer.unref?.();
|
|
6863
6926
|
const worktreeCleanupTimer = setInterval(() => void sweepDiskGuardrails(), worktreeCleanupSweepMs);
|
|
6864
6927
|
worktreeCleanupTimer.unref?.();
|
|
6928
|
+
// In-session auto-resume tunables (see the resume helpers below). setTimeout
|
|
6929
|
+
// can't be trusted past ~24.8 days and we don't want one timer owning a
|
|
6930
|
+
// multi-hour wait a restart would drop, so each timer is capped and the periodic
|
|
6931
|
+
// sweep re-arms the remainder from the persisted resumeAt.
|
|
6932
|
+
const SESSION_RESUME_MAX_TIMER_MS = 30 * 60_000;
|
|
6933
|
+
const SESSION_RESUME_SWEEP_MS = 60_000;
|
|
6934
|
+
/** Slack around "due": a capped timer may fire a touch early — drive only when
|
|
6935
|
+
* within this of the target, else re-arm. */
|
|
6936
|
+
const SESSION_RESUME_TICK_MS = 15_000;
|
|
6937
|
+
/** Hard ceiling on consecutive auto-resumes for one session before we give up and
|
|
6938
|
+
* surface the limit. The reroute controller already caps per turn, but its budget
|
|
6939
|
+
* is in-memory: a session re-resolved after its child exits on the limit (or a
|
|
6940
|
+
* daemon restart) gets a fresh controller, so without a durable count a limit that
|
|
6941
|
+
* never actually clears would re-send every MIN_RESUME_DELAY_MS indefinitely.
|
|
6942
|
+
* Generous enough to ride out a mis-parsed multi-day window (each wait is ≥1 min,
|
|
6943
|
+
* usually far longer), low enough to bound a genuinely stuck limit. */
|
|
6944
|
+
const MAX_DURABLE_RESUME_ATTEMPTS = 10;
|
|
6945
|
+
const sessionResumeTimers = new Map();
|
|
6946
|
+
// Fire due auto-resumes (a usage/rate limit that has since reset) and re-arm the
|
|
6947
|
+
// tail of long waits whose in-process timer was capped or lost to a restart.
|
|
6948
|
+
const sessionResumeTimer = setInterval(() => sessionResumeSweep(), SESSION_RESUME_SWEEP_MS);
|
|
6949
|
+
sessionResumeTimer.unref?.();
|
|
6865
6950
|
// --- server-side ephemeral teardown ----------------------------------------
|
|
6866
6951
|
// On a disposable machine (bootstrap set BIVY_EPHEMERAL=1) the daemon ends the
|
|
6867
6952
|
// machine ITSELF once it goes idle, so teardown no longer needs the launching
|
|
@@ -7111,6 +7196,136 @@ async function refreshSessionUsage(record) {
|
|
|
7111
7196
|
// Usage reporting must never affect the session it's reporting on.
|
|
7112
7197
|
}
|
|
7113
7198
|
}
|
|
7199
|
+
// ── In-session auto-resume after a usage/rate limit ─────────────────────────
|
|
7200
|
+
// When a turn ends because a provider window is exhausted ("you've hit your
|
|
7201
|
+
// weekly limit · resets 12am (UTC)") and the session's ruleset says retry, we
|
|
7202
|
+
// wait out the window and re-send the same prompt when it resets — instead of
|
|
7203
|
+
// leaving a dead error bubble. Durable: the due time is persisted (metadata
|
|
7204
|
+
// resumeAt) so a daemon restart re-arms it (sessionResumeSweep); an in-process
|
|
7205
|
+
// timer fires it promptly while the daemon is up. (Tunables + timer map are
|
|
7206
|
+
// declared up by the timer cluster so the sweep interval can reference them.)
|
|
7207
|
+
/** The authoritative reset time for the limit a session just hit: the soonest
|
|
7208
|
+
* future reset among its most-utilized usage windows (the binding one), from
|
|
7209
|
+
* the last snapshot the runtime reported. Essential for a multi-day "weekly"
|
|
7210
|
+
* window, whose error text states only a time-of-day. Undefined when unknown. */
|
|
7211
|
+
function limitResetHint(record, nowMs) {
|
|
7212
|
+
const windows = record.usage?.plan?.windows ?? [];
|
|
7213
|
+
let best;
|
|
7214
|
+
for (const w of windows) {
|
|
7215
|
+
if (!w.resetsAt)
|
|
7216
|
+
continue;
|
|
7217
|
+
const at = Date.parse(w.resetsAt);
|
|
7218
|
+
if (!Number.isFinite(at) || at <= nowMs)
|
|
7219
|
+
continue;
|
|
7220
|
+
const util = w.utilizationPct ?? 0;
|
|
7221
|
+
// Prefer the most-utilized window (the one being hit); tie-break on soonest reset.
|
|
7222
|
+
if (!best || util > best.util || (util === best.util && at < best.at))
|
|
7223
|
+
best = { at, util };
|
|
7224
|
+
}
|
|
7225
|
+
return best ? new Date(best.at).toISOString() : undefined;
|
|
7226
|
+
}
|
|
7227
|
+
/** Cancel a pending in-process resume timer (leaves the durable marker alone). */
|
|
7228
|
+
function cancelSessionResumeTimer(id) {
|
|
7229
|
+
const timer = sessionResumeTimers.get(id);
|
|
7230
|
+
if (timer) {
|
|
7231
|
+
clearTimeout(timer);
|
|
7232
|
+
sessionResumeTimers.delete(id);
|
|
7233
|
+
}
|
|
7234
|
+
}
|
|
7235
|
+
/** Clear both the durable resume marker and any armed timer — the session moved
|
|
7236
|
+
* on (a new user turn, or the resume itself started). */
|
|
7237
|
+
function clearSessionResume(id) {
|
|
7238
|
+
cancelSessionResumeTimer(id);
|
|
7239
|
+
metadata.setResumeAt(id, null);
|
|
7240
|
+
}
|
|
7241
|
+
function armSessionResumeTimer(id, dueMs) {
|
|
7242
|
+
cancelSessionResumeTimer(id);
|
|
7243
|
+
const delay = Math.min(Math.max(0, dueMs - Date.now()), SESSION_RESUME_MAX_TIMER_MS);
|
|
7244
|
+
const timer = setTimeout(() => {
|
|
7245
|
+
sessionResumeTimers.delete(id);
|
|
7246
|
+
void driveSessionResume(id);
|
|
7247
|
+
}, delay);
|
|
7248
|
+
timer.unref?.();
|
|
7249
|
+
sessionResumeTimers.set(id, timer);
|
|
7250
|
+
}
|
|
7251
|
+
/** Persist + arm an auto-resume decided by the session policy. Synchronous so the
|
|
7252
|
+
* caller can atomically suppress the turn's error toast. Returns false when the
|
|
7253
|
+
* session has already exhausted its durable auto-resume budget (a limit that never
|
|
7254
|
+
* clears) — the caller then lets the error surface instead of looping. */
|
|
7255
|
+
function scheduleSessionResume(record, plan) {
|
|
7256
|
+
const attempts = metadata.getSession(record.id)?.resumeAttempts ?? 0;
|
|
7257
|
+
if (attempts >= MAX_DURABLE_RESUME_ATTEMPTS) {
|
|
7258
|
+
console.warn(`[resume] session ${record.id} hit the durable auto-resume cap (${MAX_DURABLE_RESUME_ATTEMPTS}) without the limit clearing — giving up`);
|
|
7259
|
+
clearSessionResume(record.id);
|
|
7260
|
+
metadata.setResumeAttempts(record.id, 0);
|
|
7261
|
+
return false;
|
|
7262
|
+
}
|
|
7263
|
+
metadata.setResumeAt(record.id, plan.resumeAt);
|
|
7264
|
+
metadata.setResumeAttempts(record.id, attempts + 1);
|
|
7265
|
+
const when = Date.parse(plan.resumeAt);
|
|
7266
|
+
const cond = plan.condition.replace(/_/g, " ");
|
|
7267
|
+
broadcast({
|
|
7268
|
+
type: "session.notice",
|
|
7269
|
+
sessionId: record.id,
|
|
7270
|
+
level: "info",
|
|
7271
|
+
message: `Hit a ${cond} limit — I'll resume this automatically when it resets (${plan.resumeAt}).`,
|
|
7272
|
+
});
|
|
7273
|
+
armSessionResumeTimer(record.id, Number.isFinite(when) ? when : Date.now());
|
|
7274
|
+
return true;
|
|
7275
|
+
}
|
|
7276
|
+
/** Fire a due auto-resume: re-open the session if needed and re-send the turn's
|
|
7277
|
+
* last prompt. Clears the durable marker BEFORE driving so a crash mid-resume
|
|
7278
|
+
* can't loop. Best-effort — never throws into a timer/sweep. */
|
|
7279
|
+
async function driveSessionResume(id) {
|
|
7280
|
+
const meta = metadata.getSession(id);
|
|
7281
|
+
if (!meta?.resumeAt)
|
|
7282
|
+
return; // cancelled or already resumed
|
|
7283
|
+
const due = Date.parse(meta.resumeAt);
|
|
7284
|
+
if (Number.isFinite(due) && due - Date.now() > SESSION_RESUME_TICK_MS) {
|
|
7285
|
+
// A capped timer fired before the real due time — re-arm for the remainder.
|
|
7286
|
+
armSessionResumeTimer(id, due);
|
|
7287
|
+
return;
|
|
7288
|
+
}
|
|
7289
|
+
clearSessionResume(id);
|
|
7290
|
+
try {
|
|
7291
|
+
const live = openSessions.get(id);
|
|
7292
|
+
if (live?.isWorking)
|
|
7293
|
+
return; // a user turn is already running — don't pile on
|
|
7294
|
+
const record = live ?? (await resolveOrResumeSession(id, meta.path));
|
|
7295
|
+
if (!record)
|
|
7296
|
+
return; // transcript gone / unresolvable
|
|
7297
|
+
if (record.isWorking)
|
|
7298
|
+
return;
|
|
7299
|
+
// In-memory lastPrompt is the exact user turn to retry; after a restart it's
|
|
7300
|
+
// gone, so fall back to the generic interrupted-turn continuation nudge.
|
|
7301
|
+
const prompt = record.lastPrompt ?? buildInteractiveResumePrompt();
|
|
7302
|
+
console.log(`[resume] auto-resuming session ${id} — provider limit has reset`);
|
|
7303
|
+
broadcast({ type: "session.notice", sessionId: id, level: "info", message: "The limit has reset — resuming now." });
|
|
7304
|
+
await promptWithWatchdog(record, prompt, record.lastPromptOptions);
|
|
7305
|
+
}
|
|
7306
|
+
catch (error) {
|
|
7307
|
+
console.warn(`[resume] auto-resume after a provider limit failed for ${id}`, error);
|
|
7308
|
+
}
|
|
7309
|
+
}
|
|
7310
|
+
/** Re-arm (or immediately fire) durable auto-resume markers. Runs once at boot
|
|
7311
|
+
* and on an interval, so a wait survives a restart and a capped timer's tail
|
|
7312
|
+
* still fires. */
|
|
7313
|
+
function sessionResumeSweep() {
|
|
7314
|
+
const now = Date.now();
|
|
7315
|
+
for (const meta of metadata.sessionsWithResumeAt()) {
|
|
7316
|
+
const due = Date.parse(meta.resumeAt);
|
|
7317
|
+
if (!Number.isFinite(due)) {
|
|
7318
|
+
metadata.setResumeAt(meta.id, null);
|
|
7319
|
+
continue;
|
|
7320
|
+
}
|
|
7321
|
+
if (sessionResumeTimers.has(meta.id))
|
|
7322
|
+
continue; // already armed this run
|
|
7323
|
+
if (due <= now + SESSION_RESUME_TICK_MS)
|
|
7324
|
+
void driveSessionResume(meta.id);
|
|
7325
|
+
else
|
|
7326
|
+
armSessionResumeTimer(meta.id, due);
|
|
7327
|
+
}
|
|
7328
|
+
}
|
|
7114
7329
|
/**
|
|
7115
7330
|
* Turn a raw provider/runtime error string into something a human can read.
|
|
7116
7331
|
* Model APIs commonly return `<status> {json}` (e.g. `400 {"error":{"message":
|
|
@@ -7184,9 +7399,12 @@ function maybeSignalAuthRequired(record, errorText) {
|
|
|
7184
7399
|
}
|
|
7185
7400
|
function attachSessionListeners(record) {
|
|
7186
7401
|
record.unsubscribe?.();
|
|
7187
|
-
// In-session
|
|
7188
|
-
//
|
|
7189
|
-
|
|
7402
|
+
// In-session recovery controller — waits out a usage/rate limit and resumes
|
|
7403
|
+
// (planResume), or swaps models down a fallback chain (planReroute). One per
|
|
7404
|
+
// session; its per-turn budget resets on each user prompt. The policy reads
|
|
7405
|
+
// the active session ruleset lazily, so it's inert until one authorizes a
|
|
7406
|
+
// retry/reroute for the failing condition.
|
|
7407
|
+
if (!record.reroute) {
|
|
7190
7408
|
record.reroute = new SessionRerouteController({
|
|
7191
7409
|
policy: sessionRunPolicy,
|
|
7192
7410
|
onNotice: (n) => broadcast({ type: "session.notice", sessionId: record.id, level: n.level, message: n.message }),
|
|
@@ -7306,7 +7524,18 @@ function attachSessionListeners(record) {
|
|
|
7306
7524
|
// credential or a 4xx from the API) otherwise vanished: working cleared,
|
|
7307
7525
|
// no reply, no signal. Surface it as a session-scoped error so the client
|
|
7308
7526
|
// can show it *inline in that chat*, and notify instead of "done".
|
|
7309
|
-
|
|
7527
|
+
// A terminal turn error reaches us two ways. pi-ai puts it on the last
|
|
7528
|
+
// assistant message (stopReason:"error" → terminalTurnError), and the
|
|
7529
|
+
// server owns surfacing it. Claude Code instead throws inside the SDK
|
|
7530
|
+
// query: it emits its OWN session.error to the client AND carries the raw
|
|
7531
|
+
// text on agent_end.error (e.g. "you've hit your weekly limit · resets 12am
|
|
7532
|
+
// (UTC)"). We read that too — but only to DRIVE recovery, since the runtime
|
|
7533
|
+
// already surfaced it; re-broadcasting would double the error bubble.
|
|
7534
|
+
const messageError = terminalTurnError(event);
|
|
7535
|
+
const agentEndError = typeof event.error === "string"
|
|
7536
|
+
? humanizeAgentError(event.error)
|
|
7537
|
+
: undefined;
|
|
7538
|
+
const turnError = messageError ?? (agentEndError?.trim() ? agentEndError : undefined);
|
|
7310
7539
|
// Before surfacing a turn error, see if the session's run policy can recover
|
|
7311
7540
|
// it in place by swapping to a fallback model and retrying the same prompt.
|
|
7312
7541
|
// planReroute is synchronous, so we can atomically suppress the error toast
|
|
@@ -7314,6 +7543,18 @@ function attachSessionListeners(record) {
|
|
|
7314
7543
|
const reroutePlan = turnError && record.lastPrompt !== undefined
|
|
7315
7544
|
? record.reroute?.planReroute(turnError, record.session.getCurrentModel()?.name) ?? null
|
|
7316
7545
|
: null;
|
|
7546
|
+
// If a reroute doesn't apply, a usage/rate limit that gave a reset time can
|
|
7547
|
+
// instead be waited out and resumed when the window clears (planResume is
|
|
7548
|
+
// synchronous too, so this stays atomic with suppressing the error toast).
|
|
7549
|
+
const resumePlan = !reroutePlan && turnError && record.lastPrompt !== undefined
|
|
7550
|
+
? record.reroute?.planResume(turnError, record.session.getCurrentModel()?.name, {
|
|
7551
|
+
resetsAtHint: limitResetHint(record, Date.now()),
|
|
7552
|
+
}) ?? null
|
|
7553
|
+
: null;
|
|
7554
|
+
// Did this turn end by scheduling another auto-resume? If not, the session
|
|
7555
|
+
// made forward progress (a user turn, a resume that cleared the limit, a
|
|
7556
|
+
// reroute, or a surfaced error), so its durable resume streak resets below.
|
|
7557
|
+
let scheduledResume = false;
|
|
7317
7558
|
if (reroutePlan) {
|
|
7318
7559
|
void record.reroute.applyReroute(reroutePlan, {
|
|
7319
7560
|
getCurrentModelName: () => record.session.getCurrentModel()?.name,
|
|
@@ -7323,14 +7564,25 @@ function attachSessionListeners(record) {
|
|
|
7323
7564
|
},
|
|
7324
7565
|
});
|
|
7325
7566
|
}
|
|
7326
|
-
else if (
|
|
7567
|
+
else if (resumePlan && scheduleSessionResume(record, resumePlan)) {
|
|
7568
|
+
// Charge the attempt budget so a limit that re-fires after the reset can
|
|
7569
|
+
// eventually exhaust (→ surface) instead of looping, then park the turn
|
|
7570
|
+
// as a scheduled resume rather than a dead error. scheduleSessionResume
|
|
7571
|
+
// returns false once the durable cap is hit, so this falls through to
|
|
7572
|
+
// surface the limit instead of resuming forever.
|
|
7573
|
+
record.reroute.noteResumeApplied();
|
|
7574
|
+
scheduledResume = true;
|
|
7575
|
+
}
|
|
7576
|
+
else if (messageError) {
|
|
7577
|
+
// Only the server-owned (pi-ai) path surfaces here; a Claude Code error
|
|
7578
|
+
// the runtime already broadcast falls through to avoid a duplicate bubble.
|
|
7327
7579
|
record.lastFailureAt = Date.now();
|
|
7328
7580
|
metadata.touchSession(record.id, "failed");
|
|
7329
7581
|
scheduleAdvertise();
|
|
7330
|
-
broadcast({ type: "session.error", sessionId: record.id, error:
|
|
7582
|
+
broadcast({ type: "session.error", sessionId: record.id, error: messageError });
|
|
7331
7583
|
// If the terminal error is an auth failure (expired key/token → 4xx),
|
|
7332
7584
|
// also raise the sign-in sheet for the failing provider.
|
|
7333
|
-
maybeSignalAuthRequired(record,
|
|
7585
|
+
maybeSignalAuthRequired(record, messageError);
|
|
7334
7586
|
void sendNotificationHint({
|
|
7335
7587
|
kind: "session_error",
|
|
7336
7588
|
sessionId: record.id,
|
|
@@ -7348,6 +7600,11 @@ function attachSessionListeners(record) {
|
|
|
7348
7600
|
body: `${sessionNotifyLabel(record)} finished — tap to review the result.`,
|
|
7349
7601
|
});
|
|
7350
7602
|
}
|
|
7603
|
+
// Any turn that didn't schedule another resume broke the limit streak —
|
|
7604
|
+
// clear the durable counter so a future limit starts with a full budget
|
|
7605
|
+
// (no-op when it's already 0, so a normal turn never touches the file).
|
|
7606
|
+
if (!scheduledResume)
|
|
7607
|
+
metadata.setResumeAttempts(record.id, 0);
|
|
7351
7608
|
// First real commit on a repo-backed worktree → publish the branch to the
|
|
7352
7609
|
// remote (sets upstream), so the work is visible on GitHub. No-op until
|
|
7353
7610
|
// there's a commit, and only pushes once. Then adopt a PR the agent opened
|
|
@@ -9887,7 +10144,7 @@ async function listAccessibleRepos() {
|
|
|
9887
10144
|
try {
|
|
9888
10145
|
const token = await resolveGitHubToken();
|
|
9889
10146
|
if (!token)
|
|
9890
|
-
return { authed: false, repos: [] };
|
|
10147
|
+
return { authed: false, repos: [], reason: (await ghCliInstalled()) ? "gh-unauthed" : "no-token" };
|
|
9891
10148
|
const ghRes = await fetch("https://api.github.com/user/repos?sort=updated&per_page=100&affiliation=owner,collaborator,organization_member", {
|
|
9892
10149
|
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "user-agent": "bivy" },
|
|
9893
10150
|
});
|
|
@@ -9912,6 +10169,83 @@ async function listAccessibleRepos() {
|
|
|
9912
10169
|
return { authed: false, repos: [], error: error instanceof Error ? error.message : String(error) };
|
|
9913
10170
|
}
|
|
9914
10171
|
}
|
|
10172
|
+
let pendingGithubConnect = null;
|
|
10173
|
+
async function startGithubConnect() {
|
|
10174
|
+
const clientId = deviceFlowClientId();
|
|
10175
|
+
if (!clientId)
|
|
10176
|
+
return { status: "unconfigured" };
|
|
10177
|
+
try {
|
|
10178
|
+
const device = await requestDeviceCode(clientId, REPO_CONNECT_SCOPE);
|
|
10179
|
+
pendingGithubConnect = { clientId, device, expiresAt: Date.now() + device.expiresInSec * 1000 };
|
|
10180
|
+
return {
|
|
10181
|
+
status: "waiting",
|
|
10182
|
+
userCode: device.userCode,
|
|
10183
|
+
verificationUri: device.verificationUri,
|
|
10184
|
+
intervalMs: device.intervalSec * 1000,
|
|
10185
|
+
expiresInMs: device.expiresInSec * 1000,
|
|
10186
|
+
};
|
|
10187
|
+
}
|
|
10188
|
+
catch (error) {
|
|
10189
|
+
return { status: "error", error: error instanceof Error ? error.message : String(error) };
|
|
10190
|
+
}
|
|
10191
|
+
}
|
|
10192
|
+
async function pollGithubConnect() {
|
|
10193
|
+
const pending = pendingGithubConnect;
|
|
10194
|
+
if (!pending)
|
|
10195
|
+
return { status: "idle" };
|
|
10196
|
+
if (Date.now() > pending.expiresAt) {
|
|
10197
|
+
pendingGithubConnect = null;
|
|
10198
|
+
return { status: "expired" };
|
|
10199
|
+
}
|
|
10200
|
+
let poll;
|
|
10201
|
+
try {
|
|
10202
|
+
poll = await pollAccessTokenOnce(pending.clientId, pending.device.deviceCode);
|
|
10203
|
+
}
|
|
10204
|
+
catch (error) {
|
|
10205
|
+
// A transient network blip mid-flow — keep the code alive and let the client
|
|
10206
|
+
// poll again rather than discarding a device code the user may have authorized.
|
|
10207
|
+
return { status: "error", error: error instanceof Error ? error.message : String(error) };
|
|
10208
|
+
}
|
|
10209
|
+
switch (poll.status) {
|
|
10210
|
+
case "ok":
|
|
10211
|
+
pendingGithubConnect = null;
|
|
10212
|
+
persistConnectedGithubToken(poll.token);
|
|
10213
|
+
return { status: "connected" };
|
|
10214
|
+
case "slow_down":
|
|
10215
|
+
// GitHub says we're polling too fast — widen the interval it hands the
|
|
10216
|
+
// browser so the next poll backs off (and doesn't burn the device code).
|
|
10217
|
+
pending.device.intervalSec = poll.intervalSec ?? pending.device.intervalSec + 5;
|
|
10218
|
+
// falls through — same "keep waiting" answer, just a larger interval.
|
|
10219
|
+
case "pending":
|
|
10220
|
+
return {
|
|
10221
|
+
status: "waiting",
|
|
10222
|
+
userCode: pending.device.userCode,
|
|
10223
|
+
verificationUri: pending.device.verificationUri,
|
|
10224
|
+
intervalMs: pending.device.intervalSec * 1000,
|
|
10225
|
+
expiresInMs: Math.max(0, pending.expiresAt - Date.now()),
|
|
10226
|
+
};
|
|
10227
|
+
case "denied":
|
|
10228
|
+
pendingGithubConnect = null;
|
|
10229
|
+
return { status: "denied" };
|
|
10230
|
+
case "expired":
|
|
10231
|
+
pendingGithubConnect = null;
|
|
10232
|
+
return { status: "expired" };
|
|
10233
|
+
default:
|
|
10234
|
+
pendingGithubConnect = null;
|
|
10235
|
+
return { status: "error", error: poll.error };
|
|
10236
|
+
}
|
|
10237
|
+
}
|
|
10238
|
+
// Store the repo-scoped token exactly like `bivy github:connect`: the raw token
|
|
10239
|
+
// in the node's secret vault, and only a `secret://` reference in cli.json. But
|
|
10240
|
+
// ALSO update the LIVE process env so resolveGitHubToken() picks it up without a
|
|
10241
|
+
// restart (the Tier-1 caveat), and drop the repo-list cache so the very next
|
|
10242
|
+
// list is authed.
|
|
10243
|
+
function persistConnectedGithubToken(token) {
|
|
10244
|
+
new SecretVault(appDir).setLocal("github.repo-token", token, "GitHub repo/work-queue token");
|
|
10245
|
+
saveCliEnv({ BIVY_GITHUB_TOKEN: "secret://github.repo-token" });
|
|
10246
|
+
process.env.BIVY_GITHUB_TOKEN = "secret://github.repo-token";
|
|
10247
|
+
invalidateGithubListingCaches();
|
|
10248
|
+
}
|
|
9915
10249
|
// Fetch a repo's remote branch names with a given token (or none, for a public
|
|
9916
10250
|
// repo). One GitHub call; returns null on a non-OK response so the caller can
|
|
9917
10251
|
// decide whether to retry with a different token.
|
|
@@ -10335,6 +10669,13 @@ app.get("/github/app/manifest/callback", async (req, res, next) => {
|
|
|
10335
10669
|
app.get("/api/repos", async (_req, res) => {
|
|
10336
10670
|
res.json(await listAccessibleRepos());
|
|
10337
10671
|
});
|
|
10672
|
+
// Direct-transport (local PWA) equivalents of the github.connect.* commands.
|
|
10673
|
+
app.post("/api/github/connect/start", async (_req, res) => {
|
|
10674
|
+
res.json(await startGithubConnect());
|
|
10675
|
+
});
|
|
10676
|
+
app.get("/api/github/connect/poll", async (_req, res) => {
|
|
10677
|
+
res.json(await pollGithubConnect());
|
|
10678
|
+
});
|
|
10338
10679
|
app.get("/api/repos/branches", async (req, res) => {
|
|
10339
10680
|
res.json(await listRepoBranches(String(req.query.repo || "").trim()));
|
|
10340
10681
|
});
|
|
@@ -10655,6 +10996,14 @@ const server = app.listen(port, host, async () => {
|
|
|
10655
10996
|
// Recover interactive sessions a restart interrupted mid-turn (auto-continue, or
|
|
10656
10997
|
// flag for a one-tap manual Resume) per the node's sessionResumeMode setting.
|
|
10657
10998
|
void reconcileInterruptedSessions().catch((error) => console.warn("[resume] interrupted-session reconciliation failed", error));
|
|
10999
|
+
// Re-arm (or fire) durable auto-resume markers a limit-hit turn left behind,
|
|
11000
|
+
// so a session waiting out a usage/rate window still resumes after a restart.
|
|
11001
|
+
try {
|
|
11002
|
+
sessionResumeSweep();
|
|
11003
|
+
}
|
|
11004
|
+
catch (error) {
|
|
11005
|
+
console.warn("[resume] auto-resume sweep failed at boot", error);
|
|
11006
|
+
}
|
|
10658
11007
|
// Universal Agent Harness — network effect boundary (opt-in via
|
|
10659
11008
|
// BIVY_EGRESS_PROXY). Governs/logs outbound traffic of CLI agents, which
|
|
10660
11009
|
// inherit the proxy env from process.ts.
|