@bivy/bivy 0.10.1-staging.294 → 0.10.1-staging.296

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.
@@ -0,0 +1,87 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
7
+ /** Tracks process groups left behind by a completed shell tool call. In a
8
+ * non-interactive POSIX shell, `command &` remains in the detached shell's
9
+ * process group after the shell exits, which gives us an observable lifecycle
10
+ * without trying to infer state from the assistant's prose. */
11
+ export class BackgroundShellTracker {
12
+ groups = new Set();
13
+ listeners = new Set();
14
+ timer;
15
+ get count() { return this.groups.size; }
16
+ subscribe(listener) {
17
+ this.listeners.add(listener);
18
+ return () => this.listeners.delete(listener);
19
+ }
20
+ track(groupId) {
21
+ if (!groupId || !processGroupAlive(groupId) || this.groups.has(groupId))
22
+ return;
23
+ this.groups.add(groupId);
24
+ this.emit();
25
+ this.timer ??= setInterval(() => this.sweep(), 1_000);
26
+ this.timer.unref?.();
27
+ }
28
+ dispose() {
29
+ if (this.timer)
30
+ clearInterval(this.timer);
31
+ this.timer = undefined;
32
+ this.groups.clear();
33
+ }
34
+ sweep() {
35
+ let changed = false;
36
+ for (const id of this.groups) {
37
+ if (!processGroupAlive(id)) {
38
+ this.groups.delete(id);
39
+ changed = true;
40
+ }
41
+ }
42
+ if (changed)
43
+ this.emit();
44
+ if (this.groups.size === 0 && this.timer) {
45
+ clearInterval(this.timer);
46
+ this.timer = undefined;
47
+ }
48
+ }
49
+ emit() {
50
+ for (const listener of this.listeners)
51
+ listener(this.groups.size);
52
+ }
53
+ }
54
+ export function processGroupAlive(groupId) {
55
+ if (process.platform === "win32")
56
+ return false;
57
+ try {
58
+ process.kill(-groupId, 0);
59
+ return true;
60
+ }
61
+ catch (error) {
62
+ return error.code === "EPERM";
63
+ }
64
+ }
65
+ /** Wrap pi's normal shell backend. The tiny, output-free prefix records the
66
+ * detached shell's process-group id; after the foreground shell returns, a
67
+ * still-live group means it deliberately left background work behind. */
68
+ export function createBackgroundAwareBashOperations(tracker, base = createLocalBashOperations()) {
69
+ return {
70
+ async exec(command, cwd, options) {
71
+ if (process.platform === "win32")
72
+ return base.exec(command, cwd, options);
73
+ const marker = path.join(os.tmpdir(), `bivy-bg-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
74
+ const quotedMarker = `'${marker.replace(/'/g, `'\\''`)}'`;
75
+ try {
76
+ const result = await base.exec(`printf '%s' "$$" > ${quotedMarker}\n${command}`, cwd, options);
77
+ const groupId = Number.parseInt(await fs.promises.readFile(marker, "utf8").catch(() => ""), 10);
78
+ if (Number.isSafeInteger(groupId) && groupId > 0)
79
+ tracker.track(groupId);
80
+ return result;
81
+ }
82
+ finally {
83
+ void fs.promises.unlink(marker).catch(() => { });
84
+ }
85
+ },
86
+ };
87
+ }
@@ -9,7 +9,7 @@
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
11
  import { completeSimple } from "@earendil-works/pi-ai/compat";
