@bivy/bivy 0.7.0-staging.99 → 0.8.0-staging.110

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/bivy.mjs CHANGED
@@ -3343,9 +3343,11 @@ async function cmdSetup(args = []) {
3343
3343
  console.log(` ${agentReady ? c.green("✓") : c.yellow("!")} runtime ${agentReady ? `${setupAgent?.label || "Pi"} available` : "not installed — run 'bivy agents:install'"}`);
3344
3344
  console.log(` ${modelReady ? (setupAgent?.needsBivyModel ? c.green("✓") : c.dim("○")) : c.yellow("!")} model ${modelReady ? (setupAgent?.needsBivyModel ? "credential configured" : "agent-managed — verified by the first task") : "not configured — run 'bivy login'"}`);
3345
3345
  console.log(` ${c.dim("○")} repository chosen from the directory where you start Bivy`);
3346
+ const ghReady = githubConnected(finalConfig);
3347
+ console.log(` ${ghReady ? c.green("✓") : c.dim("○")} GitHub ${ghReady ? "connected — your repos will list in the app" : c.dim("not connected — 'bivy github:connect' to list repos (optional)")}`);
3346
3348
  console.log(` ${agentReady && modelReady ? c.green("✓") : c.yellow("!")} first task ${agentReady && modelReady ? "ready to try" : "blocked by the stage above"}`);
3347
3349
  console.log(` ${fs.existsSync(relayConfigPath) ? c.green("✓") : c.yellow("!")} remote ${fs.existsSync(relayConfigPath) ? "configured" : "not configured — run 'bivy relay:setup'"}\n`);
3348
- printFirstRunSteps(modelReady);
3350
+ printFirstRunSteps(modelReady, finalConfig);
3349
3351
  await finishSetupRemote(finalConfig, setupSession);
3350
3352
  }
3351
3353
 
@@ -3433,10 +3435,29 @@ async function openRemoteApp(config, { setupSession = null, open = true } = {})
3433
3435
  return { relay, remoteBase, accountUrl, pairedUrl, openUrl };
3434
3436
  }
3435
3437
 
