@bivy/bivy 0.17.3-staging.9 → 0.18.0

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/server.js CHANGED
@@ -32,7 +32,8 @@ import { AppRegistry } from "./apps/registry.js";
32
32
  import { AppGateway } from "./apps/gateway.js";
33
33
  import { RemotePreview } from "./apps/remote-preview.js";
34
34
  import { AppService } from "./apps/service.js";
35
- import { DisplayHost } from "./apps/display.js";
35
+ import { pngSize, reviewHint } from "./apps/review.js";
36
+ import { createDisplayHost } from "./apps/display.js";
36
37
  import { createAppCommands } from "./controllers/app-commands.js";
37
38
  import { bindClientCommandRoutes } from "./http/client-command-routes.js";
38
39
  import { collectDiscoveredSessions, planNativeAdoption } from "./runtime/native-session-discovery.js";
@@ -114,6 +115,9 @@ import { listInstalledPlugins } from "./plugins/store.js";
114
115
  import { createCapabilitiesController } from "./controllers/capabilities.js";
115
116
  import { createAccessDeviceController, createLinkedDeviceController } from "./controllers/devices.js";
116
117
  import { createSessionControlCommands } from "./controllers/session-control.js";
118
+ import { createPresenceCommands } from "./controllers/presence-commands.js";
119
+ import { createArtifactCommands } from "./controllers/artifact-commands.js";
120
+ import { PresenceBook, deviceFrom } from "./session/presence.js";
117
121
  import { createForkCommands } from "./controllers/fork-commands.js";
118
122
  import { createGithubCommands } from "./controllers/github-commands.js";
119
123
  import { createCredentialCommands } from "./controllers/credential-commands.js";