12
- import { ModelRuntime, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
12
+ import { ModelRuntime, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
13
13
  import { createPiModelRuntime } from "../../runtime/pi-oauth.js";
14
14
  import { toModelInfo as sharedToModelInfo } from "../../runtime/normalize.js";
15
15
  import { provisionPiAuthJson } from "../../runtime/credential-provisioning.js";
@@ -17,6 +17,7 @@ import { isNativeOAuthProvider } from "../../runtime/oauth/model-oauth-providers
17
17
  import { bivySessionEnv } from "../../runtime/session-env.js";
18
18
  import { withExactCapabilitySurface } from "../../runtime/types.js";
19
19
  import { mapToolCall, mapToolResult } from "../../runtime/tool-call-map.js";
20
+ import { BackgroundShellTracker, createBackgroundAwareBashOperations } from "./background-shell.js";
20
21
  /**
21
22
  * Extract Pi's own slash commands from a live AgentSession: extension commands
22
23
  * (`pi.registerCommand`, exposed via `extensionRunner.getRegisteredCommands()`),
@@ -146,10 +147,12 @@ function toolProviderFactory(provider) {
146
147
  class PiSession {
147
148
  runtime;
148
149
  tui;
150
+ backgroundShells;
149
151
  toolDetails = new Map();
150
- constructor(runtime, tui) {
152
+ constructor(runtime, tui, backgroundShells) {
151
153
  this.runtime = runtime;
152
154
  this.tui = tui;
155
+ this.backgroundShells = backgroundShells;
153
156
  }
154
157
  get session() {
155
158
  return this.runtime.session;
@@ -206,13 +209,17 @@ class PiSession {
206
209
  return this.session.abort();
207
210
  }
208
211
  dispose() {
212
+ this.backgroundShells.dispose();
209
213
  void this.runtime.dispose();
210
214
  }
211
215
  getMessages() {
212
216
  return this.session.state.messages;
213
217
  }
214
218
  subscribe(listener) {
215
- return this.session.subscribe((event) => {
219
+ const unsubscribeBackground = this.backgroundShells.subscribe((count) => {
220
+ listener({ type: "background_tasks_changed", count });
221
+ });
222
+ const unsubscribeSession = this.session.subscribe((event) => {
216
223
  const type = String(event?.type ?? "");
217
224
  const callId = String(event?.toolCallId ?? event?.toolUseId ?? event?.tool_use_id ?? event?.id ?? "");
218
225
  if (type === "tool_call" || type === "tool_execution_start") {
@@ -232,6 +239,10 @@ class PiSession {
232
239
  }
233
240
  listener(event);
234
241
  });
242
+ return () => {
243
+ unsubscribeBackground();
244
+ unsubscribeSession();
245
+ };
235
246
  }
236
247
  /**
237
248
  * Pi's own slash commands for this session: extension commands (pi.register
@@ -420,6 +431,7 @@ export class PiRuntime {
420
431
  allowModelNetwork: true,
421
432
  })
422
433
  : await createPiModelRuntime({ credsDir, piDir, allowModelNetwork: true });
434
+ const backgroundShells = new BackgroundShellTracker();
423
435
  const createRuntime = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
424
436
  const sessionId = sessionManager.getSessionId();
425
437
  const providedTools = options.toolProvider ? [toolProviderFactory(options.toolProvider)] : [];
@@ -431,7 +443,13 @@ export class PiRuntime {
431
443
  extensionFactories: [guardianFactory(sessionId, options.toolInterceptor), ...providedTools],
432
444
  },
433
445
  });
434
- const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent });
446
+ // Override only the built-in bash definition. Pi still owns rendering,
447
+ // truncation and execution; the operations wrapper observes process groups
448
+ // that remain alive after a `command &` shell call returns.
449
+ const bash = createBashToolDefinition(cwd, {
450
+ operations: createBackgroundAwareBashOperations(backgroundShells),
451
+ });
452
+ const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent, customTools: [bash] });
435
453
  return { ...result, services, diagnostics: services.diagnostics };
436
454
  };
437
455
  const runtime = await createAgentSessionRuntime(createRuntime, {
@@ -446,7 +464,7 @@ export class PiRuntime {
446
464
  piCommand: resolvePiCommand(),
447
465
  credentialOwner: this.options.credentialOwner ?? "bivy",
448
466
  };
449
- return { session: new PiSession(runtime, tui), warning: runtime.modelFallbackMessage };
467
+ return { session: new PiSession(runtime, tui, backgroundShells), warning: runtime.modelFallbackMessage };
450
468
  }
451
469
  createSession(options) {
452
470
  const sessionManager = SessionManager.create(options.workspace, this.options.sessionsDir);
package/dist/server.js CHANGED
@@ -6009,6 +6009,7 @@ function sessionState(record) {
6009
6009
  transportReachable: clients.size > 0 || Boolean(relay?.connected),
6010
6010
  processAlive,
6011
6011
  working: sessionBusy(record),
6012
+ waitingBackground: (record.backgroundTaskCount ?? 0) > 0,
6012
6013
  awaitingInput: sessionHasPendingApproval(record),
6013
6014
  workspace: record.workspaceState ?? "clean",
6014
6015
  lastTurnFailed: Boolean(record.lastFailureAt),
@@ -7131,6 +7132,28 @@ function attachSessionListeners(record) {
7131
7132
  persistSessionMetadata(record);
7132
7133
  broadcast({ type: "session.updated", sessionId: record.id, sessionFile: record.sessionFile, bivySession: bivySessionEnvelope(record) });
7133
7134
  }
7135
+ if (event.type === "background_tasks_changed") {
7136
+ const previous = record.backgroundTaskCount ?? 0;
7137
+ const countValue = Number(event.count);
7138
+ const count = Number.isSafeInteger(countValue) && countValue > 0 ? countValue : 0;
7139
+ record.backgroundTaskCount = count;
7140
+ touchSession(record);
7141
+ persistSessionMetadata(record);
7142
+ broadcastSessionState(record);
7143
+ scheduleAdvertise();
7144
+ // A background process ending is the real completion boundary when the
7145
+ // agent already ended its turn. Do not claim the session finished while
7146
+ // tests/builds it launched are still running.
7147
+ if (previous > 0 && count === 0 && !sessionBusy(record)) {
7148
+ void sendNotificationHint({
7149
+ kind: "session_done",
7150
+ sessionId: record.id,
7151
+ targetSessionId: record.id,
7152
+ title: "Background work finished",
7153
+ body: `${sessionNotifyLabel(record)} finished its background tasks — tap to review the result.`,
7154
+ });
7155
+ }
7156
+ }
7134
7157
  if ([
7135
7158
  "agent_start",
7136
7159
  "turn_start",
@@ -7296,7 +7319,7 @@ function attachSessionListeners(record) {
7296
7319
  body: `${sessionNotifyLabel(record)} failed its last turn — tap to see what went wrong.`,
7297
7320
  });
7298
7321
  }
7299
- else if (!record.isWorking && !record.remoteActive) {
7322
+ else if (!record.isWorking && !record.remoteActive && (record.backgroundTaskCount ?? 0) === 0) {
7300
7323
  void sendNotificationHint({
7301
7324
  kind: "session_done",
7302
7325
  sessionId: record.id,
@@ -16,7 +16,9 @@ export function deriveSessionState(evidence) {
16
16
  ? "awaiting-input"
17
17
  : evidence.working
18
18
  ? "working"
19
- : "idle";
19
+ : evidence.waitingBackground
20
+ ? "waiting"
21
+ : "idle";
20
22
  let displayStatus;
21
23
  if (agent === "awaiting-input")
22
24
  displayStatus = "needs_attention";
@@ -26,7 +28,7 @@ export function deriveSessionState(evidence) {
26
28
  // agent is still technically "working" — surface it so the user sees the card.
27
29
  else if (agent === "working" && evidence.turnNeedsAttention)
28
30
  displayStatus = "needs_attention";
29
- else if (agent === "working")
31
+ else if (agent === "working" || agent === "waiting")
30
32
  displayStatus = "working";
31
33
  else if (evidence.lastTurnFailed)
32
34
  displayStatus = "failed";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.10.1-staging.294",
3
+ "version": "0.10.1-staging.296",
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.",