@bivy/bivy 0.16.17 → 0.16.18-staging.10
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/agents/pi/runtime.js +1 -1
- package/dist/credentials/resolver.js +3 -21
- package/dist/credentials/selected-store.js +35 -0
- package/dist/credentials/selection.js +24 -0
- package/dist/runtime/oauth/model-oauth-providers.js +12 -6
- package/dist/runtime/oauth/model-oauth.js +86 -2
- package/dist/runtime/pi-oauth.js +2 -1
- package/dist/session/run-terminal.js +5 -3
- package/dist/terminal.js +45 -2
- package/package.json +1 -1
|
@@ -423,7 +423,7 @@ export class PiRuntime {
|
|
|
423
423
|
modelsPath: path.join(piDir, "models.json"),
|
|
424
424
|
allowModelNetwork,
|
|
425
425
|
})
|
|
426
|
-
: await createPiModelRuntime({ credsDir, piDir, allowModelNetwork });
|
|
426
|
+
: await createPiModelRuntime({ credsDir, piDir, allowModelNetwork, workspace: sessionManager.getCwd() || options.workspace });
|
|
427
427
|
const backgroundShells = new BackgroundShellTracker();
|
|
428
428
|
const createRuntime = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
|
429
429
|
const sessionId = sessionManager.getSessionId();
|
|
@@ -10,23 +10,12 @@
|
|
|
10
10
|
//
|
|
11
11
|
// This keeps Bivy's hot credential path decoupled from Pi: Pi is just another
|
|
12
12
|
// agent that reads the same store.
|
|
13
|
-
import path from "node:path";
|
|
14
13
|
import { createCredentialVault } from "./store.js";
|
|
15
|
-
import {
|
|
14
|
+
import { selectCredential } from "./selection.js";
|
|
15
|
+
export { projectIdsFromWorkspace } from "./selection.js";
|
|
16
16
|
import { loadPresets, defaultPresetsPath } from "./presets.js";
|
|
17
17
|
/** Refresh an OAuth token this many ms before it expires (clock-skew guard). */
|
|
18
18
|
const OAUTH_REFRESH_SKEW_MS = 60_000;
|
|
19
|
-
/** Stable project identifiers discoverable without importing repo/session code. */
|
|
20
|
-
export function projectIdsFromWorkspace(workspace) {
|
|
21
|
-
const resolved = path.resolve(workspace);
|
|
22
|
-
const ids = new Set([resolved, path.basename(resolved)]);
|
|
23
|
-
for (const part of resolved.split(path.sep)) {
|
|
24
|
-
const split = part.indexOf("__");
|
|
25
|
-
if (split > 0 && split < part.length - 2)
|
|
26
|
-
ids.add(`${part.slice(0, split)}/${part.slice(split + 2)}`);
|
|
27
|
-
}
|
|
28
|
-
return [...ids];
|
|
29
|
-
}
|
|
30
19
|
/** Resolver over Bivy's credential store, with OAuth refresh-on-read via the bridge. */
|
|
31
20
|
export class NodeCredentialResolver {
|
|
32
21
|
credsDir;
|
|
@@ -59,14 +48,7 @@ export class NodeCredentialResolver {
|
|
|
59
48
|
// than guessing.
|
|
60
49
|
const records = await this.store.listRecords().catch(() => []);
|
|
61
50
|
const presets = this.presets();
|
|
62
|
-
|
|
63
|
-
// Bivy-managed clones encode owner/repo as owner__repo in their workspace
|
|
64
|
-
// path; direct local workspaces also match their absolute path/basename.
|
|
65
|
-
const explicitProject = context?.project?.trim();
|
|
66
|
-
const workspace = context?.workspace?.trim();
|
|
67
|
-
const projectCandidates = [explicitProject, ...(workspace ? projectIdsFromWorkspace(workspace) : [])].filter((value) => Boolean(value));
|
|
68
|
-
const projectPreset = projectCandidates.map((value) => `project:${value}`).find((name) => presets.presets?.[name]?.[id]);
|
|
69
|
-
const selection = resolveCredential(id, records, presets, { ...(projectPreset ? { preset: projectPreset } : {}), ...(context?.preferLabel ? { preferLabel: context.preferLabel } : {}) });
|
|
51
|
+
const selection = selectCredential(id, records, presets, context);
|
|
70
52
|
if (!selection)
|
|
71
53
|
return undefined;
|
|
72
54
|
const source = selection.record.source;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defaultPresetsPath, loadPresets } from "./presets.js";
|
|
2
|
+
import { selectCredential } from "./selection.js";
|
|
3
|
+
/** A provider-addressed view of the vault for consumers without labeled accounts.
|
|
4
|
+
* Resolve on every operation; never copy a work credential into the default slot.
|
|
5
|
+
* Refresh writes stay attached to the selected record's label and metadata.
|
|
6
|
+
*/
|
|
7
|
+
export function selectedCredentialStore(store, credsDir, context) {
|
|
8
|
+
const select = async (provider) => selectCredential(provider, await store.listRecords(), loadPresets(defaultPresetsPath(credsDir)), context)?.record;
|
|
9
|
+
return {
|
|
10
|
+
async read(provider) {
|
|
11
|
+
const record = await select(provider);
|
|
12
|
+
if (record?.source.kind !== "stored")
|
|
13
|
+
return undefined;
|
|
14
|
+
const { updatedAt: _updatedAt, ...credential } = record.source.cred;
|
|
15
|
+
return credential;
|
|
16
|
+
},
|
|
17
|
+
async list() {
|
|
18
|
+
const records = await store.listRecords();
|
|
19
|
+
const presets = loadPresets(defaultPresetsPath(credsDir));
|
|
20
|
+
return [...new Set(records.map((r) => r.provider))].flatMap((providerId) => {
|
|
21
|
+
const record = selectCredential(providerId, records, presets, context)?.record;
|
|
22
|
+
if (record?.source.kind !== "stored")
|
|
23
|
+
return [];
|
|
24
|
+
const credential = record.source.cred;
|
|
25
|
+
return [{ providerId, type: credential.type, ...(credential.type === "oauth" ? { expiresAt: credential.expires } : {}) }];
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
async modify(provider, fn) {
|
|
29
|
+
const record = await select(provider);
|
|
30
|
+
if (!record)
|
|
31
|
+
throw new Error(`No account selected for ${provider}`);
|
|
32
|
+
return store.modifyRecord(provider, record.label, fn);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveCredential } from "./records.js";
|
|
4
|
+
/** Stable project identifiers discoverable without importing repo/session code. */
|
|
5
|
+
export function projectIdsFromWorkspace(workspace) {
|
|
6
|
+
const resolved = path.resolve(workspace);
|
|
7
|
+
const ids = new Set([resolved, path.basename(resolved)]);
|
|
8
|
+
for (const part of resolved.split(path.sep)) {
|
|
9
|
+
const split = part.indexOf("__");
|
|
10
|
+
if (split > 0 && split < part.length - 2)
|
|
11
|
+
ids.add(`${part.slice(0, split)}/${part.slice(split + 2)}`);
|
|
12
|
+
}
|
|
13
|
+
return [...ids];
|
|
14
|
+
}
|
|
15
|
+
export function selectCredential(provider, records, presets, context) {
|
|
16
|
+
const id = provider.trim().toLowerCase();
|
|
17
|
+
const workspace = context?.workspace?.trim();
|
|
18
|
+
const projects = [context?.project?.trim(), ...(workspace ? projectIdsFromWorkspace(workspace) : [])].filter(Boolean);
|
|
19
|
+
const projectPreset = projects.map((value) => `project:${value}`).find((name) => presets.presets?.[name]?.[id]);
|
|
20
|
+
return resolveCredential(id, records, presets, {
|
|
21
|
+
...(projectPreset ? { preset: projectPreset } : {}),
|
|
22
|
+
...(context?.preferLabel ? { preferLabel: context.preferLabel } : {}),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// not guesses. Owning these lets Bivy run the OAuth login + token refresh itself,
|
|
8
8
|
// so no credential operation depends on Pi.
|
|
9
9
|
//
|
|
10
|
-
// Covered (fully Bivy-owned): Anthropic (Claude Pro/Max), OpenAI Codex (ChatGPT
|
|
11
|
-
// xAI (Grok). GitHub Copilot (two-stage token + dynamic base URL entangled with
|
|
10
|
+
// Covered (fully Bivy-owned): Anthropic (Claude Pro/Max), OpenAI Codex (ChatGPT;
|
|
11
|
+
// device-code), xAI (Grok). GitHub Copilot (two-stage token + dynamic base URL entangled with
|
|
12
12
|
// Pi's request layer) and Radius (self-describing gateway) are intentionally not
|
|
13
13
|
// reimplemented here.
|
|
14
14
|
/** Anthropic uses a JSON token body; OpenAI/xAI use form-encoded. */
|
|
@@ -17,12 +17,16 @@ export const MODEL_OAUTH_PROVIDERS = {
|
|
|
17
17
|
id: "anthropic",
|
|
18
18
|
displayName: "Anthropic (Claude Pro/Max)",
|
|
19
19
|
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
|
20
|
-
|
|
20
|
+
// Claude Code's `setup-token` flow avoids localhost entirely: Anthropic hosts
|
|
21
|
+
// the callback page and shows a code the user can paste back into Bivy. The
|
|
22
|
+
// resulting long-lived token is inference-only, which is exactly what agent
|
|
23
|
+
// model requests need and works well from a remote PWA/headless node.
|
|
24
|
+
scopes: "user:inference",
|
|
21
25
|
flow: "auth_code",
|
|
22
26
|
tokenEncoding: "json",
|
|
23
|
-
authorizeUrl: "https://claude.
|
|
27
|
+
authorizeUrl: "https://claude.com/cai/oauth/authorize",
|
|
24
28
|
tokenUrl: "https://platform.claude.com/v1/oauth/token",
|
|
25
|
-
|
|
29
|
+
redirectUri: "https://platform.claude.com/oauth/code/callback",
|
|
26
30
|
authorizeParams: { code: "true" },
|
|
27
31
|
stateIsVerifier: true,
|
|
28
32
|
refreshSkewMs: 5 * 60 * 1000,
|
|
@@ -33,11 +37,13 @@ export const MODEL_OAUTH_PROVIDERS = {
|
|
|
33
37
|
displayName: "OpenAI (ChatGPT Plus/Pro)",
|
|
34
38
|
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
|
|
35
39
|
scopes: "openid profile email offline_access",
|
|
36
|
-
flow: "
|
|
40
|
+
flow: "openai_codex_device_code",
|
|
37
41
|
tokenEncoding: "form",
|
|
38
42
|
authorizeUrl: "https://auth.openai.com/oauth/authorize",
|
|
39
43
|
tokenUrl: "https://auth.openai.com/oauth/token",
|
|
40
44
|
callback: { port: 1455, path: "/auth/callback", redirectHost: "localhost" },
|
|
45
|
+
deviceAuthUrl: "https://auth.openai.com/api/accounts/deviceauth/usercode",
|
|
46
|
+
deviceTokenUrl: "https://auth.openai.com/api/accounts/deviceauth/token",
|
|
41
47
|
authorizeParams: {
|
|
42
48
|
id_token_add_organizations: "true",
|
|
43
49
|
codex_cli_simplified_flow: "true",
|
|
@@ -199,7 +199,7 @@ function startCallbackServer(host, port, pathName, expectedState, signal) {
|
|
|
199
199
|
async function loginAuthCode(provider, interaction) {
|
|
200
200
|
const { verifier, challenge } = createPkce();
|
|
201
201
|
const state = provider.stateIsVerifier ? verifier : randomBytes(16).toString("hex");
|
|
202
|
-
const redirectUri = `http://${provider.callback.redirectHost}:${provider.callback.port}${provider.callback.path}`;
|
|
202
|
+
const redirectUri = provider.redirectUri ?? `http://${provider.callback.redirectHost}:${provider.callback.port}${provider.callback.path}`;
|
|
203
203
|
const authorizeUrl = buildAuthorizeUrl(provider, { challenge, state, redirectUri });
|
|
204
204
|
interaction.notify({
|
|
205
205
|
type: "auth_url",
|
|
@@ -300,6 +300,88 @@ async function loginDeviceCode(provider, interaction) {
|
|
|
300
300
|
return tokensFrom(provider, payload);
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
|
+
async function postJsonObject(url, body, signal) {
|
|
304
|
+
const res = await fetch(url, {
|
|
305
|
+
method: "POST",
|
|
306
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
307
|
+
body: JSON.stringify(body),
|
|
308
|
+
signal,
|
|
309
|
+
});
|
|
310
|
+
const text = await res.text();
|
|
311
|
+
let payload = {};
|
|
312
|
+
try {
|
|
313
|
+
payload = JSON.parse(text);
|
|
314
|
+
}
|
|
315
|
+
catch { /* non-JSON error body */ }
|
|
316
|
+
return { status: res.status, ok: res.ok, payload, text };
|
|
317
|
+
}
|
|
318
|
+
function nestedOAuthErrorCode(payload) {
|
|
319
|
+
const error = payload.error;
|
|
320
|
+
if (typeof error === "string")
|
|
321
|
+
return error;
|
|
322
|
+
if (error && typeof error === "object") {
|
|
323
|
+
const code = error.code;
|
|
324
|
+
return typeof code === "string" ? code : "";
|
|
325
|
+
}
|
|
326
|
+
return "";
|
|
327
|
+
}
|
|
328
|
+
async function loginOpenAICodexDeviceCode(provider, interaction) {
|
|
329
|
+
const started = await postJsonObject(provider.deviceAuthUrl, { client_id: provider.clientId }, interaction.signal);
|
|
330
|
+
if (!started.ok)
|
|
331
|
+
throw new Error(`OpenAI Codex device authorization failed (${started.status}): ${started.text.slice(0, 200)}`);
|
|
332
|
+
const deviceAuthId = typeof started.payload.device_auth_id === "string" ? started.payload.device_auth_id : "";
|
|
333
|
+
const userCode = typeof started.payload.user_code === "string" ? started.payload.user_code : "";
|
|
334
|
+
const rawInterval = Number(started.payload.interval);
|
|
335
|
+
const interval = Number.isFinite(rawInterval) && rawInterval >= 0 ? rawInterval : 5;
|
|
336
|
+
if (!deviceAuthId || !userCode)
|
|
337
|
+
throw new Error(`Invalid OpenAI Codex device authorization response: ${JSON.stringify(started.payload)}`);
|
|
338
|
+
const expiresInSeconds = 15 * 60;
|
|
339
|
+
interaction.notify({
|
|
340
|
+
type: "device_code",
|
|
341
|
+
userCode,
|
|
342
|
+
verificationUri: "https://auth.openai.com/codex/device",
|
|
343
|
+
intervalSeconds: interval,
|
|
344
|
+
expiresInSeconds,
|
|
345
|
+
});
|
|
346
|
+
const deadline = Date.now() + expiresInSeconds * 1000;
|
|
347
|
+
let authorizationCode = "";
|
|
348
|
+
let codeVerifier = "";
|
|
349
|
+
let waitMs = interval * 1000;
|
|
350
|
+
await sleep(waitMs);
|
|
351
|
+
while (Date.now() <= deadline) {
|
|
352
|
+
if (interaction.signal?.aborted)
|
|
353
|
+
throw new Error("Login aborted");
|
|
354
|
+
const polled = await postJsonObject(provider.deviceTokenUrl, { device_auth_id: deviceAuthId, user_code: userCode }, interaction.signal);
|
|
355
|
+
if (polled.ok) {
|
|
356
|
+
authorizationCode = typeof polled.payload.authorization_code === "string" ? polled.payload.authorization_code : "";
|
|
357
|
+
codeVerifier = typeof polled.payload.code_verifier === "string" ? polled.payload.code_verifier : "";
|
|
358
|
+
if (!authorizationCode || !codeVerifier)
|
|
359
|
+
throw new Error(`Invalid OpenAI Codex device token response: ${JSON.stringify(polled.payload)}`);
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
const errorCode = nestedOAuthErrorCode(polled.payload);
|
|
363
|
+
if (polled.status === 403 || polled.status === 404 || errorCode === "deviceauth_authorization_pending") {
|
|
364
|
+
await sleep(waitMs);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (errorCode === "slow_down") {
|
|
368
|
+
waitMs += 5000;
|
|
369
|
+
await sleep(waitMs);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
throw new Error(`OpenAI Codex device authorization failed (${polled.status}): ${polled.text.slice(0, 200)}`);
|
|
373
|
+
}
|
|
374
|
+
if (!authorizationCode || !codeVerifier)
|
|
375
|
+
throw new Error("OpenAI Codex device login timed out. Please try again.");
|
|
376
|
+
const payload = await postToken(provider.tokenUrl, provider.tokenEncoding, {
|
|
377
|
+
grant_type: "authorization_code",
|
|
378
|
+
client_id: provider.clientId,
|
|
379
|
+
code: authorizationCode,
|
|
380
|
+
code_verifier: codeVerifier,
|
|
381
|
+
redirect_uri: "https://auth.openai.com/deviceauth/callback",
|
|
382
|
+
});
|
|
383
|
+
return tokensFrom(provider, payload);
|
|
384
|
+
}
|
|
303
385
|
// --- Public API --------------------------------------------------------------
|
|
304
386
|
/** Provider ids Bivy can natively drive a subscription login for. */
|
|
305
387
|
export { isNativeOAuthProvider, nativeOAuthProviderIds } from "./model-oauth-providers.js";
|
|
@@ -312,7 +394,9 @@ export async function loginModelOAuth(credsDir, providerId, interaction, label =
|
|
|
312
394
|
const provider = getModelOAuthProvider(providerId);
|
|
313
395
|
if (!provider)
|
|
314
396
|
throw new Error(`Provider "${providerId}" does not support subscription login`);
|
|
315
|
-
const tokens = provider.flow === "device_code" ? await loginDeviceCode(provider, interaction)
|
|
397
|
+
const tokens = provider.flow === "device_code" ? await loginDeviceCode(provider, interaction)
|
|
398
|
+
: provider.flow === "openai_codex_device_code" ? await loginOpenAICodexDeviceCode(provider, interaction)
|
|
399
|
+
: await loginAuthCode(provider, interaction);
|
|
316
400
|
const credential = { type: "oauth", access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, refreshedAt: tokens.refreshedAt, ...(tokens.accountId ? { accountId: tokens.accountId } : {}) };
|
|
317
401
|
await createCredentialVault(credsDir).modifyRecord(providerId, label, async () => credential);
|
|
318
402
|
}
|
package/dist/runtime/pi-oauth.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import { createCredentialVault } from "./credential-store.js";
|
|
13
13
|
import { isNativeOAuthProvider } from "./oauth/model-oauth-providers.js";
|
|
14
|
+
import { selectedCredentialStore } from "../credentials/selected-store.js";
|
|
14
15
|
/** Adapt Bivy's store to pi-ai's structurally-identical CredentialStore for injection. */
|
|
15
16
|
export function piCredentialStore(store) {
|
|
16
17
|
return store;
|
|
@@ -26,7 +27,7 @@ export async function createPiModelRuntime(opts) {
|
|
|
26
27
|
const store = opts.store ?? createCredentialVault(opts.credsDir);
|
|
27
28
|
const { ModelRuntime } = await import("@earendil-works/pi-coding-agent");
|
|
28
29
|
return ModelRuntime.create({
|
|
29
|
-
credentials: piCredentialStore(store),
|
|
30
|
+
credentials: piCredentialStore(selectedCredentialStore(store, opts.credsDir, { workspace: opts.workspace })),
|
|
30
31
|
modelsPath: path.join(opts.piDir, "models.json"),
|
|
31
32
|
allowModelNetwork: opts.allowModelNetwork ?? false,
|
|
32
33
|
});
|
|
@@ -345,12 +345,14 @@ export function createRunTerminals(deps) {
|
|
|
345
345
|
const discover = SESSION_DISCOVERY_BY_AGENT[agent];
|
|
346
346
|
if (!discover)
|
|
347
347
|
return undefined;
|
|
348
|
-
|
|
348
|
+
const attempts = Math.max(1, Math.floor(deps.takeoverDiscoveryAttempts ?? TAKEOVER_DISCOVERY_ATTEMPTS));
|
|
349
|
+
const delayMs = Math.max(0, Math.floor(deps.takeoverDiscoveryDelayMs ?? TAKEOVER_DISCOVERY_DELAY_MS));
|
|
350
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
349
351
|
const ref = await discover(workspace, createdAt);
|
|
350
352
|
if (ref)
|
|
351
353
|
return ref;
|
|
352
|
-
if (attempt + 1 <
|
|
353
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
354
|
+
if (delayMs > 0 && attempt + 1 < attempts) {
|
|
355
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
354
356
|
}
|
|
355
357
|
}
|
|
356
358
|
return undefined;
|
package/dist/terminal.js
CHANGED
|
@@ -196,6 +196,8 @@ export class TerminalManager {
|
|
|
196
196
|
env,
|
|
197
197
|
});
|
|
198
198
|
const now = Date.now();
|
|
199
|
+
let resolveExit;
|
|
200
|
+
const exitPromise = new Promise((resolve) => { resolveExit = resolve; });
|
|
199
201
|
const entry = {
|
|
200
202
|
proc,
|
|
201
203
|
workspace: options.workspace,
|
|
@@ -209,6 +211,8 @@ export class TerminalManager {
|
|
|
209
211
|
flushTimer: null,
|
|
210
212
|
closed: false,
|
|
211
213
|
onData: options.onData,
|
|
214
|
+
exitPromise,
|
|
215
|
+
resolveExit,
|
|
212
216
|
};
|
|
213
217
|
// Register the opener as a sized client so a later, smaller client shrinks
|
|
214
218
|
// the PTY to the min of the two rather than clobbering the opener's size.
|
|
@@ -269,7 +273,12 @@ export class TerminalManager {
|
|
|
269
273
|
flush();
|
|
270
274
|
entry.closed = true;
|
|
271
275
|
this.terminals.delete(id);
|
|
272
|
-
|
|
276
|
+
try {
|
|
277
|
+
options.onExit(exitCode, signal, entry.buffer);
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
entry.resolveExit();
|
|
281
|
+
}
|
|
273
282
|
});
|
|
274
283
|
return id;
|
|
275
284
|
}
|
|
@@ -347,6 +356,19 @@ export class TerminalManager {
|
|
|
347
356
|
const entry = this.terminals.get(id);
|
|
348
357
|
if (!entry)
|
|
349
358
|
return false;
|
|
359
|
+
this.closeEntry(id, entry);
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
/** Close a terminal and wait for the underlying PTY process to report exit. */
|
|
363
|
+
async closeAndWait(id, timeoutMs = 2000) {
|
|
364
|
+
const entry = this.terminals.get(id);
|
|
365
|
+
if (!entry)
|
|
366
|
+
return false;
|
|
367
|
+
this.closeEntry(id, entry);
|
|
368
|
+
await waitForExit(entry.exitPromise, timeoutMs);
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
closeEntry(id, entry) {
|
|
350
372
|
this.terminals.delete(id);
|
|
351
373
|
// Drop any queued output — the client asked to close, so don't emit a
|
|
352
374
|
// trailing batch (which would fire onData for a terminal it has torn down).
|
|
@@ -363,7 +385,6 @@ export class TerminalManager {
|
|
|
363
385
|
catch {
|
|
364
386
|
// already gone
|
|
365
387
|
}
|
|
366
|
-
return true;
|
|
367
388
|
}
|
|
368
389
|
has(id) {
|
|
369
390
|
return this.terminals.has(id);
|
|
@@ -421,6 +442,28 @@ export class TerminalManager {
|
|
|
421
442
|
for (const id of [...this.terminals.keys()])
|
|
422
443
|
this.close(id);
|
|
423
444
|
}
|
|
445
|
+
/** Kill every terminal and wait for node-pty to release its child handles. */
|
|
446
|
+
async disposeAllAndWait(timeoutMs = 2000) {
|
|
447
|
+
const exits = [];
|
|
448
|
+
for (const [id, entry] of [...this.terminals]) {
|
|
449
|
+
this.closeEntry(id, entry);
|
|
450
|
+
exits.push(waitForExit(entry.exitPromise, timeoutMs));
|
|
451
|
+
}
|
|
452
|
+
await Promise.all(exits);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
async function waitForExit(exitPromise, timeoutMs) {
|
|
456
|
+
let timer;
|
|
457
|
+
try {
|
|
458
|
+
await Promise.race([
|
|
459
|
+
exitPromise,
|
|
460
|
+
new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); }),
|
|
461
|
+
]);
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
if (timer)
|
|
465
|
+
clearTimeout(timer);
|
|
466
|
+
}
|
|
424
467
|
}
|
|
425
468
|
function clampDim(value, fallback) {
|
|
426
469
|
const n = Math.floor(Number(value));
|