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