@@ -918,6 +922,9 @@ function harnessDirFor(record) {
918
922
  * changes card — acceptable for a best-effort diff, and the prompt no longer
919
923
  * waits on git. Never throws into the prompt path. */
920
924
  function harnessBeginTurn(record) {
925
+ // A run starts: review cards measure what the app looked like before it.
926
+ appService.runStarted(record.id);
927
+ record.runReview = undefined;
921
928
  const dir = harnessDirFor(record);
922
929
  record.harnessTurnReady = undefined;
923
930
  record.harnessTurnFinished = false;
@@ -949,9 +956,12 @@ function finishHarnessTurn(record) {
949
956
  if (record.harnessTurnFinished)
950
957
  return;
951
958
  record.harnessTurnFinished = true;
952
- void harnessEndTurn(record).finally(() => {
959
+ const ended = harnessEndTurn(record);
960
+ void ended.finally(() => {
953
961
  void replication.onTurnComplete(record.id);
954
962
  });
963
+ // After the diff has bumped preview revisions: decide on this run's review card.
964
+ record.runReview = ended.then(() => appService.runEnded(record.id)).catch(() => undefined);
955
965
  }
956
966
  async function harnessEndTurn(record) {
957
967
  const previous = record.workspaceState === "dirty" ? "dirty" : "clean";
@@ -1676,6 +1686,19 @@ const sessionEvents = new SessionEventCoalescer({
1676
1686
  function broadcastTuiState(sessionId, active) {
1677
1687
  broadcast({ type: "terminal.tui", sessionId, active });
1678
1688
  }
1689
+ // Device handoff (src/session/presence.ts): which device last drove each
1690
+ // session and the draft it left, so the next device can pick up from there.
1691
+ const presenceBook = new PresenceBook();
1692
+ function publishPresence(presence) {
1693
+ broadcast({ type: "session.presence", presence });
1694
+ }
1695
+ function notePresence(sessionId, device, via) {
1696
+ if (!sessionId || !device)
1697
+ return;
1698
+ const changed = presenceBook.drove(sessionId, device, via);
1699
+ if (changed)
1700
+ publishPresence(changed);
1701
+ }
1679
1702
  // Terminals opened by relay clients (phone/web over the relay). Output is emitted
1680
1703
  // via the relay tagged with termId; clients filter by it. (Per-client unicast
1681
1704
  // is a known future hardening step.)
@@ -1760,10 +1783,15 @@ function referencedAttachmentHashes() {
1760
1783
  // `scan`, not `entries`: this visits every session ever created, and must not pin
1761
1784
  // each one's full log in the event-log cache.
1762
1785
  for (const id of ids) {
1763
- for (const entry of eventLog.scan(id, ["attachment", "outbound-attachment", "inline-image"])) {
1786
+ for (const entry of eventLog.scan(id, ["attachment", "outbound-attachment", "inline-image", "app-review"])) {
1764
1787
  if (entry.bivyKind === "attachment")
1765
1788
  for (const ref of entry.refs)
1766
1789
  hashes.add(ref.hash);
1790
+ else if (entry.bivyKind === "app-review") {
1791
+ for (const shot of [entry.review.shot, entry.review.before])
1792
+ if (shot)
1793
+ hashes.add(shot.hash);
1794
+ }
1767
1795
  else if (entry.bivyKind === "outbound-attachment" || entry.bivyKind === "inline-image")
1768
1796
  hashes.add(entry.ref.hash);
1769
1797
  }
@@ -1819,6 +1847,20 @@ const replication = new ReplicationService({
1819
1847
  },
1820
1848
  log: (m) => console.error(`[replication] ${m}`),
1821
1849
  });
1850
+ /** Take over a replicated session on this node (manual promotion). Once it
1851
+ * wins the epoch it is this node's session, not a standby copy: retag it so
1852
+ * every client stops offering "Continue here", and push the new list. */
1853
+ async function promoteReplicaHere(sessionId) {
1854
+ const epoch = await replication.promote(sessionId, identity.nodeId);
1855
+ if (epoch === undefined)
1856
+ return undefined;
1857
+ const source = metadata.getSession(sessionId)?.source;
1858
+ if (source?.startsWith("replica"))
1859
+ metadata.upsertSession({ id: sessionId, source: source.replace(/^replica/, "promoted") });
1860
+ broadcastSessionsList();
1861
+ scheduleAdvertise();
1862
+ return epoch;
1863
+ }
1822
1864
  // Retire the legacy overlay sidecars. The append-only log is the sole overlay
1823
1865
  // store now and every session was migrated into it by the prior release's boot
1824
1866
  // migration (.migrated-overlays-v1), verified complete on-disk before this landed.
@@ -1911,10 +1953,13 @@ const dedupeSessionNew = (requestId, create) => sessionNewDedupe.run(requestId,
1911
1953
  // generic key->promise cache as above, reused for a different key) — see
1912
1954
  // issue #154's queued follow-ups. A client that isn't sure whether a queued
1913
1955
  // item's send actually reached the node before the socket dropped (see
1914
- // AppController.retryStuckFollowups) resends it verbatim after reconnecting;
1956
+ // FollowupCoordinator.retrySending) resends it verbatim after reconnecting;
1915
1957
  // this makes that safe by collapsing a retried clientMessageId onto the
1916
- // original broadcast + turn instead of double-prompting the runtime.
1917
- const promptDedupe = createSessionNewDedupe();
1958
+ // original broadcast + turn instead of double-prompting the runtime. The key is
1959
+ // kept for a day, not session.new's 10 minutes: a phone backgrounded mid-send
1960
+ // can reconnect long after the turn ended, and an expired key turned its
1961
+ // retry into a fresh, duplicate turn at the bottom of the chat.
1962
+ const promptDedupe = createSessionNewDedupe({ ttlMs: 24 * 60 * 60_000 });
1918
1963
  const dedupePrompt = (clientMessageId, run) => promptDedupe.run(clientMessageId, run);
1919
1964
  // Preview traffic has isolated per-view origins and a private gateway. Relay
1920
1965
  // delivery needs no listener; direct ingress is an optional operator override.
@@ -1942,7 +1987,56 @@ const appSignIn = (view, pagePath) => {
1942
1987
  const appGateway = process.env.BIVY_APPS_ORIGIN ? new AppGateway(appRegistry, process.env.BIVY_APPS_ORIGIN, appReturnOrigins, appSignIn) : undefined;
1943
1988
  const remotePreview = new RemotePreview(appRegistry, appReturnOrigins, appSignIn);
1944
1989
  // Desktop app views: one private display per view, started on first open.
1945
- const appDisplays = new DisplayHost();
1990
+ const appDisplays = createDisplayHost(appDir);
1991
+ /** Review cards: screenshots become encrypted attachments (fetched by hash
1992
+ * like any image), and the card is logged so it survives a reload. A card
1993
+ * that updates moves to the end of the run; an expired one stays put. */
1994
+ function createReviewSink() {
1995
+ const anchors = new Map();
1996
+ const emit = (review, anchor) => {
1997
+ const record = resolveSession(review.sessionId);
1998
+ if (!record)
1999
+ return;
2000
+ const at = anchor ?? { afterMessageCount: record.session.getMessages().length, createdAt: Date.now() };
2001
+ anchors.set(review.id, at);
2002
+ eventLog.appendAppReview(record.id, { ...at, review });
2003
+ broadcast(stampSessionEvent({ type: "session.event", sessionId: record.id, event: { type: "app_review", id: review.id, review } }));
2004
+ };
2005
+ const store = (png, name) => {
2006
+ if (!png)
2007
+ return undefined;
2008
+ try {
2009
+ const ref = attachmentStore.put(png, { name, mimeType: "image/png", kind: "image" });
2010
+ return { hash: ref.hash, size: ref.size, ...pngSize(png) };
2011
+ }
2012
+ catch {
2013
+ return undefined;
2014
+ }
2015
+ };
2016
+ return {
2017
+ publish(review, images) {
2018
+ const card = { ...review };
2019
+ const shot = store(images.shot, `${review.name} ${review.path}.png`);
2020
+ const before = store(images.before, `${review.name} ${review.path} before.png`);
2021
+ if (shot)
2022
+ card.shot = shot;
2023
+ if (before)
2024
+ card.before = before;
2025
+ emit(card);
2026
+ return card;
2027
+ },
2028
+ expire(review, keep) {
2029
+ const record = resolveSession(review.sessionId);
2030
+ for (const hash of [review.shot?.hash, review.before?.hash]) {
2031
+ // Never delete bytes anything else in the chat points at.
2032
+ if (hash && !keep.has(hash) && !record?.seenAttachmentHashes?.has(hash))
2033
+ attachmentStore.remove(hash);
2034
+ }
2035
+ const { shot: _shot, before: _before, ...rest } = review;
2036
+ emit({ ...rest, expired: true }, anchors.get(review.id));
2037
+ },
2038
+ };
2039
+ }
1946
2040
  const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
1947
2041
  start: async (spec) => {
1948
2042
  let failure = "Could not start the app terminal.";
@@ -1957,7 +2051,37 @@ const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
1957
2051
  },
1958
2052
  has: (id) => terminals.has(id),
1959
2053
  close: (id) => { terminals.close(id); },
1960
- }, { screenshots: { enabled: appScreenshotsEnabled }, displays: appDisplays });
2054
+ }, { screenshots: { enabled: appScreenshotsEnabled }, displays: appDisplays, reviews: createReviewSink() });
2055
+ // A reviewer's note shows up in an open Apps sheet without reopening it, and
2056
+ // the owner gets one push per burst of notes on a view ("2 notes on
2057
+ // Storefront"), which opens the Apps sheet at that app. Counts, names and IDs
2058
+ // only: the note text never goes through push.
2059
+ const NOTES_NOTIFY_MS = 20_000;
2060
+ const noteBursts = new Map();
2061
+ appRegistry.on("notes", (viewId) => {
2062
+ const entry = appRegistry.getView(viewId);
2063
+ if (!entry)
2064
+ return;
2065
+ broadcast({ type: "apps.changed", sessionId: entry.app.sessionId });
2066
+ const pending = noteBursts.get(viewId);
2067
+ noteBursts.set(viewId, (pending ?? 0) + 1);
2068
+ if (pending !== undefined)
2069
+ return;
2070
+ setTimeout(() => {
2071
+ const count = noteBursts.get(viewId) ?? 0;
2072
+ noteBursts.delete(viewId);
2073
+ const current = appRegistry.getView(viewId);
2074
+ if (!current?.notes?.length || !count)
2075
+ return;
2076
+ const notes = count === 1 ? "A note" : `${count} notes`;
2077
+ void sendNotificationHint({
2078
+ kind: "app_notes", sessionId: current.app.sessionId, targetSessionId: current.app.sessionId,
2079
+ title: `${notes} on ${current.view.name}`,
2080
+ body: `Someone reviewing ${current.app.name} left ${count === 1 ? "a note" : `${count} notes`} — tap to read ${count === 1 ? "it" : "them"}.`,
2081
+ apps: { appId: current.app.id },
2082
+ });
2083
+ }, NOTES_NOTIFY_MS).unref?.();
2084
+ });
1961
2085
  const RELAY_COMMANDS = {
1962
2086
  ...createAppCommands(appService, (id) => { const record = resolveSession(id); return record ? harnessDirFor(record) : undefined; }, (app) => {
1963
2087
  const record = resolveSession(app.sessionId);
@@ -2000,6 +2124,12 @@ const RELAY_COMMANDS = {
2000
2124
  const meta = attachmentStore.readMeta(hash);
2001
2125
  ctx.reply({ type: "attachment.data", requestId, hash, mimeType: meta?.mimeType ?? "application/octet-stream", name: meta?.name, data: bytes.toString("base64") });
2002
2126
  },
2127
+ ...createArtifactCommands({
2128
+ sessionIds: () => new Set([...metadata.listSessions().map((session) => session.id), ...[...openSessions.values()].map((record) => record.id)]),
2129
+ scan: (id) => eventLog.scan(id, ["outbound-attachment"]),
2130
+ stored: (hash) => attachmentStore.getPath(hash) !== null,
2131
+ }),
2132
+ ...createPresenceCommands(presenceBook, publishPresence),
2003
2133
  ...createSessionControlCommands({
2004
2134
  resolve: (sessionId) => resolveSession(sessionId),
2005
2135
  pause: pauseSession,
@@ -2763,10 +2893,9 @@ const RELAY_COMMANDS = {
2763
2893
  const sessionId = String(msg.sessionId ?? "").trim();
2764
2894
  if (!sessionId)
2765
2895
  return ctx.reply({ type: "session.promote.result", requestId, ok: false, error: "Missing sessionId" });
2766
- const epoch = await replication.promote(sessionId, identity.nodeId);
2896
+ const epoch = await promoteReplicaHere(sessionId);
2767
2897
  if (epoch === undefined)
2768
2898
  return ctx.reply({ type: "session.promote.result", requestId, ok: false, error: "Promotion lost the epoch race" });
2769
- scheduleAdvertise();
2770
2899
  ctx.reply({ type: "session.promote.result", requestId, ok: true, sessionId, epoch });
2771
2900
  },
2772
2901
  // Ephemeral provisioning transport (node-broker path). A remote device that
@@ -2869,7 +2998,7 @@ const RELAY_COMMANDS = {
2869
2998
  relay?.sendEvent({ type: "integrations.updated", integrations: integrations.list() });
2870
2999
  }
2871
3000
  },
2872
- async prompt(msg) {
3001
+ async prompt(msg, ctx) {
2873
3002
  const text = String(msg.text ?? "").trim();
2874
3003
  const { images, imageNotes, imageRefs, files } = attachmentsFrom(msg.attachments);
2875
3004
  if (!text && !images.length && !files.length)
@@ -2910,6 +3039,7 @@ const RELAY_COMMANDS = {
2910
3039
  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." });
2911
3040
  return;
2912
3041
  }
3042
+ notePresence(record.id, deviceFrom(msg.device), "chat");
2913
3043
  touchSession(record);
2914
3044
  // Now that the session (and its workdir) exists, write file attachments to
2915
3045
  // disk and fold their path notes into the prompt the agent actually sees.
@@ -2921,6 +3051,14 @@ const RELAY_COMMANDS = {
2921
3051
  eventLog.appendAttachments(record.id, promptText, [...imageRefs, ...fileRefs]);
2922
3052
  const agentPrompt = promptForAgent(record, promptText);
2923
3053
  const cmid = typeof msg.clientMessageId === "string" && msg.clientMessageId ? msg.clientMessageId : undefined;
3054
+ // A retry of a prompt we already took: the caller missed our echo (that is
3055
+ // why it retried), so acknowledge it again — to the caller only, since
3056
+ // other clients already rendered the first one. Without this the client
3057
+ // keeps the message "sending" and resends it on every reconnect.
3058
+ if (promptDedupe.has(cmid)) {
3059
+ ctx.reply({ type: "session.user_message", sessionId: record.id, text: promptText, clientMessageId: cmid, retry: true });
3060
+ return;
3061
+ }
2924
3062
  void dedupePrompt(cmid, async () => {
2925
3063
  broadcast({ type: "session.user_message", sessionId: record.id, text: promptText, clientMessageId: msg.clientMessageId });
2926
3064
  void sessionNamer.maybeNameSession(record, promptText);
@@ -3052,6 +3190,8 @@ async function handleRelayMessage(msg) {
3052
3190
  // Fallthrough for kinds not in RELAY_COMMANDS: terminal.* frames go to the
3053
3191
  // PTY manager; anything else is an unknown client message.
3054
3192
  if (typeof msg.kind === "string" && msg.kind.startsWith("terminal.")) {
3193
+ if (msg.kind === "terminal.attach")
3194
+ notePresence(terminals.meta(String(msg.termId ?? ""))?.sessionId, deviceFrom(msg.device), "terminal");
3055
3195
  // The relay is a single tunnel with no per-remote-client identity at this
3056
3196
  // layer, so every relay-tunneled client shares one size slot. That still
3057
3197
  // keeps them distinct from each local socket, so a PTY shared between a
@@ -3944,6 +4084,19 @@ function sessionNotifyLabel(record, fallback = "A session") {
3944
4084
  return `${agent} · ${branch}`;
3945
4085
  return agent || branch || fallback;
3946
4086
  }
4087
+ /** How long the "finished" notification waits for a review card. */
4088
+ const DONE_REVIEW_WAIT_MS = 30_000;
4089
+ async function doneNotification(record) {
4090
+ const label = sessionNotifyLabel(record);
4091
+ const review = await Promise.race([record.runReview ?? Promise.resolve(undefined), new Promise((r) => setTimeout(r, DONE_REVIEW_WAIT_MS))]);
4092
+ const base = { kind: "session_done", sessionId: record.id, targetSessionId: record.id, title: "Session finished" };
4093
+ // No card, but the session has a preview: the notification offers Show me.
4094
+ const showMe = appRegistry.list(record.id).some((app) => app.views.some((view) => view.kind === "web"));
4095
+ if (!review || review.expired)
4096
+ return { ...base, body: `${label} finished — tap to review the result.`, ...(showMe ? { showMe } : {}) };
4097
+ const hint = reviewHint(review);
4098
+ return { ...base, title: `${label} finished`, body: hint.body, review: hint.review };
4099
+ }
3947
4100
  async function sendNotificationHint(input) {
3948
4101
  if (!sessionAdvertiseTarget)
3949
4102
  return;
@@ -6274,6 +6427,21 @@ function rememberSession(record) {
6274
6427
  persistSessionMetadata(record);
6275
6428
  recordSessionLocation(record);
6276
6429
  enforceOpenSessionCap(record.id);
6430
+ // Opened while a `bivy run` pinned to it is live (e.g. `bivy resume` on the
6431
+ // laptop, then the chat on a phone): the terminal owns it until it ends.
6432
+ runTerms.adoptLiveRun(record);
6433
+ }
6434
+ /** The open session a run's pinned id refers to — by Bivy id or by the
6435
+ * runtime's own session ref. Unlike resolveSession, never the active one. */
6436
+ function findOpenSession(ref) {
6437
+ const direct = openSessions.get(ref);
6438
+ if (direct)
6439
+ return direct;
6440
+ for (const record of openSessions.values()) {
6441
+ if (record.sessionFile === ref)
6442
+ return record;
6443
+ }
6444
+ return undefined;
6277
6445
  }
6278
6446
  // Keep the number of live (in-memory) sessions bounded. Idle-close already
6279
6447
  // trims over time, but a burst of opens within the idle window could pile up
@@ -7692,13 +7860,10 @@ function attachSessionListeners(record) {
7692
7860
  });
7693
7861
  }
7694
7862
  else if (!record.isWorking && !record.remoteActive && clients.size === 0 && (relay?.clientCount ?? 0) === 0 && (record.backgroundTaskCount ?? 0) === 0) {
7695
- void sendNotificationHint({
7696
- kind: "session_done",
7697
- sessionId: record.id,
7698
- targetSessionId: record.id,
7699
- title: "Session finished",
7700
- body: `${sessionNotifyLabel(record)} finished — tap to review the result.`,
7701
- });
7863
+ // Nobody is watching: the one "finished" notification also says when
7864
+ // the app changed, and opens its review card. Waits (bounded) for the
7865
+ // card; the hint carries IDs only, never the screenshot.
7866
+ void doneNotification(record).then((hint) => sendNotificationHint(hint));
7702
7867
  }
7703
7868
  // Any turn that didn't schedule another resume broke the limit streak —
7704
7869
  // clear the durable counter so a future limit starts with a full budget
@@ -8652,10 +8817,17 @@ const runTerms = createRunTerminals({
8652
8817
  sendNotificationHint: (hint) => void sendNotificationHint(hint),
8653
8818
  createSession: (workspace, sessionFile, opts) => createSession(workspace, sessionFile, opts),
8654
8819
  resolveSession: (id) => resolveSession(id),
8820
+ findOpenSession: (ref) => findOpenSession(ref),
8655
8821
  sessionBusy: (record) => sessionBusy(record),
8656
8822
  sessionTerminalsRecord: (sessionId, val) => sessionTerminals.record(sessionId, val),
8657
8823
  sessionTerminalsForget: (sessionId) => sessionTerminals.forget(sessionId),
8658
- upsertSessionMetadata: (patch) => metadata.upsertSession(patch),
8824
+ upsertSessionMetadata: (patch) => {
8825
+ const input = patch;
8826
+ // A run continuing a saved session (`bivy resume`) only moves its status;
8827
+ // the session keeps its own name, source and agent.
8828
+ const prev = metadata.getSession(input.id);
8829
+ metadata.upsertSession(prev ? { id: input.id, status: input.status } : input);
8830
+ },
8659
8831
  sessionListChanged: () => { broadcastSessionsList(); scheduleAdvertise(); },
8660
8832
  saveRunLog: (termId, log) => runLogs.save(termId, log),
8661
8833
  loadRunLog: (termId) => runLogs.load(termId),
@@ -11110,6 +11282,7 @@ app.post("/api/session/prompt", async (req, res, next) => {
11110
11282
  if (record.tuiTermId || record.tuiRefreshing) {
11111
11283
  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." });
11112
11284
  }
11285
+ notePresence(record.id, deviceFrom(req.body?.device), "chat");
11113
11286
  const session = record.session;
11114
11287
  const { note: fileNote, refs: fileRefs } = materializeAttachments(record, files);
11115
11288
  const promptText = [text, imageNotes.join("\n"), fileNote].filter(Boolean).join("\n\n") ||
@@ -11223,10 +11396,9 @@ app.post("/api/session/promote", async (req, res, next) => {
11223
11396
  const sessionId = String(req.body?.sessionId ?? "").trim();
11224
11397
  if (!sessionId)
11225
11398
  return res.status(400).json({ error: "Missing sessionId" });
11226
- const epoch = await replication.promote(sessionId, identity.nodeId);
11399
+ const epoch = await promoteReplicaHere(sessionId);
11227
11400
  if (epoch === undefined)
11228
11401
  return res.status(409).json({ error: "Promotion lost the epoch race (another node owns it, or it isn't replicated)" });
11229
- scheduleAdvertise();
11230
11402
  res.json({ ok: true, epoch });
11231
11403
  }
11232
11404
  catch (error) {
@@ -11444,6 +11616,7 @@ wss.on("connection", (socket, req) => {
11444
11616
  // Stable id for this socket, keying its per-terminal size so several clients
11445
11617
  // sharing a PTY size it to their min (see TerminalManager.setClientSize).
11446
11618
  const clientTerminalId = `sock-${randomUUID()}`;
11619
+ let socketDevice;
11447
11620
  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 }));
11448
11621
  // Authoritative version status on every connect: `latest` set means this node
11449
11622
  // is behind (banner shows); absent means up to date (banner + any "Updating…"
@@ -11468,6 +11641,12 @@ wss.on("connection", (socket, req) => {
11468
11641
  return;
11469
11642
  }
11470
11643
  if (typeof msg?.kind === "string" && msg.kind.startsWith("terminal.")) {
11644
+ // A terminal client (`bivy run`/`bivy resume`) names its device once; typing
11645
+ // into a session's terminal then makes it that session's driver.
11646
+ socketDevice = deviceFrom(msg.device) ?? socketDevice;
11647
+ if (msg.kind === "terminal.input" || msg.kind === "terminal.attach") {
11648
+ notePresence(terminals.meta(String(msg.termId ?? ""))?.sessionId, socketDevice, "terminal");
11649
+ }
11471
11650
  runTerms.handleTerminalMessage(msg, (event) => {
11472
11651
  if (socket.readyState === WebSocket.OPEN)
11473
11652
  socket.send(JSON.stringify(event));
@@ -195,4 +195,19 @@ export class AttachmentStore {
195
195
  return null;
196
196
  }
197
197
  }
198
+ /** Delete one blob now, for a caller that knows nothing else references it
199
+ * (an expired review card's screenshot). Best-effort. */
200
+ remove(hash) {
201
+ const p = this.getPath(hash);
202
+ if (!p)
203
+ return;
204
+ try {
205
+ fs.unlinkSync(p);
206
+ }
207
+ catch { /* already gone */ }
208
+ try {
209
+ fs.unlinkSync(this.metaPath(hash));
210
+ }
211
+ catch { /* sidecar optional */ }
212
+ }
198
213
  }
@@ -29,7 +29,7 @@
29
29
  import fs from "node:fs";
30
30
  import path from "node:path";
31
31
  import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript } from "./transcript-merge.js";
32
- import { APP_PUBLICATION_BLOCK, isAppReference } from "../apps/types.js";
32
+ import { APP_PUBLICATION_BLOCK, APP_REVIEW_BLOCK, isAppReference, isAppReview } from "../apps/types.js";
33
33
  /**
34
34
  * Decompose a transcript message into serialization-independent "atoms": the
35
35
  * agent's own tool ids (unique + identical across serializations) and its text
@@ -241,8 +241,14 @@ function isAppPublication(value) {
241
241
  const entry = value;
242
242
  return entry.bivyKind === "app-publication" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isAppReference(entry.app);
243
243
  }
244
+ function isAppReviewEntry(value) {
245
+ if (!value || typeof value !== "object")
246
+ return false;
247
+ const entry = value;
248
+ return entry.bivyKind === "app-review" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isAppReview(entry.review);
249
+ }
244
250
  function isRecord(value) {
245
- return isOverlay(value) || isBase(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value) || isAppPublication(value);
251
+ return isOverlay(value) || isBase(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value) || isAppPublication(value) || isAppReviewEntry(value);
246
252
  }
247
253
  /**
248
254
  * Fold attachment records into a text→refs list: last write wins per text (a
@@ -353,7 +359,18 @@ export function replayExtras(entries) {
353
359
  role: "assistant", content: [{ type: APP_PUBLICATION_BLOCK, app: entry.app }],
354
360
  id: entry.id, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
355
361
  }));
356
- return [...foldIntermediate(intermediate), ...foldTool(tool), ...replayOutboundAttachments(entries), ...publications];
362
+ // Last write wins: a card that updated in place shows its latest state, at its latest position.
363
+ const reviews = new Map();
364
+ for (const entry of entries)
365
+ if (entry.bivyKind === "app-review") {
366
+ reviews.delete(entry.id);
367
+ reviews.set(entry.id, entry);
368
+ }
369
+ const cards = [...reviews.values()].map((entry) => ({
370
+ role: "assistant", content: [{ type: APP_REVIEW_BLOCK, review: entry.review }],
371
+ id: entry.id, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
372
+ }));
373
+ return [...foldIntermediate(intermediate), ...foldTool(tool), ...replayOutboundAttachments(entries), ...publications, ...cards];
357
374
  }
358
375
  /**
359
376
  * Fold the outbound (agent-sent) attachment records into time-anchored synthetic
@@ -648,6 +665,11 @@ export class EventLog {
648
665
  this.load(id);
649
666
  this.enqueue(id, `app:${entry.id}`, { ...entry, app: { ...entry.app }, bivyKind: "app-publication", createdAt: Date.now() });
650
667
  }
668
+ /** `createdAt`/`afterMessageCount` place the card; an expired card keeps its original place. */
669
+ appendAppReview(id, entry) {
670
+ this.load(id);
671
+ this.enqueue(id, `review:${entry.review.id}`, { bivyKind: "app-review", createdAt: entry.createdAt ?? Date.now(), afterMessageCount: entry.afterMessageCount, id: entry.review.id, review: structuredClone(entry.review) });
672
+ }
651
673
  appendOutboundAttachment(id, entry) {
652
674
  this.load(id);
653
675
  const record = {
@@ -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
+ }