@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.
@@ -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
- const stop=()=>{box?.remove();box=null;target=null;removeEventListener('pointermove',hover,true);removeEventListener('click',pick,true);removeEventListener('keydown',key,true);};
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
- e.preventDefault();e.stopPropagation();
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
- addEventListener('pointermove',hover,true);addEventListener('click',pick,true);addEventListener('keydown',key,true);
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;
@@ -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 cond = plan.condition.replace(/_/g, " ");
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: `Hit a ${cond} limit — I'll resume this automatically when it resets (${plan.resumeAt}).`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.17.3-staging.7",
3
+ "version": "0.17.3-staging.9",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",