@oh-my-pi/pi-coding-agent 17.2.5 → 17.2.6
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/CHANGELOG.md +18 -0
- package/dist/{CHANGELOG-q2w43aw3.md → CHANGELOG-gs76k6wc.md} +18 -0
- package/dist/cli.js +3378 -3333
- package/dist/types/cli/gc-cli.d.ts +1 -0
- package/dist/types/launch/client.d.ts +9 -1
- package/dist/types/launch/protocol.d.ts +17 -0
- package/dist/types/modes/components/btw-panel.d.ts +3 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +4 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/security/contracts/schemas.d.ts +405 -403
- package/dist/types/session/agent-session-types.d.ts +5 -0
- package/dist/types/session/agent-session.d.ts +26 -2
- package/dist/types/session/launch-completion.d.ts +10 -0
- package/dist/types/session/session-entries.d.ts +15 -1
- package/dist/types/session/session-manager.d.ts +7 -0
- package/dist/types/session/yield-queue.d.ts +6 -1
- package/dist/types/tools/index.d.ts +9 -0
- package/package.json +12 -12
- package/src/cli/gc-cli.ts +641 -21
- package/src/config/model-discovery.ts +14 -14
- package/src/config/model-registry.ts +10 -8
- package/src/config/settings.ts +1 -1
- package/src/export/html/index.ts +10 -3
- package/src/export/share.ts +4 -0
- package/src/launch/broker.ts +222 -6
- package/src/launch/client.ts +161 -9
- package/src/launch/protocol.ts +52 -0
- package/src/main.ts +13 -6
- package/src/mcp/config-writer.ts +1 -1
- package/src/modes/components/btw-panel.ts +41 -4
- package/src/modes/controllers/btw-controller.ts +55 -7
- package/src/modes/controllers/command-controller.ts +31 -0
- package/src/modes/controllers/input-controller.ts +24 -7
- package/src/modes/interactive-mode.ts +16 -2
- package/src/modes/types.ts +8 -1
- package/src/prompts/session/launch-completion.md +1 -0
- package/src/sdk.ts +15 -0
- package/src/security/contracts/schemas.ts +205 -183
- package/src/security/contracts/validation.ts +5 -1
- package/src/security/store.ts +2 -2
- package/src/session/agent-session-types.ts +6 -0
- package/src/session/agent-session.ts +191 -14
- package/src/session/launch-completion.ts +37 -0
- package/src/session/session-context.ts +26 -0
- package/src/session/session-entries.ts +17 -1
- package/src/session/session-manager.ts +21 -0
- package/src/session/yield-queue.ts +121 -16
- package/src/slash-commands/builtin-registry.ts +10 -0
- package/src/tools/browser/cmux/cmux-tab.ts +92 -4
- package/src/tools/hub/launch.ts +122 -6
- package/src/tools/index.ts +9 -0
- package/dist/types/config/file-lock.d.ts +0 -29
- package/src/config/file-lock.ts +0 -164
|
@@ -38,25 +38,25 @@ export const DISCOVERY_DEFAULT_CONTEXT_WINDOW = OPENAI_COMPAT_DISCOVERY_DEFAULT_
|
|
|
38
38
|
export const DISCOVERY_DEFAULT_MAX_TOKENS = OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS;
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Run `fn` with
|
|
42
|
-
*
|
|
41
|
+
* Run `fn` with a hard deadline while also signalling cooperative transports
|
|
42
|
+
* to abort. The independent rejection keeps discovery bounded when a runtime
|
|
43
|
+
* leaves its fetch promise pending after `AbortSignal.abort()` (observed with
|
|
44
|
+
* Windows localhost probes).
|
|
43
45
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* abort-signal timeout (e.g. discovery against a mocked fetch that resolves
|
|
48
|
-
* instantly) fires seconds later and sets its abort `reason` — which crashed
|
|
49
|
-
* Bun's concurrent GC while it marked the signal's wrapped reason during an
|
|
50
|
-
* unrelated allocation (`JSAbortSignal::visitAdditionalChildren`).
|
|
46
|
+
* The backing timer is cleared as soon as `fn` settles, unlike
|
|
47
|
+
* `AbortSignal.timeout()`, whose delayed reason previously crashed Bun's
|
|
48
|
+
* concurrent GC during an unrelated allocation.
|
|
51
49
|
*/
|
|
52
50
|
async function withTimeoutSignal<T>(timeoutMs: number, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
53
51
|
const controller = new AbortController();
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
const timeout = Promise.withResolvers<never>();
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
const error = new DOMException("The operation timed out.", "TimeoutError");
|
|
55
|
+
controller.abort(error);
|
|
56
|
+
timeout.reject(error);
|
|
57
|
+
}, timeoutMs);
|
|
58
58
|
try {
|
|
59
|
-
return await fn(controller.signal);
|
|
59
|
+
return await Promise.race([fn(controller.signal), timeout.promise]);
|
|
60
60
|
} finally {
|
|
61
61
|
clearTimeout(timer);
|
|
62
62
|
}
|
|
@@ -68,6 +68,7 @@ import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai";
|
|
|
68
68
|
import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
|
|
69
69
|
import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types";
|
|
70
70
|
import { setCodexAttestationProvider } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
|
|
71
|
+
import { getProviderDefinition } from "@oh-my-pi/pi-ai/registry";
|
|
71
72
|
import {
|
|
72
73
|
getBundledModelReferenceIndex,
|
|
73
74
|
inheritReferenceThinking,
|
|
@@ -2011,14 +2012,15 @@ export class ModelRegistry {
|
|
|
2011
2012
|
this.#providerOverrides.has(descriptor.providerId) ||
|
|
2012
2013
|
this.#keylessProviders.has(descriptor.providerId));
|
|
2013
2014
|
if (isAuthenticated(apiKey) || descriptor.allowUnauthenticated || hasExplicitVllmConfig) {
|
|
2014
|
-
const
|
|
2015
|
-
|
|
2016
|
-
descriptor.
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2015
|
+
const discoveryConfig = {
|
|
2016
|
+
apiKey: isDiscoveryBearerApiKey(apiKey) ? apiKey : undefined,
|
|
2017
|
+
baseUrl: this.#descriptorBaseUrl(descriptor.providerId),
|
|
2018
|
+
fetch: this.#fetch,
|
|
2019
|
+
};
|
|
2020
|
+
const preparedConfig =
|
|
2021
|
+
getProviderDefinition(descriptor.providerId)?.prepareModelDiscovery?.(discoveryConfig) ??
|
|
2022
|
+
discoveryConfig;
|
|
2023
|
+
options.push(descriptor.createModelManagerOptions(preparedConfig));
|
|
2022
2024
|
}
|
|
2023
2025
|
}
|
|
2024
2026
|
|
package/src/config/settings.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
setWorktreesDir,
|
|
31
31
|
toError,
|
|
32
32
|
} from "@oh-my-pi/pi-utils";
|
|
33
|
+
import { withFileLock } from "@oh-my-pi/pi-utils/file-lock";
|
|
33
34
|
import { JSONC, YAML } from "bun";
|
|
34
35
|
import { invalidate as invalidateCapabilityFsCache } from "../capability/fs";
|
|
35
36
|
import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
|
|
@@ -41,7 +42,6 @@ import { AUTO_IMAGE_PROVIDER_ORDER, isImageProviderId } from "../tools/image-pro
|
|
|
41
42
|
import { type EditMode, normalizeEditMode } from "../utils/edit-mode";
|
|
42
43
|
import { INSPECT_IMAGE_MODES } from "../utils/inspect-image-mode";
|
|
43
44
|
import { isSearchProviderId, SEARCH_PROVIDER_ORDER } from "../web/search/types";
|
|
44
|
-
import { withFileLock } from "./file-lock";
|
|
45
45
|
import {
|
|
46
46
|
type BashInterceptorRule,
|
|
47
47
|
type GroupPrefix,
|
package/src/export/html/index.ts
CHANGED
|
@@ -181,10 +181,17 @@ export interface SessionData {
|
|
|
181
181
|
subSessions?: Record<string, SubSession>;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
function sessionHeaderForExport(header: SessionHeader | null): SessionHeader | null {
|
|
185
|
+
if (!header) return null;
|
|
186
|
+
const exported = { ...header };
|
|
187
|
+
delete exported.previousSessionFiles;
|
|
188
|
+
return exported;
|
|
189
|
+
}
|
|
190
|
+
|
|
184
191
|
/** Snapshot the session (plus optional agent state) into the JSON shape the viewer renders. */
|
|
185
192
|
export function buildSessionData(sm: SessionManager, state?: AgentState): SessionData {
|
|
186
193
|
return {
|
|
187
|
-
header: sm.getHeader(),
|
|
194
|
+
header: sessionHeaderForExport(sm.getHeader()),
|
|
188
195
|
entries: sm.getEntries(),
|
|
189
196
|
leafId: sm.getLeafId(),
|
|
190
197
|
systemPrompt: state?.systemPrompt.join("\n\n"),
|
|
@@ -231,7 +238,7 @@ async function collectSubSessionsFromDir(
|
|
|
231
238
|
out[key] = {
|
|
232
239
|
agentId,
|
|
233
240
|
parent: parentKey,
|
|
234
|
-
header,
|
|
241
|
+
header: sessionHeaderForExport(header),
|
|
235
242
|
entries,
|
|
236
243
|
leafId: entries.length > 0 ? entries[entries.length - 1].id : null,
|
|
237
244
|
};
|
|
@@ -295,7 +302,7 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
|
|
|
295
302
|
}
|
|
296
303
|
|
|
297
304
|
const sessionData: SessionData = {
|
|
298
|
-
header: sm.getHeader(),
|
|
305
|
+
header: sessionHeaderForExport(sm.getHeader()),
|
|
299
306
|
entries: sm.getEntries(),
|
|
300
307
|
leafId: sm.getLeafId(),
|
|
301
308
|
};
|
package/src/export/share.ts
CHANGED
|
@@ -223,6 +223,7 @@ function collectShareRegexSecretValues(o: SecretObfuscator, data: SessionData):
|
|
|
223
223
|
if (!header) return;
|
|
224
224
|
add(header.title);
|
|
225
225
|
add(header.cwd);
|
|
226
|
+
for (const previousSessionFile of header.previousSessionFiles ?? []) add(previousSessionFile);
|
|
226
227
|
};
|
|
227
228
|
|
|
228
229
|
addHeader(data.header);
|
|
@@ -246,6 +247,9 @@ function redactShareHeader(
|
|
|
246
247
|
...header,
|
|
247
248
|
title: header.title === undefined ? undefined : o.obfuscate(header.title, sharedRegexSecretValues),
|
|
248
249
|
cwd: o.obfuscate(header.cwd, sharedRegexSecretValues),
|
|
250
|
+
previousSessionFiles: header.previousSessionFiles?.map(previousSessionFile =>
|
|
251
|
+
o.obfuscate(previousSessionFile, sharedRegexSecretValues),
|
|
252
|
+
),
|
|
249
253
|
};
|
|
250
254
|
}
|
|
251
255
|
|
package/src/launch/broker.ts
CHANGED
|
@@ -15,14 +15,17 @@ import {
|
|
|
15
15
|
DAEMON_PTY_COLUMNS,
|
|
16
16
|
DAEMON_PTY_ROWS,
|
|
17
17
|
DAEMON_RUNTIME_DIR_ENV,
|
|
18
|
+
type DaemonCompletionNotification,
|
|
18
19
|
type DaemonOperation,
|
|
19
20
|
type DaemonReadySpec,
|
|
20
21
|
type DaemonRpcResult,
|
|
21
22
|
type DaemonSignal,
|
|
22
23
|
type DaemonSnapshot,
|
|
23
24
|
type DaemonSpec,
|
|
25
|
+
type DaemonWireRequest,
|
|
24
26
|
parseDaemonSnapshot,
|
|
25
27
|
parseDaemonSpec,
|
|
28
|
+
parseDaemonWireMessage,
|
|
26
29
|
parseDaemonWireRequest,
|
|
27
30
|
} from "./protocol";
|
|
28
31
|
import { resolveDaemonSpawnOptions } from "./spawn-options";
|
|
@@ -81,6 +84,9 @@ interface ManagedDaemon {
|
|
|
81
84
|
readyPattern?: RegExp;
|
|
82
85
|
restartTimer?: NodeJS.Timeout;
|
|
83
86
|
consecutiveFailures: number;
|
|
87
|
+
completionCapable: boolean;
|
|
88
|
+
pendingCompletions: DaemonCompletionNotification[];
|
|
89
|
+
completionSubscriptionId?: string;
|
|
84
90
|
persistQueue: Promise<void>;
|
|
85
91
|
}
|
|
86
92
|
|
|
@@ -107,6 +113,10 @@ function settledState(state: DaemonSnapshot["state"]): boolean {
|
|
|
107
113
|
return terminalState(state) || state === "restarting";
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
function publishesCompletionOwners(request: DaemonWireRequest): boolean {
|
|
117
|
+
return request.completionEvents === true && (request.completionAcks?.length ?? 0) === 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
110
120
|
/**
|
|
111
121
|
* Order daemons for the `list` response: non-terminal (active) daemons first,
|
|
112
122
|
* oldest to newest, so the process the user is acting on is immediately visible
|
|
@@ -352,6 +362,9 @@ class DaemonBroker {
|
|
|
352
362
|
*/
|
|
353
363
|
readonly #startingNames = new Set<string>();
|
|
354
364
|
readonly #clients = new Set<net.Socket>();
|
|
365
|
+
readonly #ownerSockets = new Map<string, { socket: net.Socket; subscriptionId: string | undefined }>();
|
|
366
|
+
readonly #completionSubscriptions = new Map<string, string | undefined>();
|
|
367
|
+
readonly #pendingCompletions = new Map<string, Map<string, DaemonCompletionNotification>>();
|
|
355
368
|
readonly #finished = Promise.withResolvers<void>();
|
|
356
369
|
readonly #sockets = new Set<net.Socket>();
|
|
357
370
|
#server: net.Server | undefined;
|
|
@@ -393,6 +406,7 @@ class DaemonBroker {
|
|
|
393
406
|
await record.log?.close();
|
|
394
407
|
await record.persistQueue;
|
|
395
408
|
}
|
|
409
|
+
this.#ownerSockets.clear();
|
|
396
410
|
for (const socket of this.#sockets) socket.destroy();
|
|
397
411
|
this.#sockets.clear();
|
|
398
412
|
this.#clients.clear();
|
|
@@ -439,6 +453,9 @@ class DaemonBroker {
|
|
|
439
453
|
if (!authenticated) return;
|
|
440
454
|
this.#clients.delete(socket);
|
|
441
455
|
this.#scheduleIdleShutdown();
|
|
456
|
+
for (const [owner, registration] of this.#ownerSockets) {
|
|
457
|
+
if (registration.socket === socket) this.#ownerSockets.delete(owner);
|
|
458
|
+
}
|
|
442
459
|
});
|
|
443
460
|
}
|
|
444
461
|
|
|
@@ -450,6 +467,78 @@ class DaemonBroker {
|
|
|
450
467
|
id = request.id;
|
|
451
468
|
if (request.token !== this.#token) throw new Error("Daemon broker authentication failed");
|
|
452
469
|
onAuthenticated();
|
|
470
|
+
for (const owner of request.completionUnsubscribes ?? []) {
|
|
471
|
+
const subscriptionId = this.#completionSubscriptions.get(owner);
|
|
472
|
+
if (
|
|
473
|
+
!this.#completionSubscriptions.has(owner) ||
|
|
474
|
+
(subscriptionId !== undefined && subscriptionId !== request.completionSubscriptionId)
|
|
475
|
+
) {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
this.#ownerSockets.delete(owner);
|
|
479
|
+
this.#completionSubscriptions.delete(owner);
|
|
480
|
+
await this.#setRecordCompletionCapability(owner, false);
|
|
481
|
+
this.#pendingCompletions.delete(owner);
|
|
482
|
+
}
|
|
483
|
+
for (const completionId of request.completionAcks ?? []) {
|
|
484
|
+
for (const [owner, pending] of this.#pendingCompletions) {
|
|
485
|
+
const registration = this.#ownerSockets.get(owner);
|
|
486
|
+
if (
|
|
487
|
+
!registration ||
|
|
488
|
+
registration.socket !== socket ||
|
|
489
|
+
registration.subscriptionId !== request.completionSubscriptionId
|
|
490
|
+
) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const completion = pending.get(completionId);
|
|
494
|
+
if (!completion) continue;
|
|
495
|
+
pending.delete(completionId);
|
|
496
|
+
if (pending.size === 0) this.#pendingCompletions.delete(owner);
|
|
497
|
+
const record = this.#records.get(completion.daemon.name);
|
|
498
|
+
const index = record?.pendingCompletions.findIndex(item => item.completionId === completionId) ?? -1;
|
|
499
|
+
if (record && index >= 0) {
|
|
500
|
+
record.pendingCompletions.splice(index, 1);
|
|
501
|
+
this.#persist(record);
|
|
502
|
+
await record.persistQueue;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (publishesCompletionOwners(request)) {
|
|
507
|
+
const replayOwners = new Set(request.completionReplays ?? []);
|
|
508
|
+
const activeOwners = new Set(request.owners ?? []);
|
|
509
|
+
const detachedOwners = new Set(request.detachedOwners ?? []);
|
|
510
|
+
const advertisedOwners = new Set([...activeOwners, ...detachedOwners]);
|
|
511
|
+
for (const [owner, subscriptionId] of this.#completionSubscriptions) {
|
|
512
|
+
if (subscriptionId !== request.completionSubscriptionId || advertisedOwners.has(owner)) continue;
|
|
513
|
+
this.#ownerSockets.delete(owner);
|
|
514
|
+
this.#completionSubscriptions.delete(owner);
|
|
515
|
+
await this.#setRecordCompletionCapability(owner, false);
|
|
516
|
+
this.#pendingCompletions.delete(owner);
|
|
517
|
+
}
|
|
518
|
+
for (const owner of activeOwners) {
|
|
519
|
+
this.#completionSubscriptions.set(owner, request.completionSubscriptionId);
|
|
520
|
+
await this.#setRecordCompletionCapability(owner, true);
|
|
521
|
+
const previous = this.#ownerSockets.get(owner);
|
|
522
|
+
this.#ownerSockets.set(owner, {
|
|
523
|
+
socket,
|
|
524
|
+
subscriptionId: request.completionSubscriptionId,
|
|
525
|
+
});
|
|
526
|
+
if (previous?.socket === socket && !replayOwners.has(owner)) continue;
|
|
527
|
+
for (const completion of this.#pendingCompletions.get(owner)?.values() ?? []) {
|
|
528
|
+
socket.write(`${JSON.stringify(completion)}\n`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
for (const owner of detachedOwners) {
|
|
532
|
+
const subscriptionId = this.#completionSubscriptions.get(owner);
|
|
533
|
+
if (this.#completionSubscriptions.has(owner) && subscriptionId !== request.completionSubscriptionId) {
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
this.#completionSubscriptions.set(owner, request.completionSubscriptionId);
|
|
537
|
+
await this.#setRecordCompletionCapability(owner, true);
|
|
538
|
+
const registration = this.#ownerSockets.get(owner);
|
|
539
|
+
if (registration?.subscriptionId === request.completionSubscriptionId) this.#ownerSockets.delete(owner);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
453
542
|
const result = await this.#dispatch(request.operation);
|
|
454
543
|
socket.write(`${JSON.stringify({ id, ok: true, result })}\n`);
|
|
455
544
|
if (request.operation.op === "shutdown") setTimeout(() => void this.shutdown(), 10);
|
|
@@ -520,6 +609,9 @@ class DaemonBroker {
|
|
|
520
609
|
if (existing && !terminalState(existing.snapshot.state)) {
|
|
521
610
|
throw new Error(`Daemon ${spec.name} is already ${existing.snapshot.state}`);
|
|
522
611
|
}
|
|
612
|
+
if (existing && existing.pendingCompletions.length > 0) {
|
|
613
|
+
throw new Error(`Daemon ${spec.name} has unacknowledged completion notifications`);
|
|
614
|
+
}
|
|
523
615
|
if (spec.ready?.log) {
|
|
524
616
|
try {
|
|
525
617
|
new RegExp(spec.ready.log, "u");
|
|
@@ -556,6 +648,9 @@ class DaemonBroker {
|
|
|
556
648
|
readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined,
|
|
557
649
|
consecutiveFailures: 0,
|
|
558
650
|
persistQueue: Promise.resolve(),
|
|
651
|
+
completionCapable: owner !== undefined && this.#completionSubscriptions.has(owner),
|
|
652
|
+
completionSubscriptionId: owner === undefined ? undefined : this.#completionSubscriptions.get(owner),
|
|
653
|
+
pendingCompletions: [],
|
|
559
654
|
};
|
|
560
655
|
syncReadyPending(record);
|
|
561
656
|
this.#records.set(spec.name, record);
|
|
@@ -785,6 +880,14 @@ class DaemonBroker {
|
|
|
785
880
|
await this.#settle(record, generation);
|
|
786
881
|
}
|
|
787
882
|
|
|
883
|
+
async #monitorRecoveredDetached(record: ManagedDaemon, generation: number): Promise<void> {
|
|
884
|
+
while (!this.#shuttingDown && generation === record.generation && !settledState(record.snapshot.state)) {
|
|
885
|
+
await Bun.sleep(100);
|
|
886
|
+
if (this.#shuttingDown || generation !== record.generation) return;
|
|
887
|
+
await this.#refreshDetached(record);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
788
891
|
async #pollPort(record: ManagedDaemon, generation: number, ready: DaemonReadySpec): Promise<void> {
|
|
789
892
|
const host = ready.host ?? "127.0.0.1";
|
|
790
893
|
const port = ready.port;
|
|
@@ -812,6 +915,15 @@ class DaemonBroker {
|
|
|
812
915
|
return this.#settle(record, generation, result.exitCode, result.timedOut ? "timed out" : undefined);
|
|
813
916
|
}
|
|
814
917
|
|
|
918
|
+
#notifyCompletion(completion: DaemonCompletionNotification): void {
|
|
919
|
+
const pending = this.#pendingCompletions.get(completion.owner) ?? new Map<string, DaemonCompletionNotification>();
|
|
920
|
+
pending.set(completion.completionId, completion);
|
|
921
|
+
this.#pendingCompletions.set(completion.owner, pending);
|
|
922
|
+
const registration = this.#ownerSockets.get(completion.owner);
|
|
923
|
+
if (!registration || registration.socket.destroyed) return;
|
|
924
|
+
registration.socket.write(`${JSON.stringify(completion)}\n`);
|
|
925
|
+
}
|
|
926
|
+
|
|
815
927
|
async #settle(record: ManagedDaemon, generation: number, exitCode?: number, error?: string): Promise<void> {
|
|
816
928
|
// `restarting` is a settled state (child exited, relaunch timer armed). Any op that
|
|
817
929
|
// runs #refreshDetached on such a record must not re-settle it: re-entry double-counts
|
|
@@ -855,9 +967,29 @@ class DaemonBroker {
|
|
|
855
967
|
return;
|
|
856
968
|
}
|
|
857
969
|
record.snapshot.state = failed && !record.stopRequested ? "failed" : "exited";
|
|
970
|
+
const completion =
|
|
971
|
+
record.snapshot.owner !== undefined &&
|
|
972
|
+
!record.stopRequested &&
|
|
973
|
+
this.#completionSubscriptions.has(record.snapshot.owner)
|
|
974
|
+
? ({
|
|
975
|
+
event: "daemon-completed",
|
|
976
|
+
completionId: crypto.randomUUID(),
|
|
977
|
+
owner: record.snapshot.owner,
|
|
978
|
+
daemon: { ...record.snapshot },
|
|
979
|
+
} satisfies DaemonCompletionNotification)
|
|
980
|
+
: undefined;
|
|
981
|
+
if (completion) record.pendingCompletions.push(completion);
|
|
858
982
|
this.#persist(record);
|
|
859
983
|
await record.log?.close();
|
|
860
984
|
record.log = undefined;
|
|
985
|
+
await record.persistQueue;
|
|
986
|
+
if (
|
|
987
|
+
completion &&
|
|
988
|
+
this.#completionSubscriptions.has(completion.owner) &&
|
|
989
|
+
record.pendingCompletions.some(pending => pending.completionId === completion.completionId)
|
|
990
|
+
) {
|
|
991
|
+
this.#notifyCompletion(completion);
|
|
992
|
+
}
|
|
861
993
|
}
|
|
862
994
|
|
|
863
995
|
async #logs(operation: Extract<DaemonOperation, { op: "logs" }>): Promise<DaemonRpcResult> {
|
|
@@ -1027,9 +1159,21 @@ class DaemonBroker {
|
|
|
1027
1159
|
#persist(record: ManagedDaemon): void {
|
|
1028
1160
|
const metaPath = path.join(record.dir, META_FILE);
|
|
1029
1161
|
const tempPath = `${metaPath}.${process.pid}.tmp`;
|
|
1162
|
+
const metadata = {
|
|
1163
|
+
daemon: { ...record.snapshot },
|
|
1164
|
+
spec: record.spec,
|
|
1165
|
+
completionEvents: record.completionCapable,
|
|
1166
|
+
completionSubscriptionId: record.completionSubscriptionId,
|
|
1167
|
+
completionPending: record.pendingCompletions.length > 0,
|
|
1168
|
+
pendingCompletion: record.pendingCompletions.at(-1)?.daemon,
|
|
1169
|
+
pendingCompletions: record.pendingCompletions.map(completion => ({
|
|
1170
|
+
...completion,
|
|
1171
|
+
daemon: { ...completion.daemon },
|
|
1172
|
+
})),
|
|
1173
|
+
};
|
|
1030
1174
|
record.persistQueue = record.persistQueue
|
|
1031
1175
|
.then(async () => {
|
|
1032
|
-
await Bun.write(tempPath, JSON.stringify(
|
|
1176
|
+
await Bun.write(tempPath, JSON.stringify(metadata));
|
|
1033
1177
|
await fs.rename(tempPath, metaPath);
|
|
1034
1178
|
})
|
|
1035
1179
|
.catch(error => {
|
|
@@ -1040,6 +1184,28 @@ class DaemonBroker {
|
|
|
1040
1184
|
});
|
|
1041
1185
|
}
|
|
1042
1186
|
|
|
1187
|
+
async #setRecordCompletionCapability(owner: string, capable: boolean): Promise<void> {
|
|
1188
|
+
const subscriptionId = capable ? this.#completionSubscriptions.get(owner) : undefined;
|
|
1189
|
+
const persistence: Promise<void>[] = [];
|
|
1190
|
+
for (const record of this.#records.values()) {
|
|
1191
|
+
const clearPendingCompletions = !capable && record.pendingCompletions.length > 0;
|
|
1192
|
+
if (
|
|
1193
|
+
record.snapshot.owner !== owner ||
|
|
1194
|
+
(record.completionCapable === capable &&
|
|
1195
|
+
record.completionSubscriptionId === subscriptionId &&
|
|
1196
|
+
!clearPendingCompletions)
|
|
1197
|
+
) {
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
record.completionCapable = capable;
|
|
1201
|
+
record.completionSubscriptionId = subscriptionId;
|
|
1202
|
+
if (!capable) record.pendingCompletions = [];
|
|
1203
|
+
this.#persist(record);
|
|
1204
|
+
persistence.push(record.persistQueue);
|
|
1205
|
+
}
|
|
1206
|
+
await Promise.all(persistence);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1043
1209
|
async #recoverRecords(): Promise<void> {
|
|
1044
1210
|
const root = path.join(this.#runtimeDir, "daemons");
|
|
1045
1211
|
const entries = await fs.readdir(root, { withFileTypes: true }).catch(error => {
|
|
@@ -1057,11 +1223,9 @@ class DaemonBroker {
|
|
|
1057
1223
|
const snapshot = parseDaemonSnapshot(decoded.daemon);
|
|
1058
1224
|
const spec = parseDaemonSpec(decoded.spec);
|
|
1059
1225
|
const processRef = snapshot.pid === undefined ? null : Process.fromPid(snapshot.pid);
|
|
1060
|
-
const
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
snapshot.state !== "stopping" &&
|
|
1064
|
-
processRef?.status() === "running";
|
|
1226
|
+
const recoverableExit = !terminalState(snapshot.state) && snapshot.state !== "stopping";
|
|
1227
|
+
const detached = spec.detached && recoverableExit && processRef?.status() === "running";
|
|
1228
|
+
const recoveredDead = recoverableExit && !detached;
|
|
1065
1229
|
if (!detached) {
|
|
1066
1230
|
// Reap only records that were still alive when the previous broker
|
|
1067
1231
|
// exited; already-terminal records keep their real exit time so
|
|
@@ -1088,12 +1252,64 @@ class DaemonBroker {
|
|
|
1088
1252
|
readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined,
|
|
1089
1253
|
consecutiveFailures: 0,
|
|
1090
1254
|
persistQueue: Promise.resolve(),
|
|
1255
|
+
completionCapable: "completionEvents" in decoded && decoded.completionEvents === true,
|
|
1256
|
+
completionSubscriptionId:
|
|
1257
|
+
"completionSubscriptionId" in decoded && typeof decoded.completionSubscriptionId === "string"
|
|
1258
|
+
? decoded.completionSubscriptionId
|
|
1259
|
+
: undefined,
|
|
1260
|
+
pendingCompletions: (() => {
|
|
1261
|
+
if ("pendingCompletions" in decoded && Array.isArray(decoded.pendingCompletions)) {
|
|
1262
|
+
return decoded.pendingCompletions.map(value => {
|
|
1263
|
+
const message = parseDaemonWireMessage(value);
|
|
1264
|
+
if (!("event" in message)) throw new Error("Pending daemon completion is not an event");
|
|
1265
|
+
return message;
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
const pendingSnapshot =
|
|
1269
|
+
"pendingCompletion" in decoded
|
|
1270
|
+
? parseDaemonSnapshot(decoded.pendingCompletion)
|
|
1271
|
+
: "completionPending" in decoded && decoded.completionPending === true
|
|
1272
|
+
? { ...snapshot }
|
|
1273
|
+
: undefined;
|
|
1274
|
+
return pendingSnapshot
|
|
1275
|
+
? [
|
|
1276
|
+
{
|
|
1277
|
+
event: "daemon-completed",
|
|
1278
|
+
completionId: crypto.randomUUID(),
|
|
1279
|
+
owner: pendingSnapshot.owner ?? snapshot.owner ?? "",
|
|
1280
|
+
daemon: pendingSnapshot,
|
|
1281
|
+
},
|
|
1282
|
+
]
|
|
1283
|
+
: [];
|
|
1284
|
+
})(),
|
|
1091
1285
|
};
|
|
1286
|
+
if (recoveredDead && record.completionCapable && snapshot.owner && record.pendingCompletions.length === 0) {
|
|
1287
|
+
record.pendingCompletions.push({
|
|
1288
|
+
event: "daemon-completed",
|
|
1289
|
+
completionId: crypto.randomUUID(),
|
|
1290
|
+
owner: snapshot.owner,
|
|
1291
|
+
daemon: { ...snapshot },
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1092
1294
|
syncReadyPending(record);
|
|
1093
1295
|
this.#records.set(snapshot.name, record);
|
|
1296
|
+
if (snapshot.owner && record.completionCapable && (detached || record.pendingCompletions.length > 0)) {
|
|
1297
|
+
this.#completionSubscriptions.set(snapshot.owner, record.completionSubscriptionId);
|
|
1298
|
+
}
|
|
1299
|
+
if (record.completionCapable) {
|
|
1300
|
+
for (const completion of record.pendingCompletions) this.#notifyCompletion(completion);
|
|
1301
|
+
}
|
|
1094
1302
|
if (detached && spec.ready?.port !== undefined && snapshot.state !== "ready") {
|
|
1095
1303
|
void this.#pollPort(record, record.generation, spec.ready);
|
|
1096
1304
|
}
|
|
1305
|
+
if (detached) {
|
|
1306
|
+
void this.#monitorRecoveredDetached(record, record.generation).catch(error => {
|
|
1307
|
+
logger.warn("Failed to monitor recovered detached daemon", {
|
|
1308
|
+
name: record.snapshot.name,
|
|
1309
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1310
|
+
});
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1097
1313
|
this.#persist(record);
|
|
1098
1314
|
} catch (error) {
|
|
1099
1315
|
logger.warn("Failed to recover daemon record", {
|