@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/bin/bivy.mjs +157 -12
- package/dist/apps/annotate.js +94 -0
- package/dist/apps/display-viewer.js +80 -7
- package/dist/apps/display.js +11 -1
- package/dist/apps/gateway.js +106 -14
- package/dist/apps/input.js +96 -0
- package/dist/apps/inspector.js +84 -29
- package/dist/apps/macos-display.js +170 -0
- package/dist/apps/macos-display.swift +873 -0
- package/dist/apps/menu.js +76 -0
- package/dist/apps/preview-shell.js +232 -16
- package/dist/apps/preview-stream.js +17 -3
- package/dist/apps/registry.js +76 -5
- package/dist/apps/review.js +111 -0
- package/dist/apps/rfb.js +69 -20
- package/dist/apps/screenshot.js +8 -2
- package/dist/apps/service.js +343 -13
- package/dist/apps/styles.css +162 -10
- package/dist/apps/tokens.css +6 -0
- package/dist/apps/types.js +17 -0
- package/dist/attach.js +37 -4
- package/dist/controllers/app-commands.js +16 -4
- package/dist/controllers/artifact-commands.js +42 -0
- package/dist/controllers/presence-commands.js +19 -0
- package/dist/protocol/client-command-routes.js +3 -1
- package/dist/protocol/client-command-schemas.js +19 -3
- package/dist/server.js +200 -21
- package/dist/session/attachment-store.js +15 -0
- package/dist/session/event-log.js +25 -3
- package/dist/session/presence.js +82 -0
- package/dist/session/run-terminal.js +113 -69
- package/dist/session/session-new-dedupe.js +1 -1
- package/dist/terminal.js +23 -0
- package/dist/worktree.js +40 -3
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
7696
|
-
|
|
7697
|
-
|
|
7698
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|