@bivy/bivy 0.7.0-staging.96 → 0.7.0-staging.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/acp-shim.mjs +128 -14
- package/bin/agent-manifest.json +3 -2
- package/dist/metadata.js +18 -0
- package/dist/policy/conditions.js +54 -3
- package/dist/policy/run-policy.js +2 -1
- package/dist/policy/session-reroute.js +52 -0
- package/dist/runtime/index.js +94 -17
- package/dist/runtime/protocol.js +15 -0
- package/dist/server.js +202 -15
- package/package.json +1 -1
package/bin/acp-shim.mjs
CHANGED
|
@@ -57,45 +57,112 @@ function bivy(obj) {
|
|
|
57
57
|
// --- ACP agent (child JSON-RPC over its stdio) ------------------------------
|
|
58
58
|
const agent = spawn(agentCmd, agentArgs, { stdio: ["pipe", "pipe", "pipe"] });
|
|
59
59
|
agent.stderr.on("data", (d) => process.stderr.write(`[acp-agent] ${d}`));
|
|
60
|
-
agent.on("error", (e) => bivy({ type: "session.error", error: `acp agent spawn failed: ${e.message}` }));
|
|
61
|
-
agent.on("exit", (code) => {
|
|
62
|
-
if (code && code !== 0) bivy({ type: "session.error", error: `acp agent exited (${code})` });
|
|
63
|
-
});
|
|
64
|
-
|
|
65
60
|
let nextId = 1;
|
|
66
61
|
const pending = new Map(); // jsonrpc id -> {resolve, reject}
|
|
67
|
-
|
|
62
|
+
let agentDead = null; // set to an Error once the child is gone
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The child is gone (spawn failure or exit). Every in-flight request must be
|
|
66
|
+
* rejected: without this, a CLI whose ACP mode doesn't exist leaves `initialize`
|
|
67
|
+
* pending forever and the daemon waits out its whole session.create timeout instead
|
|
68
|
+
* of surfacing the real reason. Fail fast, with the reason.
|
|
69
|
+
*/
|
|
70
|
+
function killPending(reason) {
|
|
71
|
+
if (agentDead) return;
|
|
72
|
+
agentDead = reason instanceof Error ? reason : new Error(String(reason));
|
|
73
|
+
bivy({ type: "session.error", error: agentDead.message });
|
|
74
|
+
for (const [id, p] of [...pending]) { pending.delete(id); p.reject(agentDead); }
|
|
75
|
+
}
|
|
76
|
+
agent.on("error", (e) => killPending(`acp agent spawn failed: ${e.message}`));
|
|
77
|
+
agent.on("exit", (code, signal) => {
|
|
78
|
+
if (code || signal) killPending(`acp agent exited (${code ?? signal})`);
|
|
79
|
+
});
|
|
80
|
+
// Writing to a dead child's stdin raises EPIPE; with no listener that's an uncaught
|
|
81
|
+
// exception that takes the shim down mid-turn instead of reporting the cause.
|
|
82
|
+
agent.stdin.on("error", (e) => killPending(`acp agent stdin closed: ${e.message}`));
|
|
83
|
+
|
|
84
|
+
function agentWrite(payload) {
|
|
85
|
+
if (agentDead) throw agentDead;
|
|
86
|
+
agent.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
87
|
+
}
|
|
88
|
+
function agentRequest(method, params, { timeoutMs } = {}) {
|
|
68
89
|
const id = nextId++;
|
|
69
|
-
|
|
70
|
-
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
let timer;
|
|
92
|
+
pending.set(id, {
|
|
93
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
94
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
95
|
+
});
|
|
96
|
+
if (timeoutMs) {
|
|
97
|
+
timer = setTimeout(() => {
|
|
98
|
+
if (pending.delete(id)) reject(new Error(`acp ${method} timed out after ${timeoutMs}ms`));
|
|
99
|
+
}, timeoutMs);
|
|
100
|
+
}
|
|
101
|
+
try { agentWrite({ jsonrpc: "2.0", id, method, params }); }
|
|
102
|
+
catch (e) { clearTimeout(timer); pending.delete(id); reject(e); }
|
|
103
|
+
});
|
|
71
104
|
}
|
|
72
105
|
function agentReply(id, result) {
|
|
73
|
-
|
|
106
|
+
try { agentWrite({ jsonrpc: "2.0", id, result }); } catch { /* child gone; killPending already reported it */ }
|
|
74
107
|
}
|
|
75
108
|
function agentReplyError(id, code, message) {
|
|
76
|
-
|
|
109
|
+
try { agentWrite({ jsonrpc: "2.0", id, error: { code, message } }); } catch { /* child gone */ }
|
|
77
110
|
}
|
|
78
111
|
function agentNotify(method, params) {
|
|
79
|
-
|
|
112
|
+
try { agentWrite({ jsonrpc: "2.0", method, params }); } catch { /* child gone */ }
|
|
80
113
|
}
|
|
81
114
|
|
|
82
115
|
// --- session state ----------------------------------------------------------
|
|
83
116
|
let sessionId = null;
|
|
84
117
|
let cwd = process.cwd();
|
|
85
118
|
let initialized = false;
|
|
119
|
+
// The ACP config-option id that selects the model (usually "model"), learned from
|
|
120
|
+
// session/new; used for the session/set_config_option fallback.
|
|
121
|
+
let modelConfigId = "model";
|
|
122
|
+
// A model chosen before the ACP session existed, applied once it does.
|
|
123
|
+
let pendingModel = null;
|
|
86
124
|
// toolCallId -> { requestId, options } so a later bivy tool.decision answers the
|
|
87
125
|
// right ACP permission request with a concrete optionId.
|
|
88
126
|
const permissionRequests = new Map();
|
|
89
127
|
|
|
90
128
|
async function ensureInitialized() {
|
|
91
129
|
if (initialized) return;
|
|
130
|
+
// Bounded: a binary that accepts the launch args but never speaks ACP would
|
|
131
|
+
// otherwise hang here until the daemon's own session timeout, hiding the cause.
|
|
92
132
|
await agentRequest("initialize", {
|
|
93
133
|
protocolVersion: 1,
|
|
94
134
|
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
|
95
|
-
});
|
|
135
|
+
}, { timeoutMs: 20_000 });
|
|
96
136
|
initialized = true;
|
|
97
137
|
}
|
|
98
138
|
|
|
139
|
+
/**
|
|
140
|
+
* ACP exposes a session's selectable models as a `select` config option on the
|
|
141
|
+
* session/new|load result (opencode: `configOptions: [{id:"model", currentValue,
|
|
142
|
+
* options:[{value,name}]}]`). Models are per-NODE — they depend on which providers
|
|
143
|
+
* the user has authenticated in the agent — so a hardcoded list would offer models
|
|
144
|
+
* the agent rejects. Publish what the agent actually reports, as a post-hello
|
|
145
|
+
* `runtime.models` event ProtocolRuntime folds into its picker.
|
|
146
|
+
*/
|
|
147
|
+
function publishModels(result) {
|
|
148
|
+
const options = Array.isArray(result?.configOptions) ? result.configOptions : [];
|
|
149
|
+
const modelOption = options.find((o) => String(o?.id ?? "") === "model" || String(o?.category ?? "") === "model");
|
|
150
|
+
const choices = Array.isArray(modelOption?.options) ? modelOption.options : [];
|
|
151
|
+
const models = choices
|
|
152
|
+
.map((o) => ({ id: String(o?.value ?? ""), name: String(o?.name ?? o?.value ?? "") }))
|
|
153
|
+
.filter((m) => m.id)
|
|
154
|
+
// ACP model ids are `provider/model`; split the provider so Bivy can group and
|
|
155
|
+
// scope provider-specific settings the same way it does for other runtimes.
|
|
156
|
+
.map((m) => ({ ...m, provider: m.id.includes("/") ? m.id.split("/")[0] : "agent" }));
|
|
157
|
+
if (!models.length) return;
|
|
158
|
+
modelConfigId = String(modelOption?.id ?? "model");
|
|
159
|
+
bivy({
|
|
160
|
+
type: "runtime.models",
|
|
161
|
+
models,
|
|
162
|
+
...(modelOption?.currentValue ? { currentModel: String(modelOption.currentValue) } : {}),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
99
166
|
// --- ACP → bivy: streamed session/update notifications ----------------------
|
|
100
167
|
function onSessionUpdate(params) {
|
|
101
168
|
const u = params?.update;
|
|
@@ -211,6 +278,33 @@ createInterface({ input: agent.stdout }).on("line", (line) => {
|
|
|
211
278
|
if (msg.method === "session/update") onSessionUpdate(msg.params);
|
|
212
279
|
});
|
|
213
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Select a model on the live ACP session. `session/set_model` is the direct form;
|
|
283
|
+
* agents that only expose the generic config-option surface take the same choice as
|
|
284
|
+
* `session/set_config_option`. A rejection propagates: ProtocolRuntime only commits
|
|
285
|
+
* the selection once we ack, so a model the agent won't accept must not look applied.
|
|
286
|
+
*/
|
|
287
|
+
async function setAgentModel(model) {
|
|
288
|
+
try {
|
|
289
|
+
await agentRequest("session/set_model", { sessionId, modelId: model }, { timeoutMs: 15_000 });
|
|
290
|
+
} catch (primary) {
|
|
291
|
+
try {
|
|
292
|
+
await agentRequest("session/set_config_option", { sessionId, configId: modelConfigId, value: model }, { timeoutMs: 15_000 });
|
|
293
|
+
} catch {
|
|
294
|
+
throw primary;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function applyPendingModel() {
|
|
300
|
+
if (!pendingModel || !sessionId) return;
|
|
301
|
+
const model = pendingModel;
|
|
302
|
+
pendingModel = null;
|
|
303
|
+
// Best-effort: a stale pick shouldn't block the session from opening.
|
|
304
|
+
try { await setAgentModel(model); }
|
|
305
|
+
catch (e) { bivy({ type: "runtime.debug", message: `acp set_model failed: ${e instanceof Error ? e.message : String(e)}` }); }
|
|
306
|
+
}
|
|
307
|
+
|
|
214
308
|
// --- bivy commands in (daemon → us) -----------------------------------------
|
|
215
309
|
async function onBivyCommand(msg) {
|
|
216
310
|
const type = String(msg.type || "");
|
|
@@ -224,6 +318,8 @@ async function onBivyCommand(msg) {
|
|
|
224
318
|
cwd = String(msg.cwd || msg.workspace || cwd);
|
|
225
319
|
const res = await agentRequest("session/new", { cwd, mcpServers: [] });
|
|
226
320
|
sessionId = res?.sessionId ?? res?.session?.id ?? null;
|
|
321
|
+
publishModels(res);
|
|
322
|
+
await applyPendingModel();
|
|
227
323
|
bivy({ replyTo: id, ok: true, runtimeSessionRef: sessionId });
|
|
228
324
|
return;
|
|
229
325
|
}
|
|
@@ -235,11 +331,14 @@ async function onBivyCommand(msg) {
|
|
|
235
331
|
try {
|
|
236
332
|
const res = await agentRequest("session/load", { sessionId: ref, cwd, mcpServers: [] });
|
|
237
333
|
sessionId = res?.sessionId ?? ref;
|
|
334
|
+
publishModels(res);
|
|
238
335
|
} catch {
|
|
239
336
|
// Agent doesn't support session/load — start fresh so the chat still opens.
|
|
240
337
|
const res = await agentRequest("session/new", { cwd, mcpServers: [] });
|
|
241
338
|
sessionId = res?.sessionId ?? null;
|
|
339
|
+
publishModels(res);
|
|
242
340
|
}
|
|
341
|
+
await applyPendingModel();
|
|
243
342
|
bivy({ replyTo: id, ok: true, runtimeSessionRef: sessionId });
|
|
244
343
|
return;
|
|
245
344
|
}
|
|
@@ -270,6 +369,19 @@ async function onBivyCommand(msg) {
|
|
|
270
369
|
}
|
|
271
370
|
return;
|
|
272
371
|
}
|
|
372
|
+
case "model.set": {
|
|
373
|
+
const model = String(msg.model ?? "").trim();
|
|
374
|
+
if (!model) { bivy({ replyTo: id, ok: true }); return; }
|
|
375
|
+
if (!sessionId) {
|
|
376
|
+
// Chosen before the session exists — remember and apply at session/new.
|
|
377
|
+
pendingModel = model;
|
|
378
|
+
bivy({ replyTo: id, ok: true });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
await setAgentModel(model);
|
|
382
|
+
bivy({ replyTo: id, ok: true });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
273
385
|
case "session.abort": {
|
|
274
386
|
if (sessionId) agentNotify("session/cancel", { sessionId });
|
|
275
387
|
if (id !== undefined) bivy({ replyTo: id, ok: true });
|
|
@@ -286,8 +398,10 @@ async function onBivyCommand(msg) {
|
|
|
286
398
|
}
|
|
287
399
|
|
|
288
400
|
// Announce capabilities: ACP agents are governed (per-tool permission) and
|
|
289
|
-
// resumable (session/load).
|
|
290
|
-
//
|
|
401
|
+
// resumable (session/load). modelSelection starts FALSE and is upgraded later by a
|
|
402
|
+
// `runtime.models` event if the session reports selectable models — the list is
|
|
403
|
+
// per-node (it depends on the providers the user has authenticated in the agent)
|
|
404
|
+
// and only arrives with session/new, so claiming a picker here would be a guess.
|
|
291
405
|
bivy({ type: "hello", runtime: { capabilities: { toolInterception: true, modelSelection: false, resume: true } } });
|
|
292
406
|
|
|
293
407
|
createInterface({ input: process.stdin }).on("line", (line) => {
|
package/bin/agent-manifest.json
CHANGED
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
"label": "OpenCode",
|
|
21
21
|
"command": "opencode",
|
|
22
22
|
"hidden": false,
|
|
23
|
-
"supportTier": "
|
|
24
|
-
"certification": "
|
|
23
|
+
"supportTier": "supported",
|
|
24
|
+
"certification": "release-tested",
|
|
25
|
+
"testedVersion": "1.18.13",
|
|
25
26
|
"headlessFlags": [
|
|
26
27
|
"run",
|
|
27
28
|
"-s"
|
package/dist/metadata.js
CHANGED
|
@@ -169,6 +169,24 @@ export class MetadataStore {
|
|
|
169
169
|
this.data.sessions[id] = { ...prev, resumePending: pending, updatedAt: nowIso() };
|
|
170
170
|
this.save();
|
|
171
171
|
}
|
|
172
|
+
/** Set/clear the durable auto-resume time (rate/usage-limit recovery). Pass
|
|
173
|
+
* null to clear. No-op when the row is missing or already in the requested
|
|
174
|
+
* state, so it never churns the file on the hot turn path. */
|
|
175
|
+
setResumeAt(id, resumeAt) {
|
|
176
|
+
const prev = this.data.sessions[id];
|
|
177
|
+
if (!prev)
|
|
178
|
+
return;
|
|
179
|
+
const next = resumeAt ?? undefined;
|
|
180
|
+
if ((prev.resumeAt ?? undefined) === next)
|
|
181
|
+
return;
|
|
182
|
+
this.data.sessions[id] = { ...prev, resumeAt: next, updatedAt: nowIso() };
|
|
183
|
+
this.save();
|
|
184
|
+
}
|
|
185
|
+
/** Sessions with a durable auto-resume time set — the resume sweep re-arms
|
|
186
|
+
* these after a restart. */
|
|
187
|
+
sessionsWithResumeAt() {
|
|
188
|
+
return Object.values(this.data.sessions).filter((s) => typeof s.resumeAt === "string" && s.resumeAt);
|
|
189
|
+
}
|
|
172
190
|
/** Look up a session's durable metadata by id, or by session-file path. */
|
|
173
191
|
getSession(idOrPath) {
|
|
174
192
|
if (!idOrPath)
|
|
@@ -50,6 +50,48 @@ export function parseResetsAt(raw) {
|
|
|
50
50
|
const iso = /(20\d\d-\d\d-\d\dT[\d:.]+(?:Z|[+-]\d\d:?\d\d))/.exec(raw);
|
|
51
51
|
return iso?.[1];
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Parse a bare wall-clock reset time — the shape Claude's subscription limits
|
|
55
|
+
* surface, e.g. `resets 12am (UTC)`, `resets at 3pm UTC`, `resets 09:00 UTC` —
|
|
56
|
+
* into the ISO timestamp of its NEXT occurrence (interpreted as UTC, which is
|
|
57
|
+
* what these messages state). Returns undefined when there's no clear clock
|
|
58
|
+
* time, so a relative phrase ("resets in 2 hours", handled by
|
|
59
|
+
* parseRetryAfterMs) or an unrelated number never masquerades as a reset.
|
|
60
|
+
*
|
|
61
|
+
* NB: a bare time-of-day can't say WHICH day, so for a multi-day window (a
|
|
62
|
+
* "weekly limit") this resolves to the nearest matching midnight, which may be
|
|
63
|
+
* earlier than the real reset. Prefer a structured resetsAtHint when available.
|
|
64
|
+
*/
|
|
65
|
+
export function parseResetClock(raw, nowMs) {
|
|
66
|
+
const m = /reset[a-z]*\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i.exec(raw);
|
|
67
|
+
if (!m)
|
|
68
|
+
return undefined;
|
|
69
|
+
const meridiem = m[3]?.toLowerCase();
|
|
70
|
+
// Require a real clock signal — a meridiem, an explicit minutes field, or a
|
|
71
|
+
// trailing UTC/GMT marker — so a bare "resets 5 nodes" can't parse as 05:00.
|
|
72
|
+
const tzFollows = /\b(?:utc|gmt)\b/i.test(raw.slice(m.index));
|
|
73
|
+
if (!meridiem && m[2] === undefined && !tzFollows)
|
|
74
|
+
return undefined;
|
|
75
|
+
let hour = Number(m[1]);
|
|
76
|
+
const minute = m[2] ? Number(m[2]) : 0;
|
|
77
|
+
if (hour > 23 || minute > 59)
|
|
78
|
+
return undefined;
|
|
79
|
+
if (meridiem === "am") {
|
|
80
|
+
if (hour === 12)
|
|
81
|
+
hour = 0;
|
|
82
|
+
}
|
|
83
|
+
else if (meridiem === "pm") {
|
|
84
|
+
if (hour !== 12)
|
|
85
|
+
hour += 12;
|
|
86
|
+
}
|
|
87
|
+
if (hour > 23)
|
|
88
|
+
return undefined;
|
|
89
|
+
const now = new Date(nowMs);
|
|
90
|
+
let target = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hour, minute, 0, 0);
|
|
91
|
+
if (target <= nowMs)
|
|
92
|
+
target += 86_400_000; // already past today → next day's occurrence
|
|
93
|
+
return new Date(target).toISOString();
|
|
94
|
+
}
|
|
53
95
|
// Ordered classifiers: the FIRST match wins, so more-specific/actionable
|
|
54
96
|
// conditions are tested before broader ones (auth 401 before generic HTTP
|
|
55
97
|
// noise; explicit billing/quota before a bare rate-limit; context-window before
|
|
@@ -58,7 +100,12 @@ const CLASSIFIERS = [
|
|
|
58
100
|
{ condition: "auth_failed", test: (r) => isAnthropicAuthError(r) },
|
|
59
101
|
{
|
|
60
102
|
condition: "credits_exhausted",
|
|
61
|
-
|
|
103
|
+
// Includes subscription usage caps — a Claude "5-hour" or "weekly" window
|
|
104
|
+
// hit reads "you've hit your weekly limit · resets 12am (UTC)". The window
|
|
105
|
+
// qualifier (weekly/daily/5-hour/7-day) is optional and may sit between
|
|
106
|
+
// "your" and "limit", so it must not break the match (it did before — the
|
|
107
|
+
// word "weekly" left these limits classified "unknown" and never resumed).
|
|
108
|
+
test: (r) => /\b402\b|payment required|insufficient\s+(?:credit|quota|balance|funds)|credit balance (?:is )?too low|quota (?:exceeded|exhausted)|billing|(?:usage|session|weekly|daily|monthly|5[\s-]?hour|7[\s-]?day)[\s-]?limit(?:\s+(?:reached|hit))?|(?:you(?:'ve| have)\s+)?hit your (?:(?:weekly|daily|monthly|session|usage|5[\s-]?hour|7[\s-]?day)\s+)?limit|out of credits|plan (?:limit|allowance)/i.test(r),
|
|
62
109
|
},
|
|
63
110
|
{
|
|
64
111
|
condition: "rate_limited",
|
|
@@ -86,7 +133,7 @@ const CLASSIFIERS = [
|
|
|
86
133
|
* recovery metadata. Unmatched failures are `"unknown"` — deliberately left for
|
|
87
134
|
* a human rather than blindly retried.
|
|
88
135
|
*/
|
|
89
|
-
export function classifyFailure(error) {
|
|
136
|
+
export function classifyFailure(error, opts = {}) {
|
|
90
137
|
const raw = rawText(error).slice(0, 2000);
|
|
91
138
|
const condition = CLASSIFIERS.find((c) => c.test(raw))?.condition ?? "unknown";
|
|
92
139
|
const out = { condition, raw };
|
|
@@ -95,7 +142,11 @@ export function classifyFailure(error) {
|
|
|
95
142
|
const retryAfterMs = parseRetryAfterMs(raw);
|
|
96
143
|
if (retryAfterMs !== undefined)
|
|
97
144
|
out.retryAfterMs = retryAfterMs;
|
|
98
|
-
|
|
145
|
+
// Reset time, most-authoritative first: a structured hint the caller
|
|
146
|
+
// supplied (the provider's own usage snapshot), then an ISO stamp in the
|
|
147
|
+
// text, then a bare wall-clock ("resets 12am (UTC)") resolved to its next
|
|
148
|
+
// occurrence.
|
|
149
|
+
const resetsAt = opts.resetsAtHint ?? parseResetsAt(raw) ?? parseResetClock(raw, opts.now ?? Date.now());
|
|
99
150
|
if (resetsAt !== undefined)
|
|
100
151
|
out.resetsAt = resetsAt;
|
|
101
152
|
}
|
|
@@ -34,7 +34,7 @@ export function createRunPolicy(deps = {}) {
|
|
|
34
34
|
const now = deps.now ?? Date.now;
|
|
35
35
|
return {
|
|
36
36
|
decide(ctx) {
|
|
37
|
-
const classified = classifyFailure(ctx.error);
|
|
37
|
+
const classified = classifyFailure(ctx.error, { now: now(), resetsAtHint: ctx.resetsAtHint });
|
|
38
38
|
const { condition } = classified;
|
|
39
39
|
const rule = findRule(ruleset, condition, context);
|
|
40
40
|
if (!rule)
|
|
@@ -73,6 +73,7 @@ export function createRunPolicy(deps = {}) {
|
|
|
73
73
|
delayMs,
|
|
74
74
|
condition,
|
|
75
75
|
summary: `${condition}: transient — retrying (attempt ${nextAttempt}/${rule.maxAttempts})${timing}.`,
|
|
76
|
+
...(resetDelayMs !== undefined && classified.resetsAt ? { resetsAt: classified.resetsAt } : {}),
|
|
76
77
|
};
|
|
77
78
|
}
|
|
78
79
|
// action === "reroute": walk the chain from the current cursor, skipping
|
|
@@ -21,6 +21,17 @@
|
|
|
21
21
|
// whether to suppress the turn's error toast before kicking off the async swap +
|
|
22
22
|
// retry (`applyReroute`). Reroute happens only at the turn boundary, so there is
|
|
23
23
|
// no partial-work hazard.
|
|
24
|
+
//
|
|
25
|
+
// It also plans the OTHER in-place recovery a live session can do: waiting out a
|
|
26
|
+
// provider usage/rate limit and re-sending the same prompt when the window
|
|
27
|
+
// resets (`planResume`). Unlike a reroute (which the controller applies itself),
|
|
28
|
+
// a resume can be hours away and must survive a daemon restart, so scheduling +
|
|
29
|
+
// persistence live in the caller (src/server.ts) — the controller only decides
|
|
30
|
+
// whether a resume is warranted and by when.
|
|
31
|
+
/** Below this, a "retry" is ordinary backoff (seconds) — not worth deferring an
|
|
32
|
+
* interactive turn for; let it surface. A real usage/rate window reset is
|
|
33
|
+
* minutes-to-days out and always clears this bar. */
|
|
34
|
+
const MIN_RESUME_DELAY_MS = 60_000;
|
|
24
35
|
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
25
36
|
export class SessionRerouteController {
|
|
26
37
|
deps;
|
|
@@ -50,6 +61,47 @@ export class SessionRerouteController {
|
|
|
50
61
|
attempt: this.attempt,
|
|
51
62
|
rerouteCount: this.rerouteCount,
|
|
52
63
|
});
|
|
64
|
+
if (decision.action !== "reroute")
|
|
65
|
+
return null;
|
|
66
|
+
return this.rerouteFrom(decision, currentModel);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Decide whether this turn error should be recovered by WAITING for a provider
|
|
70
|
+
* usage/rate limit to reset and re-sending the same prompt. Returns a plan the
|
|
71
|
+
* caller should persist + schedule, or null (surface the error as usual).
|
|
72
|
+
*
|
|
73
|
+
* `resetsAtHint` is the authoritative reset time when the caller has one (the
|
|
74
|
+
* provider's structured usage snapshot) — essential for a multi-day "weekly"
|
|
75
|
+
* window, whose error text only states a time-of-day. `now` is injectable for
|
|
76
|
+
* deterministic tests. Pure w.r.t. the controller's counters.
|
|
77
|
+
*/
|
|
78
|
+
planResume(rawError, currentModel, opts = {}) {
|
|
79
|
+
if (this.applying)
|
|
80
|
+
return null;
|
|
81
|
+
const now = opts.now ?? Date.now();
|
|
82
|
+
const decision = this.deps.policy.decide({
|
|
83
|
+
routing: { model: currentModel },
|
|
84
|
+
error: rawError,
|
|
85
|
+
attempt: this.attempt,
|
|
86
|
+
rerouteCount: this.rerouteCount,
|
|
87
|
+
resetsAtHint: opts.resetsAtHint,
|
|
88
|
+
});
|
|
89
|
+
if (decision.action !== "retry")
|
|
90
|
+
return null;
|
|
91
|
+
// Only defer for a concrete recovery window — a provider reset, or a delay
|
|
92
|
+
// long enough that it's clearly a limit rather than routine backoff.
|
|
93
|
+
if (decision.resetsAt === undefined && decision.delayMs < MIN_RESUME_DELAY_MS)
|
|
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 };
|
|
97
|
+
}
|
|
98
|
+
/** Advance the attempt budget once the caller has committed to a resume, so a
|
|
99
|
+
* limit that re-fires after the reset counts toward `maxAttempts` and can
|
|
100
|
+
* eventually exhaust (→ park) instead of looping forever. */
|
|
101
|
+
noteResumeApplied() {
|
|
102
|
+
this.attempt += 1;
|
|
103
|
+
}
|
|
104
|
+
rerouteFrom(decision, currentModel) {
|
|
53
105
|
if (decision.action !== "reroute")
|
|
54
106
|
return null;
|
|
55
107
|
const model = decision.routing.model;
|
package/dist/runtime/index.js
CHANGED
|
@@ -179,7 +179,11 @@ const CLI_AGENT_SPECS = {
|
|
|
179
179
|
// reply to stdout (the TUI needs a real TTY and would hang over a pipe).
|
|
180
180
|
args: ["run"],
|
|
181
181
|
promptMode: "argv",
|
|
182
|
-
|
|
182
|
+
// Supported tier: OpenCode runs on the governed ACP path by default (per-tool
|
|
183
|
+
// Approve/Deny + session/load resume + a real model picker), the same bar Pi,
|
|
184
|
+
// Claude Code, and Codex clear. See `acp` below for the version fallback.
|
|
185
|
+
supportTier: "supported",
|
|
186
|
+
testedVersion: "1.18.13",
|
|
183
187
|
blurb: "The most widely used open-source coding harness (OpenCode CLI).",
|
|
184
188
|
// `opencode run -s <id> "<prompt>"` continues a prior session by its own id
|
|
185
189
|
// (`-s, --session session id to continue`, per `opencode run --help`).
|
|
@@ -195,11 +199,14 @@ const CLI_AGENT_SPECS = {
|
|
|
195
199
|
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro", provider: "google" },
|
|
196
200
|
],
|
|
197
201
|
},
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
202
|
+
// `opencode acp` ("start ACP (Agent Client Protocol) server") drives OpenCode
|
|
203
|
+
// through the governed ProtocolRuntime instead of the one-shot pipe: per-tool
|
|
204
|
+
// Approve/Deny, streaming, `session/load` resume, and `session/set_model`.
|
|
205
|
+
// Validated against opencode 1.18.13, so it is ON by default (`preferred`) —
|
|
206
|
+
// gated on the binary actually listing the `acp` subcommand, so an older
|
|
207
|
+
// OpenCode falls back to the pipe path rather than opening a dead session.
|
|
208
|
+
// Force the pipe path back with BIVY_OPENCODE_ACP=0.
|
|
209
|
+
acp: { args: ["acp"], helpToken: "acp", preferred: true },
|
|
203
210
|
install: { kind: "npm", pkg: "opencode-ai" },
|
|
204
211
|
},
|
|
205
212
|
aider: {
|
|
@@ -857,10 +864,28 @@ function cliThinkingConfig(id) {
|
|
|
857
864
|
// installed binary doesn't actually mention. It never UPGRADES — adding a
|
|
858
865
|
// capability needs the exact arg template, which help text can't safely supply — so
|
|
859
866
|
// probing can only make the catalog MORE honest, never invent a no-op control.
|
|
867
|
+
/**
|
|
868
|
+
* Absolute path of a command on the current PATH, or null when it isn't there.
|
|
869
|
+
* Used to key the help-probe cache: caching by the bare NAME would keep serving a
|
|
870
|
+
* stale answer after the binary behind that name changed (a CLI upgraded or
|
|
871
|
+
* installed while the daemon is running, or a different PATH entry winning).
|
|
872
|
+
*/
|
|
873
|
+
function resolveCommandPath(command) {
|
|
874
|
+
if (!command.trim())
|
|
875
|
+
return null;
|
|
876
|
+
const res = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
|
|
877
|
+
shell: process.platform !== "win32",
|
|
878
|
+
encoding: "utf8",
|
|
879
|
+
});
|
|
880
|
+
if (res.status !== 0)
|
|
881
|
+
return null;
|
|
882
|
+
return (res.stdout ?? "").split(/\r?\n/)[0]?.trim() || null;
|
|
883
|
+
}
|
|
860
884
|
const HELP_PROBE_CACHE = new Map();
|
|
861
885
|
function probeHelpText(command) {
|
|
862
|
-
|
|
863
|
-
|
|
886
|
+
const key = resolveCommandPath(command) ?? command;
|
|
887
|
+
if (HELP_PROBE_CACHE.has(key))
|
|
888
|
+
return HELP_PROBE_CACHE.get(key) ?? null;
|
|
864
889
|
let text = null;
|
|
865
890
|
try {
|
|
866
891
|
const res = spawnSync(command, ["--help"], { encoding: "utf8", timeout: 4000 });
|
|
@@ -870,7 +895,7 @@ function probeHelpText(command) {
|
|
|
870
895
|
catch {
|
|
871
896
|
text = null;
|
|
872
897
|
}
|
|
873
|
-
HELP_PROBE_CACHE.set(
|
|
898
|
+
HELP_PROBE_CACHE.set(key, text);
|
|
874
899
|
return text;
|
|
875
900
|
}
|
|
876
901
|
// A resume template mixes launch flags (`-p`, `--force`) with the resume-specific
|
|
@@ -963,9 +988,14 @@ function cliAgentInfo(id) {
|
|
|
963
988
|
// src/harness/mcp-inject.ts + governMcpCall in src/server.ts.
|
|
964
989
|
capabilities: { toolInterception: acpActive, mcpToolApprovals: acpActive || Boolean(process.env.BIVY_MCP_PROXY), modelSelection, resume, packages: false, fork: false, usageReporting, sessionDiscovery: id === "codex" },
|
|
965
990
|
supportTier: spec.supportTier ?? (id === "codex" ? "supported" : "experimental"),
|
|
991
|
+
testedVersion: spec.testedVersion,
|
|
966
992
|
authOwner: spec.authOwner ?? "agent",
|
|
967
993
|
notes: installed
|
|
968
|
-
?
|
|
994
|
+
? acpActive
|
|
995
|
+
// Promoted to ACP: the description must match the governed path actually in
|
|
996
|
+
// use, not the pipe path this agent would otherwise take.
|
|
997
|
+
? `Available on PATH, driven through its Agent Client Protocol server (\`${spec.command} ${spec.acp?.args.join(" ")}\`): each tool call is gated by Bivy's Approve/Deny before it runs, and sessions resume natively. Force the plain stdout pipe with BIVY_${id.toUpperCase()}_ACP=0.`
|
|
998
|
+
: `Available on PATH. This process adapter ${spec.parserId && !spec.parserUnverified ? "parses its native JSON stream into a structured transcript" : spec.parserId ? "streams stdout/stderr (a structured JSON parser is available; opt in with BIVY_AGENT_STRUCTURED=1 once validated for your version)" : "streams stdout/stderr"}; Bivy governs its filesystem/exec/MCP effects at the sandbox tier rather than intercepting each tool call. Override its launch flags with BIVY_${id.toUpperCase()}_ARGS if your CLI version differs.`
|
|
969
999
|
: `${spec.command} was not found on PATH. Install it on this node, then select this agent again.`,
|
|
970
1000
|
install: installed || !installCommand ? undefined : {
|
|
971
1001
|
label: `Install ${spec.displayName}`,
|
|
@@ -980,6 +1010,13 @@ function cliAgentInfo(id) {
|
|
|
980
1010
|
// Approve/Deny card via guardianInterceptor, AND it resumes a prior thread by its
|
|
981
1011
|
// rollout id (thread/resume). Governed + resumable in one runtime supersedes the
|
|
982
1012
|
// exec path, which stays runnable via `BIVY_RUNTIME=codex` for a no-approval flow.
|
|
1013
|
+
/**
|
|
1014
|
+
* The Codex CLI release this adapter was last certified against. Unlike Pi and the
|
|
1015
|
+
* Claude Agent SDK, Codex is an external binary rather than a pinned npm dependency,
|
|
1016
|
+
* so there is no lockfile entry to derive this from — it is bumped deliberately when
|
|
1017
|
+
* the app-server shim is re-validated against a new Codex release.
|
|
1018
|
+
*/
|
|
1019
|
+
const CODEX_TESTED_VERSION = "0.145.0";
|
|
983
1020
|
function codexApprovalsInfo() {
|
|
984
1021
|
const installed = commandAvailable("codex");
|
|
985
1022
|
return {
|
|
@@ -1009,7 +1046,12 @@ function codexApprovalsInfo() {
|
|
|
1009
1046
|
nativeSessionDiscovery: true,
|
|
1010
1047
|
nativeSessionAdoption: true,
|
|
1011
1048
|
},
|
|
1012
|
-
|
|
1049
|
+
// Supported tier: the app-server shim already clears the same bar as Pi and
|
|
1050
|
+
// Claude Code — per-tool Approve/Deny, model selection, thread resume, usage
|
|
1051
|
+
// reporting, and native session discovery/adoption — all over a bidirectional
|
|
1052
|
+
// protocol rather than a one-shot pipe.
|
|
1053
|
+
supportTier: "supported",
|
|
1054
|
+
testedVersion: CODEX_TESTED_VERSION,
|
|
1013
1055
|
authOwner: "agent",
|
|
1014
1056
|
notes: installed
|
|
1015
1057
|
? "Drives Codex's experimental app-server so tool calls surface as in-chat approval cards, and resumes a prior thread by its rollout id (thread/resume). Governance AND resume in one runtime."
|
|
@@ -1202,15 +1244,50 @@ function acpRuntimeFromEnv(credsDir) {
|
|
|
1202
1244
|
return acpRuntimeOptions({ id: "acp", displayName: process.env.BIVY_ACP_NAME?.trim() || "ACP Agent", command, agentArgs, credsDir });
|
|
1203
1245
|
}
|
|
1204
1246
|
/**
|
|
1205
|
-
*
|
|
1206
|
-
*
|
|
1207
|
-
*
|
|
1208
|
-
*
|
|
1247
|
+
* Does the INSTALLED binary actually evidence the agent's ACP mode? A default-on
|
|
1248
|
+
* promotion must never be taken on faith: ACP is a hard switch (the pipe path is
|
|
1249
|
+
* unreachable once a session opens), so a CLI too old to have the subcommand would
|
|
1250
|
+
* otherwise hang and die instead of degrading. We reuse the same cached `--help`
|
|
1251
|
+
* probe the opt-in capability refinement uses, and fail CLOSED — a missing binary
|
|
1252
|
+
* or unreadable help keeps the agent on the honest pipe path.
|
|
1253
|
+
*/
|
|
1254
|
+
function acpSupportedByBinary(id) {
|
|
1255
|
+
const spec = CLI_AGENT_SPECS[id];
|
|
1256
|
+
if (!spec.acp)
|
|
1257
|
+
return false;
|
|
1258
|
+
if (!commandAvailable(spec.command))
|
|
1259
|
+
return false;
|
|
1260
|
+
const help = probeHelpText(spec.command);
|
|
1261
|
+
if (!help)
|
|
1262
|
+
return false;
|
|
1263
|
+
const token = (spec.acp.helpToken ?? spec.acp.args[0] ?? "acp").toLowerCase();
|
|
1264
|
+
return help.includes(token);
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Whether a CLI agent should be driven through ACP rather than the one-shot pipe.
|
|
1268
|
+
* Three ways in, in precedence order:
|
|
1269
|
+
* - `BIVY_<ID>_ACP=0` — operator forces the pipe path back (escape hatch).
|
|
1270
|
+
* - `BIVY_<ID>_ACP=1` / `BIVY_PREFER_ACP=1` — operator forces ACP, no probe (they
|
|
1271
|
+
* know their binary; an explicit request shouldn't be second-guessed).
|
|
1272
|
+
* - `spec.acp.preferred` — validated agents are promoted by DEFAULT, but only
|
|
1273
|
+
* when the installed binary evidences the ACP mode (see acpSupportedByBinary).
|
|
1274
|
+
* Still no per-agent code: a spec field plus a flag.
|
|
1275
|
+
*
|
|
1276
|
+
* Both the catalog (cliAgentInfo) and the launch path (makeCliRuntime) call this,
|
|
1277
|
+
* so what the picker advertises and what actually starts cannot disagree.
|
|
1209
1278
|
*/
|
|
1210
1279
|
function prefersAcp(id) {
|
|
1211
|
-
|
|
1280
|
+
const spec = CLI_AGENT_SPECS[id];
|
|
1281
|
+
if (!spec.acp)
|
|
1282
|
+
return false;
|
|
1283
|
+
const override = process.env[`BIVY_${id.toUpperCase()}_ACP`];
|
|
1284
|
+
if (override === "0")
|
|
1285
|
+
return false;
|
|
1286
|
+
if (override === "1" || process.env.BIVY_PREFER_ACP === "1")
|
|
1287
|
+
return true;
|
|
1288
|
+
if (!spec.acp.preferred)
|
|
1212
1289
|
return false;
|
|
1213
|
-
return
|
|
1290
|
+
return acpSupportedByBinary(id);
|
|
1214
1291
|
}
|
|
1215
1292
|
/**
|
|
1216
1293
|
* Resolve the communication mode for a CLI agent. This is deliberately pure so
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -410,6 +410,21 @@ class ProtocolSession {
|
|
|
410
410
|
this.runtimeSessionRef = msg.runtimeSessionRef;
|
|
411
411
|
return;
|
|
412
412
|
}
|
|
413
|
+
// Late-arriving model registry. A shim that knows its models up front puts them
|
|
414
|
+
// in `hello`; one whose list is only knowable per session — an ACP agent's
|
|
415
|
+
// models depend on which providers the user has authenticated, and arrive with
|
|
416
|
+
// session/new — publishes them here instead. Same contract as the hello path: a
|
|
417
|
+
// picker backed by a real `model.set` the shim answers, never a claimed one.
|
|
418
|
+
if (type === "runtime.models") {
|
|
419
|
+
const models = parseModels(msg.models);
|
|
420
|
+
if (models.length) {
|
|
421
|
+
this.models = models;
|
|
422
|
+
this.capabilitiesRef.modelSelection = true;
|
|
423
|
+
if (typeof msg.currentModel === "string")
|
|
424
|
+
this.currentModelId = msg.currentModel;
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
413
428
|
if (type === "message.delta") {
|
|
414
429
|
const text = String(msg.text ?? "");
|
|
415
430
|
if (!this.assistantText)
|
package/dist/server.js
CHANGED
|
@@ -501,11 +501,11 @@ const terminals = new TerminalManager();
|
|
|
501
501
|
// and fixed for that session's life; switching agents in the UI starts a new one.
|
|
502
502
|
let defaultRuntimeId = (process.env.BIVY_RUNTIME ?? "pi").toLowerCase();
|
|
503
503
|
const runtimeHost = new RuntimeHost({ credsDir, piDir, sessionsDir, attachToChat: attachToChatForSession });
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
// hits an exhausted-credits / rate-limit turn error swaps down the
|
|
507
|
-
// runtime's live setModel) and retries
|
|
508
|
-
//
|
|
504
|
+
// A built-in in-session model-fallback ruleset from BIVY_SESSION_MODEL_FALLBACK
|
|
505
|
+
// (docs/rulesets.md). Opt-in: set it to a comma-separated model list and a
|
|
506
|
+
// session that hits an exhausted-credits / rate-limit turn error swaps down the
|
|
507
|
+
// list (via the runtime's live setModel) and retries. Used only when the user
|
|
508
|
+
// hasn't authored their own session-scoped ruleset in the UI.
|
|
509
509
|
function sessionModelFallbackRuleset() {
|
|
510
510
|
const models = (process.env.BIVY_SESSION_MODEL_FALLBACK ?? "")
|
|
511
511
|
.split(",")
|
|
@@ -529,9 +529,21 @@ function sessionModelFallbackRuleset() {
|
|
|
529
529
|
],
|
|
530
530
|
};
|
|
531
531
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
532
|
+
/** The ruleset in-session recovery runs under right now: the user's active
|
|
533
|
+
* ruleset if it applies to sessions, else the env model-fallback ruleset, else
|
|
534
|
+
* undefined (→ built-in DEFAULT_RULESET). Read lazily on each turn error so UI
|
|
535
|
+
* edits take effect without a restart, mirroring activeQueueRuleset. */
|
|
536
|
+
function activeSessionRuleset() {
|
|
537
|
+
return activeRulesetFor(rulesetsDir, "session") ?? sessionModelFallbackRuleset();
|
|
538
|
+
}
|
|
539
|
+
// The in-session recovery effector's policy. Always available: an interactive
|
|
540
|
+
// session can wait out a provider usage/rate limit and resume when it resets
|
|
541
|
+
// (planResume), or swap models down a fallback chain (planReroute). Thin wrapper
|
|
542
|
+
// so a freshly-saved active ruleset is picked up on the next turn error.
|
|
543
|
+
const sessionRunPolicy = {
|
|
544
|
+
decide: (ctx) => createRunPolicy({ context: "session", ruleset: activeSessionRuleset() }).decide(ctx),
|
|
545
|
+
};
|
|
546
|
+
if (process.env.BIVY_SESSION_MODEL_FALLBACK) {
|
|
535
547
|
console.log(`[policy] in-session model reroute enabled: ${process.env.BIVY_SESSION_MODEL_FALLBACK}`);
|
|
536
548
|
}
|
|
537
549
|
let lastUpdateCheckAt = 0;
|
|
@@ -3542,6 +3554,9 @@ const RELAY_COMMANDS = {
|
|
|
3542
3554
|
record.lastPrompt = agentPrompt;
|
|
3543
3555
|
record.lastPromptOptions = promptOptionsFor(record, msg.streamingBehavior, images);
|
|
3544
3556
|
record.reroute?.beginTurn();
|
|
3557
|
+
// The user is driving this turn manually — supersede any pending auto-resume
|
|
3558
|
+
// that was scheduled after a prior limit so it can't re-fire on top of them.
|
|
3559
|
+
clearSessionResume(record.id);
|
|
3545
3560
|
await promptWithWatchdog(record, agentPrompt, record.lastPromptOptions);
|
|
3546
3561
|
}).catch((error) => {
|
|
3547
3562
|
// Mirror the HTTP path (see the /prompt route): a rejected turn after
|
|
@@ -6862,6 +6877,20 @@ const idleCloseTimer = setInterval(() => { closeIdleSessions(); pruneGhostSessio
|
|
|
6862
6877
|
idleCloseTimer.unref?.();
|
|
6863
6878
|
const worktreeCleanupTimer = setInterval(() => void sweepDiskGuardrails(), worktreeCleanupSweepMs);
|
|
6864
6879
|
worktreeCleanupTimer.unref?.();
|
|
6880
|
+
// In-session auto-resume tunables (see the resume helpers below). setTimeout
|
|
6881
|
+
// can't be trusted past ~24.8 days and we don't want one timer owning a
|
|
6882
|
+
// multi-hour wait a restart would drop, so each timer is capped and the periodic
|
|
6883
|
+
// sweep re-arms the remainder from the persisted resumeAt.
|
|
6884
|
+
const SESSION_RESUME_MAX_TIMER_MS = 30 * 60_000;
|
|
6885
|
+
const SESSION_RESUME_SWEEP_MS = 60_000;
|
|
6886
|
+
/** Slack around "due": a capped timer may fire a touch early — drive only when
|
|
6887
|
+
* within this of the target, else re-arm. */
|
|
6888
|
+
const SESSION_RESUME_TICK_MS = 15_000;
|
|
6889
|
+
const sessionResumeTimers = new Map();
|
|
6890
|
+
// Fire due auto-resumes (a usage/rate limit that has since reset) and re-arm the
|
|
6891
|
+
// tail of long waits whose in-process timer was capped or lost to a restart.
|
|
6892
|
+
const sessionResumeTimer = setInterval(() => sessionResumeSweep(), SESSION_RESUME_SWEEP_MS);
|
|
6893
|
+
sessionResumeTimer.unref?.();
|
|
6865
6894
|
// --- server-side ephemeral teardown ----------------------------------------
|
|
6866
6895
|
// On a disposable machine (bootstrap set BIVY_EPHEMERAL=1) the daemon ends the
|
|
6867
6896
|
// machine ITSELF once it goes idle, so teardown no longer needs the launching
|
|
@@ -7111,6 +7140,125 @@ async function refreshSessionUsage(record) {
|
|
|
7111
7140
|
// Usage reporting must never affect the session it's reporting on.
|
|
7112
7141
|
}
|
|
7113
7142
|
}
|
|
7143
|
+
// ── In-session auto-resume after a usage/rate limit ─────────────────────────
|
|
7144
|
+
// When a turn ends because a provider window is exhausted ("you've hit your
|
|
7145
|
+
// weekly limit · resets 12am (UTC)") and the session's ruleset says retry, we
|
|
7146
|
+
// wait out the window and re-send the same prompt when it resets — instead of
|
|
7147
|
+
// leaving a dead error bubble. Durable: the due time is persisted (metadata
|
|
7148
|
+
// resumeAt) so a daemon restart re-arms it (sessionResumeSweep); an in-process
|
|
7149
|
+
// timer fires it promptly while the daemon is up. (Tunables + timer map are
|
|
7150
|
+
// declared up by the timer cluster so the sweep interval can reference them.)
|
|
7151
|
+
/** The authoritative reset time for the limit a session just hit: the soonest
|
|
7152
|
+
* future reset among its most-utilized usage windows (the binding one), from
|
|
7153
|
+
* the last snapshot the runtime reported. Essential for a multi-day "weekly"
|
|
7154
|
+
* window, whose error text states only a time-of-day. Undefined when unknown. */
|
|
7155
|
+
function limitResetHint(record, nowMs) {
|
|
7156
|
+
const windows = record.usage?.plan?.windows ?? [];
|
|
7157
|
+
let best;
|
|
7158
|
+
for (const w of windows) {
|
|
7159
|
+
if (!w.resetsAt)
|
|
7160
|
+
continue;
|
|
7161
|
+
const at = Date.parse(w.resetsAt);
|
|
7162
|
+
if (!Number.isFinite(at) || at <= nowMs)
|
|
7163
|
+
continue;
|
|
7164
|
+
const util = w.utilizationPct ?? 0;
|
|
7165
|
+
// Prefer the most-utilized window (the one being hit); tie-break on soonest reset.
|
|
7166
|
+
if (!best || util > best.util || (util === best.util && at < best.at))
|
|
7167
|
+
best = { at, util };
|
|
7168
|
+
}
|
|
7169
|
+
return best ? new Date(best.at).toISOString() : undefined;
|
|
7170
|
+
}
|
|
7171
|
+
/** Cancel a pending in-process resume timer (leaves the durable marker alone). */
|
|
7172
|
+
function cancelSessionResumeTimer(id) {
|
|
7173
|
+
const timer = sessionResumeTimers.get(id);
|
|
7174
|
+
if (timer) {
|
|
7175
|
+
clearTimeout(timer);
|
|
7176
|
+
sessionResumeTimers.delete(id);
|
|
7177
|
+
}
|
|
7178
|
+
}
|
|
7179
|
+
/** Clear both the durable resume marker and any armed timer — the session moved
|
|
7180
|
+
* on (a new user turn, or the resume itself started). */
|
|
7181
|
+
function clearSessionResume(id) {
|
|
7182
|
+
cancelSessionResumeTimer(id);
|
|
7183
|
+
metadata.setResumeAt(id, null);
|
|
7184
|
+
}
|
|
7185
|
+
function armSessionResumeTimer(id, dueMs) {
|
|
7186
|
+
cancelSessionResumeTimer(id);
|
|
7187
|
+
const delay = Math.min(Math.max(0, dueMs - Date.now()), SESSION_RESUME_MAX_TIMER_MS);
|
|
7188
|
+
const timer = setTimeout(() => {
|
|
7189
|
+
sessionResumeTimers.delete(id);
|
|
7190
|
+
void driveSessionResume(id);
|
|
7191
|
+
}, delay);
|
|
7192
|
+
timer.unref?.();
|
|
7193
|
+
sessionResumeTimers.set(id, timer);
|
|
7194
|
+
}
|
|
7195
|
+
/** Persist + arm an auto-resume decided by the session policy. Synchronous so
|
|
7196
|
+
* the caller can atomically suppress the turn's error toast. */
|
|
7197
|
+
function scheduleSessionResume(record, plan) {
|
|
7198
|
+
metadata.setResumeAt(record.id, plan.resumeAt);
|
|
7199
|
+
const when = Date.parse(plan.resumeAt);
|
|
7200
|
+
const cond = plan.condition.replace(/_/g, " ");
|
|
7201
|
+
broadcast({
|
|
7202
|
+
type: "session.notice",
|
|
7203
|
+
sessionId: record.id,
|
|
7204
|
+
level: "info",
|
|
7205
|
+
message: `Hit a ${cond} limit — I'll resume this automatically when it resets (${plan.resumeAt}).`,
|
|
7206
|
+
});
|
|
7207
|
+
armSessionResumeTimer(record.id, Number.isFinite(when) ? when : Date.now());
|
|
7208
|
+
}
|
|
7209
|
+
/** Fire a due auto-resume: re-open the session if needed and re-send the turn's
|
|
7210
|
+
* last prompt. Clears the durable marker BEFORE driving so a crash mid-resume
|
|
7211
|
+
* can't loop. Best-effort — never throws into a timer/sweep. */
|
|
7212
|
+
async function driveSessionResume(id) {
|
|
7213
|
+
const meta = metadata.getSession(id);
|
|
7214
|
+
if (!meta?.resumeAt)
|
|
7215
|
+
return; // cancelled or already resumed
|
|
7216
|
+
const due = Date.parse(meta.resumeAt);
|
|
7217
|
+
if (Number.isFinite(due) && due - Date.now() > SESSION_RESUME_TICK_MS) {
|
|
7218
|
+
// A capped timer fired before the real due time — re-arm for the remainder.
|
|
7219
|
+
armSessionResumeTimer(id, due);
|
|
7220
|
+
return;
|
|
7221
|
+
}
|
|
7222
|
+
clearSessionResume(id);
|
|
7223
|
+
try {
|
|
7224
|
+
const live = openSessions.get(id);
|
|
7225
|
+
if (live?.isWorking)
|
|
7226
|
+
return; // a user turn is already running — don't pile on
|
|
7227
|
+
const record = live ?? (await resolveOrResumeSession(id, meta.path));
|
|
7228
|
+
if (!record)
|
|
7229
|
+
return; // transcript gone / unresolvable
|
|
7230
|
+
if (record.isWorking)
|
|
7231
|
+
return;
|
|
7232
|
+
// In-memory lastPrompt is the exact user turn to retry; after a restart it's
|
|
7233
|
+
// gone, so fall back to the generic interrupted-turn continuation nudge.
|
|
7234
|
+
const prompt = record.lastPrompt ?? buildInteractiveResumePrompt();
|
|
7235
|
+
console.log(`[resume] auto-resuming session ${id} — provider limit has reset`);
|
|
7236
|
+
broadcast({ type: "session.notice", sessionId: id, level: "info", message: "The limit has reset — resuming now." });
|
|
7237
|
+
await promptWithWatchdog(record, prompt, record.lastPromptOptions);
|
|
7238
|
+
}
|
|
7239
|
+
catch (error) {
|
|
7240
|
+
console.warn(`[resume] auto-resume after a provider limit failed for ${id}`, error);
|
|
7241
|
+
}
|
|
7242
|
+
}
|
|
7243
|
+
/** Re-arm (or immediately fire) durable auto-resume markers. Runs once at boot
|
|
7244
|
+
* and on an interval, so a wait survives a restart and a capped timer's tail
|
|
7245
|
+
* still fires. */
|
|
7246
|
+
function sessionResumeSweep() {
|
|
7247
|
+
const now = Date.now();
|
|
7248
|
+
for (const meta of metadata.sessionsWithResumeAt()) {
|
|
7249
|
+
const due = Date.parse(meta.resumeAt);
|
|
7250
|
+
if (!Number.isFinite(due)) {
|
|
7251
|
+
metadata.setResumeAt(meta.id, null);
|
|
7252
|
+
continue;
|
|
7253
|
+
}
|
|
7254
|
+
if (sessionResumeTimers.has(meta.id))
|
|
7255
|
+
continue; // already armed this run
|
|
7256
|
+
if (due <= now + SESSION_RESUME_TICK_MS)
|
|
7257
|
+
void driveSessionResume(meta.id);
|
|
7258
|
+
else
|
|
7259
|
+
armSessionResumeTimer(meta.id, due);
|
|
7260
|
+
}
|
|
7261
|
+
}
|
|
7114
7262
|
/**
|
|
7115
7263
|
* Turn a raw provider/runtime error string into something a human can read.
|
|
7116
7264
|
* Model APIs commonly return `<status> {json}` (e.g. `400 {"error":{"message":
|
|
@@ -7184,9 +7332,12 @@ function maybeSignalAuthRequired(record, errorText) {
|
|
|
7184
7332
|
}
|
|
7185
7333
|
function attachSessionListeners(record) {
|
|
7186
7334
|
record.unsubscribe?.();
|
|
7187
|
-
// In-session
|
|
7188
|
-
//
|
|
7189
|
-
|
|
7335
|
+
// In-session recovery controller — waits out a usage/rate limit and resumes
|
|
7336
|
+
// (planResume), or swaps models down a fallback chain (planReroute). One per
|
|
7337
|
+
// session; its per-turn budget resets on each user prompt. The policy reads
|
|
7338
|
+
// the active session ruleset lazily, so it's inert until one authorizes a
|
|
7339
|
+
// retry/reroute for the failing condition.
|
|
7340
|
+
if (!record.reroute) {
|
|
7190
7341
|
record.reroute = new SessionRerouteController({
|
|
7191
7342
|
policy: sessionRunPolicy,
|
|
7192
7343
|
onNotice: (n) => broadcast({ type: "session.notice", sessionId: record.id, level: n.level, message: n.message }),
|
|
@@ -7306,7 +7457,18 @@ function attachSessionListeners(record) {
|
|
|
7306
7457
|
// credential or a 4xx from the API) otherwise vanished: working cleared,
|
|
7307
7458
|
// no reply, no signal. Surface it as a session-scoped error so the client
|
|
7308
7459
|
// can show it *inline in that chat*, and notify instead of "done".
|
|
7309
|
-
|
|
7460
|
+
// A terminal turn error reaches us two ways. pi-ai puts it on the last
|
|
7461
|
+
// assistant message (stopReason:"error" → terminalTurnError), and the
|
|
7462
|
+
// server owns surfacing it. Claude Code instead throws inside the SDK
|
|
7463
|
+
// query: it emits its OWN session.error to the client AND carries the raw
|
|
7464
|
+
// text on agent_end.error (e.g. "you've hit your weekly limit · resets 12am
|
|
7465
|
+
// (UTC)"). We read that too — but only to DRIVE recovery, since the runtime
|
|
7466
|
+
// already surfaced it; re-broadcasting would double the error bubble.
|
|
7467
|
+
const messageError = terminalTurnError(event);
|
|
7468
|
+
const agentEndError = typeof event.error === "string"
|
|
7469
|
+
? humanizeAgentError(event.error)
|
|
7470
|
+
: undefined;
|
|
7471
|
+
const turnError = messageError ?? (agentEndError?.trim() ? agentEndError : undefined);
|
|
7310
7472
|
// Before surfacing a turn error, see if the session's run policy can recover
|
|
7311
7473
|
// it in place by swapping to a fallback model and retrying the same prompt.
|
|
7312
7474
|
// planReroute is synchronous, so we can atomically suppress the error toast
|
|
@@ -7314,6 +7476,14 @@ function attachSessionListeners(record) {
|
|
|
7314
7476
|
const reroutePlan = turnError && record.lastPrompt !== undefined
|
|
7315
7477
|
? record.reroute?.planReroute(turnError, record.session.getCurrentModel()?.name) ?? null
|
|
7316
7478
|
: null;
|
|
7479
|
+
// If a reroute doesn't apply, a usage/rate limit that gave a reset time can
|
|
7480
|
+
// instead be waited out and resumed when the window clears (planResume is
|
|
7481
|
+
// synchronous too, so this stays atomic with suppressing the error toast).
|
|
7482
|
+
const resumePlan = !reroutePlan && turnError && record.lastPrompt !== undefined
|
|
7483
|
+
? record.reroute?.planResume(turnError, record.session.getCurrentModel()?.name, {
|
|
7484
|
+
resetsAtHint: limitResetHint(record, Date.now()),
|
|
7485
|
+
}) ?? null
|
|
7486
|
+
: null;
|
|
7317
7487
|
if (reroutePlan) {
|
|
7318
7488
|
void record.reroute.applyReroute(reroutePlan, {
|
|
7319
7489
|
getCurrentModelName: () => record.session.getCurrentModel()?.name,
|
|
@@ -7323,14 +7493,23 @@ function attachSessionListeners(record) {
|
|
|
7323
7493
|
},
|
|
7324
7494
|
});
|
|
7325
7495
|
}
|
|
7326
|
-
else if (
|
|
7496
|
+
else if (resumePlan) {
|
|
7497
|
+
// Charge the attempt budget so a limit that re-fires after the reset can
|
|
7498
|
+
// eventually exhaust (→ surface) instead of looping, then park the turn
|
|
7499
|
+
// as a scheduled resume rather than a dead error.
|
|
7500
|
+
record.reroute.noteResumeApplied();
|
|
7501
|
+
scheduleSessionResume(record, resumePlan);
|
|
7502
|
+
}
|
|
7503
|
+
else if (messageError) {
|
|
7504
|
+
// Only the server-owned (pi-ai) path surfaces here; a Claude Code error
|
|
7505
|
+
// the runtime already broadcast falls through to avoid a duplicate bubble.
|
|
7327
7506
|
record.lastFailureAt = Date.now();
|
|
7328
7507
|
metadata.touchSession(record.id, "failed");
|
|
7329
7508
|
scheduleAdvertise();
|
|
7330
|
-
broadcast({ type: "session.error", sessionId: record.id, error:
|
|
7509
|
+
broadcast({ type: "session.error", sessionId: record.id, error: messageError });
|
|
7331
7510
|
// If the terminal error is an auth failure (expired key/token → 4xx),
|
|
7332
7511
|
// also raise the sign-in sheet for the failing provider.
|
|
7333
|
-
maybeSignalAuthRequired(record,
|
|
7512
|
+
maybeSignalAuthRequired(record, messageError);
|
|
7334
7513
|
void sendNotificationHint({
|
|
7335
7514
|
kind: "session_error",
|
|
7336
7515
|
sessionId: record.id,
|
|
@@ -10655,6 +10834,14 @@ const server = app.listen(port, host, async () => {
|
|
|
10655
10834
|
// Recover interactive sessions a restart interrupted mid-turn (auto-continue, or
|
|
10656
10835
|
// flag for a one-tap manual Resume) per the node's sessionResumeMode setting.
|
|
10657
10836
|
void reconcileInterruptedSessions().catch((error) => console.warn("[resume] interrupted-session reconciliation failed", error));
|
|
10837
|
+
// Re-arm (or fire) durable auto-resume markers a limit-hit turn left behind,
|
|
10838
|
+
// so a session waiting out a usage/rate window still resumes after a restart.
|
|
10839
|
+
try {
|
|
10840
|
+
sessionResumeSweep();
|
|
10841
|
+
}
|
|
10842
|
+
catch (error) {
|
|
10843
|
+
console.warn("[resume] auto-resume sweep failed at boot", error);
|
|
10844
|
+
}
|
|
10658
10845
|
// Universal Agent Harness — network effect boundary (opt-in via
|
|
10659
10846
|
// BIVY_EGRESS_PROXY). Governs/logs outbound traffic of CLI agents, which
|
|
10660
10847
|
// inherit the proxy env from process.ts.
|
package/package.json
CHANGED