@bivy/bivy 0.18.10-staging.3 → 0.18.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 +38 -1
- package/dist/agent-instructions.js +4 -0
- package/dist/apps/gateway.js +6 -2
- package/dist/apps/preview-shell.js +7 -0
- package/dist/apps/remote-preview.js +4 -2
- package/dist/apps/styles.css +13 -3
- package/dist/config-cli.js +2 -0
- package/dist/node-config.js +6 -3
- package/dist/server.js +73 -3
- package/dist/session/event-log.js +17 -2
- package/dist/session/start-like.js +16 -0
- package/dist/session/suggestions.js +13 -0
- package/package.json +1 -1
package/bin/bivy.mjs
CHANGED
|
@@ -1827,7 +1827,7 @@ function cmdCompletions(args = []) {
|
|
|
1827
1827
|
const shell = (args[0] || "").toLowerCase();
|
|
1828
1828
|
const commands = [
|
|
1829
1829
|
"run", "runs", "sessions", "ls", "resume", "promote", "rename", "nodes", "agent", "agents", "agents:install", "shim", "takeover", "token", "exec",
|
|
1830
|
-
"send", "attach", "kill", "setup", "start", "stop", "restart", "status", "doctor", "diagnostics", "capabilities", "logs", "login", "logout", "signout", "provider", "model",
|
|
1830
|
+
"send", "attach", "suggest", "kill", "setup", "start", "stop", "restart", "status", "doctor", "diagnostics", "capabilities", "logs", "login", "logout", "signout", "provider", "model",
|
|
1831
1831
|
"update", "update:log", "audit", "automation", "config", "plugin", "open", "service", "secrets", "voice", "link", "relay:setup",
|
|
1832
1832
|
"github:connect", "github:app-create", "github:app-connect", "github:app-sync", "prune", "uninstall", "help", "version",
|
|
1833
1833
|
];
|
|
@@ -2428,6 +2428,40 @@ async function cmdAttach(args = []) {
|
|
|
2428
2428
|
console.log(c.green(`Attached ${body.name} (${body.kind}, ${body.size} bytes) to the chat.`));
|
|
2429
2429
|
}
|
|
2430
2430
|
|
|
2431
|
+
// `bivy suggest "<task>" [--title "…"] [--session <id>]` — propose a task the
|
|
2432
|
+
// user can start in one tap, beside this session or in it. For an agent
|
|
2433
|
+
// offering next steps: write the task as a complete instruction.
|
|
2434
|
+
async function cmdSuggest(args = []) {
|
|
2435
|
+
const flagsWithValue = new Set(["--session", "--title"]);
|
|
2436
|
+
const flag = (name) => {
|
|
2437
|
+
const i = args.indexOf(name);
|
|
2438
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
|
|
2439
|
+
};
|
|
2440
|
+
const text = args.filter((a, i) => !a.startsWith("-") && !(i > 0 && flagsWithValue.has(args[i - 1]))).join(" ").trim();
|
|
2441
|
+
const sessionId = resolveAttachSessionId({ sessionFlag: flag("--session"), env: process.env });
|
|
2442
|
+
if (!text) { console.error(c.red('Usage: bivy suggest "<task>" [--title "short label"] [--session <id>]')); process.exit(1); return; }
|
|
2443
|
+
if (!sessionId) { console.error(c.red("No session id. Set --session <id> or run inside an agent session ($BIVY_SESSION_ID).")); process.exit(1); return; }
|
|
2444
|
+
const config = loadConfig();
|
|
2445
|
+
if (!(await ensureNodeRunning(config))) { console.error(c.red(`Could not reach the Bivy node at ${url(config)}.`)); process.exit(1); return; }
|
|
2446
|
+
let token;
|
|
2447
|
+
try { token = await localDeviceToken(config); } catch { token = undefined; }
|
|
2448
|
+
let res;
|
|
2449
|
+
try {
|
|
2450
|
+
res = await fetch(`${url(config)}/api/session/${encodeURIComponent(sessionId)}/suggest`, {
|
|
2451
|
+
method: "POST",
|
|
2452
|
+
headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
2453
|
+
body: JSON.stringify({ text, title: flag("--title") }),
|
|
2454
|
+
});
|
|
2455
|
+
} catch (error) {
|
|
2456
|
+
console.error(c.red(`Could not reach the Bivy node: ${error?.message || String(error)}`));
|
|
2457
|
+
process.exit(1);
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
const body = await res.json().catch(() => ({}));
|
|
2461
|
+
if (!res.ok) { console.error(c.red(`Suggest failed (${res.status}): ${body?.error || "unknown error"}`)); process.exit(1); return; }
|
|
2462
|
+
console.log(c.green("Suggested in the chat. The user can start it in one tap."));
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2431
2465
|
// Universal app publishing: every agent that can write JSON and run a command
|
|
2432
2466
|
// uses the same contract. Relative static directories are manifest-relative.
|
|
2433
2467
|
async function cmdApp(args = []) {
|
|
@@ -5419,6 +5453,9 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
|
|
|
5419
5453
|
case "attach":
|
|
5420
5454
|
await cmdAttach(args);
|
|
5421
5455
|
break;
|
|
5456
|
+
case "suggest":
|
|
5457
|
+
await cmdSuggest(args);
|
|
5458
|
+
break;
|
|
5422
5459
|
case "completions":
|
|
5423
5460
|
case "completion":
|
|
5424
5461
|
cmdCompletions(args);
|
|
@@ -38,6 +38,10 @@ export const BIVY_AGENT_NOTE = [
|
|
|
38
38
|
"`bivy app run -- <command>` (a desktop app) gives the user a live preview; `bivy app shot` screenshots it so you " +
|
|
39
39
|
"can check your work; `bivy app present` tells the user a visible change is ready to look at. " +
|
|
40
40
|
"`bivy app --help` has the details.",
|
|
41
|
+
"- Proposing tasks the user could hand you (next steps, ideas, options): post each with " +
|
|
42
|
+
'`bivy suggest "<complete instruction>" [--title "short label"]` instead of only listing them. The user can ' +
|
|
43
|
+
"start each in one tap, in this session or in a parallel one that works in its own copy of the project, so " +
|
|
44
|
+
"write it to stand on its own, with paths relative to the project root.",
|
|
41
45
|
].join("\n");
|
|
42
46
|
const PREAMBLE = "The user's global Bivy instructions follow. They apply to every workspace; where they conflict with " +
|
|
43
47
|
"instructions from the current repository (AGENTS.md, CLAUDE.md, …), the repository's instructions win.";
|
package/dist/apps/gateway.js
CHANGED
|
@@ -195,6 +195,7 @@ export class AppGateway {
|
|
|
195
195
|
returnOrigins;
|
|
196
196
|
signIn;
|
|
197
197
|
captureNote;
|
|
198
|
+
badge;
|
|
198
199
|
server;
|
|
199
200
|
template;
|
|
200
201
|
tickets = new Map();
|
|
@@ -211,11 +212,12 @@ export class AppGateway {
|
|
|
211
212
|
displays = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024, perMessageDeflate: false });
|
|
212
213
|
/** `signIn` sends a signed-out visit to a view's stable address back
|
|
213
214
|
* through Bivy, which re-opens it for a signed-in device. */
|
|
214
|
-
constructor(registry, originTemplate, returnOrigins = () => [], signIn, captureNote) {
|
|
215
|
+
constructor(registry, originTemplate, returnOrigins = () => [], signIn, captureNote, badge) {
|
|
215
216
|
this.registry = registry;
|
|
216
217
|
this.returnOrigins = returnOrigins;
|
|
217
218
|
this.signIn = signIn;
|
|
218
219
|
this.captureNote = captureNote;
|
|
220
|
+
this.badge = badge;
|
|
219
221
|
this.template = previewOriginTemplate(originTemplate);
|
|
220
222
|
this.server = http.createServer((req, res) => { void this.handle(req, res).catch(() => { if (!res.headersSent)
|
|
221
223
|
res.writeHead(502); res.end("Preview request failed."); }); });
|
|
@@ -365,7 +367,9 @@ export class AppGateway {
|
|
|
365
367
|
return;
|
|
366
368
|
}
|
|
367
369
|
res.setHeader("Content-Type", "application/json");
|
|
368
|
-
|
|
370
|
+
// Someone opening a shared link sees "Made with Bivy" under the app; the owner doesn't.
|
|
371
|
+
const badge = grant.reusable && this.badge ? await this.badge().catch(() => true) : false;
|
|
372
|
+
res.end(JSON.stringify({ name: entry.app.name, origin: this.origin(id), returnTo: grant.returnTo, ...(grant.reusable ? { reviewer: true } : {}), ...(badge ? { badge: true } : {}), ...(entry.target.kind === "display" ? { inspect: false } : {}) }));
|
|
369
373
|
return;
|
|
370
374
|
}
|
|
371
375
|
res.writeHead(404);
|
|
@@ -81,11 +81,16 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
|
|
|
81
81
|
#compare[data-drawing] #compare-slider, #compare[data-drawing] #compare-hint, #compare[data-drawing] #compare-draw { display:none; }
|
|
82
82
|
.label-narrow { display:none; }
|
|
83
83
|
@media (max-width: 699px) { #lens { display:none; } #name { display:none; } .label-wide { display:none; } .label-narrow { display:inline; } }
|
|
84
|
+
/* "Made with Bivy": a quiet strip under a shared app, never over it. */
|
|
85
|
+
#made-with { flex-shrink:0; display:flex; align-items:center; justify-content:center; gap:var(--space-1); min-height:var(--space-6); padding:0 var(--space-3) env(safe-area-inset-bottom); box-sizing:border-box; background:var(--surface); border-top:thin solid var(--line); color:var(--muted); font-size:var(--text-xs); text-decoration:none; }
|
|
86
|
+
#made-with:hover, #made-with:focus-visible { color:var(--ink); }
|
|
87
|
+
body.badged #dock { bottom:calc(var(--space-2) + var(--space-6) + env(safe-area-inset-bottom)); }
|
|
84
88
|
[hidden] { display:none !important; }
|
|
85
89
|
</style></head><body>
|
|
86
90
|
<div class="banner" data-tone="warn" id="down" hidden><span class="banner-text" id="down-text" role="status"></span><span class="banner-actions"><button class="btn sm" id="ask" hidden>Ask agent to fix</button></span></div>
|
|
87
91
|
<p id="status" role="status">Opening app…</p>
|
|
88
92
|
<div id="stage" data-lens="full"><iframe id="app" title="App preview" hidden sandbox="allow-scripts allow-same-origin allow-forms allow-downloads allow-popups" referrerpolicy="no-referrer"></iframe></div>
|
|
93
|
+
<a id="made-with" href="https://bivy.sh/?ref=preview" target="_blank" rel="noopener" hidden>Made with <strong>Bivy</strong><span aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a>
|
|
89
94
|
<div id="ink" hidden aria-hidden="true"><svg id="ink-marks"></svg></div>
|
|
90
95
|
<div id="dock">
|
|
91
96
|
<section class="panel" id="console" hidden aria-labelledby="console-title">
|
|
@@ -157,6 +162,8 @@ function show(data,launch){
|
|
|
157
162
|
$('draft-text').maxLength=1000;
|
|
158
163
|
$('send-errors').textContent='Leave a note…';
|
|
159
164
|
}
|
|
165
|
+
$('made-with').hidden=!data.badge;
|
|
166
|
+
document.body.classList.toggle('badged',Boolean(data.badge));
|
|
160
167
|
$('title').textContent=data.name;
|
|
161
168
|
$('title').title=data.name;
|
|
162
169
|
document.title='Bivy · '+data.name;
|
|
@@ -10,16 +10,18 @@ export class RemotePreview {
|
|
|
10
10
|
returnOrigins;
|
|
11
11
|
signIn;
|
|
12
12
|
captureNote;
|
|
13
|
+
badge;
|
|
13
14
|
gateway;
|
|
14
15
|
origin;
|
|
15
16
|
relayUrl;
|
|
16
17
|
online = false;
|
|
17
18
|
sockets = new Set();
|
|
18
|
-
constructor(registry, returnOrigins, signIn, captureNote) {
|
|
19
|
+
constructor(registry, returnOrigins, signIn, captureNote, badge) {
|
|
19
20
|
this.registry = registry;
|
|
20
21
|
this.returnOrigins = returnOrigins;
|
|
21
22
|
this.signIn = signIn;
|
|
22
23
|
this.captureNote = captureNote;
|
|
24
|
+
this.badge = badge;
|
|
23
25
|
}
|
|
24
26
|
get available() { return this.online && Boolean(this.gateway); }
|
|
25
27
|
ready(origin, relayUrl) {
|
|
@@ -32,7 +34,7 @@ export class RemotePreview {
|
|
|
32
34
|
return;
|
|
33
35
|
if (this.origin !== template) {
|
|
34
36
|
this.gateway?.close();
|
|
35
|
-
this.gateway = new AppGateway(this.registry, template, this.returnOrigins, this.signIn, this.captureNote);
|
|
37
|
+
this.gateway = new AppGateway(this.registry, template, this.returnOrigins, this.signIn, this.captureNote, this.badge);
|
|
36
38
|
this.origin = template;
|
|
37
39
|
}
|
|
38
40
|
this.relayUrl = relayUrl.replace(/\/$/, "") + "/preview/stream";
|
package/dist/apps/styles.css
CHANGED
|
@@ -1054,16 +1054,17 @@ button.sheet-action:last-child { border-bottom: 0; }
|
|
|
1054
1054
|
.followup-card { flex: none; padding: 0; overflow: hidden; }
|
|
1055
1055
|
.followup-card.locked { opacity: 0.75; }
|
|
1056
1056
|
.followup-card-row {
|
|
1057
|
-
display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px;
|
|
1057
|
+
display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; padding: 8px 10px;
|
|
1058
1058
|
}
|
|
1059
1059
|
.followup-card-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius-md); }
|
|
1060
1060
|
.followup-reorder { display: flex; flex-direction: column; gap: 2px; flex: none; }
|
|
1061
|
-
|
|
1061
|
+
/* The text keeps a readable width; on a phone the actions wrap onto their own line. */
|
|
1062
|
+
.followup-body { flex: 1 1 12rem; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
1062
1063
|
.followup-status { font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.02em; }
|
|
1063
1064
|
.followup-card.locked .followup-status { color: var(--accent); }
|
|
1064
1065
|
.followup-text { font-size: 14px; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
1065
1066
|
.followup-attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; }
|
|
1066
|
-
.followup-card-actions { display: flex; align-items: center; gap: 6px; flex: none; flex-wrap: wrap; justify-content: flex-end; }
|
|
1067
|
+
.followup-card-actions { display: flex; align-items: center; gap: 6px; flex: none; flex-wrap: wrap; justify-content: flex-end; margin-left: auto; }
|
|
1067
1068
|
.followup-edit { display: flex; flex-direction: column; gap: 8px; padding: 8px 10px; }
|
|
1068
1069
|
.followup-edit-input { resize: vertical; min-height: 44px; }
|
|
1069
1070
|
.followup-edit-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
@@ -3095,6 +3096,15 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
|
|
|
3095
3096
|
.app-row-meta { color: var(--muted); font-size: var(--text-xs); line-height: var(--lh-xs); }
|
|
3096
3097
|
.app-row-action { flex: none; display: flex; align-items: center; gap: var(--space-1); }
|
|
3097
3098
|
.app-message { margin-block: var(--space-2); }
|
|
3099
|
+
/* Suggested task (SuggestionCard.tsx): a task the agent proposed, one tap to
|
|
3100
|
+
start in its own session. Shell from .card; layout only here. */
|
|
3101
|
+
.suggestion-card { max-inline-size: 440px; display: flex; flex-direction: column; gap: var(--space-1); }
|
|
3102
|
+
.suggestion-eyebrow { margin: 0; color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
|
|
3103
|
+
.suggestion-title { margin: 0; font-size: var(--text-base); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
|
|
3104
|
+
.suggestion-text { margin: 0; color: var(--muted); font-size: var(--text-sm); white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
|
3105
|
+
.suggestion-actions, .suggestion-all { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-2); }
|
|
3106
|
+
.suggestion-all { padding-top: var(--space-2); border-top: 1px solid var(--line); }
|
|
3107
|
+
.suggestion-status { margin: var(--space-2) 0 0; display: flex; align-items: center; gap: var(--space-2); color: var(--ok); font-size: var(--text-sm); font-weight: var(--weight-medium); }
|
|
3098
3108
|
/* Review card (ReviewCard.tsx): the app at a moment worth judging. The
|
|
3099
3109
|
screenshot is shown whole, phone-shaped, on a quiet stage; Before/Now is the
|
|
3100
3110
|
canonical .segmented switch; one primary action. */
|
package/dist/config-cli.js
CHANGED
|
@@ -109,6 +109,7 @@ const ENV_FOR_KEY = {
|
|
|
109
109
|
"defaults.approval": "BIVY_APPROVAL_MODE",
|
|
110
110
|
"sessions.autoAttachToolImages": "BIVY_AUTO_ATTACH_TOOL_IMAGES",
|
|
111
111
|
"sessions.appScreenshots": "BIVY_APP_SCREENSHOTS",
|
|
112
|
+
"sessions.previewBadge": "BIVY_PREVIEW_BADGE",
|
|
112
113
|
"automation.checks": "BIVY_AUTOMATION_CHECKS",
|
|
113
114
|
"automation.checkTimeoutMinutes": "BIVY_AUTOMATION_CHECK_TIMEOUT_MS",
|
|
114
115
|
};
|
|
@@ -124,6 +125,7 @@ const BUILTIN_VALUES = {
|
|
|
124
125
|
"sessions.resume": "auto",
|
|
125
126
|
"sessions.autoAttachToolImages": false,
|
|
126
127
|
"sessions.appScreenshots": false,
|
|
128
|
+
"sessions.previewBadge": true,
|
|
127
129
|
"automation.checks": ["test", "lint", "typecheck"],
|
|
128
130
|
"automation.checkTimeoutMinutes": 10,
|
|
129
131
|
};
|
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", "appScreenshots", "forkWorkspaceMaxBytes", "wedgedTurnMinutes"], errors);
|
|
77
|
+
const sessions = section(root, "sessions", ["sync", "worktreeSync", "standbyNodeId", "resume", "autoAttachToolImages", "appScreenshots", "previewBadge", "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);
|
|
@@ -134,6 +134,7 @@ export function validateNodeConfig(value) {
|
|
|
134
134
|
errors.push("sessions.resume must be auto or manual");
|
|
135
135
|
const autoAttachToolImages = optionalBoolean(sessions.autoAttachToolImages, "sessions.autoAttachToolImages", errors);
|
|
136
136
|
const appScreenshots = optionalBoolean(sessions.appScreenshots, "sessions.appScreenshots", errors);
|
|
137
|
+
const previewBadge = optionalBoolean(sessions.previewBadge, "sessions.previewBadge", errors);
|
|
137
138
|
const forkWorkspaceMaxBytes = optionalInteger(sessions.forkWorkspaceMaxBytes, "sessions.forkWorkspaceMaxBytes", errors, 1_048_576, 1_073_741_824);
|
|
138
139
|
// Upper bound is the wall-clock turn cap (60 min): a wedged window at/above it
|
|
139
140
|
// would never fire before the cap. 0 disables the band.
|
|
@@ -217,7 +218,7 @@ export function validateNodeConfig(value) {
|
|
|
217
218
|
...(Object.keys(node).length ? { node: { workspace, port, maxConcurrentAutomations, capabilities } } : {}),
|
|
218
219
|
...(Object.keys(defaults).length ? { defaults: { agent, model, sandbox, approval } } : {}),
|
|
219
220
|
...(Object.keys(safety).length ? { safety: { maxSandbox, approvalFloor } } : {}),
|
|
220
|
-
...(Object.keys(sessions).length ? { sessions: { sync, worktreeSync, standbyNodeId, resume, autoAttachToolImages, appScreenshots, forkWorkspaceMaxBytes, wedgedTurnMinutes } } : {}),
|
|
221
|
+
...(Object.keys(sessions).length ? { sessions: { sync, worktreeSync, standbyNodeId, resume, autoAttachToolImages, appScreenshots, previewBadge, forkWorkspaceMaxBytes, wedgedTurnMinutes } } : {}),
|
|
221
222
|
...(Object.keys(github).length ? { github: { issuePrompt } } : {}),
|
|
222
223
|
...(Object.keys(automation).length ? { automation: { checks, checkTimeoutMinutes } } : {}),
|
|
223
224
|
...(Object.keys(agents).length ? { agents } : {}),
|
|
@@ -274,6 +275,7 @@ export function configToLegacySettings(config) {
|
|
|
274
275
|
...(config.sessions?.resume ? { sessionResumeMode: config.sessions.resume } : {}),
|
|
275
276
|
...(config.sessions?.autoAttachToolImages !== undefined ? { autoAttachToolImages: config.sessions.autoAttachToolImages } : {}),
|
|
276
277
|
...(config.sessions?.appScreenshots !== undefined ? { appScreenshots: config.sessions.appScreenshots } : {}),
|
|
278
|
+
...(config.sessions?.previewBadge !== undefined ? { previewBadge: config.sessions.previewBadge } : {}),
|
|
277
279
|
...(config.sessions?.forkWorkspaceMaxBytes !== undefined ? { forkWorkspaceMaxBytes: config.sessions.forkWorkspaceMaxBytes } : {}),
|
|
278
280
|
};
|
|
279
281
|
}
|
|
@@ -338,6 +340,7 @@ export function mergeLegacyIntoNodeConfig(cli, settings) {
|
|
|
338
340
|
resume: settings.sessionResumeMode === "manual" ? "manual" : "auto",
|
|
339
341
|
autoAttachToolImages: settings.autoAttachToolImages === true,
|
|
340
342
|
appScreenshots: settings.appScreenshots === true,
|
|
343
|
+
previewBadge: settings.previewBadge !== false,
|
|
341
344
|
forkWorkspaceMaxBytes: Number.isInteger(settings.forkWorkspaceMaxBytes) ? Number(settings.forkWorkspaceMaxBytes) : undefined,
|
|
342
345
|
},
|
|
343
346
|
github: { issuePrompt: typeof settings.githubIssuePrompt === "string" ? settings.githubIssuePrompt : undefined },
|
|
@@ -364,7 +367,7 @@ export function setConfigValue(config, dotted, value) {
|
|
|
364
367
|
"node.workspace", "node.port", "node.maxConcurrentAutomations", "node.capabilities",
|
|
365
368
|
"defaults.agent", "defaults.model", "defaults.sandbox", "defaults.approval",
|
|
366
369
|
"safety.maxSandbox", "safety.approvalFloor",
|
|
367
|
-
"sessions.sync", "sessions.worktreeSync", "sessions.standbyNodeId", "sessions.resume", "sessions.autoAttachToolImages", "sessions.appScreenshots", "sessions.forkWorkspaceMaxBytes", "sessions.wedgedTurnMinutes",
|
|
370
|
+
"sessions.sync", "sessions.worktreeSync", "sessions.standbyNodeId", "sessions.resume", "sessions.autoAttachToolImages", "sessions.appScreenshots", "sessions.previewBadge", "sessions.forkWorkspaceMaxBytes", "sessions.wedgedTurnMinutes",
|
|
368
371
|
"github.issuePrompt", "automation.checks", "automation.checkTimeoutMinutes",
|
|
369
372
|
]);
|
|
370
373
|
if (!allowed.has(dotted) && !/^agents\.[a-z][a-z0-9-]{1,47}$/.test(dotted) && !/^environment\.[A-Z][A-Z0-9_]+$/.test(dotted))
|
package/dist/server.js
CHANGED
|
@@ -72,6 +72,8 @@ import { createSessionEngine } from "./session/engine.js";
|
|
|
72
72
|
import { exportProviderAuth, exportAccountApiKeys, exportAccountOAuthCredentials, importAccountOAuthCredentials, exportSyncableProviderAuth, exportProviderAuthTombstones, importProviderAuth, removeProvider, setProviderApiKey, listCredentialRecords, setProviderApiKeyLabeled, setProviderReferenceLabeled, removeProviderCredential, setCredentialSync, setCredentialUnattended, exportUnattendedRecords, unattendedCredentialRevision, getCredentialPresets, setActiveCredentialPreset, setCredentialPresetMapping, exportSyncableRecords, exportRecordTombstones, importCredentialRecords, reconcileHostedCredentialRecords } from "./credentials/api.js";
|
|
73
73
|
import { listProviders } from "./runtime/provider-catalog.js";
|
|
74
74
|
import { exportLocalModels, importLocalModels } from "./runtime/local-model-store.js";
|
|
75
|
+
import { sessionLikeFields } from "./session/start-like.js";
|
|
76
|
+
import { MAX_SUGGESTION_TEXT, MAX_SUGGESTION_TITLE, isTaskSuggestion } from "./session/suggestions.js";
|
|
75
77
|
import { BIVY_AGENT_NOTE, mergeSyncedAgentInstructions, readAgentInstructions, sessionInstructions, writeAgentInstructions, MAX_AGENT_INSTRUCTIONS_BYTES } from "./agent-instructions.js";
|
|
76
78
|
import { execEphemeralRequest } from "./ephemeral-exec.js";
|
|
77
79
|
import { ApprovalManager } from "./approval.js";
|
|
@@ -1918,8 +1920,32 @@ const captureReviewerNote = async (entry, mark) => {
|
|
|
1918
1920
|
const ref = attachmentStore.put(Buffer.from(image.data, "base64"), { name: "Reviewer note (approximate).png", mimeType: "image/png", kind: "image" });
|
|
1919
1921
|
return { hash: ref.hash, size: ref.size, width: image.width, height: image.height };
|
|
1920
1922
|
};
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
+
/** "Made with Bivy" under shared previews. Shown unless this machine turned it
|
|
1924
|
+
* off (sessions.previewBadge / BIVY_PREVIEW_BADGE) and — when it belongs to an
|
|
1925
|
+
* account — the control plane allows that account to hide it. Without a
|
|
1926
|
+
* control plane the machine's owner decides. Allowance cached for 10 minutes;
|
|
1927
|
+
* if it can't be checked, the badge shows. */
|
|
1928
|
+
let previewBadgeAllowance;
|
|
1929
|
+
async function previewBadgeShown() {
|
|
1930
|
+
const env = process.env.BIVY_PREVIEW_BADGE;
|
|
1931
|
+
const off = env === "0" || env === "false" || (env === undefined && readSettings().previewBadge === false);
|
|
1932
|
+
if (!off)
|
|
1933
|
+
return true;
|
|
1934
|
+
if (!sessionAdvertiseTarget)
|
|
1935
|
+
return false;
|
|
1936
|
+
if (!previewBadgeAllowance || Date.now() - previewBadgeAllowance.at > 10 * 60_000) {
|
|
1937
|
+
try {
|
|
1938
|
+
const body = await delegatedRunRequest("/node/preview-badge");
|
|
1939
|
+
previewBadgeAllowance = { at: Date.now(), hideAllowed: body.hideAllowed === true };
|
|
1940
|
+
}
|
|
1941
|
+
catch {
|
|
1942
|
+
return true;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
return !previewBadgeAllowance.hideAllowed;
|
|
1946
|
+
}
|
|
1947
|
+
const appGateway = process.env.BIVY_APPS_ORIGIN ? new AppGateway(appRegistry, process.env.BIVY_APPS_ORIGIN, appReturnOrigins, appSignIn, captureReviewerNote, previewBadgeShown) : undefined;
|
|
1948
|
+
const remotePreview = new RemotePreview(appRegistry, appReturnOrigins, appSignIn, captureReviewerNote, previewBadgeShown);
|
|
1923
1949
|
// Desktop app views: one private display per view, started on first open.
|
|
1924
1950
|
const appDisplays = createDisplayHost(appDir);
|
|
1925
1951
|
/** Review cards: screenshots become encrypted attachments (fetched by hash
|
|
@@ -3021,12 +3047,25 @@ const RELAY_COMMANDS = {
|
|
|
3021
3047
|
broadcast({ type: "session.error", sessionId: record.id, error: sessionAgentError(record, error) });
|
|
3022
3048
|
});
|
|
3023
3049
|
},
|
|
3024
|
-
async "session.new"(msg) {
|
|
3050
|
+
async "session.new"(msg, ctx) {
|
|
3025
3051
|
// Start a fresh session. With `repo` ("owner/repo"), clone it and branch
|
|
3026
3052
|
// off origin/main (or, with `branch`, the requested remote branch) into a
|
|
3027
3053
|
// new git worktree named from `title` (the user's first message). With an
|
|
3028
3054
|
// explicit `workspace` path, start in that folder. Otherwise use the
|
|
3029
3055
|
// default Bivy workspace.
|
|
3056
|
+
//
|
|
3057
|
+
// `like: <sessionId>` starts another session like that one (same project,
|
|
3058
|
+
// agent, model and safety; explicit fields still win), and `prompt` sends
|
|
3059
|
+
// it a first message — so a client can start parallel work in the
|
|
3060
|
+
// background without switching to it.
|
|
3061
|
+
if (typeof msg.like === "string" && msg.like) {
|
|
3062
|
+
const source = openSessions.get(msg.like);
|
|
3063
|
+
if (!source) {
|
|
3064
|
+
relay?.sendEvent({ type: "session.error", requestId: typeof msg.requestId === "string" ? msg.requestId : undefined, error: "Session not found" });
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
msg = { ...sessionLikeFields(source), ...msg };
|
|
3068
|
+
}
|
|
3030
3069
|
const repoInput = typeof msg.repo === "string" ? msg.repo.trim() : "";
|
|
3031
3070
|
const workspaceInput = typeof msg.workspace === "string" ? msg.workspace.trim() : "";
|
|
3032
3071
|
const title = typeof msg.title === "string" ? msg.title : undefined;
|
|
@@ -3112,6 +3151,9 @@ const RELAY_COMMANDS = {
|
|
|
3112
3151
|
}),
|
|
3113
3152
|
requestId,
|
|
3114
3153
|
});
|
|
3154
|
+
if (typeof msg.prompt === "string" && msg.prompt.trim()) {
|
|
3155
|
+
await clientCommands.dispatch("prompt", { kind: "prompt", sessionId: record.id, text: msg.prompt, ...(msg.attachments ? { attachments: msg.attachments } : {}) }, ctx);
|
|
3156
|
+
}
|
|
3115
3157
|
},
|
|
3116
3158
|
};
|
|
3117
3159
|
const clientCommands = new CommandRegistry(RELAY_COMMANDS, CLIENT_COMMAND_SCHEMAS);
|
|
@@ -10404,6 +10446,13 @@ app.get("/api/session/history", async (req, res) => {
|
|
|
10404
10446
|
});
|
|
10405
10447
|
app.post("/api/session", async (req, res, next) => {
|
|
10406
10448
|
try {
|
|
10449
|
+
// `like` / `prompt`: see the relay `session.new` handler.
|
|
10450
|
+
if (typeof req.body?.like === "string" && req.body.like) {
|
|
10451
|
+
const source = openSessions.get(req.body.like);
|
|
10452
|
+
if (!source)
|
|
10453
|
+
return res.status(404).json({ error: "Session not found" });
|
|
10454
|
+
req.body = { ...sessionLikeFields(source), ...req.body };
|
|
10455
|
+
}
|
|
10407
10456
|
// A git-workspace session: clone "owner/repo" and branch off origin/main into
|
|
10408
10457
|
// a new worktree named from `title` (the user's first message), mirroring the
|
|
10409
10458
|
// relay `session.new` repo path. Takes precedence over a manual workspace path.
|
|
@@ -10466,6 +10515,12 @@ app.post("/api/session", async (req, res, next) => {
|
|
|
10466
10515
|
session.contract = computeSessionContract({ runtime: getRuntime(session.runtimeId), preview: false, sandbox: session.sandbox, approvalMode: session.approvalMode, acknowledgedAt: acknowledgeReducedProtections ? gateNow : undefined }, gateNow);
|
|
10467
10516
|
persistSessionMetadata(session);
|
|
10468
10517
|
res.json({ id: session.id, workspace: session.workspace, source: session.source, branch: session.worktree?.branch, prUrl: session.prUrl, sessionFile: session.sessionFile, name: session.session.getName(), runtimeId: session.runtimeId, agentName: getRuntime(session.runtimeId).displayName, model: publicModel(session.session.getCurrentModel(), session.session.getCurrentModel()), sessionState: sessionState(session) });
|
|
10518
|
+
if (typeof req.body?.prompt === "string" && req.body.prompt.trim()) {
|
|
10519
|
+
const { prompt, attachments } = req.body;
|
|
10520
|
+
// The response is already sent; a failed turn reaches clients as session.error.
|
|
10521
|
+
void clientCommands.dispatch("prompt", { kind: "prompt", sessionId: session.id, text: prompt, ...(attachments ? { attachments } : {}) }, { reply: broadcast, broadcast })
|
|
10522
|
+
.catch((error) => console.warn("[session] first prompt failed:", error instanceof Error ? error.message : error));
|
|
10523
|
+
}
|
|
10469
10524
|
}
|
|
10470
10525
|
catch (error) {
|
|
10471
10526
|
// Creating an empty session does not contact the model provider. Failures
|
|
@@ -11174,6 +11229,21 @@ app.post("/api/session/:id/attach", (req, res) => {
|
|
|
11174
11229
|
const { hash, name, mimeType, size, kind } = result.ref;
|
|
11175
11230
|
res.json({ ok: true, hash, name, mimeType, size, kind });
|
|
11176
11231
|
});
|
|
11232
|
+
// `bivy suggest "<task>"`: the agent proposes a task the user can start in one
|
|
11233
|
+
// tap, here or in a parallel session (see packages/web SuggestionCard).
|
|
11234
|
+
app.post("/api/session/:id/suggest", (req, res) => {
|
|
11235
|
+
const record = openSessions.get(String(req.params.id));
|
|
11236
|
+
if (!record)
|
|
11237
|
+
return res.status(404).json({ error: "Session not found" });
|
|
11238
|
+
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
|
|
11239
|
+
const title = typeof req.body?.title === "string" && req.body.title.trim() ? req.body.title.trim() : undefined;
|
|
11240
|
+
const suggestion = { id: `suggestion-${randomBytes(8).toString("hex")}`, text, ...(title ? { title } : {}) };
|
|
11241
|
+
if (!isTaskSuggestion(suggestion))
|
|
11242
|
+
return res.status(400).json({ error: `A suggestion needs text (up to ${MAX_SUGGESTION_TEXT} characters) and an optional title (up to ${MAX_SUGGESTION_TITLE}).` });
|
|
11243
|
+
eventLog.appendSuggestion(record.id, { afterMessageCount: record.session.getMessages().length, suggestion });
|
|
11244
|
+
broadcast(stampSessionEvent({ type: "session.event", sessionId: record.id, event: { type: "suggestion", id: suggestion.id, suggestion } }));
|
|
11245
|
+
res.json({ ok: true, id: suggestion.id });
|
|
11246
|
+
});
|
|
11177
11247
|
// Explicit child Run API for first-party/user-directed workflows. These routes
|
|
11178
11248
|
// are intentionally not advertised to agents as tools; the service still
|
|
11179
11249
|
// authorizes every status lookup against this parent Session's provenance.
|
|
@@ -30,6 +30,7 @@ import fs from "node:fs";
|
|
|
30
30
|
import path from "node:path";
|
|
31
31
|
import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript } from "./transcript-merge.js";
|
|
32
32
|
import { APP_PUBLICATION_BLOCK, APP_REVIEW_BLOCK, isAppReference, isAppReview } from "../apps/types.js";
|
|
33
|
+
import { SUGGESTION_BLOCK, isTaskSuggestion } from "./suggestions.js";
|
|
33
34
|
/**
|
|
34
35
|
* Decompose a transcript message into serialization-independent "atoms": the
|
|
35
36
|
* agent's own tool ids (unique + identical across serializations) and its text
|
|
@@ -241,6 +242,12 @@ function isAppPublication(value) {
|
|
|
241
242
|
const entry = value;
|
|
242
243
|
return entry.bivyKind === "app-publication" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isAppReference(entry.app);
|
|
243
244
|
}
|
|
245
|
+
function isSuggestionEntry(value) {
|
|
246
|
+
if (!value || typeof value !== "object")
|
|
247
|
+
return false;
|
|
248
|
+
const entry = value;
|
|
249
|
+
return entry.bivyKind === "suggestion" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isTaskSuggestion(entry.suggestion);
|
|
250
|
+
}
|
|
244
251
|
function isAppReviewEntry(value) {
|
|
245
252
|
if (!value || typeof value !== "object")
|
|
246
253
|
return false;
|
|
@@ -248,7 +255,7 @@ function isAppReviewEntry(value) {
|
|
|
248
255
|
return entry.bivyKind === "app-review" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isAppReview(entry.review);
|
|
249
256
|
}
|
|
250
257
|
function isRecord(value) {
|
|
251
|
-
return isOverlay(value) || isBase(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value) || isAppPublication(value) || isAppReviewEntry(value);
|
|
258
|
+
return isOverlay(value) || isBase(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value) || isAppPublication(value) || isAppReviewEntry(value) || isSuggestionEntry(value);
|
|
252
259
|
}
|
|
253
260
|
/**
|
|
254
261
|
* Fold attachment records into a text→refs list: last write wins per text (a
|
|
@@ -370,7 +377,11 @@ export function replayExtras(entries) {
|
|
|
370
377
|
role: "assistant", content: [{ type: APP_REVIEW_BLOCK, review: entry.review }],
|
|
371
378
|
id: entry.id, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
|
|
372
379
|
}));
|
|
373
|
-
|
|
380
|
+
const suggestions = entries.filter((entry) => entry.bivyKind === "suggestion").map((entry) => ({
|
|
381
|
+
role: "assistant", content: [{ type: SUGGESTION_BLOCK, suggestion: entry.suggestion }],
|
|
382
|
+
id: entry.id, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
|
|
383
|
+
}));
|
|
384
|
+
return [...foldIntermediate(intermediate), ...foldTool(tool), ...replayOutboundAttachments(entries), ...publications, ...cards, ...suggestions];
|
|
374
385
|
}
|
|
375
386
|
/**
|
|
376
387
|
* Fold the outbound (agent-sent) attachment records into time-anchored synthetic
|
|
@@ -670,6 +681,10 @@ export class EventLog {
|
|
|
670
681
|
this.load(id);
|
|
671
682
|
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
683
|
}
|
|
684
|
+
appendSuggestion(id, entry) {
|
|
685
|
+
this.load(id);
|
|
686
|
+
this.enqueue(id, `suggestion:${entry.suggestion.id}`, { bivyKind: "suggestion", createdAt: Date.now(), afterMessageCount: entry.afterMessageCount, id: entry.suggestion.id, suggestion: { ...entry.suggestion } });
|
|
687
|
+
}
|
|
673
688
|
appendOutboundAttachment(id, entry) {
|
|
674
689
|
this.load(id);
|
|
675
690
|
const record = {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The creation fields of an existing session, for "start another like it"
|
|
3
|
+
* (`session.new` with `like`): same project, agent, model and safety. A git
|
|
4
|
+
* worktree session points back at its checkout, so the new session gets its own
|
|
5
|
+
* worktree off the default branch instead of sharing this one's; a plain folder
|
|
6
|
+
* is shared, as any new session in it would be.
|
|
7
|
+
*/
|
|
8
|
+
export function sessionLikeFields(source) {
|
|
9
|
+
const model = source.session.getCurrentModel();
|
|
10
|
+
return {
|
|
11
|
+
workspace: source.worktree?.repoRoot ?? source.workspace,
|
|
12
|
+
agent: source.runtimeId,
|
|
13
|
+
...(source.sandbox ? { sandbox: source.sandbox } : {}),
|
|
14
|
+
...(model?.id ? { model: { provider: String(model.provider ?? ""), id: String(model.id) } } : {}),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
export const SUGGESTION_BLOCK = "bivy_suggestion";
|
|
4
|
+
export const MAX_SUGGESTION_TEXT = 4000;
|
|
5
|
+
export const MAX_SUGGESTION_TITLE = 120;
|
|
6
|
+
export function isTaskSuggestion(value) {
|
|
7
|
+
if (!value || typeof value !== "object")
|
|
8
|
+
return false;
|
|
9
|
+
const s = value;
|
|
10
|
+
return typeof s.id === "string" && s.id.length > 0
|
|
11
|
+
&& typeof s.text === "string" && s.text.length > 0 && s.text.length <= MAX_SUGGESTION_TEXT
|
|
12
|
+
&& (s.title === undefined || (typeof s.title === "string" && s.title.length <= MAX_SUGGESTION_TITLE));
|
|
13
|
+
}
|