@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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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