@bivy/bivy 0.17.3-staging.7 → 0.17.3-staging.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/apps/inspector.js
CHANGED
|
@@ -33,15 +33,30 @@ const selector=el=>{
|
|
|
33
33
|
return parts.join(' > ')||el.localName;
|
|
34
34
|
};
|
|
35
35
|
let box=null,target=null,onPicked=post,host=null;
|
|
36
|
-
|
|
36
|
+
// Picks on pointerup: iOS never fires click for a tap on a non-clickable
|
|
37
|
+
// element when only window listens, so click would miss most of the page.
|
|
38
|
+
// While pointing, the app sees none of the gesture, including the click after.
|
|
39
|
+
const SWALLOW=['pointerdown','mousedown','mouseup','touchend','click','dblclick','contextmenu'];
|
|
40
|
+
const swallow=e=>{e.preventDefault();e.stopPropagation();};
|
|
41
|
+
// A second listener, so stopping (which the shell does right after a pick)
|
|
42
|
+
// doesn't also drop the guard on the rest of the tap. The tap's click ends it.
|
|
43
|
+
const aftermath=e=>{swallow(e);if(e.type==='click')listen(false,aftermath);};
|
|
44
|
+
const listen=(on,handler=swallow)=>{
|
|
45
|
+
const method=on?addEventListener:removeEventListener;
|
|
46
|
+
if(handler===swallow){method('pointermove',hover,true);method('pointerup',pick,true);method('keydown',key,true);}
|
|
47
|
+
for(const type of SWALLOW)method(type,handler,true);
|
|
48
|
+
};
|
|
49
|
+
const stop=()=>{box?.remove();box=null;target=null;listen(false);};
|
|
37
50
|
const hover=e=>{
|
|
38
51
|
const el=document.elementFromPoint(e.clientX,e.clientY);
|
|
39
52
|
if(!el||el===box||el===host)return;target=el;const r=el.getBoundingClientRect();
|
|
40
53
|
Object.assign(box.style,{left:r.left+'px',top:r.top+'px',width:r.width+'px',height:r.height+'px'});
|
|
41
54
|
};
|
|
42
55
|
const pick=e=>{
|
|
43
|
-
|
|
56
|
+
swallow(e);
|
|
44
57
|
const el=document.elementFromPoint(e.clientX,e.clientY)||target;stop();
|
|
58
|
+
// Keep eating the click (and iOS's delayed mouse events) this tap still fires.
|
|
59
|
+
listen(true,aftermath);setTimeout(()=>listen(false,aftermath),500);
|
|
45
60
|
if(!el||el===host){onPicked({type:'picked',cancelled:true});return;}const r=el.getBoundingClientRect();
|
|
46
61
|
onPicked({type:'picked',selector:selector(el),tag:el.localName,text:(el.innerText||el.getAttribute('aria-label')||el.getAttribute('alt')||'').trim().replace(/\\s+/g,' ').slice(0,200),
|
|
47
62
|
rect:{x:Math.round(r.left),y:Math.round(r.top),width:Math.round(r.width),height:Math.round(r.height)},
|
|
@@ -54,7 +69,7 @@ function startPointing(){
|
|
|
54
69
|
box.setAttribute('aria-hidden','true');
|
|
55
70
|
Object.assign(box.style,{position:'fixed',zIndex:2147483647,pointerEvents:'none',outline:'2px solid Highlight',outlineOffset:'2px',borderRadius:'4px',transition:'all 60ms ease-out'});
|
|
56
71
|
document.documentElement.append(box);
|
|
57
|
-
|
|
72
|
+
listen(true);
|
|
58
73
|
}
|
|
59
74
|
addEventListener('message',e=>{
|
|
60
75
|
if(!framed||e.origin!==SHELL||e.source!==parent||e.data?.type!=='bivy:point')return;
|
package/dist/apps/styles.css
CHANGED
|
@@ -551,6 +551,8 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
|
|
|
551
551
|
.hljs-addition { color: var(--syntax-addition); }
|
|
552
552
|
.msg.system { align-self: center; color: var(--muted); font-size: 13px; font-style: italic; display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; }
|
|
553
553
|
.msg.system .system-text { display: block; }
|
|
554
|
+
.entry-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); font-style: normal; }
|
|
555
|
+
.msg.system .entry-actions { justify-content: center; }
|
|
554
556
|
.msg.thinking { align-self: flex-start; color: var(--muted); font-size: 13px; border-left: 2px solid var(--line-strong); padding-left: 10px; }
|
|
555
557
|
.msg.error {
|
|
556
558
|
align-self: stretch; display: flex; gap: 8px; align-items: flex-start;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Session limit recovery — the user-driven counterpart to the policy's automatic
|
|
5
|
+
// resume (session-reroute.ts planResume).
|
|
6
|
+
//
|
|
7
|
+
// When a turn ends because the agent hit a provider usage/rate limit and no
|
|
8
|
+
// ruleset recovered it automatically, the session shouldn't just show a dead
|
|
9
|
+
// error. This decides whether the failure IS such a limit, and what the user can
|
|
10
|
+
// do about it: fork the work to another agent right away, or opt in to retrying
|
|
11
|
+
// the same prompt automatically once the window resets. The caller (server.ts)
|
|
12
|
+
// surfaces the offer as a notice with action buttons and, when the user accepts
|
|
13
|
+
// the retry, schedules it through the same durable resume machinery the
|
|
14
|
+
// automatic path uses.
|
|
15
|
+
import { classifyFailure } from "./conditions.js";
|
|
16
|
+
/** The conditions a user can route around (fork) or wait out (retry at reset),
|
|
17
|
+
* with how the notice describes each. Adding a limit-like condition is a row. */
|
|
18
|
+
const LIMIT_COPY = {
|
|
19
|
+
credits_exhausted: "reached its usage limit",
|
|
20
|
+
rate_limited: "is being rate-limited",
|
|
21
|
+
};
|
|
22
|
+
/** Retry a little after the stated reset: providers round reset times and a
|
|
23
|
+
* re-send that lands a few seconds early just hits the same limit again. */
|
|
24
|
+
const RESET_GRACE_MS = 60_000;
|
|
25
|
+
/** Classify a turn error; returns the limit it represents, or undefined when it
|
|
26
|
+
* is some other failure. `resetsAtHint` is the provider's structured reset time
|
|
27
|
+
* (see ClassifyOptions) and wins over anything scraped from the text. */
|
|
28
|
+
export function detectSessionLimit(error, opts = {}) {
|
|
29
|
+
const now = opts.now ?? Date.now();
|
|
30
|
+
const classified = classifyFailure(error, { now, resetsAtHint: opts.resetsAtHint });
|
|
31
|
+
if (!LIMIT_COPY[classified.condition])
|
|
32
|
+
return undefined;
|
|
33
|
+
const resetsAt = classified.resetsAt
|
|
34
|
+
?? (classified.retryAfterMs !== undefined ? new Date(now + classified.retryAfterMs).toISOString() : undefined);
|
|
35
|
+
return { condition: classified.condition, ...(resetsAt ? { resetsAt } : {}) };
|
|
36
|
+
}
|
|
37
|
+
/** The notice offering the user a way past the limit. Actions are client
|
|
38
|
+
* action ids: `fork` opens the fork sheet; `retry-at-reset:<iso>` opts in to an
|
|
39
|
+
* automatic retry (the timestamp is only for the button's label — the daemon
|
|
40
|
+
* schedules from its own remembered limit). */
|
|
41
|
+
export function sessionLimitNotice(agentName, limit) {
|
|
42
|
+
const what = `${agentName} ${LIMIT_COPY[limit.condition] ?? "hit a limit"}.`;
|
|
43
|
+
return limit.resetsAt
|
|
44
|
+
? {
|
|
45
|
+
message: `${what} Fork to another agent to keep going, or retry automatically when the limit resets.`,
|
|
46
|
+
actions: ["fork", `retry-at-reset:${limit.resetsAt}`],
|
|
47
|
+
}
|
|
48
|
+
: { message: `${what} Fork to another agent to keep going.`, actions: ["fork"] };
|
|
49
|
+
}
|
|
50
|
+
/** A resume plan for a user who opted in to retrying at reset. Undefined when
|
|
51
|
+
* the reset time is unknown. A reset already in the past retries shortly. */
|
|
52
|
+
export function limitResumePlan(limit, now = Date.now()) {
|
|
53
|
+
const reset = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN;
|
|
54
|
+
if (!Number.isFinite(reset))
|
|
55
|
+
return undefined;
|
|
56
|
+
const dueMs = Math.max(reset, now) + RESET_GRACE_MS;
|
|
57
|
+
return {
|
|
58
|
+
condition: limit.condition,
|
|
59
|
+
summary: `${limit.condition}: retrying when the limit resets at ${limit.resetsAt}.`,
|
|
60
|
+
delayMs: dueMs - now,
|
|
61
|
+
resumeAt: new Date(dueMs).toISOString(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -42,6 +42,7 @@ export const CLIENT_COMMAND_SCHEMAS = {
|
|
|
42
42
|
"session.rename": Type.Object({ ...session, name: Type.String() }),
|
|
43
43
|
abort: Type.Object(optionalSession),
|
|
44
44
|
"session.turn_attention.resolve": Type.Object({ ...session, action: Type.Union([Type.Literal("stop"), Type.Literal("continue")]) }),
|
|
45
|
+
"session.limit_retry": Type.Object({ ...session, enabled: Type.Boolean() }),
|
|
45
46
|
"session.command.invoke": Type.Object({ ...session, name: Type.String(), args: Type.Optional(Type.String()) }),
|
|
46
47
|
"branches.list": Type.Object({ repo: Type.String() }),
|
|
47
48
|
"history": Type.Object({ ...request, ...optionalSession, have: Type.Optional(Type.Number()), haveToken: Type.Optional(Type.String()) }),
|
package/dist/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import { listRuntimes, catalogRuntimes, agentInstallSpec, canonicalAgentId, inva
|
|
|
16
16
|
import { createRunPolicy } from "./policy/run-policy.js";
|
|
17
17
|
import { DEFAULT_BACKOFF } from "./policy/ruleset.js";
|
|
18
18
|
import { SessionRerouteController } from "./policy/session-reroute.js";
|
|
19
|
+
import { detectSessionLimit, limitResumePlan, sessionLimitNotice } from "./policy/session-limit.js";
|
|
19
20
|
import { activeRulesetFor } from "./runtime/ruleset-store.js";
|
|
20
21
|
import { createRulesetController } from "./controllers/rulesets.js";
|
|
21
22
|
import { createAuditLog, readAuditEvents } from "./audit/index.js";
|
|
@@ -2215,6 +2216,11 @@ const RELAY_COMMANDS = {
|
|
|
2215
2216
|
else
|
|
2216
2217
|
abortSessionRecord(record, ctx.broadcast);
|
|
2217
2218
|
},
|
|
2219
|
+
"session.limit_retry"(msg) {
|
|
2220
|
+
const record = resolveSession(msg.sessionId);
|
|
2221
|
+
if (record)
|
|
2222
|
+
setSessionLimitRetry(record, msg.enabled === true);
|
|
2223
|
+
},
|
|
2218
2224
|
"session.turn_attention.resolve"(msg) {
|
|
2219
2225
|
const record = resolveSession(msg.sessionId);
|
|
2220
2226
|
const action = msg.action === "stop" ? "stop" : msg.action === "continue" ? "continue" : undefined;
|
|
@@ -2927,6 +2933,7 @@ const RELAY_COMMANDS = {
|
|
|
2927
2933
|
// The user is driving this turn manually — supersede any pending auto-resume
|
|
2928
2934
|
// that was scheduled after a prior limit so it can't re-fire on top of them.
|
|
2929
2935
|
clearSessionResume(record.id);
|
|
2936
|
+
record.limitHit = undefined;
|
|
2930
2937
|
await turnWatchdog.promptWithWatchdog(record, agentPrompt, record.lastPromptOptions);
|
|
2931
2938
|
}).catch((error) => {
|
|
2932
2939
|
// Mirror the HTTP path (see the /prompt route): a rejected turn after
|
|
@@ -7274,12 +7281,15 @@ function scheduleSessionResume(record, plan) {
|
|
|
7274
7281
|
metadata.setResumeAt(record.id, plan.resumeAt);
|
|
7275
7282
|
metadata.setResumeAttempts(record.id, attempts + 1);
|
|
7276
7283
|
const when = Date.parse(plan.resumeAt);
|
|
7277
|
-
const
|
|
7284
|
+
const at = Number.isFinite(when)
|
|
7285
|
+
? new Date(when).toLocaleString("en-US", { timeZone: "UTC", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false })
|
|
7286
|
+
: plan.resumeAt;
|
|
7278
7287
|
broadcast({
|
|
7279
7288
|
type: "session.notice",
|
|
7280
7289
|
sessionId: record.id,
|
|
7281
7290
|
level: "info",
|
|
7282
|
-
message: `
|
|
7291
|
+
message: `I'll retry this automatically when the limit resets (${at} UTC).`,
|
|
7292
|
+
actions: ["cancel-resume"],
|
|
7283
7293
|
});
|
|
7284
7294
|
armSessionResumeTimer(record.id, Number.isFinite(when) ? when : Date.now());
|
|
7285
7295
|
return true;
|
|
@@ -7318,6 +7328,23 @@ async function driveSessionResume(id) {
|
|
|
7318
7328
|
console.warn(`[resume] auto-resume after a provider limit failed for ${id}`, error);
|
|
7319
7329
|
}
|
|
7320
7330
|
}
|
|
7331
|
+
/** The user's answer to a surfaced limit: opt in to retrying the turn when the
|
|
7332
|
+
* window resets (scheduled through the same durable resume path the policy
|
|
7333
|
+
* uses), or cancel a pending retry. */
|
|
7334
|
+
function setSessionLimitRetry(record, enabled) {
|
|
7335
|
+
if (!enabled) {
|
|
7336
|
+
clearSessionResume(record.id);
|
|
7337
|
+
metadata.setResumeAttempts(record.id, 0);
|
|
7338
|
+
broadcast({ type: "session.notice", sessionId: record.id, level: "info", message: "Automatic retry cancelled." });
|
|
7339
|
+
return;
|
|
7340
|
+
}
|
|
7341
|
+
const plan = record.limitHit && limitResumePlan(record.limitHit);
|
|
7342
|
+
if (!plan) {
|
|
7343
|
+
broadcast({ type: "session.notice", sessionId: record.id, level: "info", message: "There's no pending limit to retry — send a message to continue." });
|
|
7344
|
+
return;
|
|
7345
|
+
}
|
|
7346
|
+
scheduleSessionResume(record, plan);
|
|
7347
|
+
}
|
|
7321
7348
|
/** Re-arm (or immediately fire) durable auto-resume markers. Runs once at boot
|
|
7322
7349
|
* and on an interval, so a wait survives a restart and a capped timer's tail
|
|
7323
7350
|
* still fires. */
|
|
@@ -7646,6 +7673,13 @@ function attachSessionListeners(record) {
|
|
|
7646
7673
|
broadcast({ type: "session.failed", sessionId: record.id, failedAt: record.lastFailureAt });
|
|
7647
7674
|
if (messageError)
|
|
7648
7675
|
broadcast({ type: "session.error", sessionId: record.id, error: sessionAgentError(record, messageError) });
|
|
7676
|
+
// A usage/rate limit nothing recovered automatically: offer the ways past
|
|
7677
|
+
// it — fork to another agent now, or retry when the window resets.
|
|
7678
|
+
record.limitHit = detectSessionLimit(turnError, { resetsAtHint: limitResetHint(record, Date.now()) });
|
|
7679
|
+
if (record.limitHit) {
|
|
7680
|
+
const notice = sessionLimitNotice(getRuntime(record.runtimeId).displayName, record.limitHit);
|
|
7681
|
+
broadcast({ type: "session.notice", sessionId: record.id, level: "warn", ...notice });
|
|
7682
|
+
}
|
|
7649
7683
|
// If the terminal error is an auth failure (expired key/token → 4xx),
|
|
7650
7684
|
// also raise the sign-in sheet for the failing provider.
|
|
7651
7685
|
maybeSignalAuthRequired(record, turnError);
|
|
@@ -11158,6 +11192,13 @@ app.post("/api/session/abort", (req, res, next) => {
|
|
|
11158
11192
|
next(error);
|
|
11159
11193
|
}
|
|
11160
11194
|
});
|
|
11195
|
+
app.post("/api/session/limit-retry", (req, res) => {
|
|
11196
|
+
const record = resolveSession(req.body?.sessionId);
|
|
11197
|
+
if (!record)
|
|
11198
|
+
return res.status(404).json({ error: "No active session" });
|
|
11199
|
+
setSessionLimitRetry(record, req.body?.enabled === true);
|
|
11200
|
+
res.json({ ok: true });
|
|
11201
|
+
});
|
|
11161
11202
|
app.post("/api/session/turn-attention", (req, res, next) => {
|
|
11162
11203
|
try {
|
|
11163
11204
|
const record = resolveSession(req.body?.sessionId);
|
package/package.json
CHANGED