@bivy/bivy 0.16.25-staging.2 → 0.16.25-staging.20

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.
@@ -193,7 +193,10 @@
193
193
  "certification": "release-tested",
194
194
  "testedVersion": "1.0.0",
195
195
  "headlessFlags": [
196
+ "--always-approve",
196
197
  "-p",
198
+ "--output-format",
199
+ "streaming-json",
197
200
  "--resume"
198
201
  ],
199
202
  "install": {
@@ -268,8 +268,29 @@ class PiSession {
268
268
  * reload. Async because that resolution reads the store.
269
269
  */
270
270
  async getModels() {
271
- const models = await this.session.modelRuntime.getAvailable();
272
- return models.map((model) => toModelInfo(model, true));
271
+ const runtime = this.session.modelRuntime;
272
+ try {
273
+ const models = await runtime.getAvailable();
274
+ return models.map((model) => toModelInfo(model, true));
275
+ }
276
+ catch (error) {
277
+ // The runtime's availability refresh is a Promise.all across every
278
+ // provider's auth check, so one broken provider (an unreachable custom
279
+ // endpoint, a single failing credential read) rejects the whole catalog
280
+ // and the picker shows nothing. Degrade to per-provider listing and
281
+ // skip only the broken ones; rethrow only when truly nothing survives.
282
+ const models = [];
283
+ for (const provider of runtime.getProviders()) {
284
+ try {
285
+ for (const model of await runtime.getAvailable(provider.id))
286
+ models.push(toModelInfo(model, true));
287
+ }
288
+ catch { /* this provider is broken — the rest still list */ }
289
+ }
290
+ if (models.length)
291
+ return models;
292
+ throw error instanceof Error ? error : new Error(String(error));
293
+ }
273
294
  }
274
295
  /**
275
296
  * Every model Pi knows about — built-in and custom, regardless of whether
@@ -281,7 +302,10 @@ class PiSession {
281
302
  async getAllModels() {
282
303
  const runtime = this.session.modelRuntime;
283
304
  // Refresh availability first so `hasConfiguredAuth` reflects current creds.
284
- await runtime.getAvailable();
305
+ // Best-effort: a failed refresh (one broken provider rejects the shared
306
+ // Promise.all) must not blank the full catalog — the flags then reflect
307
+ // the last successful refresh, which the picker can still act on.
308
+ await runtime.getAvailable().catch(() => { });
285
309
  return runtime.getModels().map((model) => toModelInfo(model, runtime.hasConfiguredAuth(model.provider)));
286
310
  }
287
311
  /** Reload the projected models.json in-place so a live session immediately
@@ -370,32 +370,49 @@ export const AGENT_PROFILES = {
370
370
  // UUID (sessions live under ~/.grok/sessions/<cwd>/<id>/). The interactive
371
371
  // TUI uses the same store via `grok --resume <id>`. Model ids match
372
372
  // `grok models` for the official CLI (override with BIVY_GROK_MODELS).
373
- args: ["-p"],
374
- // The current CLI's `--output-format streaming-json` emits newline-delimited
375
- // JSON keyed off `type`: {type:"text",data} for the answer, {type:"thought",
376
- // data} for reasoning, {type:"end"} to close the turn (plus tool frames). The
377
- // shared tolerant generic-stream-json parser understands this shape (and the
378
- // ACP session/update envelope other CLIs use), so Grok gets faithful
379
- // transcripts answer prose, a thinking sidecar, and tool cards — without a
380
- // Grok-specific adapter. Keep the plain args as the explicit
381
- // BIVY_AGENT_STRUCTURED=0 fallback.
382
- jsonArgs: ["--output-format", "streaming-json", "-p"],
373
+ // `--always-approve` auto-approves every tool execution. Without it, Grok's
374
+ // headless `-p` turn emits a tool_call and then waits for the client to
375
+ // approve it over the streaming-json (ACP) channel; Bivy governs effects at
376
+ // the sandbox tier and closes stdin, so the pending call races to a "User
377
+ // cancelled the execution" i.e. Grok could never actually run a tool under
378
+ // Bivy. The flag is the Grok analogue of Cursor's `--force`, Copilot's
379
+ // `--allow-all-tools`, Droid's `--auto`, etc.
380
+ args: ["--always-approve", "-p"],
381
+ // The current CLI's `--output-format streaming-json` emits NDJSON of the
382
+ // agent-native ACP session updates: assistant text, a {type:"thought"}
383
+ // reasoning sidecar, and tool_call/tool_call_update frames. The shared
384
+ // tolerant generic-stream-json parser understands this shape (and the ACP
385
+ // session/update envelope other CLIs use), so Grok gets faithful transcripts
386
+ // — answer prose, a thinking sidecar, and tool cards — without a Grok-specific
387
+ // adapter. Keep the plain args as the explicit BIVY_AGENT_STRUCTURED=0
388
+ // fallback.
389
+ jsonArgs: ["--output-format", "streaming-json", "--always-approve", "-p"],
383
390
  parserId: "generic-stream-json",
391
+ // Resume mirrors the fresh-launch recipe (structured output + auto-approve)
392
+ // so a continued turn streams and runs tools exactly like the first one.
384
393
  resume: {
385
- template: ["--resume", "{id}", "-p"],
394
+ template: ["--output-format", "streaming-json", "--resume", "{id}", "--always-approve", "-p"],
386
395
  historyLoader: "grok",
387
396
  },
388
397
  model: {
389
398
  flag: "-m",
390
399
  models: [
391
400
  // Official Grok CLI (1.x) advertises grok-4.6 as the default subscription
392
- // model (verified against `grok models`); the older grok-4.5 / grok-4-latest
393
- // / grok-code-fast-1 ids now return "unknown model id". Keep the list
394
- // honest; operators can override with BIVY_GROK_MODELS if their install
395
- // exposes more.
401
+ // model (verified against the authenticated `~/.grok/models_cache.json`,
402
+ // origin cli-chat-proxy.grok.com/v1/models); the older grok-4.5 /
403
+ // grok-4-latest / grok-code-fast-1 ids now return "unknown model id".
404
+ // Keep the list honest; operators can override with BIVY_GROK_MODELS if
405
+ // their install exposes more.
396
406
  { id: "grok-4.6", name: "Grok 4.6", provider: "xai" },
397
407
  ],
398
408
  },
409
+ // Grok's reasoning models expose a `--reasoning-effort <EFFORT>` flag
410
+ // (verified against `grok --help`, 1.0.0); the authenticated model catalog
411
+ // lists low/medium/high/xhigh with `high` as the default. `{level}` fills
412
+ // the value; insertAt: 0 keeps the trailing `-p` prompt flag last so the
413
+ // prompt still lands as its value. Threads through the resume path too, so a
414
+ // continued turn keeps the chosen effort. Override with BIVY_GROK_THINKING.
415
+ thinking: { levels: ["low", "medium", "high", "xhigh"], default: "high", template: ["--reasoning-effort", "{level}"], insertAt: 0 },
399
416
  promptMode: "argv",
400
417
  install: {
401
418
  kind: "curl",
@@ -95,7 +95,8 @@ export function createCredentialCommands(deps) {
95
95
  async "credential.remove"(msg, ctx) {
96
96
  try {
97
97
  await removeProviderCredential(credsDir, String(msg.provider ?? ""), String(msg.label ?? ""));
98
- await deps.pushModelAuthToControlPlane();
98
+ // Deleting a granted credential must also remove its escrowed cloud copy.
99
+ await deps.pushModelAuthToControlPlane({ allowEmptyHostedEscrow: true });
99
100
  await deps.refreshSessionAfterAuth();
100
101
  deps.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
101
102
  deps.broadcast({ type: "providers.list", providers: await deps.listProvidersUnified() });
@@ -111,7 +112,8 @@ export function createCredentialCommands(deps) {
111
112
  try {
112
113
  const sync = msg.sync === "node" ? "node" : "account";
113
114
  await setCredentialSync(credsDir, String(msg.provider ?? ""), String(msg.label ?? ""), sync);
114
- await deps.pushModelAuthToControlPlane();
115
+ // Demoting to machine-only revokes any custody grant; let it clear the escrow.
116
+ await deps.pushModelAuthToControlPlane({ allowEmptyHostedEscrow: sync === "node" });
115
117
  deps.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
116
118
  ctx.reply({ type: "credential.sync.set.ok", requestId: msg.requestId });
117
119
  }
@@ -127,7 +129,10 @@ export function createCredentialCommands(deps) {
127
129
  const previous = (await listCredentialRecords(credsDir)).find((record) => record.provider === provider.trim().toLowerCase() && record.label === label)?.unattended === true;
128
130
  try {
129
131
  await setCredentialUnattended(credsDir, provider, label, msg.unattended === true);
130
- await deps.pushModelAuthToControlPlane();
132
+ // Fail loud: the grant reply must reflect whether the encrypted cloud
133
+ // copy actually reached the control plane. An explicit disable is the
134
+ // one caller allowed to publish an empty escrow snapshot.
135
+ await deps.pushModelAuthToControlPlane({ throwOnFailure: true, allowEmptyHostedEscrow: msg.unattended !== true });
131
136
  deps.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
132
137
  ctx.reply({ type: "credential.unattended.set.ok", requestId: msg.requestId });
133
138
  }
@@ -87,17 +87,22 @@ function sameRecordContent(a, b) {
87
87
  const strip = ({ updatedAt: _drop, ...rest }) => rest;
88
88
  return JSON.stringify(strip(a)) === JSON.stringify(strip(b));
89
89
  }
90
+ const stable = (value) => {
91
+ if (Array.isArray(value))
92
+ return value.map(stable);
93
+ if (!value || typeof value !== "object")
94
+ return value;
95
+ return Object.fromEntries(Object.entries(value)
96
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
97
+ .map(([key, child]) => [key, stable(child)]));
98
+ };
90
99
  function canonicalCredential(credential) {
91
100
  const { updatedAt: _drop, ...content } = credential;
92
- const stable = (value) => {
93
- if (Array.isArray(value))
94
- return value.map(stable);
95
- if (!value || typeof value !== "object")
96
- return value;
97
- return Object.fromEntries(Object.entries(value)
98
- .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
99
- .map(([key, child]) => [key, stable(child)]));
100
- };
101
+ return JSON.stringify(stable(content));
102
+ }
103
+ /** Stable record content sans the store-owned stamp — the final merge tie-break. */
104
+ function canonicalRecord(record) {
105
+ const { updatedAt: _drop, ...content } = record;
101
106
  return JSON.stringify(stable(content));
102
107
  }
103
108
  /**
@@ -132,6 +137,19 @@ export function preferIncomingRecord(local, incoming) {
132
137
  const incomingOauth = oauthOf(incoming);
133
138
  if (!localOauth || !incomingOauth)
134
139
  return true;
140
+ // Identical token content means this is a metadata-only change (an
141
+ // unattended-runs grant/revoke, a sync-tier move). Token freshness cannot
142
+ // order those, so the newer record write wins — without this, a grant made
143
+ // on one machine never reaches its peers, and a peer's next hosted-escrow
144
+ // push would read as "nothing granted". Equal stamps fall back to canonical
145
+ // record content so both merge directions still converge.
146
+ if (canonicalCredential(localOauth) === canonicalCredential(incomingOauth)) {
147
+ const localAt = Number(local.updatedAt) || 0;
148
+ const incomingAt = Number(incoming.updatedAt) || 0;
149
+ if (incomingAt !== localAt)
150
+ return incomingAt > localAt;
151
+ return canonicalRecord(incoming) > canonicalRecord(local);
152
+ }
135
153
  return preferIncomingOAuthCredential(localOauth, incomingOauth);
136
154
  }
137
155
  /** A tombstone wins only when it is newer than the record it would remove. */
@@ -251,7 +269,14 @@ export function mergeDocuments(local, incomingCredentials, incomingDeletedAt = {
251
269
  continue;
252
270
  if (!(key in credentials))
253
271
  imported += 1;
254
- credentials[key] = incoming;
272
+ // An incoming record with no opinion on unattended custody (undefined — the
273
+ // v2 wire and plaintext ingest never carry the flag) must not strip an
274
+ // explicit local grant/revoke: agent-side token refreshes flow through this
275
+ // merge, and dropping the flag would make the node's next escrow push
276
+ // silently revoke the encrypted cloud copy the user still relies on.
277
+ credentials[key] = localRecord && incoming.unattended === undefined && localRecord.unattended !== undefined
278
+ ? { ...incoming, unattended: localRecord.unattended }
279
+ : incoming;
255
280
  const incomingUpdatedAt = Number(incoming.updatedAt);
256
281
  if (!Number.isFinite(deletedAt[key]) || incomingUpdatedAt > deletedAt[key])
257
282
  delete deletedAt[key];
@@ -554,6 +554,23 @@ export class BivyCredentialStore {
554
554
  // engine (merge-never-destroy, freshest-OAuth-wins, rotation-safe,
555
555
  // tombstone-newer-wins — see document.ts).
556
556
  const incoming = migrateToV3({ v: 2, providers: snapshot, deletedAt });
557
+ // The v2 wire carries no record metadata, so migrateToV3 synthesizes
558
+ // defaults (sync: "account", origin: "bivy", no unattended flag). Those
559
+ // defaults must not clobber local intent — a machine-only sync tier or an
560
+ // unattended-runs custody grant — when only the token content is newer
561
+ // (e.g. an agent's own TUI refreshed the OAuth token set). Inherit the
562
+ // existing record's metadata; the merge engine still decides freshness.
563
+ for (const [key, record] of Object.entries(incoming.credentials)) {
564
+ const existing = document.credentials[key];
565
+ if (!existing)
566
+ continue;
567
+ incoming.credentials[key] = {
568
+ ...record,
569
+ sync: existing.sync,
570
+ origin: existing.origin,
571
+ ...(existing.unattended !== undefined ? { unattended: existing.unattended } : {}),
572
+ };
573
+ }
557
574
  const result = mergeDocuments(document, incoming.credentials, incoming.deletedAt);
558
575
  if (result.changed)
559
576
  this.writeDocument(result.document);
@@ -0,0 +1,30 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // One pure policy decision: does THIS daemon hold only control-plane-custodied
4
+ // credentials (the separately escrowed unattended snapshot), or is it a normal
5
+ // personal node that owns its vault?
6
+ //
7
+ // The distinction matters everywhere model-auth sync branches: a custody node
8
+ // never pushes/pulls the account vault, never publishes the unattended escrow,
9
+ // and treats the control plane's filtered snapshot as an authoritative set
10
+ // (reconcile deletes what a newer snapshot omits). Getting this wrong on a
11
+ // personal node silently breaks credential sync AND makes the unattended-runs
12
+ // toggle a no-op — the "Allowed — encrypted cloud copy enabled" UI then lies,
13
+ // and every Bivy Cloud session fails with "credential didn't reach Bivy Cloud".
14
+ /**
15
+ * True when this daemon must run with hosted-custody semantics:
16
+ * - `BIVY_HOSTED_CREDENTIAL_CUSTODY` — the dedicated flag the provisioner sets
17
+ * on managed (Bivy Cloud) machines; always authoritative.
18
+ * - `BIVY_GITHUB_HOSTED_TASKS` + `BIVY_EPHEMERAL` — back-compat for
19
+ * provisioned user-owned queue runners that predate the dedicated flag.
20
+ *
21
+ * `BIVY_GITHUB_HOSTED_TASKS` ALONE is not custody: it is persisted onto any
22
+ * long-lived personal node when a GitHub App is connected (it only means "poll
23
+ * the hosted task queue"). Such a node owns its own logins and must keep full
24
+ * personal-vault semantics — account sync, escrow publishing, loud toggles.
25
+ */
26
+ export function hostedCustodyNode(env = process.env) {
27
+ if (env.BIVY_HOSTED_CREDENTIAL_CUSTODY)
28
+ return true;
29
+ return Boolean(env.BIVY_GITHUB_HOSTED_TASKS && env.BIVY_EPHEMERAL);
30
+ }
@@ -369,8 +369,14 @@ class ProcessSession {
369
369
  this.nativeSessionRef = learnedRef;
370
370
  this.upstreamSessionStarted = true;
371
371
  }
372
- for (const event of events)
373
- this.emit(event);
372
+ // Sync the parser's final transcript into this.messages BEFORE emitting the
373
+ // terminal events (message_end/turn_end/agent_end). The daemon persists a
374
+ // base-transcript snapshot synchronously on message_end/turn_end by reading
375
+ // getMessages() — so if the push happened after the emit loop (as it once
376
+ // did), that snapshot saw an empty turn and the agent's answer + paired
377
+ // tool blocks were never persisted, vanishing on reload for every
378
+ // structured-pipe CLI (Grok, Goose, Gemini, …). Populating first makes
379
+ // getMessages() authoritative the instant message_end fires.
374
380
  if (!messagesPushed && events.some((e) => e.type === "agent_end")) {
375
381
  messagesPushed = true;
376
382
  for (const message of activeParser.messages())
@@ -381,6 +387,8 @@ class ProcessSession {
381
387
  if (usage)
382
388
  this.lastUsage = usage;
383
389
  }
390
+ for (const event of events)
391
+ this.emit(event);
384
392
  };
385
393
  const feedParser = (activeParser, text) => {
386
394
  lineBuf += text;
package/dist/server.js CHANGED
@@ -80,6 +80,7 @@ import { forceAbortTurn } from "./session/abort-recovery.js";
80
80
  import { runRequiredAutomationChecks } from "./automation-checks.js";
81
81
  import { configToLegacySettings, mergeLegacyIntoNodeConfig, readNodeConfig, writeNodeConfig } from "./node-config.js";
82
82
  import { loadProjectPolicy, resolveProjectSafety } from "./project-policy.js";
83
+ import { hostedCustodyNode } from "./hosted-custody.js";
83
84
  import { PolicyEngine } from "./policy/policy-engine.js";
84
85
  import { SessionAllowRules } from "./policy/session-allow.js";
85
86
  import { TerminalManager } from "./terminal.js";
@@ -1966,7 +1967,7 @@ const RELAY_COMMANDS = {
1966
1967
  credsDir,
1967
1968
  sendEvent: (event) => relay?.sendEvent(event),
1968
1969
  broadcast,
1969
- pushModelAuthToControlPlane,
1970
+ pushModelAuthToControlPlane: (opts) => pushModelAuthToControlPlane(false, opts?.throwOnFailure === true, opts?.allowEmptyHostedEscrow === true),
1970
1971
  refreshSessionAfterAuth,
1971
1972
  listProvidersUnified,
1972
1973
  }),
@@ -2407,7 +2408,19 @@ const RELAY_COMMANDS = {
2407
2408
  // this a models.list answered for one agent (e.g. Codex's models) could
2408
2409
  // linger on the composer/picker after the user switched to another agent
2409
2410
  // (e.g. Claude) — the "Claude shows Codex models" bug.
2410
- relay?.sendEvent(await modelsListEventFor(record));
2411
+ let event;
2412
+ try {
2413
+ event = await modelsListEventFor(record);
2414
+ }
2415
+ catch (error) {
2416
+ // A catalog read failure must never answer with silence: the generic
2417
+ // relay catch would swallow the rejection and the client (notably a
2418
+ // Cloud launch waiting to validate its saved model) could only guess
2419
+ // via timeout. Name the real reason on the session instead.
2420
+ relay?.sendEvent({ type: "session.error", sessionId: requestedSessionId ?? record.id, error: `Couldn't read this machine's model catalog: ${error instanceof Error ? error.message : String(error)}` });
2421
+ return;
2422
+ }
2423
+ relay?.sendEvent(event);
2411
2424
  },
2412
2425
  "models.prefetch"(msg) {
2413
2426
  // The composer's agent picker opened: warm the scratch session for each
@@ -2569,7 +2582,8 @@ const RELAY_COMMANDS = {
2569
2582
  }
2570
2583
  }
2571
2584
  await removeProvider(credsDir, id);
2572
- await pushModelAuthToControlPlane();
2585
+ // Deleting a granted credential must also remove its escrowed cloud copy.
2586
+ await pushModelAuthToControlPlane(false, false, true);
2573
2587
  await refreshSessionAfterAuth();
2574
2588
  broadcast({ type: "provider.oauth.reset", provider: id, ok: true, providers: await listProvidersUnified() });
2575
2589
  broadcast({ type: "providers.list", providers: await listProvidersUnified() });
@@ -2581,7 +2595,8 @@ const RELAY_COMMANDS = {
2581
2595
  async "provider.remove"(msg) {
2582
2596
  try {
2583
2597
  await removeProvider(credsDir, String(msg.provider ?? msg.id ?? ""));
2584
- await pushModelAuthToControlPlane();
2598
+ // Deleting a granted credential must also remove its escrowed cloud copy.
2599
+ await pushModelAuthToControlPlane(false, false, true);
2585
2600
  await refreshSessionAfterAuth();
2586
2601
  broadcast({ type: "providers.list", providers: await listProvidersUnified() });
2587
2602
  }
@@ -3065,7 +3080,11 @@ const hostedImportedRecordsPath = path.join(appDir, "model-auth-hosted-records.j
3065
3080
  let lastPushedModelAuthCiphertext = "";
3066
3081
  let lastPushedHostedModelAuthCiphertext = "";
3067
3082
  let lastPushedHostedModelAuthRevision = -1;
3068
- const isHostedCustodyNode = () => Boolean(process.env.BIVY_HOSTED_CREDENTIAL_CUSTODY || process.env.BIVY_GITHUB_HOSTED_TASKS);
3083
+ // Custody semantics are keyed on the dedicated flag (plus an ephemeral
3084
+ // back-compat case) — see src/hosted-custody.ts. A persistent personal node
3085
+ // that polls the hosted GitHub queue is NOT a custody guest: it owns its
3086
+ // logins and must keep account sync and unattended-escrow publishing.
3087
+ const isHostedCustodyNode = () => hostedCustodyNode();
3069
3088
  function readLocalModelAuthVaultKey() {
3070
3089
  try {
3071
3090
  const parsed = JSON.parse(fs.readFileSync(modelAuthVaultKeyPath, "utf8"));
@@ -3376,12 +3395,19 @@ async function processModelAuthKeyRequests(requests) {
3376
3395
  });
3377
3396
  }
3378
3397
  }
3379
- async function pushHostedModelAuthToControlPlane() {
3398
+ async function pushHostedModelAuthToControlPlane(allowEmpty = false) {
3380
3399
  const [records, revision] = await Promise.all([exportUnattendedRecords(credsDir), unattendedCredentialRevision(credsDir)]);
3381
3400
  // A setup guest must not establish an empty snapshot when the credential is
3382
3401
  // first saved (before the explicit grant command follows). Otherwise its
3383
3402
  // one allowed initial write would be consumed by an unusable vault.
3384
- if (isHostedCustodyNode() && Object.keys(records).length === 0)
3403
+ //
3404
+ // And an IMPLICIT empty push must never revoke the escrow: an empty snapshot
3405
+ // is how "disable unattended runs" removes the cloud copy, but every vault
3406
+ // change funnels through here — so a node whose copy simply lacks the grant
3407
+ // (not yet synced, or granted elsewhere) would otherwise wipe the encrypted
3408
+ // cloud copy the user's other machines just published. Only an explicit
3409
+ // local revoke/delete (allowEmpty) may publish an empty snapshot.
3410
+ if (Object.keys(records).length === 0 && (!allowEmpty || isHostedCustodyNode()))
3385
3411
  return;
3386
3412
  if (revision === lastPushedHostedModelAuthRevision)
3387
3413
  return;
@@ -3413,7 +3439,7 @@ async function pushHostedModelAuthToControlPlane() {
3413
3439
  throw new Error(`hosted model-auth push failed (${response?.status ?? "offline"})`);
3414
3440
  }
3415
3441
  }
3416
- async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = false) {
3442
+ async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = false, allowEmptyHostedEscrow = false) {
3417
3443
  if (!sessionAdvertiseTarget)
3418
3444
  return;
3419
3445
  // Piggyback the (plaintext, non-secret) provider status summary on every
@@ -3448,7 +3474,7 @@ async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = f
3448
3474
  writeLocalModelAuthVaultKey(vaultKeyB64);
3449
3475
  const ciphertext = encryptModelAuthProviders(providers, deletedAt, localModels, vaultKeyB64, records, recordsDeletedAt);
3450
3476
  if (!rotateKey && ciphertext === lastPushedModelAuthCiphertext) {
3451
- await pushHostedModelAuthToControlPlane();
3477
+ await pushHostedModelAuthToControlPlane(allowEmptyHostedEscrow);
3452
3478
  return;
3453
3479
  }
3454
3480
  const push = await modelAuthFetch("/node/model-auth-vault", { method: "PUT", body: JSON.stringify({ ciphertext, rotated: rotateKey }) });
@@ -3469,7 +3495,7 @@ async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = f
3469
3495
  // Publish a DIFFERENT ciphertext under a DIFFERENT key containing only
3470
3496
  // records with `unattended:true`. Escrowing this key cannot decrypt the E2E
3471
3497
  // account vault, which is the critical custody separation.
3472
- await pushHostedModelAuthToControlPlane();
3498
+ await pushHostedModelAuthToControlPlane(allowEmptyHostedEscrow);
3473
3499
  }
3474
3500
  catch (error) {
3475
3501
  console.warn("[auth-sync] could not push model auth:", error.message);
@@ -9295,7 +9321,7 @@ async function modelsListEventFor(record) {
9295
9321
  const current = session.getCurrentModel();
9296
9322
  const models = await publicModelsList(session, current);
9297
9323
  const thinking = publicThinkingInfo(session);
9298
- return { type: "models.list", sessionId: record.id, runtimeId: record.runtimeId, current: current ? publicModel(current, current) : null, models, thinking };
9324
+ return { type: "models.list", sessionId: record.id, runtimeId: record.runtimeId, modelSelection: getRuntime(record.runtimeId).capabilities.modelSelection !== false, current: current ? publicModel(current, current) : null, models, thinking };
9299
9325
  }
9300
9326
  function publicThinkingInfo(session) {
9301
9327
  const supports = typeof session.supportsThinking === "function" ? session.supportsThinking() : false;
@@ -9322,7 +9348,7 @@ app.get("/api/models", async (req, res, next) => {
9322
9348
  const current = session.getCurrentModel();
9323
9349
  const models = await publicModelsList(session, current);
9324
9350
  const thinking = publicThinkingInfo(session);
9325
- res.json({ sessionId: record.id, current: current ? publicModel(current, current) : null, models, thinking });
9351
+ res.json({ sessionId: record.id, modelSelection: getRuntime(record.runtimeId).capabilities.modelSelection !== false, current: current ? publicModel(current, current) : null, models, thinking });
9326
9352
  }
9327
9353
  catch (error) {
9328
9354
  next(error);
@@ -9678,7 +9704,8 @@ app.post("/api/auth/credentials", async (req, res, next) => {
9678
9704
  app.delete("/api/auth/credentials/:provider/:label", async (req, res, next) => {
9679
9705
  try {
9680
9706
  await removeProviderCredential(credsDir, String(req.params.provider), String(req.params.label));
9681
- await pushModelAuthToControlPlane();
9707
+ // Deleting a granted credential must also remove its escrowed cloud copy.
9708
+ await pushModelAuthToControlPlane(false, false, true);
9682
9709
  await refreshSessionAfterAuth();
9683
9710
  res.json({ ok: true, records: await listCredentialRecords(credsDir), providers: await listProvidersUnified() });
9684
9711
  }
@@ -9690,7 +9717,8 @@ app.post("/api/auth/credentials/:provider/:label/availability", async (req, res,
9690
9717
  try {
9691
9718
  const sync = req.body?.sync === "node" ? "node" : "account";
9692
9719
  await setCredentialSync(credsDir, String(req.params.provider), String(req.params.label), sync);
9693
- await pushModelAuthToControlPlane();
9720
+ // Demoting to machine-only revokes any custody grant; let that clear the escrow.
9721
+ await pushModelAuthToControlPlane(false, false, sync === "node");
9694
9722
  res.json({ ok: true, records: await listCredentialRecords(credsDir) });
9695
9723
  }
9696
9724
  catch (error) {
@@ -9698,12 +9726,21 @@ app.post("/api/auth/credentials/:provider/:label/availability", async (req, res,
9698
9726
  }
9699
9727
  });
9700
9728
  app.post("/api/auth/credentials/:provider/:label/unattended", async (req, res, next) => {
9701
- try {
9702
- await setCredentialUnattended(credsDir, String(req.params.provider), String(req.params.label), req.body?.unattended === true);
9703
- await pushModelAuthToControlPlane();
9729
+ const provider = String(req.params.provider);
9730
+ const label = String(req.params.label);
9731
+ const enable = req.body?.unattended === true;
9732
+ const previous = (await listCredentialRecords(credsDir).catch(() => []))
9733
+ .find((record) => record.provider === provider.trim().toLowerCase() && record.label === label)?.unattended === true;
9734
+ try {
9735
+ await setCredentialUnattended(credsDir, provider, label, enable);
9736
+ // Fail loud (mirrors the relay command): the UI must never claim an
9737
+ // encrypted cloud copy exists when custody publication failed. An explicit
9738
+ // disable is the one caller allowed to publish an empty escrow snapshot.
9739
+ await pushModelAuthToControlPlane(false, true, !enable);
9704
9740
  res.json({ ok: true, records: await listCredentialRecords(credsDir) });
9705
9741
  }
9706
9742
  catch (error) {
9743
+ await setCredentialUnattended(credsDir, provider, label, previous).catch(() => { });
9707
9744
  next(error);
9708
9745
  }
9709
9746
  });
@@ -31,7 +31,15 @@ function capThinkingForPersistence(text) {
31
31
  function toolEventId(event) {
32
32
  const toolCall = event.toolCall;
33
33
  const input = (event.input || event.toolInput || event.args || toolCall?.input || {});
34
- const explicit = event.toolUseId || event.tool_use_id || event.toolCallId || event.callId || event.id || toolCall?.id;
34
+ // Structured-pipe parsers (Grok/Goose/Gemini/… via the shared TurnAccumulator)
35
+ // emit a `tool_result` whose id lives under `result.toolCallId`, not at the top
36
+ // level. Without reading it here the result overlay was keyed `"<name>:"` and
37
+ // never paired with its `tool_call` overlay — orphaning the output in the
38
+ // transcript. Check the nested result id as a general shape, not a per-agent
39
+ // branch (protocol agents that already set a top-level id are unaffected).
40
+ const result = (event.result || event.toolResult);
41
+ const explicit = event.toolUseId || event.tool_use_id || event.toolCallId || event.callId || event.id || toolCall?.id ||
42
+ result?.toolCallId || result?.tool_use_id || result?.toolUseId || result?.id;
35
43
  if (explicit)
36
44
  return String(explicit);
37
45
  return `${String(event.toolName || event.name || toolCall?.name || "tool")}:${String(input.path || input.file || input.filePath || input.command || input.cmd || input.query || "")}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.16.25-staging.2",
3
+ "version": "0.16.25-staging.20",
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.",