@bivy/bivy 0.17.3-staging.2 → 0.17.3-staging.21

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.
@@ -8,9 +8,15 @@ const optionalSession = { sessionId: Type.Optional(Type.String()) };
8
8
  * this table remains migration-compatible; adding validation is one data row. */
9
9
  export const CLIENT_COMMAND_SCHEMAS = {
10
10
  ping: Type.Object(request),
11
- "apps.list": Type.Object({ ...request, ...session }),
11
+ "apps.list": Type.Object({ ...request, ...optionalSession }),
12
+ "artifacts.list": Type.Object(request),
12
13
  "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 })) }),
14
+ "apps.offers": Type.Object({ ...request, ...session }),
15
+ "apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
16
+ "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 })) }),
17
+ "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 })) }),
18
+ "apps.clearNotes": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
19
+ "apps.logs": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
14
20
  "apps.share": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
15
21
  "apps.revoke": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
16
22
  "apps.remove": Type.Object({ ...request, ...session, appId: Type.String() }),
@@ -23,6 +29,8 @@ export const CLIENT_COMMAND_SCHEMAS = {
23
29
  }),
24
30
  "attachment.fetch": Type.Object({ ...request, hash: Type.String() }),
25
31
  "session.pause": Type.Object(session),
32
+ "session.presence.get": Type.Object({ ...request, ...session }),
33
+ "session.presence.draft": Type.Object({ ...request, ...session, device: Type.Object({ id: Type.String({ maxLength: 100 }), label: Type.Optional(Type.String({ maxLength: 200 })) }), text: Type.String({ maxLength: 20_000 }) }),
26
34
  "session.resume": Type.Object(session),
27
35
  "session.question.answer": Type.Object({ ...optionalSession, requestId: Type.String(), answers: Type.Optional(Type.Record(Type.String(), Type.String())) }),
28
36
  "session.replay": Type.Object({ ...session, afterSeq: Type.Optional(Type.Number()) }),