3436
- function printFirstRunSteps(modelReady = false) {
3438
+ // Whether GitHub is connected for repo listing/cloning. Bivy's own connect flow
3439
+ // (`bivy github:connect`, or the app's Connect button) writes BIVY_GITHUB_TOKEN
3440
+ // — usually a `secret://` vault reference — into cli.json's env; an explicit env
3441
+ // token counts too. A `gh auth login` session also works at runtime (the node
3442
+ // falls back to `gh auth token`), but that can't be known without shelling out,
3443
+ // so it's treated as "not connected here" — the hint is optional either way.
3444
+ function githubConnected(config = null) {
3445
+ const token = String(
3446
+ config?.env?.BIVY_GITHUB_TOKEN || process.env.BIVY_GITHUB_TOKEN || process.env.GITHUB_TOKEN || "",
3447
+ ).trim();
3448
+ return Boolean(token);
3449
+ }
3450
+
3451
+ function printFirstRunSteps(modelReady = false, config = null) {
3437
3452
  console.log(" Run your first task:");
3438
- if (!modelReady) console.log(` 1. Model access: ${c.cyan("bivy login")} ${c.dim("(for Pi; other agents use their own login)")}`);
3439
- console.log(` ${modelReady ? "1" : "2"}. Start chatting: ${c.cyan("bivy")}`);
3453
+ let n = 0;
3454
+ if (!modelReady) console.log(` ${++n}. Model access: ${c.cyan("bivy login")} ${c.dim("(for Pi; other agents use their own login)")}`);
3455
+ // GitHub is optional — "No repo" sessions work without it — so this only shows
3456
+ // when nothing is connected yet, and never blocks the flow.
3457
+ if (!githubConnected(config)) {
3458
+ console.log(` ${++n}. GitHub ${c.dim("(optional)")}: ${c.cyan("bivy github:connect")} ${c.dim("— lets the app list your repos; required for private ones")}`);
3459
+ }
3460
+ console.log(` ${++n}. Start chatting: ${c.cyan("bivy")}`);
3440
3461
  console.log(` Starter task: ${c.cyan('bivy exec "explain this repository and identify one low-risk improvement"')}\n`);
3441
3462
  }
3442
3463
 
@@ -3614,6 +3635,17 @@ async function cmdDoctor(args = []) {
3614
3635
  console.log(c.bold("\n Bivy doctor\n"));
3615
3636
  console.log(` ${mark(hasSupportedNode())} Node ${process.version}${hasSupportedNode() ? "" : c.dim(" (needs >= 22.19.0)")}`);
3616
3637
  console.log(` ${mark(commandExists("git"), true)} git${commandExists("git") ? "" : c.dim(" (recommended for repo-backed sessions)")}`);
3638
+ // GitHub is optional (a "No repo" session needs none), so this only ever warns.
3639
+ // `gh` is NOT required — it's a token fallback; the primary path is Bivy's own
3640
+ // 'bivy github:connect' (or the app's Connect button). We surface gh only as an
3641
+ // available shortcut when it's installed but nothing is connected yet.
3642
+ const ghConnected = githubConnected(config);
3643
+ const ghHint = ghConnected
3644
+ ? c.green("connected")
3645
+ : commandExists("gh")
3646
+ ? c.dim("not connected — 'bivy github:connect' (or 'gh auth login')")
3647
+ : c.dim("not connected — 'bivy github:connect' to list/clone private repos");
3648
+ console.log(` ${mark(ghConnected, true)} GitHub ${ghHint}`);
3617
3649
  console.log(` ${mark(reachable)} node ${reachable ? c.green("reachable") : c.dim("not reachable — 'bivy start'")} at ${url(config)}`);
3618
3650
  console.log(` ${mark(/running/.test(serviceStatusLine()), true)} ${serviceStatusLine()}`);
3619
3651
  const defaultAgent = String(config.env?.BIVY_RUNTIME || runtimes?.current?.id || "pi");
@@ -67,6 +67,22 @@ export function interpretTokenResponse(data) {
67
67
  }
68
68
  }
69
69
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
70
+ /**
71
+ * A SINGLE access-token poll (no internal waiting) — for a caller that drives
72
+ * its own cadence. The web-driven connect flow uses this: the node holds the
73
+ * device code and the browser polls it on GitHub's interval, so the node never
74
+ * blocks a request thread in a poll loop. `pollForAccessToken` is the CLI's
75
+ * self-driving loop built on the same interpretation.
76
+ */
77
+ export async function pollAccessTokenOnce(clientId, deviceCode) {
78
+ const res = await fetch(ACCESS_TOKEN_URL, {
79
+ method: "POST",
80
+ headers: { accept: "application/json", "content-type": "application/json" },
81
+ body: JSON.stringify({ client_id: clientId, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" }),
82
+ });
83
+ const data = (await res.json().catch(() => ({})));
84
+ return interpretTokenResponse(data);
85
+ }
70
86
  /** Step 2: poll until the user authorizes (or the code expires). */
71
87
  export async function pollForAccessToken(clientId, device, signal) {
72
88
  let intervalMs = device.intervalSec * 1000;
package/dist/metadata.js CHANGED
@@ -182,6 +182,20 @@ export class MetadataStore {
182
182
  this.data.sessions[id] = { ...prev, resumeAt: next, updatedAt: nowIso() };
183
183
  this.save();
184
184
  }
185
+ /** Set the durable consecutive auto-resume counter (the restart-safe backstop
186
+ * for the in-memory reroute budget). Pass 0 to clear. No-op when the row is
187
+ * missing or already in the requested state, so a normal turn (counter already
188
+ * 0) never churns the file. */
189
+ setResumeAttempts(id, attempts) {
190
+ const prev = this.data.sessions[id];
191
+ if (!prev)
192
+ return;
193
+ const next = attempts > 0 ? attempts : undefined;
194
+ if ((prev.resumeAttempts ?? undefined) === next)
195
+ return;
196
+ this.data.sessions[id] = { ...prev, resumeAttempts: next, updatedAt: nowIso() };
197
+ this.save();
198
+ }
185
199
  /** Sessions with a durable auto-resume time set — the resume sweep re-arms
186
200
  * these after a restart. */
187
201
  sessionsWithResumeAt() {
@@ -92,8 +92,22 @@ export class SessionRerouteController {
92
92
  // long enough that it's clearly a limit rather than routine backoff.
93
93
  if (decision.resetsAt === undefined && decision.delayMs < MIN_RESUME_DELAY_MS)
94
94
  return null;
95
- const resumeAt = decision.resetsAt ?? new Date(now + decision.delayMs).toISOString();
96
- return { condition: decision.condition, summary: decision.summary, delayMs: Math.max(0, decision.delayMs), resumeAt };
95
+ // Resolve the due time (provider reset when known, else backoff) and floor it
96
+ // to at least MIN_RESUME_DELAY_MS in the FUTURE. A reset time can be in the
97
+ // past or ~now — a stale/elapsed reset, clock skew, or (most often) a window
98
+ // that already lapsed while the daemon was down — and using it verbatim yields
99
+ // a 0ms delay. The caller arms a timer at that delay, so a 0ms resume re-sends
100
+ // instantly, re-hits the still-standing limit, and re-schedules 0ms again: a
101
+ // tight loop that pins a CPU core and never settles. Flooring turns a
102
+ // not-yet-cleared limit into a slow retry the attempt budget can still park.
103
+ const rawDueMs = decision.resetsAt ? Date.parse(decision.resetsAt) : now + decision.delayMs;
104
+ const dueMs = Math.max(Number.isFinite(rawDueMs) ? rawDueMs : now, now + MIN_RESUME_DELAY_MS);
105
+ return {
106
+ condition: decision.condition,
107
+ summary: decision.summary,
108
+ delayMs: dueMs - now,
109
+ resumeAt: new Date(dueMs).toISOString(),
110
+ };
97
111
  }
98
112
  /** Advance the attempt budget once the caller has committed to a resume, so a
99
113
  * limit that re-fires after the reset counts toward `maxAttempts` and can
@@ -100,6 +100,25 @@ export async function resolveGitHubToken(env = process.env) {
100
100
  return undefined;
101
101
  }
102
102
  }
103
+ /**
104
+ * Whether the GitHub CLI (`gh`) is installed on this machine — used only to
105
+ * shade the "no GitHub token" message: when `gh` is present but `gh auth token`
106
+ * gave us nothing, the user is one `gh auth login` away, so the picker can say
107
+ * so. It never means `gh` is REQUIRED — `bivy github:connect` is the primary
108
+ * path and needs no CLI (see resolveGitHubToken). Mirrors the `command -v`
109
+ * probe in secrets.ts.
110
+ */
111
+ export async function ghCliInstalled() {
112
+ const which = process.platform === "win32" ? "where" : "command";
113
+ const args = process.platform === "win32" ? ["gh"] : ["-v", "gh"];
114
+ try {
115
+ await exec(which, args, process.platform === "win32" ? {} : { shell: true });
116
+ return true;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
103
122
  /**
104
123
  * Refresh the remote-tracking refs so a session branches off the CURRENT state
105
124
  * of `origin`, not whatever the local checkout last saw. Best-effort: an offline
@@ -116,14 +116,27 @@ function claudeCodeInfo() {
116
116
  },
117
117
  };
118
118
  }
119
+ // Memoized CLI probes. `commandAvailable`/`resolveCommandPath`/`probeHelpText`
120
+ // each shell out with a BLOCKING spawnSync, and the runtime catalog that calls
121
+ // them (cliAgentInfo → prefersAcp/acpSupportedByBinary) is rebuilt often — on
122
+ // every advertise and every runtimes.list send. Re-probing per build stalled the
123
+ // event loop for seconds at a time (a synchronous spawn storm). A CLI's presence
124
+ // is effectively constant for the daemon's run, so cache per command for the
125
+ // process lifetime and clear on install (invalidateCliProbeCache).
126
+ const COMMAND_AVAILABLE_CACHE = new Map();
119
127
  function commandAvailable(command) {
120
128
  if (!command.trim())
121
129
  return false;
130
+ const cached = COMMAND_AVAILABLE_CACHE.get(command);
131
+ if (cached !== undefined)
132
+ return cached;
122
133
  const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
123
134
  shell: process.platform !== "win32",
124
135
  stdio: "ignore",
125
136
  });
126
- return result.status === 0;
137
+ const available = result.status === 0;
138
+ COMMAND_AVAILABLE_CACHE.set(command, available);
139
+ return available;
127
140
  }
128
141
  function genericCliInfo() {
129
142
  const options = processRuntimeFromEnv();
@@ -869,17 +882,23 @@ function cliThinkingConfig(id) {
869
882
  * Used to key the help-probe cache: caching by the bare NAME would keep serving a
870
883
  * stale answer after the binary behind that name changed (a CLI upgraded or
871
884
  * installed while the daemon is running, or a different PATH entry winning).
885
+ * Memoized per command (see COMMAND_AVAILABLE_CACHE) — it spawnSyncs, and is hit
886
+ * on every catalog build.
872
887
  */
888
+ const COMMAND_PATH_CACHE = new Map();
873
889
  function resolveCommandPath(command) {
874
890
  if (!command.trim())
875
891
  return null;
892
+ const cached = COMMAND_PATH_CACHE.get(command);
893
+ if (cached !== undefined)
894
+ return cached;
876
895
  const res = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
877
896
  shell: process.platform !== "win32",
878
897
  encoding: "utf8",
879
898
  });
880
- if (res.status !== 0)
881
- return null;
882
- return (res.stdout ?? "").split(/\r?\n/)[0]?.trim() || null;
899
+ const resolved = res.status !== 0 ? null : ((res.stdout ?? "").split(/\r?\n/)[0]?.trim() || null);
900
+ COMMAND_PATH_CACHE.set(command, resolved);
901
+ return resolved;
883
902
  }
884
903
  const HELP_PROBE_CACHE = new Map();
885
904
  function probeHelpText(command) {
@@ -898,6 +917,19 @@ function probeHelpText(command) {
898
917
  HELP_PROBE_CACHE.set(key, text);
899
918
  return text;
900
919
  }
920
+ /**
921
+ * Drop every memoized CLI probe (availability, resolved path, --help text). These
922
+ * probes shell out with a blocking spawnSync and are cached for the process
923
+ * lifetime to keep the frequently-rebuilt runtime catalog off the event loop, so a
924
+ * CLI installed/updated mid-run wouldn't otherwise be noticed until a restart.
925
+ * Call this right after Bivy installs a runtime so the next catalog build re-probes
926
+ * and reflects the new binary immediately.
927
+ */
928
+ export function invalidateCliProbeCache() {
929
+ COMMAND_AVAILABLE_CACHE.clear();
930
+ COMMAND_PATH_CACHE.clear();
931
+ HELP_PROBE_CACHE.clear();
932
+ }
901
933
  // A resume template mixes launch flags (`-p`, `--force`) with the resume-specific
902
934
  // token(s) (`--resume`, `threads continue`, `-s`, `--restore`, …). Only the latter
903
935
  // evidence resume support, so we match on those — otherwise a shared launch flag
package/dist/secrets.js CHANGED
@@ -183,8 +183,12 @@ export class SecretVault {
183
183
  if (fs.existsSync(this.keyFile))
184
184
  checks.push({ name: "local key file permissions", ok: modeIsPrivate(this.keyFile), detail: this.keyFile });
185
185
  checks.push({ name: "1Password CLI", ok: await commandExists("op"), detail: "required for op:// references" });
186
- checks.push({ name: "GitHub CLI", ok: await commandExists("gh"), detail: "fallback for GitHub tokens when no Bivy secret is configured" });
187
- return { ok: checks.every((c) => c.ok || c.name === "1Password CLI" || c.name === "GitHub CLI"), checks };
186
+ checks.push({ name: "GitHub CLI (optional)", ok: await commandExists("gh"), detail: "optional token shortcut; Bivy connects GitHub itself via `bivy github:connect`" });
187
+ // 1Password and the GitHub CLI are optional shortcuts a missing one must
188
+ // not fail the vault's health (Bivy connects GitHub itself; op is only for
189
+ // op:// refs). Match on a stable prefix so renaming the label can't silently
190
+ // turn either back into a hard failure.
191
+ return { ok: checks.every((c) => c.ok || c.name.startsWith("1Password CLI") || c.name.startsWith("GitHub CLI")), checks };
188
192
  }
189
193
  key() {
190
194
  try {
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";
@@ -2824,6 +2825,15 @@ const RELAY_COMMANDS = {
2824
2825
  async "repos.list"() {
2825
2826
  relay?.sendEvent({ type: "repos.list", ...(await listAccessibleRepos()) });
2826
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
+ },
2827
2837
  // Branches for the repo the composer's repo pill just picked, so the branch
2828
2838
  // pill next to it can offer a specific remote branch to clone/base a new
2829
2839
  // session from instead of always the repo's default. See listRepoBranches.
@@ -3217,6 +3227,9 @@ const RELAY_COMMANDS = {
3217
3227
  const before = runtimeList().find((runtime) => runtime.id === spec.id);
3218
3228
  if (before?.status !== "available")
3219
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();
3220
3233
  const activeAgent = active?.runtimeId ?? defaultRuntimeId;
3221
3234
  const runtimes = runtimeList(activeAgent);
3222
3235
  relay?.sendEvent({ type: "runtime.install.done", id: spec.id, runtimes });
@@ -4345,6 +4358,12 @@ function startModelAuthWatcher() {
4345
4358
  let sessionAdvertiseTarget;
4346
4359
  let advertiseTimer;
4347
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;
4348
4367
  // How often the node re-affirms it's online to the control plane. Kept well
4349
4368
  // under the control plane's NODE_ONLINE_TTL_MS (90s) so a missed beat or two
4350
4369
  // doesn't flap a healthy node's status.
@@ -4575,11 +4594,17 @@ async function advertiseSessions() {
4575
4594
  : [];
4576
4595
  return {
4577
4596
  sessionId: s.id,
4578
- status: pendingApproval || failureAttention.length ? "needs_action" : (record ? (sessionBusy(record) ? "working" : "idle") : "saved"),
4579
- 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,
4580
4602
  source: record?.source || meta?.source,
4581
4603
  titleEnc: name ? relay.sealString(name) : undefined,
4582
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),
4583
4608
  agentServiceAddress,
4584
4609
  githubIssueUrl: record?.githubIssueUrl,
4585
4610
  prUrl: record?.prUrl,
@@ -4597,16 +4622,39 @@ async function advertiseSessions() {
4597
4622
  // best effort; the periodic resync and the next change will retry
4598
4623
  }
4599
4624
  }
4600
- /** 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. */
4601
4627
  function scheduleAdvertise() {
4602
- if (!sessionAdvertiseTarget || advertiseTimer)
4628
+ if (!sessionAdvertiseTarget)
4629
+ return;
4630
+ if (advertiseRunning) {
4631
+ advertiseAgain = true;
4632
+ return;
4633
+ }
4634
+ if (advertiseTimer)
4603
4635
  return;
4604
4636
  advertiseTimer = setTimeout(() => {
4605
4637
  advertiseTimer = undefined;
4606
- void advertiseSessions();
4638
+ void drainSessionAdverts();
4607
4639
  }, 1000);
4608
4640
  advertiseTimer.unref?.();
4609
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
+ }
4610
4658
  let githubPoller;
4611
4659
  /**
4612
4660
  * Serialize all work for one issue (keyed by its `issue:owner/repo#N` source).
@@ -6886,6 +6934,14 @@ const SESSION_RESUME_SWEEP_MS = 60_000;
6886
6934
  /** Slack around "due": a capped timer may fire a touch early — drive only when
6887
6935
  * within this of the target, else re-arm. */
6888
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;
6889
6945
  const sessionResumeTimers = new Map();
6890
6946
  // Fire due auto-resumes (a usage/rate limit that has since reset) and re-arm the
6891
6947
  // tail of long waits whose in-process timer was capped or lost to a restart.
@@ -7192,10 +7248,20 @@ function armSessionResumeTimer(id, dueMs) {
7192
7248
  timer.unref?.();
7193
7249
  sessionResumeTimers.set(id, timer);
7194
7250
  }
7195
- /** Persist + arm an auto-resume decided by the session policy. Synchronous so
7196
- * the caller can atomically suppress the turn's error toast. */
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. */
7197
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
+ }
7198
7263
  metadata.setResumeAt(record.id, plan.resumeAt);
7264
+ metadata.setResumeAttempts(record.id, attempts + 1);
7199
7265
  const when = Date.parse(plan.resumeAt);
7200
7266
  const cond = plan.condition.replace(/_/g, " ");
7201
7267
  broadcast({
@@ -7205,6 +7271,7 @@ function scheduleSessionResume(record, plan) {
7205
7271
  message: `Hit a ${cond} limit — I'll resume this automatically when it resets (${plan.resumeAt}).`,
7206
7272
  });
7207
7273
  armSessionResumeTimer(record.id, Number.isFinite(when) ? when : Date.now());
7274
+ return true;
7208
7275
  }
7209
7276
  /** Fire a due auto-resume: re-open the session if needed and re-send the turn's
7210
7277
  * last prompt. Clears the durable marker BEFORE driving so a crash mid-resume
@@ -7484,6 +7551,10 @@ function attachSessionListeners(record) {
7484
7551
  resetsAtHint: limitResetHint(record, Date.now()),
7485
7552
  }) ?? null
7486
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;
7487
7558
  if (reroutePlan) {
7488
7559
  void record.reroute.applyReroute(reroutePlan, {
7489
7560
  getCurrentModelName: () => record.session.getCurrentModel()?.name,
@@ -7493,12 +7564,14 @@ function attachSessionListeners(record) {
7493
7564
  },
7494
7565
  });
7495
7566
  }
7496
- else if (resumePlan) {
7567
+ else if (resumePlan && scheduleSessionResume(record, resumePlan)) {
7497
7568
  // Charge the attempt budget so a limit that re-fires after the reset can
7498
7569
  // eventually exhaust (→ surface) instead of looping, then park the turn
7499
- // as a scheduled resume rather than a dead error.
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.
7500
7573
  record.reroute.noteResumeApplied();
7501
- scheduleSessionResume(record, resumePlan);
7574
+ scheduledResume = true;
7502
7575
  }
7503
7576
  else if (messageError) {
7504
7577
  // Only the server-owned (pi-ai) path surfaces here; a Claude Code error
@@ -7527,6 +7600,11 @@ function attachSessionListeners(record) {
7527
7600
  body: `${sessionNotifyLabel(record)} finished — tap to review the result.`,
7528
7601
  });
7529
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);
7530
7608
  // First real commit on a repo-backed worktree → publish the branch to the
7531
7609
  // remote (sets upstream), so the work is visible on GitHub. No-op until
7532
7610
  // there's a commit, and only pushes once. Then adopt a PR the agent opened
@@ -10066,7 +10144,7 @@ async function listAccessibleRepos() {
10066
10144
  try {
10067
10145
  const token = await resolveGitHubToken();
10068
10146
  if (!token)
10069
- return { authed: false, repos: [] };
10147
+ return { authed: false, repos: [], reason: (await ghCliInstalled()) ? "gh-unauthed" : "no-token" };
10070
10148
  const ghRes = await fetch("https://api.github.com/user/repos?sort=updated&per_page=100&affiliation=owner,collaborator,organization_member", {
10071
10149
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "user-agent": "bivy" },
10072
10150
  });
@@ -10091,6 +10169,83 @@ async function listAccessibleRepos() {
10091
10169
  return { authed: false, repos: [], error: error instanceof Error ? error.message : String(error) };
10092
10170
  }
10093
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
+ }
10094
10249
  // Fetch a repo's remote branch names with a given token (or none, for a public
10095
10250
  // repo). One GitHub call; returns null on a non-OK response so the caller can
10096
10251
  // decide whether to retry with a different token.
@@ -10514,6 +10669,13 @@ app.get("/github/app/manifest/callback", async (req, res, next) => {
10514
10669
  app.get("/api/repos", async (_req, res) => {
10515
10670
  res.json(await listAccessibleRepos());
10516
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
+ });
10517
10679
  app.get("/api/repos/branches", async (req, res) => {
10518
10680
  res.json(await listRepoBranches(String(req.query.repo || "").trim()));
10519
10681
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.7.0-staging.99",
3
+ "version": "0.8.0-staging.110",
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.",