@bivy/bivy 0.17.2 → 0.17.3-staging.10

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.
@@ -1,16 +1,29 @@
1
1
  export function createAppCommands(service, workspaceFor, published = () => { }) {
2
+ const workspaceOf = (sessionId) => {
3
+ const workspace = workspaceFor(sessionId);
4
+ if (!workspace)
5
+ throw new Error("Open the session on this machine before publishing an app.");
6
+ return workspace;
7
+ };
2
8
  const operations = {
3
9
  "apps.list": (msg) => service.list(String(msg.sessionId)),
4
10
  "apps.publish": (msg) => {
5
11
  const sessionId = String(msg.sessionId);
6
- const workspace = workspaceFor(sessionId);
7
- if (!workspace)
8
- throw new Error("Open the session on this machine before publishing an app.");
9
- const app = service.publish(sessionId, workspace, msg.manifest);
12
+ const app = service.publish(sessionId, workspaceOf(sessionId), msg.manifest);
13
+ published(app);
14
+ return { app };
15
+ },
16
+ "apps.offers": (msg) => service.offers(String(msg.sessionId), workspaceOf(String(msg.sessionId))),
17
+ "apps.adopt": async (msg) => {
18
+ const sessionId = String(msg.sessionId);
19
+ const app = await service.adopt(sessionId, workspaceOf(sessionId), Number(msg.port));
10
20
  published(app);
11
21
  return { app };
12
22
  },
13
- "apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined),
23
+ "apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined, msg.direct === true, typeof msg.scale === "number" ? msg.scale : undefined),
24
+ "apps.shot": (msg) => service.shot(String(msg.sessionId), typeof msg.appId === "string" ? msg.appId : undefined, { widths: msg.widths, themes: msg.themes, path: typeof msg.path === "string" ? msg.path : undefined }),
25
+ "apps.clearNotes": (msg) => service.clearNotes(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
26
+ "apps.logs": (msg) => service.logs(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
14
27
  "apps.share": (msg) => service.share(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
15
28
  "apps.revoke": (msg) => service.revoke(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
16
29
  "apps.remove": (msg) => { service.remove(String(msg.sessionId), String(msg.appId)); return { ok: true }; },
@@ -18,7 +31,7 @@ export function createAppCommands(service, workspaceFor, published = () => { })
18
31
  return Object.fromEntries(Object.entries(operations).map(([kind, execute]) => [kind, async (msg, ctx) => {
19
32
  try {
20
33
  ctx.reply({ type: `${kind}.ok`, requestId: msg.requestId, ...await execute(msg) });
21
- if (kind === "apps.publish" || kind === "apps.remove")
34
+ if (kind === "apps.publish" || kind === "apps.adopt" || kind === "apps.remove")
22
35
  ctx.broadcast({ type: "apps.changed", sessionId: msg.sessionId });
23
36
  }
24
37
  catch (error) {
@@ -74,7 +74,7 @@ export function validateNodeConfig(value) {
74
74
  const node = section(root, "node", ["workspace", "port", "maxConcurrentAutomations", "capabilities"], errors);
75
75
  const defaults = section(root, "defaults", ["agent", "model", "sandbox", "approval"], errors);
76
76
  const safety = section(root, "safety", ["maxSandbox", "approvalFloor"], errors);
77
- const sessions = section(root, "sessions", ["sync", "worktreeSync", "standbyNodeId", "resume", "autoAttachToolImages", "forkWorkspaceMaxBytes", "wedgedTurnMinutes"], errors);
77
+ const sessions = section(root, "sessions", ["sync", "worktreeSync", "standbyNodeId", "resume", "autoAttachToolImages", "appScreenshots", "forkWorkspaceMaxBytes", "wedgedTurnMinutes"], errors);
78
78
  const github = section(root, "github", ["issuePrompt"], errors);
79
79
  const automation = section(root, "automation", ["checks", "checkTimeoutMinutes"], errors);
80
80
  const agentsRaw = section(root, "agents", Object.keys(record(root.agents) ?? {}), errors);
@@ -133,6 +133,7 @@ export function validateNodeConfig(value) {
133
133
  if (resume !== undefined && resume !== "auto" && resume !== "manual")
134
134
  errors.push("sessions.resume must be auto or manual");
135
135
  const autoAttachToolImages = optionalBoolean(sessions.autoAttachToolImages, "sessions.autoAttachToolImages", errors);
136
+ const appScreenshots = optionalBoolean(sessions.appScreenshots, "sessions.appScreenshots", errors);
136
137
  const forkWorkspaceMaxBytes = optionalInteger(sessions.forkWorkspaceMaxBytes, "sessions.forkWorkspaceMaxBytes", errors, 1_048_576, 1_073_741_824);
137
138
  // Upper bound is the wall-clock turn cap (60 min): a wedged window at/above it
138
139
  // would never fire before the cap. 0 disables the band.
@@ -216,7 +217,7 @@ export function validateNodeConfig(value) {
216
217
  ...(Object.keys(node).length ? { node: { workspace, port, maxConcurrentAutomations, capabilities } } : {}),
217
218
  ...(Object.keys(defaults).length ? { defaults: { agent, model, sandbox, approval } } : {}),
218
219
  ...(Object.keys(safety).length ? { safety: { maxSandbox, approvalFloor } } : {}),
219
- ...(Object.keys(sessions).length ? { sessions: { sync, worktreeSync, standbyNodeId, resume, autoAttachToolImages, forkWorkspaceMaxBytes, wedgedTurnMinutes } } : {}),
220
+ ...(Object.keys(sessions).length ? { sessions: { sync, worktreeSync, standbyNodeId, resume, autoAttachToolImages, appScreenshots, forkWorkspaceMaxBytes, wedgedTurnMinutes } } : {}),
220
221
  ...(Object.keys(github).length ? { github: { issuePrompt } } : {}),
221
222
  ...(Object.keys(automation).length ? { automation: { checks, checkTimeoutMinutes } } : {}),
222
223
  ...(Object.keys(agents).length ? { agents } : {}),
@@ -272,6 +273,7 @@ export function configToLegacySettings(config) {
272
273
  ...(config.sessions?.standbyNodeId ? { syncStandbyNodeId: config.sessions.standbyNodeId } : {}),
273
274
  ...(config.sessions?.resume ? { sessionResumeMode: config.sessions.resume } : {}),
274
275
  ...(config.sessions?.autoAttachToolImages !== undefined ? { autoAttachToolImages: config.sessions.autoAttachToolImages } : {}),
276
+ ...(config.sessions?.appScreenshots !== undefined ? { appScreenshots: config.sessions.appScreenshots } : {}),
275
277
  ...(config.sessions?.forkWorkspaceMaxBytes !== undefined ? { forkWorkspaceMaxBytes: config.sessions.forkWorkspaceMaxBytes } : {}),
276
278
  };
277
279
  }
@@ -335,6 +337,7 @@ export function mergeLegacyIntoNodeConfig(cli, settings) {
335
337
  standbyNodeId: typeof settings.syncStandbyNodeId === "string" ? settings.syncStandbyNodeId : undefined,
336
338
  resume: settings.sessionResumeMode === "manual" ? "manual" : "auto",
337
339
  autoAttachToolImages: settings.autoAttachToolImages === true,
340
+ appScreenshots: settings.appScreenshots === true,
338
341
  forkWorkspaceMaxBytes: Number.isInteger(settings.forkWorkspaceMaxBytes) ? Number(settings.forkWorkspaceMaxBytes) : undefined,
339
342
  },
340
343
  github: { issuePrompt: typeof settings.githubIssuePrompt === "string" ? settings.githubIssuePrompt : undefined },
@@ -361,7 +364,7 @@ export function setConfigValue(config, dotted, value) {
361
364
  "node.workspace", "node.port", "node.maxConcurrentAutomations", "node.capabilities",
362
365
  "defaults.agent", "defaults.model", "defaults.sandbox", "defaults.approval",
363
366
  "safety.maxSandbox", "safety.approvalFloor",
364
- "sessions.sync", "sessions.worktreeSync", "sessions.standbyNodeId", "sessions.resume", "sessions.autoAttachToolImages", "sessions.forkWorkspaceMaxBytes", "sessions.wedgedTurnMinutes",
367
+ "sessions.sync", "sessions.worktreeSync", "sessions.standbyNodeId", "sessions.resume", "sessions.autoAttachToolImages", "sessions.appScreenshots", "sessions.forkWorkspaceMaxBytes", "sessions.wedgedTurnMinutes",
365
368
  "github.issuePrompt", "automation.checks", "automation.checkTimeoutMinutes",
366
369
  ]);
367
370
  if (!allowed.has(dotted) && !/^agents\.[a-z][a-z0-9-]{1,47}$/.test(dotted) && !/^environment\.[A-Z][A-Z0-9_]+$/.test(dotted))
@@ -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
+ }
@@ -3,7 +3,7 @@
3
3
  /** Direct-mode adapters for operations whose canonical implementation is the
4
4
  * client command registry. Relay and HTTP now differ only in framing. */
5
5
  export const CLIENT_COMMAND_ROUTES = [
6
- ...["list", "publish", "open", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
6
+ ...["list", "publish", "offers", "adopt", "open", "logs", "shot", "clearNotes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
7
7
  { method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
8
8
  { method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
9
9
  { method: "post", path: "/api/session/pause", kind: "session.pause" },
@@ -10,7 +10,12 @@ export const CLIENT_COMMAND_SCHEMAS = {
10
10
  ping: Type.Object(request),
11
11
  "apps.list": Type.Object({ ...request, ...session }),
12
12
  "apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
13
- "apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })) }),
13
+ "apps.offers": Type.Object({ ...request, ...session }),
14
+ "apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
15
+ "apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()), scale: Type.Optional(Type.Integer({ minimum: 1, maximum: 2 })) }),
16
+ "apps.shot": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()), widths: Type.Optional(Type.Array(Type.Integer(), { maxItems: 4 })), themes: Type.Optional(Type.Array(Type.Union([Type.Literal("light"), Type.Literal("dark")]), { maxItems: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
17
+ "apps.clearNotes": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
18
+ "apps.logs": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
14
19
  "apps.share": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
15
20
  "apps.revoke": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
16
21
  "apps.remove": Type.Object({ ...request, ...session, appId: Type.String() }),
@@ -37,6 +42,7 @@ export const CLIENT_COMMAND_SCHEMAS = {
37
42
  "session.rename": Type.Object({ ...session, name: Type.String() }),
38
43
  abort: Type.Object(optionalSession),
39
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() }),
40
46
  "session.command.invoke": Type.Object({ ...session, name: Type.String(), args: Type.Optional(Type.String()) }),
41
47
  "branches.list": Type.Object({ repo: Type.String() }),
42
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";
@@ -31,6 +32,7 @@ import { AppRegistry } from "./apps/registry.js";
31
32
  import { AppGateway } from "./apps/gateway.js";
32
33
  import { RemotePreview } from "./apps/remote-preview.js";
33
34
  import { AppService } from "./apps/service.js";
35
+ import { DisplayHost } from "./apps/display.js";
34
36
  import { createAppCommands } from "./controllers/app-commands.js";
35
37
  import { bindClientCommandRoutes } from "./http/client-command-routes.js";
36
38
  import { collectDiscoveredSessions, planNativeAdoption } from "./runtime/native-session-discovery.js";
@@ -963,6 +965,8 @@ async function harnessEndTurn(record) {
963
965
  record.workspaceState = (await harness.isDirty(record.id)) ? "dirty" : "clean";
964
966
  if (!result || result.changes.length === 0)
965
967
  return;
968
+ // Open previews of this session reload; static snapshots are re-taken.
969
+ appService.turnChanged(record.id);
966
970
  broadcast({
967
971
  type: "session.changes",
968
972
  sessionId: record.id,
@@ -1332,6 +1336,7 @@ function writeSettings(settings) {
1332
1336
  ...("syncStandbyNodeId" in settings ? { standbyNodeId: typeof settings.syncStandbyNodeId === "string" ? settings.syncStandbyNodeId || undefined : undefined } : {}),
1333
1337
  ...(settings.sessionResumeMode === "auto" || settings.sessionResumeMode === "manual" ? { resume: settings.sessionResumeMode } : {}),
1334
1338
  ...(typeof settings.autoAttachToolImages === "boolean" ? { autoAttachToolImages: settings.autoAttachToolImages } : {}),
1339
+ ...(typeof settings.appScreenshots === "boolean" ? { appScreenshots: settings.appScreenshots } : {}),
1335
1340
  ...(Number.isInteger(settings.forkWorkspaceMaxBytes) ? { forkWorkspaceMaxBytes: Number(settings.forkWorkspaceMaxBytes) } : {}),
1336
1341
  };
1337
1342
  next.github = {
@@ -1418,6 +1423,15 @@ function nodeConfiguredDefaultAgent() {
1418
1423
  }
1419
1424
  /** How interactive sessions recover after a restart interrupted them mid-turn.
1420
1425
  * Defaults to "auto" (re-drive the turn); "manual" waits for a user tap. */
1426
+ /** Agents may screenshot app previews: the node setting, or BIVY_APP_SCREENSHOTS. */
1427
+ function appScreenshotsEnabled() {
1428
+ const env = process.env.BIVY_APP_SCREENSHOTS;
1429
+ if (env === "1" || env === "true")
1430
+ return true;
1431
+ if (env === "0" || env === "false")
1432
+ return false;
1433
+ return readSettings().appScreenshots === true;
1434
+ }
1421
1435
  function nodeSessionResumeMode() {
1422
1436
  return readSettings().sessionResumeMode === "manual" ? "manual" : "auto";
1423
1437
  }
@@ -1451,6 +1465,7 @@ function nodeSettingsSnapshot() {
1451
1465
  })(),
1452
1466
  sessionResumeMode: nodeSessionResumeMode(),
1453
1467
  autoAttachToolImages: readSettings().autoAttachToolImages === true,
1468
+ appScreenshots: appScreenshotsEnabled(),
1454
1469
  forkWorkspaceMaxBytes: Number.isInteger(readSettings().forkWorkspaceMaxBytes) ? Number(readSettings().forkWorkspaceMaxBytes) : 50 * 1024 * 1024,
1455
1470
  };
1456
1471
  }
@@ -1508,6 +1523,8 @@ async function applyNodeSettings(patch) {
1508
1523
  if ("sessionResumeMode" in patch) {
1509
1524
  settings.sessionResumeMode = patch.sessionResumeMode === "manual" ? "manual" : "auto";
1510
1525
  }
1526
+ if ("appScreenshots" in patch)
1527
+ settings.appScreenshots = patch.appScreenshots === true;
1511
1528
  if ("autoAttachToolImages" in patch) {
1512
1529
  settings.autoAttachToolImages = patch.autoAttachToolImages === true;
1513
1530
  setConfiguredAutoAttachToolImages(settings.autoAttachToolImages);
@@ -1906,14 +1923,26 @@ const appPreviewPort = Number(process.env.BIVY_APPS_PORT || 4318);
1906
1923
  if (process.env.BIVY_APPS_ORIGIN && (!Number.isInteger(appPreviewPort) || appPreviewPort < 1024 || appPreviewPort > 65535 || appPreviewPort === port)) {
1907
1924
  throw new Error("BIVY_APPS_PORT must be between 1024 and 65535 and different from the node API port.");
1908
1925
  }
1909
- const appRegistry = new AppRegistry([port, ...(process.env.BIVY_APPS_ORIGIN ? [appPreviewPort] : [])]);
1926
+ // Apps persist with their IDs, so chat launchers and preview addresses survive
1927
+ // restarts; see AppRegistry for which views are restored.
1928
+ const appRegistry = new AppRegistry([port, ...(process.env.BIVY_APPS_ORIGIN ? [appPreviewPort] : [])], path.join(appDir, "apps.json"));
1910
1929
  const appReturnOrigins = () => {
1911
1930
  const config = loadRelayConfig(appDir);
1912
1931
  return [config?.clientBaseUrl, config?.controlPlaneUrl, ...(process.env.BIVY_APPS_RETURN_ORIGINS ?? "").split(",")]
1913
1932
  .filter((value) => Boolean(value?.trim())).map((value) => new URL(value.trim()).origin);
1914
1933
  };
1915
- const appGateway = process.env.BIVY_APPS_ORIGIN ? new AppGateway(appRegistry, process.env.BIVY_APPS_ORIGIN, appReturnOrigins) : undefined;
1916
- const remotePreview = new RemotePreview(appRegistry, appReturnOrigins);
1934
+ // A signed-out visit to a preview's stable address goes through the Bivy
1935
+ // client: open the session on this node, then the client re-opens the view.
1936
+ const appSignIn = (view, pagePath) => {
1937
+ const client = loadRelayConfig(appDir)?.clientBaseUrl;
1938
+ if (!client)
1939
+ return undefined;
1940
+ return `${new URL(client).origin}/sessions/${encodeURIComponent(view.app.sessionId)}?node=${encodeURIComponent(identity.nodeId)}#preview=${view.app.id}.${view.view.id}.${encodeURIComponent(pagePath)}`;
1941
+ };
1942
+ const appGateway = process.env.BIVY_APPS_ORIGIN ? new AppGateway(appRegistry, process.env.BIVY_APPS_ORIGIN, appReturnOrigins, appSignIn) : undefined;
1943
+ const remotePreview = new RemotePreview(appRegistry, appReturnOrigins, appSignIn);
1944
+ // Desktop app views: one private display per view, started on first open.
1945
+ const appDisplays = new DisplayHost();
1917
1946
  const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
1918
1947
  start: async (spec) => {
1919
1948
  let failure = "Could not start the app terminal.";
@@ -1928,7 +1957,7 @@ const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
1928
1957
  },
1929
1958
  has: (id) => terminals.has(id),
1930
1959
  close: (id) => { terminals.close(id); },
1931
- });
1960
+ }, { screenshots: { enabled: appScreenshotsEnabled }, displays: appDisplays });
1932
1961
  const RELAY_COMMANDS = {
1933
1962
  ...createAppCommands(appService, (id) => { const record = resolveSession(id); return record ? harnessDirFor(record) : undefined; }, (app) => {
1934
1963
  const record = resolveSession(app.sessionId);
@@ -2187,6 +2216,11 @@ const RELAY_COMMANDS = {
2187
2216
  else
2188
2217
  abortSessionRecord(record, ctx.broadcast);
2189
2218
  },
2219
+ "session.limit_retry"(msg) {
2220
+ const record = resolveSession(msg.sessionId);
2221
+ if (record)
2222
+ setSessionLimitRetry(record, msg.enabled === true);
2223
+ },
2190
2224
  "session.turn_attention.resolve"(msg) {
2191
2225
  const record = resolveSession(msg.sessionId);
2192
2226
  const action = msg.action === "stop" ? "stop" : msg.action === "continue" ? "continue" : undefined;
@@ -2899,6 +2933,7 @@ const RELAY_COMMANDS = {
2899
2933
  // The user is driving this turn manually — supersede any pending auto-resume
2900
2934
  // that was scheduled after a prior limit so it can't re-fire on top of them.
2901
2935
  clearSessionResume(record.id);
2936
+ record.limitHit = undefined;
2902
2937
  await turnWatchdog.promptWithWatchdog(record, agentPrompt, record.lastPromptOptions);
2903
2938
  }).catch((error) => {
2904
2939
  // Mirror the HTTP path (see the /prompt route): a rejected turn after
@@ -6239,6 +6274,21 @@ function rememberSession(record) {
6239
6274
  persistSessionMetadata(record);
6240
6275
  recordSessionLocation(record);
6241
6276
  enforceOpenSessionCap(record.id);
6277
+ // Opened while a `bivy run` pinned to it is live (e.g. `bivy resume` on the
6278
+ // laptop, then the chat on a phone): the terminal owns it until it ends.
6279
+ runTerms.adoptLiveRun(record);
6280
+ }
6281
+ /** The open session a run's pinned id refers to — by Bivy id or by the
6282
+ * runtime's own session ref. Unlike resolveSession, never the active one. */
6283
+ function findOpenSession(ref) {
6284
+ const direct = openSessions.get(ref);
6285
+ if (direct)
6286
+ return direct;
6287
+ for (const record of openSessions.values()) {
6288
+ if (record.sessionFile === ref)
6289
+ return record;
6290
+ }
6291
+ return undefined;
6242
6292
  }
6243
6293
  // Keep the number of live (in-memory) sessions bounded. Idle-close already
6244
6294
  // trims over time, but a burst of opens within the idle window could pile up
@@ -7246,12 +7296,15 @@ function scheduleSessionResume(record, plan) {
7246
7296
  metadata.setResumeAt(record.id, plan.resumeAt);
7247
7297
  metadata.setResumeAttempts(record.id, attempts + 1);
7248
7298
  const when = Date.parse(plan.resumeAt);
7249
- const cond = plan.condition.replace(/_/g, " ");
7299
+ const at = Number.isFinite(when)
7300
+ ? new Date(when).toLocaleString("en-US", { timeZone: "UTC", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false })
7301
+ : plan.resumeAt;
7250
7302
  broadcast({
7251
7303
  type: "session.notice",
7252
7304
  sessionId: record.id,
7253
7305
  level: "info",
7254
- message: `Hit a ${cond} limit — I'll resume this automatically when it resets (${plan.resumeAt}).`,
7306
+ message: `I'll retry this automatically when the limit resets (${at} UTC).`,
7307
+ actions: ["cancel-resume"],
7255
7308
  });
7256
7309
  armSessionResumeTimer(record.id, Number.isFinite(when) ? when : Date.now());
7257
7310
  return true;
@@ -7290,6 +7343,23 @@ async function driveSessionResume(id) {
7290
7343
  console.warn(`[resume] auto-resume after a provider limit failed for ${id}`, error);
7291
7344
  }
7292
7345
  }
7346
+ /** The user's answer to a surfaced limit: opt in to retrying the turn when the
7347
+ * window resets (scheduled through the same durable resume path the policy
7348
+ * uses), or cancel a pending retry. */
7349
+ function setSessionLimitRetry(record, enabled) {
7350
+ if (!enabled) {
7351
+ clearSessionResume(record.id);
7352
+ metadata.setResumeAttempts(record.id, 0);
7353
+ broadcast({ type: "session.notice", sessionId: record.id, level: "info", message: "Automatic retry cancelled." });
7354
+ return;
7355
+ }
7356
+ const plan = record.limitHit && limitResumePlan(record.limitHit);
7357
+ if (!plan) {
7358
+ broadcast({ type: "session.notice", sessionId: record.id, level: "info", message: "There's no pending limit to retry — send a message to continue." });
7359
+ return;
7360
+ }
7361
+ scheduleSessionResume(record, plan);
7362
+ }
7293
7363
  /** Re-arm (or immediately fire) durable auto-resume markers. Runs once at boot
7294
7364
  * and on an interval, so a wait survives a restart and a capped timer's tail
7295
7365
  * still fires. */
@@ -7618,6 +7688,13 @@ function attachSessionListeners(record) {
7618
7688
  broadcast({ type: "session.failed", sessionId: record.id, failedAt: record.lastFailureAt });
7619
7689
  if (messageError)
7620
7690
  broadcast({ type: "session.error", sessionId: record.id, error: sessionAgentError(record, messageError) });
7691
+ // A usage/rate limit nothing recovered automatically: offer the ways past
7692
+ // it — fork to another agent now, or retry when the window resets.
7693
+ record.limitHit = detectSessionLimit(turnError, { resetsAtHint: limitResetHint(record, Date.now()) });
7694
+ if (record.limitHit) {
7695
+ const notice = sessionLimitNotice(getRuntime(record.runtimeId).displayName, record.limitHit);
7696
+ broadcast({ type: "session.notice", sessionId: record.id, level: "warn", ...notice });
7697
+ }
7621
7698
  // If the terminal error is an auth failure (expired key/token → 4xx),
7622
7699
  // also raise the sign-in sheet for the failing provider.
7623
7700
  maybeSignalAuthRequired(record, turnError);
@@ -8590,10 +8667,17 @@ const runTerms = createRunTerminals({
8590
8667
  sendNotificationHint: (hint) => void sendNotificationHint(hint),
8591
8668
  createSession: (workspace, sessionFile, opts) => createSession(workspace, sessionFile, opts),
8592
8669
  resolveSession: (id) => resolveSession(id),
8670
+ findOpenSession: (ref) => findOpenSession(ref),
8593
8671
  sessionBusy: (record) => sessionBusy(record),
8594
8672
  sessionTerminalsRecord: (sessionId, val) => sessionTerminals.record(sessionId, val),
8595
8673
  sessionTerminalsForget: (sessionId) => sessionTerminals.forget(sessionId),
8596
- upsertSessionMetadata: (patch) => metadata.upsertSession(patch),
8674
+ upsertSessionMetadata: (patch) => {
8675
+ const input = patch;
8676
+ // A run continuing a saved session (`bivy resume`) only moves its status;
8677
+ // the session keeps its own name, source and agent.
8678
+ const prev = metadata.getSession(input.id);
8679
+ metadata.upsertSession(prev ? { id: input.id, status: input.status } : input);
8680
+ },
8597
8681
  sessionListChanged: () => { broadcastSessionsList(); scheduleAdvertise(); },
8598
8682
  saveRunLog: (termId, log) => runLogs.save(termId, log),
8599
8683
  loadRunLog: (termId) => runLogs.load(termId),
@@ -11130,6 +11214,13 @@ app.post("/api/session/abort", (req, res, next) => {
11130
11214
  next(error);
11131
11215
  }
11132
11216
  });
11217
+ app.post("/api/session/limit-retry", (req, res) => {
11218
+ const record = resolveSession(req.body?.sessionId);
11219
+ if (!record)
11220
+ return res.status(404).json({ error: "No active session" });
11221
+ setSessionLimitRetry(record, req.body?.enabled === true);
11222
+ res.json({ ok: true });
11223
+ });
11133
11224
  app.post("/api/session/turn-attention", (req, res, next) => {
11134
11225
  try {
11135
11226
  const record = resolveSession(req.body?.sessionId);
@@ -11443,6 +11534,7 @@ function shutdown(signal) {
11443
11534
  }
11444
11535
  catch { }
11445
11536
  terminals.disposeAll();
11537
+ appDisplays.stopAll();
11446
11538
  // Drop persistent client/relay sockets so server.close() can actually drain;
11447
11539
  // otherwise a lingering WebSocket keeps the process alive until the supervisor
11448
11540
  // SIGKILLs it, orphaning children.