@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 +36 -4
- package/dist/github-device-auth.js +16 -0
- package/dist/metadata.js +14 -0
- package/dist/policy/session-reroute.js +16 -2
- package/dist/repo-workspace.js +19 -0
- package/dist/runtime/index.js +36 -4
- package/dist/secrets.js +6 -2
- package/dist/server.js +175 -13
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
3439
|
-
console.log(` ${
|
|
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
|
-
|
|
96
|
-
|
|
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
|
package/dist/repo-workspace.js
CHANGED
|
@@ -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
|
package/dist/runtime/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
return
|
|
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: "
|
|
187
|
-
|
|
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
|
-
|
|
4579
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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