@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.
- package/bin/bivy.mjs +57 -10
- package/dist/agents/claude-code/runtime.js +17 -0
- package/dist/apps/display-viewer.js +81 -0
- package/dist/apps/display.js +135 -0
- package/dist/apps/gateway.js +388 -25
- package/dist/apps/inspector.js +106 -0
- package/dist/apps/listeners.js +115 -0
- package/dist/apps/preview-shell.js +206 -17
- package/dist/apps/registry.js +125 -21
- package/dist/apps/remote-preview.js +10 -2
- package/dist/apps/rfb.js +126 -0
- package/dist/apps/screenshot.js +194 -0
- package/dist/apps/service.js +244 -5
- package/dist/apps/styles.css +124 -9
- package/dist/apps/x11.js +273 -0
- package/dist/attach.js +37 -4
- package/dist/config-cli.js +2 -0
- package/dist/controllers/app-commands.js +20 -7
- package/dist/controllers/artifact-commands.js +42 -0
- package/dist/controllers/presence-commands.js +19 -0
- package/dist/node-config.js +6 -3
- package/dist/policy/session-limit.js +63 -0
- package/dist/protocol/client-command-routes.js +3 -1
- package/dist/protocol/client-command-schemas.js +11 -2
- package/dist/server.js +168 -14
- package/dist/session/presence.js +82 -0
- package/dist/session/run-terminal.js +114 -70
- package/dist/session/session-new-dedupe.js +1 -1
- package/package.json +2 -1
|
@@ -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, ...
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1916
|
-
|
|
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
|
|
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
|
|
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: `
|
|
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) =>
|
|
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
|
|
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
|
+
}
|