@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/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
- // In-session model reroute (docs/rulesets.md). Opt-in: set
505
- // BIVY_SESSION_MODEL_FALLBACK to a comma-separated model list and a session that
506
- // hits an exhausted-credits / rate-limit turn error swaps down the list (via the
507
- // runtime's live setModel) and retries, instead of surfacing the error. Absent =
508
- // inert, session behavior unchanged.
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
- const sessionRuleset = sessionModelFallbackRuleset();
533
- const sessionRunPolicy = sessionRuleset ? createRunPolicy({ ruleset: sessionRuleset, context: "session" }) : undefined;
534
- if (sessionRunPolicy) {
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
- status: pendingApproval || failureAttention.length ? "needs_action" : (record ? (sessionBusy(record) ? "working" : "idle") : "saved"),
4564
- needsAction: pendingApproval || failureAttention.length > 0,
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 POST. */
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 || advertiseTimer)
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 advertiseSessions();
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 model reroute controller (inert unless BIVY_SESSION_MODEL_FALLBACK
7188
- // is set). One per session; its per-turn budget resets on each user prompt.
7189
- if (sessionRunPolicy && !record.reroute) {
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
- const turnError = terminalTurnError(event);
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 (turnError) {
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: turnError });
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, turnError);
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",