@bivy/bivy 0.19.4-staging.2 → 0.19.4-staging.4
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 +395 -153
- package/bin/cli-commands.mjs +273 -0
- package/bin/guides/automate.md +18 -0
- package/bin/guides/long-work.md +23 -0
- package/bin/guides/more-hands.md +18 -0
- package/bin/guides/show-the-user.md +44 -0
- package/bin/guides/talk-to-the-user.md +38 -0
- package/dist/agent-instructions.js +5 -0
- package/dist/apps/styles.css +8 -2
- package/dist/harness/mcp-serve-cli.js +70 -8
- package/dist/question.js +1 -1
- package/dist/server.js +84 -0
- package/dist/session/agent-context.js +15 -0
- package/dist/session/agent-questions.js +94 -0
- package/dist/session/event-log.js +17 -2
- package/dist/session/notices.js +25 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -126,6 +126,9 @@ import { createSessionControlCommands } from "./controllers/session-control.js";
|
|
|
126
126
|
import { createPresenceCommands } from "./controllers/presence-commands.js";
|
|
127
127
|
import { createArtifactCommands } from "./controllers/artifact-commands.js";
|
|
128
128
|
import { PresenceBook, deviceFrom } from "./session/presence.js";
|
|
129
|
+
import { buildAgentContext } from "./session/agent-context.js";
|
|
130
|
+
import { AgentQuestions, askQuestionsFrom } from "./session/agent-questions.js";
|
|
131
|
+
import { MAX_NOTICE_TEXT, isAgentNotice, noticePush } from "./session/notices.js";
|
|
129
132
|
import { createForkCommands } from "./controllers/fork-commands.js";
|
|
130
133
|
import { createGithubCommands } from "./controllers/github-commands.js";
|
|
131
134
|
import { createCredentialCommands } from "./controllers/credential-commands.js";
|
|
@@ -397,6 +400,7 @@ const auditLog = createAuditLog(path.join(appDir, "audit"), { signer: auditKey.s
|
|
|
397
400
|
// clarifying question across sessions; the guardian interceptor below raises
|
|
398
401
|
// them, and session.question(.resolved) is broadcast from its listeners.
|
|
399
402
|
const questionManager = new QuestionManager();
|
|
403
|
+
const agentQuestions = new AgentQuestions(questionManager);
|
|
400
404
|
questionManager.onRequest((request) => {
|
|
401
405
|
scheduleAdvertise();
|
|
402
406
|
broadcast({ type: "session.question", sessionId: request.sessionId, requestId: request.id, questions: request.questions, createdAt: request.createdAt });
|
|
@@ -11505,6 +11509,86 @@ app.post("/api/session/:id/attach", (req, res) => {
|
|
|
11505
11509
|
const { hash, name, mimeType, size, kind } = result.ref;
|
|
11506
11510
|
res.json({ ok: true, hash, name, mimeType, size, kind });
|
|
11507
11511
|
});
|
|
11512
|
+
// `bivy context`: what an agent in this session needs to orient itself.
|
|
11513
|
+
app.get("/api/session/:id/context", (req, res) => {
|
|
11514
|
+
const record = openSessions.get(String(req.params.id));
|
|
11515
|
+
if (!record)
|
|
11516
|
+
return res.status(404).json({ error: "Session not found" });
|
|
11517
|
+
const { apps, previewAvailable } = appService.list(record.id);
|
|
11518
|
+
res.json(buildAgentContext({
|
|
11519
|
+
session: {
|
|
11520
|
+
id: record.id,
|
|
11521
|
+
name: record.session.getName() || undefined,
|
|
11522
|
+
agent: record.runtimeId,
|
|
11523
|
+
agentName: getRuntime(record.runtimeId).displayName,
|
|
11524
|
+
workspace: harnessDirFor(record),
|
|
11525
|
+
branch: record.worktree?.branch,
|
|
11526
|
+
prUrl: record.prUrl,
|
|
11527
|
+
forkedFrom: record.forkedFrom,
|
|
11528
|
+
delegatedFrom: record.delegatedFrom?.sessionId,
|
|
11529
|
+
busy: sessionBusy(record),
|
|
11530
|
+
},
|
|
11531
|
+
machine: { name: identity.name, platform: process.platform, arch: process.arch },
|
|
11532
|
+
presence: presenceBook.get(record.id),
|
|
11533
|
+
userConnected: clients.size > 0 || (relay?.clientCount ?? 0) > 0,
|
|
11534
|
+
apps,
|
|
11535
|
+
previewAvailable,
|
|
11536
|
+
}));
|
|
11537
|
+
});
|
|
11538
|
+
// `bivy notify "<message>"`: a card in the chat, and a push naming the session
|
|
11539
|
+
// (never the text) when nobody has the app open, or when it's urgent. At most
|
|
11540
|
+
// one push a minute per session; later notices still post a card.
|
|
11541
|
+
const lastNotifyPush = new Map();
|
|
11542
|
+
app.post("/api/session/:id/notify", (req, res) => {
|
|
11543
|
+
const record = openSessions.get(String(req.params.id));
|
|
11544
|
+
if (!record)
|
|
11545
|
+
return res.status(404).json({ error: "Session not found" });
|
|
11546
|
+
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
|
|
11547
|
+
const notice = { id: `notice-${randomBytes(8).toString("hex")}`, text, ...(req.body?.urgent === true ? { urgent: true } : {}) };
|
|
11548
|
+
if (!isAgentNotice(notice))
|
|
11549
|
+
return res.status(400).json({ error: `A notice needs text (up to ${MAX_NOTICE_TEXT} characters).` });
|
|
11550
|
+
eventLog.appendNotice(record.id, { afterMessageCount: record.session.getMessages().length, notice });
|
|
11551
|
+
broadcast(stampSessionEvent({ type: "session.event", sessionId: record.id, event: { type: "notice", id: notice.id, notice } }));
|
|
11552
|
+
const watching = clients.size > 0 || (relay?.clientCount ?? 0) > 0;
|
|
11553
|
+
const push = noticePush({ urgent: notice.urgent, userWatching: watching, lastPushAt: lastNotifyPush.get(record.id), now: Date.now(), pushConfigured: Boolean(sessionAdvertiseTarget) });
|
|
11554
|
+
if (push === "sent") {
|
|
11555
|
+
lastNotifyPush.set(record.id, Date.now());
|
|
11556
|
+
void sendNotificationHint({
|
|
11557
|
+
kind: "agent_notice",
|
|
11558
|
+
sessionId: record.id,
|
|
11559
|
+
targetSessionId: record.id,
|
|
11560
|
+
title: sessionNotifyLabel(record),
|
|
11561
|
+
body: "Has a message for you — tap to read it.",
|
|
11562
|
+
});
|
|
11563
|
+
}
|
|
11564
|
+
res.json({ ok: true, id: notice.id, push, userWatching: watching });
|
|
11565
|
+
});
|
|
11566
|
+
// `bivy ask`: the question card and "needs your input" push, for any agent.
|
|
11567
|
+
// Returns at once with an id; `/wait` blocks up to 240s per call (under common
|
|
11568
|
+
// HTTP client timeouts), so the CLI loops until the user answers.
|
|
11569
|
+
app.post("/api/session/:id/ask", (req, res) => {
|
|
11570
|
+
const record = openSessions.get(String(req.params.id));
|
|
11571
|
+
if (!record)
|
|
11572
|
+
return res.status(404).json({ error: "Session not found" });
|
|
11573
|
+
const questions = askQuestionsFrom(req.body?.questions);
|
|
11574
|
+
if (typeof questions === "string")
|
|
11575
|
+
return res.status(400).json({ error: questions });
|
|
11576
|
+
const timeoutSeconds = Number(req.body?.timeoutSeconds);
|
|
11577
|
+
res.status(201).json(agentQuestions.ask(record.id, questions, Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? timeoutSeconds * 1000 : 10 * 60 * 1000));
|
|
11578
|
+
});
|
|
11579
|
+
app.get("/api/session/:id/ask/:askId", (req, res) => {
|
|
11580
|
+
const result = agentQuestions.get(String(req.params.id), String(req.params.askId));
|
|
11581
|
+
if (!result)
|
|
11582
|
+
return res.status(404).json({ error: "Question not found" });
|
|
11583
|
+
res.json(result);
|
|
11584
|
+
});
|
|
11585
|
+
app.post("/api/session/:id/ask/:askId/wait", async (req, res) => {
|
|
11586
|
+
const seconds = Math.min(Math.max(Number(req.body?.timeoutSeconds) || 240, 1), 240);
|
|
11587
|
+
const result = await agentQuestions.wait(String(req.params.id), String(req.params.askId), seconds * 1000);
|
|
11588
|
+
if (!result)
|
|
11589
|
+
return res.status(404).json({ error: "Question not found" });
|
|
11590
|
+
res.json(result);
|
|
11591
|
+
});
|
|
11508
11592
|
// `bivy suggest "<task>"`: the agent proposes a task the user can start in one
|
|
11509
11593
|
// tap, here or in a parallel session (see packages/web SuggestionCard).
|
|
11510
11594
|
app.post("/api/session/:id/suggest", (req, res) => {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function buildAgentContext(input) {
|
|
2
|
+
const driver = input.presence.driver;
|
|
3
|
+
return {
|
|
4
|
+
session: input.session,
|
|
5
|
+
machine: input.machine,
|
|
6
|
+
userConnected: input.userConnected,
|
|
7
|
+
...(driver ? { lastDriver: { label: driver.label, via: driver.via, at: driver.at } } : {}),
|
|
8
|
+
apps: input.apps.map((app) => ({
|
|
9
|
+
id: app.id,
|
|
10
|
+
name: app.name,
|
|
11
|
+
views: app.views.map((view) => ({ id: view.id, name: view.name, kind: view.kind })),
|
|
12
|
+
})),
|
|
13
|
+
previewAvailable: input.previewAvailable,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
export const MAX_ASK_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
const KEEP_SETTLED_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
const KEEP_MAX = 200;
|
|
7
|
+
/** Validates `bivy ask` input: 1–4 questions, each with text, a short header and
|
|
8
|
+
* none (a free-text answer) or 2–8 options. Returns an error message, or the questions. */
|
|
9
|
+
export function askQuestionsFrom(value) {
|
|
10
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 4)
|
|
11
|
+
return "Ask 1 to 4 questions.";
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const raw of value) {
|
|
14
|
+
const question = typeof raw?.question === "string" ? raw.question.trim() : "";
|
|
15
|
+
if (!question || question.length > 1000)
|
|
16
|
+
return "Each question needs text (up to 1000 characters).";
|
|
17
|
+
const header = typeof raw.header === "string" && raw.header.trim() ? raw.header.trim().slice(0, 40) : "Question";
|
|
18
|
+
const options = Array.isArray(raw.options) ? raw.options : [];
|
|
19
|
+
if (options.length === 1 || options.length > 8)
|
|
20
|
+
return "Offer 2 to 8 options, or none for a free-text answer.";
|
|
21
|
+
const labels = options.map((o) => (typeof o === "string" ? o : o?.label)).map((l) => (typeof l === "string" ? l.trim() : ""));
|
|
22
|
+
if (labels.some((l) => !l || l.length > 200))
|
|
23
|
+
return "Each option needs a label (up to 200 characters).";
|
|
24
|
+
if (new Set(labels).size !== labels.length)
|
|
25
|
+
return "Options must be different.";
|
|
26
|
+
out.push({ question, header, options: labels.map((label) => ({ label })), ...(raw.multiSelect === true ? { multiSelect: true } : {}) });
|
|
27
|
+
}
|
|
28
|
+
if (new Set(out.map((q) => q.question)).size !== out.length)
|
|
29
|
+
return "Questions must be different.";
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
export class AgentQuestions {
|
|
33
|
+
questions;
|
|
34
|
+
now;
|
|
35
|
+
results = new Map();
|
|
36
|
+
waiters = new Map();
|
|
37
|
+
/** How each of our questions settled. QuestionManager reports it to onResolved
|
|
38
|
+
* listeners synchronously, before the request's promise callbacks run. */
|
|
39
|
+
settled = new Map();
|
|
40
|
+
constructor(questions, now = Date.now) {
|
|
41
|
+
this.questions = questions;
|
|
42
|
+
this.now = now;
|
|
43
|
+
questions.onResolved((request) => { if (this.results.has(request.id))
|
|
44
|
+
this.settled.set(request.id, request.status); });
|
|
45
|
+
}
|
|
46
|
+
/** Raise the card and return at once; the answer lands in `get`/`wait`. */
|
|
47
|
+
ask(sessionId, items, timeoutMs) {
|
|
48
|
+
this.prune();
|
|
49
|
+
const id = randomUUID();
|
|
50
|
+
const result = { id, sessionId, status: "pending" };
|
|
51
|
+
this.results.set(id, result);
|
|
52
|
+
void this.questions.request({ id, sessionId, questions: items, timeoutMs: Math.min(Math.max(timeoutMs, 1000), MAX_ASK_TIMEOUT_MS) }).then((answer) => {
|
|
53
|
+
const status = this.settled.get(id);
|
|
54
|
+
this.settled.delete(id);
|
|
55
|
+
result.status = answer.behavior === "completed" ? "answered" : status === "expired" ? "expired" : "dismissed";
|
|
56
|
+
if (answer.behavior === "completed")
|
|
57
|
+
result.answers = answer.answers;
|
|
58
|
+
result.settledAt = this.now();
|
|
59
|
+
for (const wake of this.waiters.get(id) ?? [])
|
|
60
|
+
wake();
|
|
61
|
+
this.waiters.delete(id);
|
|
62
|
+
});
|
|
63
|
+
return this.view(result);
|
|
64
|
+
}
|
|
65
|
+
get(sessionId, id) {
|
|
66
|
+
const result = this.results.get(id);
|
|
67
|
+
return result && result.sessionId === sessionId ? this.view(result) : undefined;
|
|
68
|
+
}
|
|
69
|
+
/** Resolves when the question settles or after `ms`, whichever is first. */
|
|
70
|
+
async wait(sessionId, id, ms) {
|
|
71
|
+
const result = this.results.get(id);
|
|
72
|
+
if (!result || result.sessionId !== sessionId)
|
|
73
|
+
return undefined;
|
|
74
|
+
if (result.status === "pending") {
|
|
75
|
+
await new Promise((resolve) => {
|
|
76
|
+
const timer = setTimeout(done, ms);
|
|
77
|
+
function done() { clearTimeout(timer); resolve(); }
|
|
78
|
+
const set = this.waiters.get(id) ?? new Set();
|
|
79
|
+
set.add(done);
|
|
80
|
+
this.waiters.set(id, set);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return this.view(result);
|
|
84
|
+
}
|
|
85
|
+
view(result) {
|
|
86
|
+
return { id: result.id, sessionId: result.sessionId, status: result.status, ...(result.answers ? { answers: { ...result.answers } } : {}) };
|
|
87
|
+
}
|
|
88
|
+
prune() {
|
|
89
|
+
const cutoff = this.now() - KEEP_SETTLED_MS;
|
|
90
|
+
for (const [id, result] of this.results)
|
|
91
|
+
if (result.settledAt !== undefined && (result.settledAt < cutoff || this.results.size > KEEP_MAX))
|
|
92
|
+
this.results.delete(id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -32,6 +32,7 @@ import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript }
|
|
|
32
32
|
import { DELEGATION_BLOCK, isDelegationCard } from "./delegations.js";
|
|
33
33
|
import { APP_PIN_BLOCK, APP_PUBLICATION_BLOCK, APP_REVIEW_BLOCK, isAppPin, isAppReference, isAppReview } from "../apps/types.js";
|
|
34
34
|
import { SUGGESTION_BLOCK, isTaskSuggestion } from "./suggestions.js";
|
|
35
|
+
import { NOTICE_BLOCK, isAgentNotice } from "./notices.js";
|
|
35
36
|
/**
|
|
36
37
|
* Decompose a transcript message into serialization-independent "atoms": the
|
|
37
38
|
* agent's own tool ids (unique + identical across serializations) and its text
|
|
@@ -255,6 +256,12 @@ function isSuggestionEntry(value) {
|
|
|
255
256
|
const entry = value;
|
|
256
257
|
return entry.bivyKind === "suggestion" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isTaskSuggestion(entry.suggestion);
|
|
257
258
|
}
|
|
259
|
+
function isNoticeEntry(value) {
|
|
260
|
+
if (!value || typeof value !== "object")
|
|
261
|
+
return false;
|
|
262
|
+
const entry = value;
|
|
263
|
+
return entry.bivyKind === "notice" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isAgentNotice(entry.notice);
|
|
264
|
+
}
|
|
258
265
|
function isDelegationEntry(value) {
|
|
259
266
|
if (!value || typeof value !== "object")
|
|
260
267
|
return false;
|
|
@@ -274,7 +281,7 @@ function isAppPinEntry(value) {
|
|
|
274
281
|
return entry.bivyKind === "app-pin" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isAppPin(entry.pin);
|
|
275
282
|
}
|
|
276
283
|
function isRecord(value) {
|
|
277
|
-
return isDelegationEntry(value) || isOverlay(value) || isBase(value) || isForkDisplay(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value) || isAppPublication(value) || isAppReviewEntry(value) || isAppPinEntry(value) || isSuggestionEntry(value);
|
|
284
|
+
return isDelegationEntry(value) || isOverlay(value) || isBase(value) || isForkDisplay(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value) || isAppPublication(value) || isAppReviewEntry(value) || isAppPinEntry(value) || isSuggestionEntry(value) || isNoticeEntry(value);
|
|
278
285
|
}
|
|
279
286
|
/**
|
|
280
287
|
* Fold attachment records into a text→refs list: last write wins per text (a
|
|
@@ -421,7 +428,11 @@ export function replayExtras(entries) {
|
|
|
421
428
|
role: "assistant", content: [{ type: SUGGESTION_BLOCK, suggestion: entry.suggestion }],
|
|
422
429
|
id: entry.id, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
|
|
423
430
|
}));
|
|
424
|
-
|
|
431
|
+
const notices = entries.filter((entry) => entry.bivyKind === "notice").map((entry) => ({
|
|
432
|
+
role: "assistant", content: [{ type: NOTICE_BLOCK, notice: entry.notice }],
|
|
433
|
+
id: entry.id, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
|
|
434
|
+
}));
|
|
435
|
+
return [...foldIntermediate(intermediate), ...foldTool(tool), ...replayOutboundAttachments(entries), ...publications, ...cards, ...pinCards, ...delegationCards, ...suggestions, ...notices];
|
|
425
436
|
}
|
|
426
437
|
/**
|
|
427
438
|
* Fold the outbound (agent-sent) attachment records into time-anchored synthetic
|
|
@@ -742,6 +753,10 @@ export class EventLog {
|
|
|
742
753
|
this.load(id);
|
|
743
754
|
this.enqueue(id, `suggestion:${entry.suggestion.id}`, { bivyKind: "suggestion", createdAt: Date.now(), afterMessageCount: entry.afterMessageCount, id: entry.suggestion.id, suggestion: { ...entry.suggestion } });
|
|
744
755
|
}
|
|
756
|
+
appendNotice(id, entry) {
|
|
757
|
+
this.load(id);
|
|
758
|
+
this.enqueue(id, `notice:${entry.notice.id}`, { bivyKind: "notice", createdAt: Date.now(), afterMessageCount: entry.afterMessageCount, id: entry.notice.id, notice: { ...entry.notice } });
|
|
759
|
+
}
|
|
745
760
|
appendOutboundAttachment(id, entry) {
|
|
746
761
|
this.load(id);
|
|
747
762
|
const record = {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
export const NOTICE_BLOCK = "bivy_notice";
|
|
4
|
+
export const MAX_NOTICE_TEXT = 2000;
|
|
5
|
+
/** At most one push per session in this window; later notices still post a card. */
|
|
6
|
+
export const NOTICE_PUSH_GAP_MS = 60_000;
|
|
7
|
+
/** Whether a notice also buzzes the user's devices. `pushConfigured` is false on
|
|
8
|
+
* a node with no account connection, which has no way to push. */
|
|
9
|
+
export function noticePush(input) {
|
|
10
|
+
if (input.pushConfigured === false)
|
|
11
|
+
return "unavailable";
|
|
12
|
+
if (input.lastPushAt !== undefined && input.now - input.lastPushAt < NOTICE_PUSH_GAP_MS)
|
|
13
|
+
return "rate_limited";
|
|
14
|
+
if (input.userWatching && !input.urgent)
|
|
15
|
+
return "user_watching";
|
|
16
|
+
return "sent";
|
|
17
|
+
}
|
|
18
|
+
export function isAgentNotice(value) {
|
|
19
|
+
if (!value || typeof value !== "object")
|
|
20
|
+
return false;
|
|
21
|
+
const n = value;
|
|
22
|
+
return typeof n.id === "string" && n.id.length > 0
|
|
23
|
+
&& typeof n.text === "string" && n.text.length > 0 && n.text.length <= MAX_NOTICE_TEXT
|
|
24
|
+
&& (n.urgent === undefined || typeof n.urgent === "boolean");
|
|
25
|
+
}
|
package/package.json
CHANGED