@@ -37,6 +45,7 @@ export const CLIENT_COMMAND_SCHEMAS = {
37
45
  "session.rename": Type.Object({ ...session, name: Type.String() }),
38
46
  abort: Type.Object(optionalSession),
39
47
  "session.turn_attention.resolve": Type.Object({ ...session, action: Type.Union([Type.Literal("stop"), Type.Literal("continue")]) }),
48
+ "session.limit_retry": Type.Object({ ...session, enabled: Type.Boolean() }),
40
49
  "session.command.invoke": Type.Object({ ...session, name: Type.String(), args: Type.Optional(Type.String()) }),
41
50
  "branches.list": Type.Object({ repo: Type.String() }),
42
51
  "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";
@@ -112,6 +114,9 @@ import { listInstalledPlugins } from "./plugins/store.js";
112
114
  import { createCapabilitiesController } from "./controllers/capabilities.js";
113
115
  import { createAccessDeviceController, createLinkedDeviceController } from "./controllers/devices.js";
114
116
  import { createSessionControlCommands } from "./controllers/session-control.js";
117
+ import { createPresenceCommands } from "./controllers/presence-commands.js";
118
+ import { createArtifactCommands } from "./controllers/artifact-commands.js";
119
+ import { PresenceBook, deviceFrom } from "./session/presence.js";
115
120
  import { createForkCommands } from "./controllers/fork-commands.js";
116
121
  import { createGithubCommands } from "./controllers/github-commands.js";
117
122
  import { createCredentialCommands } from "./controllers/credential-commands.js";
@@ -963,6 +968,8 @@ async function harnessEndTurn(record) {
963
968
  record.workspaceState = (await harness.isDirty(record.id)) ? "dirty" : "clean";
964
969
  if (!result || result.changes.length === 0)
965
970
  return;
971
+ // Open previews of this session reload; static snapshots are re-taken.
972
+ appService.turnChanged(record.id);
966
973
  broadcast({
967
974
  type: "session.changes",
968
975
  sessionId: record.id,
@@ -1332,6 +1339,7 @@ function writeSettings(settings) {
1332
1339
  ...("syncStandbyNodeId" in settings ? { standbyNodeId: typeof settings.syncStandbyNodeId === "string" ? settings.syncStandbyNodeId || undefined : undefined } : {}),
1333
1340
  ...(settings.sessionResumeMode === "auto" || settings.sessionResumeMode === "manual" ? { resume: settings.sessionResumeMode } : {}),
1334
1341
  ...(typeof settings.autoAttachToolImages === "boolean" ? { autoAttachToolImages: settings.autoAttachToolImages } : {}),
1342
+ ...(typeof settings.appScreenshots === "boolean" ? { appScreenshots: settings.appScreenshots } : {}),
1335
1343
  ...(Number.isInteger(settings.forkWorkspaceMaxBytes) ? { forkWorkspaceMaxBytes: Number(settings.forkWorkspaceMaxBytes) } : {}),
1336
1344
  };
1337
1345
  next.github = {
@@ -1418,6 +1426,15 @@ function nodeConfiguredDefaultAgent() {
1418
1426
  }
1419
1427
  /** How interactive sessions recover after a restart interrupted them mid-turn.
1420
1428
  * Defaults to "auto" (re-drive the turn); "manual" waits for a user tap. */
1429
+ /** Agents may screenshot app previews: the node setting, or BIVY_APP_SCREENSHOTS. */
1430
+ function appScreenshotsEnabled() {
1431
+ const env = process.env.BIVY_APP_SCREENSHOTS;
1432
+ if (env === "1" || env === "true")
1433
+ return true;
1434
+ if (env === "0" || env === "false")
1435
+ return false;
1436
+ return readSettings().appScreenshots === true;
1437
+ }
1421
1438
  function nodeSessionResumeMode() {
1422
1439
  return readSettings().sessionResumeMode === "manual" ? "manual" : "auto";
1423
1440
  }
@@ -1451,6 +1468,7 @@ function nodeSettingsSnapshot() {
1451
1468
  })(),
1452
1469
  sessionResumeMode: nodeSessionResumeMode(),
1453
1470
  autoAttachToolImages: readSettings().autoAttachToolImages === true,
1471
+ appScreenshots: appScreenshotsEnabled(),
1454
1472
  forkWorkspaceMaxBytes: Number.isInteger(readSettings().forkWorkspaceMaxBytes) ? Number(readSettings().forkWorkspaceMaxBytes) : 50 * 1024 * 1024,
1455
1473
  };
1456
1474
  }
@@ -1508,6 +1526,8 @@ async function applyNodeSettings(patch) {
1508
1526
  if ("sessionResumeMode" in patch) {
1509
1527
  settings.sessionResumeMode = patch.sessionResumeMode === "manual" ? "manual" : "auto";
1510
1528
  }
1529
+ if ("appScreenshots" in patch)
1530
+ settings.appScreenshots = patch.appScreenshots === true;
1511
1531
  if ("autoAttachToolImages" in patch) {
1512
1532
  settings.autoAttachToolImages = patch.autoAttachToolImages === true;
1513
1533
  setConfiguredAutoAttachToolImages(settings.autoAttachToolImages);
@@ -1659,6 +1679,19 @@ const sessionEvents = new SessionEventCoalescer({
1659
1679
  function broadcastTuiState(sessionId, active) {
1660
1680
  broadcast({ type: "terminal.tui", sessionId, active });
1661
1681
  }
1682
+ // Device handoff (src/session/presence.ts): which device last drove each
1683
+ // session and the draft it left, so the next device can pick up from there.
1684
+ const presenceBook = new PresenceBook();
1685
+ function publishPresence(presence) {
1686
+ broadcast({ type: "session.presence", presence });
1687
+ }
1688
+ function notePresence(sessionId, device, via) {
1689
+ if (!sessionId || !device)
1690
+ return;
1691
+ const changed = presenceBook.drove(sessionId, device, via);
1692
+ if (changed)
1693
+ publishPresence(changed);
1694
+ }
1662
1695
  // Terminals opened by relay clients (phone/web over the relay). Output is emitted
1663
1696
  // via the relay tagged with termId; clients filter by it. (Per-client unicast
1664
1697
  // is a known future hardening step.)
@@ -1802,6 +1835,20 @@ const replication = new ReplicationService({
1802
1835
  },
1803
1836
  log: (m) => console.error(`[replication] ${m}`),
1804
1837
  });
1838
+ /** Take over a replicated session on this node (manual promotion). Once it
1839
+ * wins the epoch it is this node's session, not a standby copy: retag it so
1840
+ * every client stops offering "Continue here", and push the new list. */
1841
+ async function promoteReplicaHere(sessionId) {
1842
+ const epoch = await replication.promote(sessionId, identity.nodeId);
1843
+ if (epoch === undefined)
1844
+ return undefined;
1845
+ const source = metadata.getSession(sessionId)?.source;
1846
+ if (source?.startsWith("replica"))
1847
+ metadata.upsertSession({ id: sessionId, source: source.replace(/^replica/, "promoted") });
1848
+ broadcastSessionsList();
1849
+ scheduleAdvertise();
1850
+ return epoch;
1851
+ }
1805
1852
  // Retire the legacy overlay sidecars. The append-only log is the sole overlay
1806
1853
  // store now and every session was migrated into it by the prior release's boot
1807
1854
  // migration (.migrated-overlays-v1), verified complete on-disk before this landed.
@@ -1894,10 +1941,13 @@ const dedupeSessionNew = (requestId, create) => sessionNewDedupe.run(requestId,
1894
1941
  // generic key->promise cache as above, reused for a different key) — see
1895
1942
  // issue #154's queued follow-ups. A client that isn't sure whether a queued
1896
1943
  // item's send actually reached the node before the socket dropped (see
1897
- // AppController.retryStuckFollowups) resends it verbatim after reconnecting;
1944
+ // FollowupCoordinator.retrySending) resends it verbatim after reconnecting;
1898
1945
  // this makes that safe by collapsing a retried clientMessageId onto the
1899
- // original broadcast + turn instead of double-prompting the runtime.
1900
- const promptDedupe = createSessionNewDedupe();
1946
+ // original broadcast + turn instead of double-prompting the runtime. The key is
1947
+ // kept for a day, not session.new's 10 minutes: a phone backgrounded mid-send
1948
+ // can reconnect long after the turn ended, and an expired key turned its
1949
+ // retry into a fresh, duplicate turn at the bottom of the chat.
1950
+ const promptDedupe = createSessionNewDedupe({ ttlMs: 24 * 60 * 60_000 });
1901
1951
  const dedupePrompt = (clientMessageId, run) => promptDedupe.run(clientMessageId, run);
1902
1952
  // Preview traffic has isolated per-view origins and a private gateway. Relay
1903
1953
  // delivery needs no listener; direct ingress is an optional operator override.
@@ -1906,14 +1956,26 @@ const appPreviewPort = Number(process.env.BIVY_APPS_PORT || 4318);
1906
1956
  if (process.env.BIVY_APPS_ORIGIN && (!Number.isInteger(appPreviewPort) || appPreviewPort < 1024 || appPreviewPort > 65535 || appPreviewPort === port)) {
1907
1957
  throw new Error("BIVY_APPS_PORT must be between 1024 and 65535 and different from the node API port.");
1908
1958
  }
1909
- const appRegistry = new AppRegistry([port, ...(process.env.BIVY_APPS_ORIGIN ? [appPreviewPort] : [])]);
1959
+ // Apps persist with their IDs, so chat launchers and preview addresses survive
1960
+ // restarts; see AppRegistry for which views are restored.
1961
+ const appRegistry = new AppRegistry([port, ...(process.env.BIVY_APPS_ORIGIN ? [appPreviewPort] : [])], path.join(appDir, "apps.json"));
1910
1962
  const appReturnOrigins = () => {
1911
1963
  const config = loadRelayConfig(appDir);
1912
1964
  return [config?.clientBaseUrl, config?.controlPlaneUrl, ...(process.env.BIVY_APPS_RETURN_ORIGINS ?? "").split(",")]
1913
1965
  .filter((value) => Boolean(value?.trim())).map((value) => new URL(value.trim()).origin);
1914
1966
  };
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);
1967
+ // A signed-out visit to a preview's stable address goes through the Bivy
1968
+ // client: open the session on this node, then the client re-opens the view.
1969
+ const appSignIn = (view, pagePath) => {
1970
+ const client = loadRelayConfig(appDir)?.clientBaseUrl;
1971
+ if (!client)
1972
+ return undefined;
1973
+ return `${new URL(client).origin}/sessions/${encodeURIComponent(view.app.sessionId)}?node=${encodeURIComponent(identity.nodeId)}#preview=${view.app.id}.${view.view.id}.${encodeURIComponent(pagePath)}`;
1974
+ };
1975
+ const appGateway = process.env.BIVY_APPS_ORIGIN ? new AppGateway(appRegistry, process.env.BIVY_APPS_ORIGIN, appReturnOrigins, appSignIn) : undefined;
1976
+ const remotePreview = new RemotePreview(appRegistry, appReturnOrigins, appSignIn);
1977
+ // Desktop app views: one private display per view, started on first open.
1978
+ const appDisplays = new DisplayHost();
1917
1979
  const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
1918
1980
  start: async (spec) => {
1919
1981
  let failure = "Could not start the app terminal.";
@@ -1928,6 +1990,12 @@ const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
1928
1990
  },
1929
1991
  has: (id) => terminals.has(id),
1930
1992
  close: (id) => { terminals.close(id); },
1993
+ }, { screenshots: { enabled: appScreenshotsEnabled }, displays: appDisplays });
1994
+ // A reviewer's note shows up in an open Apps sheet without reopening it.
1995
+ appRegistry.on("notes", (viewId) => {
1996
+ const entry = appRegistry.getView(viewId);
1997
+ if (entry)
1998
+ broadcast({ type: "apps.changed", sessionId: entry.app.sessionId });
1931
1999
  });
1932
2000
  const RELAY_COMMANDS = {
1933
2001
  ...createAppCommands(appService, (id) => { const record = resolveSession(id); return record ? harnessDirFor(record) : undefined; }, (app) => {
@@ -1971,6 +2039,12 @@ const RELAY_COMMANDS = {
1971
2039
  const meta = attachmentStore.readMeta(hash);
1972
2040
  ctx.reply({ type: "attachment.data", requestId, hash, mimeType: meta?.mimeType ?? "application/octet-stream", name: meta?.name, data: bytes.toString("base64") });
1973
2041
  },
2042
+ ...createArtifactCommands({
2043
+ sessionIds: () => new Set([...metadata.listSessions().map((session) => session.id), ...[...openSessions.values()].map((record) => record.id)]),
2044
+ scan: (id) => eventLog.scan(id, ["outbound-attachment"]),
2045
+ stored: (hash) => attachmentStore.getPath(hash) !== null,
2046
+ }),
2047
+ ...createPresenceCommands(presenceBook, publishPresence),
1974
2048
  ...createSessionControlCommands({
1975
2049
  resolve: (sessionId) => resolveSession(sessionId),
1976
2050
  pause: pauseSession,
@@ -2187,6 +2261,11 @@ const RELAY_COMMANDS = {
2187
2261
  else
2188
2262
  abortSessionRecord(record, ctx.broadcast);
2189
2263
  },
2264
+ "session.limit_retry"(msg) {
2265
+ const record = resolveSession(msg.sessionId);
2266
+ if (record)
2267
+ setSessionLimitRetry(record, msg.enabled === true);
2268
+ },
2190
2269
  "session.turn_attention.resolve"(msg) {
2191
2270
  const record = resolveSession(msg.sessionId);
2192
2271
  const action = msg.action === "stop" ? "stop" : msg.action === "continue" ? "continue" : undefined;
@@ -2729,10 +2808,9 @@ const RELAY_COMMANDS = {
2729
2808
  const sessionId = String(msg.sessionId ?? "").trim();
2730
2809
  if (!sessionId)
2731
2810
  return ctx.reply({ type: "session.promote.result", requestId, ok: false, error: "Missing sessionId" });
2732
- const epoch = await replication.promote(sessionId, identity.nodeId);
2811
+ const epoch = await promoteReplicaHere(sessionId);
2733
2812
  if (epoch === undefined)
2734
2813
  return ctx.reply({ type: "session.promote.result", requestId, ok: false, error: "Promotion lost the epoch race" });
2735
- scheduleAdvertise();
2736
2814
  ctx.reply({ type: "session.promote.result", requestId, ok: true, sessionId, epoch });
2737
2815
  },
2738
2816
  // Ephemeral provisioning transport (node-broker path). A remote device that
@@ -2835,7 +2913,7 @@ const RELAY_COMMANDS = {
2835
2913
  relay?.sendEvent({ type: "integrations.updated", integrations: integrations.list() });
2836
2914
  }
2837
2915
  },
2838
- async prompt(msg) {
2916
+ async prompt(msg, ctx) {
2839
2917
  const text = String(msg.text ?? "").trim();
2840
2918
  const { images, imageNotes, imageRefs, files } = attachmentsFrom(msg.attachments);
2841
2919
  if (!text && !images.length && !files.length)
@@ -2876,6 +2954,7 @@ const RELAY_COMMANDS = {
2876
2954
  broadcast({ type: "session.error", sessionId: record.id, error: record.tuiRefreshing ? "This session is returning from the terminal. Try again in a moment." : "This session is open in the terminal (TUI). Close the TUI to chat here." });
2877
2955
  return;
2878
2956
  }
2957
+ notePresence(record.id, deviceFrom(msg.device), "chat");
2879
2958
  touchSession(record);
2880
2959
  // Now that the session (and its workdir) exists, write file attachments to
2881
2960
  // disk and fold their path notes into the prompt the agent actually sees.
@@ -2887,6 +2966,14 @@ const RELAY_COMMANDS = {
2887
2966
  eventLog.appendAttachments(record.id, promptText, [...imageRefs, ...fileRefs]);
2888
2967
  const agentPrompt = promptForAgent(record, promptText);
2889
2968
  const cmid = typeof msg.clientMessageId === "string" && msg.clientMessageId ? msg.clientMessageId : undefined;
2969
+ // A retry of a prompt we already took: the caller missed our echo (that is
2970
+ // why it retried), so acknowledge it again — to the caller only, since
2971
+ // other clients already rendered the first one. Without this the client
2972
+ // keeps the message "sending" and resends it on every reconnect.
2973
+ if (promptDedupe.has(cmid)) {
2974
+ ctx.reply({ type: "session.user_message", sessionId: record.id, text: promptText, clientMessageId: cmid, retry: true });
2975
+ return;
2976
+ }
2890
2977
  void dedupePrompt(cmid, async () => {
2891
2978
  broadcast({ type: "session.user_message", sessionId: record.id, text: promptText, clientMessageId: msg.clientMessageId });
2892
2979
  void sessionNamer.maybeNameSession(record, promptText);
@@ -2899,6 +2986,7 @@ const RELAY_COMMANDS = {
2899
2986
  // The user is driving this turn manually — supersede any pending auto-resume
2900
2987
  // that was scheduled after a prior limit so it can't re-fire on top of them.
2901
2988
  clearSessionResume(record.id);
2989
+ record.limitHit = undefined;
2902
2990
  await turnWatchdog.promptWithWatchdog(record, agentPrompt, record.lastPromptOptions);
2903
2991
  }).catch((error) => {
2904
2992
  // Mirror the HTTP path (see the /prompt route): a rejected turn after
@@ -3017,6 +3105,8 @@ async function handleRelayMessage(msg) {
3017
3105
  // Fallthrough for kinds not in RELAY_COMMANDS: terminal.* frames go to the
3018
3106
  // PTY manager; anything else is an unknown client message.
3019
3107
  if (typeof msg.kind === "string" && msg.kind.startsWith("terminal.")) {
3108
+ if (msg.kind === "terminal.attach")
3109
+ notePresence(terminals.meta(String(msg.termId ?? ""))?.sessionId, deviceFrom(msg.device), "terminal");
3020
3110
  // The relay is a single tunnel with no per-remote-client identity at this
3021
3111
  // layer, so every relay-tunneled client shares one size slot. That still
3022
3112
  // keeps them distinct from each local socket, so a PTY shared between a
@@ -6239,6 +6329,21 @@ function rememberSession(record) {
6239
6329
  persistSessionMetadata(record);
6240
6330
  recordSessionLocation(record);
6241
6331
  enforceOpenSessionCap(record.id);
6332
+ // Opened while a `bivy run` pinned to it is live (e.g. `bivy resume` on the
6333
+ // laptop, then the chat on a phone): the terminal owns it until it ends.
6334
+ runTerms.adoptLiveRun(record);
6335
+ }
6336
+ /** The open session a run's pinned id refers to — by Bivy id or by the
6337
+ * runtime's own session ref. Unlike resolveSession, never the active one. */
6338
+ function findOpenSession(ref) {
6339
+ const direct = openSessions.get(ref);
6340
+ if (direct)
6341
+ return direct;
6342
+ for (const record of openSessions.values()) {
6343
+ if (record.sessionFile === ref)
6344
+ return record;
6345
+ }
6346
+ return undefined;
6242
6347
  }
6243
6348
  // Keep the number of live (in-memory) sessions bounded. Idle-close already
6244
6349
  // trims over time, but a burst of opens within the idle window could pile up
@@ -7246,12 +7351,15 @@ function scheduleSessionResume(record, plan) {
7246
7351
  metadata.setResumeAt(record.id, plan.resumeAt);
7247
7352
  metadata.setResumeAttempts(record.id, attempts + 1);
7248
7353
  const when = Date.parse(plan.resumeAt);
7249
- const cond = plan.condition.replace(/_/g, " ");
7354
+ const at = Number.isFinite(when)
7355
+ ? new Date(when).toLocaleString("en-US", { timeZone: "UTC", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false })
7356
+ : plan.resumeAt;
7250
7357
  broadcast({
7251
7358
  type: "session.notice",
7252
7359
  sessionId: record.id,
7253
7360
  level: "info",
7254
- message: `Hit a ${cond} limit — I'll resume this automatically when it resets (${plan.resumeAt}).`,
7361
+ message: `I'll retry this automatically when the limit resets (${at} UTC).`,
7362
+ actions: ["cancel-resume"],
7255
7363
  });
7256
7364
  armSessionResumeTimer(record.id, Number.isFinite(when) ? when : Date.now());
7257
7365
  return true;
@@ -7290,6 +7398,23 @@ async function driveSessionResume(id) {
7290
7398
  console.warn(`[resume] auto-resume after a provider limit failed for ${id}`, error);
7291
7399
  }
7292
7400
  }
7401
+ /** The user's answer to a surfaced limit: opt in to retrying the turn when the
7402
+ * window resets (scheduled through the same durable resume path the policy
7403
+ * uses), or cancel a pending retry. */
7404
+ function setSessionLimitRetry(record, enabled) {
7405
+ if (!enabled) {
7406
+ clearSessionResume(record.id);
7407
+ metadata.setResumeAttempts(record.id, 0);
7408
+ broadcast({ type: "session.notice", sessionId: record.id, level: "info", message: "Automatic retry cancelled." });
7409
+ return;
7410
+ }
7411
+ const plan = record.limitHit && limitResumePlan(record.limitHit);
7412
+ if (!plan) {
7413
+ broadcast({ type: "session.notice", sessionId: record.id, level: "info", message: "There's no pending limit to retry — send a message to continue." });
7414
+ return;
7415
+ }
7416
+ scheduleSessionResume(record, plan);
7417
+ }
7293
7418
  /** Re-arm (or immediately fire) durable auto-resume markers. Runs once at boot
7294
7419
  * and on an interval, so a wait survives a restart and a capped timer's tail
7295
7420
  * still fires. */
@@ -7618,6 +7743,13 @@ function attachSessionListeners(record) {
7618
7743
  broadcast({ type: "session.failed", sessionId: record.id, failedAt: record.lastFailureAt });
7619
7744
  if (messageError)
7620
7745
  broadcast({ type: "session.error", sessionId: record.id, error: sessionAgentError(record, messageError) });
7746
+ // A usage/rate limit nothing recovered automatically: offer the ways past
7747
+ // it — fork to another agent now, or retry when the window resets.
7748
+ record.limitHit = detectSessionLimit(turnError, { resetsAtHint: limitResetHint(record, Date.now()) });
7749
+ if (record.limitHit) {
7750
+ const notice = sessionLimitNotice(getRuntime(record.runtimeId).displayName, record.limitHit);
7751
+ broadcast({ type: "session.notice", sessionId: record.id, level: "warn", ...notice });
7752
+ }
7621
7753
  // If the terminal error is an auth failure (expired key/token → 4xx),
7622
7754
  // also raise the sign-in sheet for the failing provider.
7623
7755
  maybeSignalAuthRequired(record, turnError);
@@ -8590,10 +8722,17 @@ const runTerms = createRunTerminals({
8590
8722
  sendNotificationHint: (hint) => void sendNotificationHint(hint),
8591
8723
  createSession: (workspace, sessionFile, opts) => createSession(workspace, sessionFile, opts),
8592
8724
  resolveSession: (id) => resolveSession(id),
8725
+ findOpenSession: (ref) => findOpenSession(ref),
8593
8726
  sessionBusy: (record) => sessionBusy(record),
8594
8727
  sessionTerminalsRecord: (sessionId, val) => sessionTerminals.record(sessionId, val),
8595
8728
  sessionTerminalsForget: (sessionId) => sessionTerminals.forget(sessionId),
8596
- upsertSessionMetadata: (patch) => metadata.upsertSession(patch),
8729
+ upsertSessionMetadata: (patch) => {
8730
+ const input = patch;
8731
+ // A run continuing a saved session (`bivy resume`) only moves its status;
8732
+ // the session keeps its own name, source and agent.
8733
+ const prev = metadata.getSession(input.id);
8734
+ metadata.upsertSession(prev ? { id: input.id, status: input.status } : input);
8735
+ },
8597
8736
  sessionListChanged: () => { broadcastSessionsList(); scheduleAdvertise(); },
8598
8737
  saveRunLog: (termId, log) => runLogs.save(termId, log),
8599
8738
  loadRunLog: (termId) => runLogs.load(termId),
@@ -11048,6 +11187,7 @@ app.post("/api/session/prompt", async (req, res, next) => {
11048
11187
  if (record.tuiTermId || record.tuiRefreshing) {
11049
11188
  return res.status(409).json({ error: record.tuiRefreshing ? "This session is returning from the terminal. Try again in a moment." : "This session is open in the terminal (TUI). Close the TUI to chat here." });
11050
11189
  }
11190
+ notePresence(record.id, deviceFrom(req.body?.device), "chat");
11051
11191
  const session = record.session;
11052
11192
  const { note: fileNote, refs: fileRefs } = materializeAttachments(record, files);
11053
11193
  const promptText = [text, imageNotes.join("\n"), fileNote].filter(Boolean).join("\n\n") ||
@@ -11130,6 +11270,13 @@ app.post("/api/session/abort", (req, res, next) => {
11130
11270
  next(error);
11131
11271
  }
11132
11272
  });
11273
+ app.post("/api/session/limit-retry", (req, res) => {
11274
+ const record = resolveSession(req.body?.sessionId);
11275
+ if (!record)
11276
+ return res.status(404).json({ error: "No active session" });
11277
+ setSessionLimitRetry(record, req.body?.enabled === true);
11278
+ res.json({ ok: true });
11279
+ });
11133
11280
  app.post("/api/session/turn-attention", (req, res, next) => {
11134
11281
  try {
11135
11282
  const record = resolveSession(req.body?.sessionId);
@@ -11154,10 +11301,9 @@ app.post("/api/session/promote", async (req, res, next) => {
11154
11301
  const sessionId = String(req.body?.sessionId ?? "").trim();
11155
11302
  if (!sessionId)
11156
11303
  return res.status(400).json({ error: "Missing sessionId" });
11157
- const epoch = await replication.promote(sessionId, identity.nodeId);
11304
+ const epoch = await promoteReplicaHere(sessionId);
11158
11305
  if (epoch === undefined)
11159
11306
  return res.status(409).json({ error: "Promotion lost the epoch race (another node owns it, or it isn't replicated)" });
11160
- scheduleAdvertise();
11161
11307
  res.json({ ok: true, epoch });
11162
11308
  }
11163
11309
  catch (error) {
@@ -11375,6 +11521,7 @@ wss.on("connection", (socket, req) => {
11375
11521
  // Stable id for this socket, keying its per-terminal size so several clients
11376
11522
  // sharing a PTY size it to their min (see TerminalManager.setClientSize).
11377
11523
  const clientTerminalId = `sock-${randomUUID()}`;
11524
+ let socketDevice;
11378
11525
  socket.send(JSON.stringify({ type: "hello", activeSessionId: active?.id, activeSession: active ? { id: active.id, isStreaming: sessionBusy(active), sessionState: sessionState(active), lastActivity: active.lastActivity, workingStartedAt: active.workingStartedAt } : null }));
11379
11526
  // Authoritative version status on every connect: `latest` set means this node
11380
11527
  // is behind (banner shows); absent means up to date (banner + any "Updating…"
@@ -11399,6 +11546,12 @@ wss.on("connection", (socket, req) => {
11399
11546
  return;
11400
11547
  }
11401
11548
  if (typeof msg?.kind === "string" && msg.kind.startsWith("terminal.")) {
11549
+ // A terminal client (`bivy run`/`bivy resume`) names its device once; typing
11550
+ // into a session's terminal then makes it that session's driver.
11551
+ socketDevice = deviceFrom(msg.device) ?? socketDevice;
11552
+ if (msg.kind === "terminal.input" || msg.kind === "terminal.attach") {
11553
+ notePresence(terminals.meta(String(msg.termId ?? ""))?.sessionId, socketDevice, "terminal");
11554
+ }
11402
11555
  runTerms.handleTerminalMessage(msg, (event) => {
11403
11556
  if (socket.readyState === WebSocket.OPEN)
11404
11557
  socket.send(JSON.stringify(event));
@@ -11443,6 +11596,7 @@ function shutdown(signal) {
11443
11596
  }
11444
11597
  catch { }
11445
11598
  terminals.disposeAll();
11599
+ appDisplays.stopAll();
11446
11600
  // Drop persistent client/relay sockets so server.close() can actually drain;
11447
11601
  // otherwise a lingering WebSocket keeps the process alive until the supervisor
11448
11602
  // SIGKILLs it, orphaning children.
@@ -0,0 +1,82 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Which device is driving a session, and the draft it left behind. This is a
5
+ // soft lease, not a lock. Nothing is ever refused because another device drove
6
+ // last; it only lets the next device say "you were on your Mac a minute ago" and
7
+ // carry the unsent draft over. Devices name themselves (the room key already
8
+ // trusts every linked device, so this is presentation, not authentication).
9
+ // In-memory: a handoff is minutes-scale, and a node restart is a fine reset.
10
+ /** Longest device label kept; they're shown in a one-line strip. */
11
+ const LABEL_MAX = 60;
12
+ /** Longest draft carried between devices. */
13
+ const DRAFT_MAX = 20_000;
14
+ /** A driver that keeps typing re-announces at most this often. */
15
+ const REANNOUNCE_MS = 30_000;
16
+ /** Presence older than this is forgotten. */
17
+ const TTL_MS = 6 * 60 * 60 * 1000;
18
+ /** A device reference from untrusted client input, or undefined. */
19
+ export function deviceFrom(value) {
20
+ if (!value || typeof value !== "object")
21
+ return undefined;
22
+ const { id, label } = value;
23
+ if (typeof id !== "string" || !id.trim() || id.length > 100)
24
+ return undefined;
25
+ const name = typeof label === "string" && label.trim() ? label.trim().slice(0, LABEL_MAX) : "Another device";
26
+ return { id: id.trim(), label: name };
27
+ }
28
+ export class PresenceBook {
29
+ now;
30
+ sessions = new Map();
31
+ constructor(now = Date.now) {
32
+ this.now = now;
33
+ }
34
+ get(sessionId) {
35
+ this.expire();
36
+ return this.sessions.get(sessionId) ?? { sessionId };
37
+ }
38
+ /** A device sent input to the session. Returns the presence to broadcast when
39
+ * the driver changed (or is due a re-announce), else undefined. A chat send
40
+ * also consumes that device's draft, since the draft has now been sent. */
41
+ drove(sessionId, device, via) {
42
+ const at = this.now();
43
+ const entry = this.entry(sessionId);
44
+ const previous = entry.driver;
45
+ entry.driver = { ...device, via, at };
46
+ const sentDraft = via === "chat" && entry.draft?.device.id === device.id;
47
+ if (sentDraft)
48
+ entry.draft = undefined;
49
+ const changed = !previous || previous.id !== device.id || previous.via !== via || at - previous.at >= REANNOUNCE_MS;
50
+ return changed || sentDraft ? { ...entry } : undefined;
51
+ }
52
+ /** A device's unsent draft for the session (empty text clears it). */
53
+ draft(sessionId, device, text) {
54
+ const entry = this.entry(sessionId);
55
+ const trimmed = text.slice(0, DRAFT_MAX);
56
+ if (trimmed.trim())
57
+ entry.draft = { device, text: trimmed, at: this.now() };
58
+ else if (entry.draft?.device.id === device.id)
59
+ entry.draft = undefined;
60
+ return { ...entry };
61
+ }
62
+ entry(sessionId) {
63
+ this.expire();
64
+ let entry = this.sessions.get(sessionId);
65
+ if (!entry) {
66
+ entry = { sessionId };
67
+ this.sessions.set(sessionId, entry);
68
+ }
69
+ return entry;
70
+ }
71
+ expire() {
72
+ const cutoff = this.now() - TTL_MS;
73
+ for (const [id, entry] of this.sessions) {
74
+ if (entry.driver && entry.driver.at < cutoff)
75
+ entry.driver = undefined;
76
+ if (entry.draft && entry.draft.at < cutoff)
77
+ entry.draft = undefined;
78
+ if (!entry.driver && !entry.draft)
79
+ this.sessions.delete(id);
80
+ }
81
+ }
82
+ }