@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/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
- return [...foldIntermediate(intermediate), ...foldTool(tool), ...replayOutboundAttachments(entries), ...publications, ...cards, ...pinCards, ...delegationCards, ...suggestions];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.19.4-staging.2",
3
+ "version": "0.19.4-staging.4",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",