pi-subagents 0.65.0 → 0.66.0
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 +87 -0
- package/README.md +1 -1
- package/agents/researcher.md +23 -13
- package/docs/agents.md +17 -3
- package/docs/configuration.md +34 -0
- package/docs/extension-api.md +94 -0
- package/docs/models.md +58 -1
- package/docs/observability.md +42 -2
- package/docs/tool-reference.md +11 -5
- package/docs/workflows.md +22 -7
- package/package.json +4 -1
- package/runner-server-preload.mjs +13 -0
- package/skills/pi-subagents/SKILL.md +2 -1
- package/skills/pi-subagents/references/execution-controls.md +19 -2
- package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
- package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
- package/src/agents/advertised-agent-prompt.ts +63 -0
- package/src/agents/agent-management.ts +14 -1
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +8 -0
- package/src/api/preflight.ts +5 -1
- package/src/api/shared-types.ts +1 -1
- package/src/api/workflow-resources.ts +6 -0
- package/src/extension/config.ts +4 -2
- package/src/extension/index.ts +71 -4
- package/src/extension/public-execution.ts +0 -1
- package/src/extension/rpc.ts +4 -21
- package/src/extension/schemas.ts +9 -7
- package/src/extension/tool-description.ts +10 -5
- package/src/integrations/pi-web-session-liveness.ts +73 -0
- package/src/intercom/native-supervisor-channel.ts +102 -88
- package/src/intercom/supervisor-ui.ts +3 -2
- package/src/missions/workflow-state.ts +37 -16
- package/src/runs/background/active-async-capacity.ts +18 -18
- package/src/runs/background/async-execution.ts +8 -1
- package/src/runs/background/async-job-tracker.ts +35 -3
- package/src/runs/background/async-resume.ts +3 -1
- package/src/runs/background/async-retention.ts +9 -0
- package/src/runs/background/async-status-snapshot.ts +10 -12
- package/src/runs/background/async-status.ts +17 -9
- package/src/runs/background/auto-drain.ts +40 -29
- package/src/runs/background/chain-root-attachment.ts +8 -0
- package/src/runs/background/control-channel.ts +78 -44
- package/src/runs/background/notify.ts +88 -12
- package/src/runs/background/owned-process-tree.ts +6 -6
- package/src/runs/background/process-terminal.ts +23 -23
- package/src/runs/background/retained-nested-route-tracker.ts +96 -0
- package/src/runs/background/run-child-session.ts +62 -33
- package/src/runs/background/run-status.ts +75 -5
- package/src/runs/background/runner-aliases.ts +46 -8
- package/src/runs/background/runner-child-launch.ts +86 -0
- package/src/runs/background/stale-run-reconciler.ts +3 -1
- package/src/runs/background/subagent-runner.ts +430 -208
- package/src/runs/background/subagent-wait.ts +3 -0
- package/src/runs/background/wait-completions.ts +4 -0
- package/src/runs/foreground/async-steering-action.ts +19 -0
- package/src/runs/foreground/execution.ts +116 -26
- package/src/runs/foreground/foreground-history.ts +3 -1
- package/src/runs/foreground/prompt-audit.ts +9 -5
- package/src/runs/foreground/subagent-executor.ts +531 -227
- package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
- package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
- package/src/runs/shared/acceptance.ts +16 -3
- package/src/runs/shared/agent-contract.ts +1 -1
- package/src/runs/shared/async-status-projection.ts +47 -47
- package/src/runs/shared/child-hooks.ts +151 -2
- package/src/runs/shared/child-launch.ts +18 -13
- package/src/runs/shared/child-session.ts +55 -24
- package/src/runs/shared/child-tool-plan.ts +2 -2
- package/src/runs/shared/completion-evidence.ts +2 -2
- package/src/runs/shared/completion-guard.ts +1 -0
- package/src/runs/shared/host-step-status.ts +11 -11
- package/src/runs/shared/llm-intent-arbiter.ts +30 -20
- package/src/runs/shared/model-exclusions.ts +2 -1
- package/src/runs/shared/model-fallback.ts +41 -8
- package/src/runs/shared/nested-events.ts +8 -8
- package/src/runs/shared/orca-progress-tabs.ts +6 -0
- package/src/runs/shared/parallel-handoff.ts +57 -12
- package/src/runs/shared/parallel-utils.ts +3 -2
- package/src/runs/shared/readonly-drain-observation.ts +42 -0
- package/src/runs/shared/readonly-model-continuation.ts +69 -0
- package/src/runs/shared/readonly-session-evidence.ts +307 -0
- package/src/runs/shared/run-fanout-budget.ts +8 -8
- package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
- package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
- package/src/runs/shared/worktree-cleanup-plan.ts +6 -3
- package/src/runs/shared/worktree-setup-command.ts +190 -0
- package/src/runs/shared/worktree.ts +403 -210
- package/src/shared/model-response-aliases.ts +13 -0
- package/src/shared/types.ts +89 -60
- package/src/shared/utils.ts +10 -2
- package/src/shared/watch-strategy.ts +2 -0
- package/src/shared/workflow-child-permit.ts +18 -13
- package/src/tui/fleet-status.ts +1 -1
- package/src/tui/fleet.ts +11 -5
- package/src/tui/render.ts +44 -15
- package/src/workflows/chat-progress.ts +3 -3
- package/src/workflows/scripted-workflow.ts +70 -16
- package/src/workflows/workflow-checklist.ts +15 -18
- package/src/workflows/workflow-child-summary.ts +57 -8
- package/src/workflows/workflow-preflight.ts +19 -19
- package/src/workflows/workflow-receipt.ts +3 -3
- package/src/workflows/workflow-resources.ts +96 -21
- package/src/workflows/workflow-settlement.ts +3 -0
|
@@ -275,28 +275,36 @@ function parseSteerRequest(raw: unknown): SteerRequest | undefined {
|
|
|
275
275
|
};
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
-
export function consumeSteerRequestsFromDir(dir: string, fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs): SteerRequest[] {
|
|
279
|
-
if (!fsImpl.existsSync(dir)) return [];
|
|
278
|
+
export function consumeSteerRequestsFromDir(dir: string, fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs, onError: (error: unknown) => void = () => {}): SteerRequest[] {
|
|
280
279
|
let entries: string[];
|
|
281
280
|
try {
|
|
282
281
|
entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort();
|
|
283
|
-
} catch {
|
|
282
|
+
} catch (error) {
|
|
284
283
|
// Leave requests in place so the periodic poll can retry the scan.
|
|
284
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") onError(error);
|
|
285
285
|
return [];
|
|
286
286
|
}
|
|
287
287
|
const requests: SteerRequest[] = [];
|
|
288
288
|
for (const entry of entries) {
|
|
289
289
|
const requestPath = path.join(dir, entry);
|
|
290
290
|
let parsed: SteerRequest | undefined;
|
|
291
|
+
let text: string;
|
|
292
|
+
try {
|
|
293
|
+
text = fsImpl.readFileSync(requestPath, "utf-8");
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") onError(error);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
291
298
|
try {
|
|
292
|
-
parsed = parseSteerRequest(JSON.parse(
|
|
299
|
+
parsed = parseSteerRequest(JSON.parse(text));
|
|
293
300
|
} catch {
|
|
294
301
|
parsed = undefined;
|
|
295
302
|
}
|
|
296
303
|
try {
|
|
297
304
|
fsImpl.rmSync(requestPath, { recursive: true });
|
|
298
|
-
} catch {
|
|
305
|
+
} catch (error) {
|
|
299
306
|
// Already removed by a concurrent check — do not execute it twice.
|
|
307
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") onError(error);
|
|
300
308
|
continue;
|
|
301
309
|
}
|
|
302
310
|
if (parsed) requests.push(parsed);
|
|
@@ -304,8 +312,8 @@ export function consumeSteerRequestsFromDir(dir: string, fsImpl: Pick<typeof fs,
|
|
|
304
312
|
return requests.sort((left, right) => left.ts - right.ts || left.id.localeCompare(right.id));
|
|
305
313
|
}
|
|
306
314
|
|
|
307
|
-
export function consumeSteerRequests(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs): SteerRequest[] {
|
|
308
|
-
return consumeSteerRequestsFromDir(steerRequestsDir(asyncDir), fsImpl);
|
|
315
|
+
export function consumeSteerRequests(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs, onError?: (error: unknown) => void): SteerRequest[] {
|
|
316
|
+
return consumeSteerRequestsFromDir(steerRequestsDir(asyncDir), fsImpl, onError);
|
|
309
317
|
}
|
|
310
318
|
|
|
311
319
|
export function queueRevivalBrief(asyncDir: string, request: SteerRequest): string {
|
|
@@ -387,17 +395,26 @@ function parseStopRequest(raw: unknown): StopRequest | undefined {
|
|
|
387
395
|
function consumeStopRequestFile(
|
|
388
396
|
requestPath: string,
|
|
389
397
|
fsImpl: Pick<typeof fs, "rmSync" | "readFileSync">,
|
|
398
|
+
onError?: (error: unknown) => void,
|
|
390
399
|
): StopRequest | undefined {
|
|
400
|
+
let text: string;
|
|
401
|
+
try {
|
|
402
|
+
text = fsImpl.readFileSync(requestPath, "utf-8");
|
|
403
|
+
} catch (error) {
|
|
404
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) onError?.(error);
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
391
407
|
let request: StopRequest | undefined;
|
|
392
408
|
try {
|
|
393
|
-
request = parseStopRequest(JSON.parse(
|
|
409
|
+
request = parseStopRequest(JSON.parse(text));
|
|
394
410
|
} catch {
|
|
395
411
|
request = undefined;
|
|
396
412
|
}
|
|
397
413
|
try {
|
|
398
414
|
fsImpl.rmSync(requestPath, { force: true, recursive: true });
|
|
399
|
-
} catch {
|
|
400
|
-
//
|
|
415
|
+
} catch (error) {
|
|
416
|
+
// Execute only after successful removal.
|
|
417
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) onError?.(error);
|
|
401
418
|
return undefined;
|
|
402
419
|
}
|
|
403
420
|
return request;
|
|
@@ -406,6 +423,7 @@ function consumeStopRequestFile(
|
|
|
406
423
|
export function consumeStopRequestPayloads(
|
|
407
424
|
asyncDir: string,
|
|
408
425
|
fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs,
|
|
426
|
+
onError?: (error: unknown) => void,
|
|
409
427
|
): StopRequest[] {
|
|
410
428
|
const dir = stopRequestsDir(asyncDir);
|
|
411
429
|
const requests: StopRequest[] = [];
|
|
@@ -413,18 +431,19 @@ export function consumeStopRequestPayloads(
|
|
|
413
431
|
let entries: string[];
|
|
414
432
|
try {
|
|
415
433
|
entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort();
|
|
416
|
-
} catch {
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) onError?.(error);
|
|
417
436
|
entries = [];
|
|
418
437
|
}
|
|
419
438
|
for (const entry of entries) {
|
|
420
|
-
const request = consumeStopRequestFile(path.join(dir, entry), fsImpl);
|
|
439
|
+
const request = consumeStopRequestFile(path.join(dir, entry), fsImpl, onError);
|
|
421
440
|
if (request) requests.push(request);
|
|
422
441
|
}
|
|
423
442
|
}
|
|
424
443
|
|
|
425
444
|
const legacyPath = stopRequestPath(asyncDir);
|
|
426
445
|
if (fsImpl.existsSync(legacyPath)) {
|
|
427
|
-
const request = consumeStopRequestFile(legacyPath, fsImpl);
|
|
446
|
+
const request = consumeStopRequestFile(legacyPath, fsImpl, onError);
|
|
428
447
|
if (request) requests.push(request);
|
|
429
448
|
}
|
|
430
449
|
return requests.sort((left, right) => (left.ts ?? 0) - (right.ts ?? 0));
|
|
@@ -470,19 +489,21 @@ export function deliverStopRequest(input: {
|
|
|
470
489
|
requestAsyncStop(input.asyncDir, { ...(input.source ? { source: input.source } : {}), ...(input.targetIndex !== undefined ? { targetIndex: input.targetIndex } : {}), ...(input.childId ? { childId: input.childId } : {}) }, { now: input.now });
|
|
471
490
|
}
|
|
472
491
|
|
|
492
|
+
|
|
473
493
|
/**
|
|
474
|
-
*
|
|
475
|
-
*
|
|
494
|
+
* Active owner: watch and consume only kinds with installed handlers.
|
|
495
|
+
* Uses `fs.watch` when available and starts interval polling
|
|
476
496
|
* only when native watching is unavailable or fails. Fires once per distinct
|
|
477
497
|
* request. Returns a disposer.
|
|
478
498
|
*/
|
|
479
499
|
export function watchAsyncControlInbox(
|
|
480
500
|
asyncDir: string,
|
|
481
501
|
opts: {
|
|
482
|
-
onInterrupt
|
|
502
|
+
onInterrupt?: () => void;
|
|
483
503
|
onTimeout?: () => void;
|
|
484
504
|
onStop?: (request: StopRequest) => void;
|
|
485
505
|
onSteer?: (request: SteerRequest) => void;
|
|
506
|
+
onError?: (error: unknown, phase: "install" | "scan" | "callback", request?: SteerRequest) => void;
|
|
486
507
|
pollIntervalMs?: number;
|
|
487
508
|
safetyPollIntervalMs?: number;
|
|
488
509
|
platform?: NodeJS.Platform;
|
|
@@ -493,22 +514,44 @@ export function watchAsyncControlInbox(
|
|
|
493
514
|
const fsImpl = opts.fs ?? fs;
|
|
494
515
|
const timers = opts.timers ?? { setInterval, clearInterval };
|
|
495
516
|
const dir = controlInboxDir(asyncDir);
|
|
517
|
+
const report = (error: unknown, phase: "install" | "scan" | "callback", request?: SteerRequest): void => {
|
|
518
|
+
try {
|
|
519
|
+
if (opts.onError) opts.onError(error, phase, request);
|
|
520
|
+
else console.error(`Control inbox ${phase} failed:`, error);
|
|
521
|
+
} catch (reportError) {
|
|
522
|
+
console.error("Control inbox error reporter failed:", reportError);
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
const dirs = [
|
|
526
|
+
...(opts.onInterrupt || opts.onTimeout || opts.onStop ? [dir] : []),
|
|
527
|
+
...(opts.onStop ? [stopRequestsDir(asyncDir)] : []),
|
|
528
|
+
...(opts.onSteer ? [steerRequestsDir(asyncDir)] : []),
|
|
529
|
+
];
|
|
530
|
+
if (dirs.length === 0) return () => {};
|
|
496
531
|
try {
|
|
497
|
-
fsImpl.mkdirSync(
|
|
498
|
-
} catch {
|
|
499
|
-
|
|
532
|
+
for (const target of dirs) fsImpl.mkdirSync(target, { recursive: true });
|
|
533
|
+
} catch (error) {
|
|
534
|
+
report(error, "install");
|
|
500
535
|
}
|
|
501
536
|
|
|
502
537
|
let disposed = false;
|
|
503
538
|
const check = (): void => {
|
|
504
539
|
if (disposed) return;
|
|
505
540
|
try {
|
|
506
|
-
for (const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
541
|
+
if (opts.onStop) for (const request of consumeStopRequestPayloads(asyncDir, fsImpl, (error) => report(error, "scan"))) {
|
|
542
|
+
try { opts.onStop(request); } catch (error) { report(error, "callback"); }
|
|
543
|
+
}
|
|
544
|
+
if (opts.onTimeout && consumeTimeoutRequest(asyncDir, fsImpl)) {
|
|
545
|
+
try { opts.onTimeout(); } catch (error) { report(error, "callback"); }
|
|
546
|
+
}
|
|
547
|
+
if (opts.onInterrupt && consumeInterruptRequest(asyncDir, fsImpl)) {
|
|
548
|
+
try { opts.onInterrupt(); } catch (error) { report(error, "callback"); }
|
|
549
|
+
}
|
|
550
|
+
if (opts.onSteer) for (const request of consumeSteerRequestsFromDir(steerRequestsDir(asyncDir), fsImpl, (error) => report(error, "scan"))) {
|
|
551
|
+
try { opts.onSteer(request); } catch (error) { report(error, "callback", request); }
|
|
552
|
+
}
|
|
553
|
+
} catch (error) {
|
|
554
|
+
report(error, "scan");
|
|
512
555
|
}
|
|
513
556
|
};
|
|
514
557
|
|
|
@@ -516,40 +559,31 @@ export function watchAsyncControlInbox(
|
|
|
516
559
|
check();
|
|
517
560
|
|
|
518
561
|
const watchers: fs.FSWatcher[] = [];
|
|
519
|
-
const watchedDirs = new Set<string>();
|
|
520
562
|
let interval: ReturnType<typeof setInterval> | undefined;
|
|
521
563
|
let safetyInterval: ReturnType<typeof setInterval> | undefined;
|
|
522
564
|
const startPolling = (): void => {
|
|
523
565
|
if (interval || disposed) return;
|
|
566
|
+
if (safetyInterval) { timers.clearInterval(safetyInterval); safetyInterval = undefined; }
|
|
524
567
|
interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
|
|
525
568
|
interval.unref?.();
|
|
526
569
|
};
|
|
527
|
-
const startSafetyPolling = (): void => {
|
|
528
|
-
if (safetyInterval || disposed) return;
|
|
529
|
-
safetyInterval = timers.setInterval(check, opts.safetyPollIntervalMs ?? CONTROL_SAFETY_POLL_INTERVAL_MS);
|
|
530
|
-
safetyInterval.unref?.();
|
|
531
|
-
};
|
|
532
|
-
const watchDir = (target: string, create = false): void => {
|
|
533
|
-
if (disposed || watchedDirs.has(target)) return;
|
|
534
|
-
if (create) fsImpl.mkdirSync(target, { recursive: true });
|
|
535
|
-
const watcher = fsImpl.watch(resolveWatchPath(target, fsImpl.realpathSync.native), () => check());
|
|
536
|
-
watcher.on?.("error", startPolling);
|
|
537
|
-
watchers.push(watcher);
|
|
538
|
-
watchedDirs.add(target);
|
|
539
|
-
};
|
|
540
570
|
try {
|
|
541
571
|
if (shouldUseNativeFsWatch("runner-control-inbox", opts.platform)) {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
572
|
+
for (const target of dirs) {
|
|
573
|
+
const watcher = fsImpl.watch(resolveWatchPath(target, fsImpl.realpathSync.native), check);
|
|
574
|
+
watcher.on?.("error", startPolling);
|
|
575
|
+
watchers.push(watcher);
|
|
576
|
+
}
|
|
577
|
+
if (!interval) {
|
|
578
|
+
safetyInterval = timers.setInterval(check, opts.safetyPollIntervalMs ?? CONTROL_SAFETY_POLL_INTERVAL_MS);
|
|
579
|
+
safetyInterval.unref?.();
|
|
580
|
+
}
|
|
546
581
|
} else {
|
|
547
582
|
startPolling();
|
|
548
583
|
}
|
|
549
584
|
} catch {
|
|
550
585
|
startPolling();
|
|
551
586
|
}
|
|
552
|
-
|
|
553
587
|
return () => {
|
|
554
588
|
if (disposed) return;
|
|
555
589
|
disposed = true;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* observation channel, not a delivery acknowledgement.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { debuglog } from "node:util";
|
|
9
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import { buildCompletionKey, markSeenWithTtl } from "./completion-dedupe.ts";
|
|
11
12
|
import {
|
|
@@ -34,6 +35,7 @@ export interface SubagentNotifyChildOutput {
|
|
|
34
35
|
export type SubagentNotifyWatchdogBlocker = Pick<ChildWatchdogWarningSummary, "summary" | "addressed" | "stalemate"> & { agent: string };
|
|
35
36
|
|
|
36
37
|
export interface SubagentNotifyDetails {
|
|
38
|
+
workflowReceiptPath?: string;
|
|
37
39
|
agent: string;
|
|
38
40
|
status: "completed" | "failed" | "paused" | "stopped";
|
|
39
41
|
source?: "async" | "foreground";
|
|
@@ -123,6 +125,7 @@ export interface RegisterSubagentNotifyOptions {
|
|
|
123
125
|
|
|
124
126
|
export interface CompletionNotifier {
|
|
125
127
|
deliver(result: CompletionNotification): Promise<boolean>;
|
|
128
|
+
hasPendingDelivery(): boolean;
|
|
126
129
|
dispose(): void;
|
|
127
130
|
}
|
|
128
131
|
|
|
@@ -188,11 +191,17 @@ function structuredOutputText(value: unknown): string | undefined {
|
|
|
188
191
|
}
|
|
189
192
|
}
|
|
190
193
|
|
|
194
|
+
function isDegenerateOutput(text: string): boolean {
|
|
195
|
+
return !text.trim() || text.trim() === "</think>";
|
|
196
|
+
}
|
|
197
|
+
|
|
191
198
|
function childInlinePreview(child: CompletionChild): { preview?: string; truncated?: boolean; unavailableReason?: string } {
|
|
192
199
|
const output = typeof child.output === "string" ? child.output : "";
|
|
193
200
|
const outputReference = childSavedOutputPath(child);
|
|
194
201
|
const referenceOnly = Boolean(outputReference && output.trim().startsWith("Output saved to:"));
|
|
195
|
-
const raw = child.outputState === "absent" || referenceOnly
|
|
202
|
+
const raw = child.outputState === "absent" || referenceOnly
|
|
203
|
+
? structuredOutputText(child.structuredOutput)
|
|
204
|
+
: isDegenerateOutput(output) ? structuredOutputText(child.structuredOutput) ?? output : output;
|
|
196
205
|
if (!raw?.trim()) {
|
|
197
206
|
return { unavailableReason: referenceOnly ? "saved output is file-only" : "no safe inline output" };
|
|
198
207
|
}
|
|
@@ -282,6 +291,7 @@ export function formatSingleCompletion(details: SubagentNotifyDetails): string {
|
|
|
282
291
|
: undefined;
|
|
283
292
|
return [
|
|
284
293
|
`${taskKind} ${details.status}: **${details.agent}**${details.taskInfo ?? ""}`,
|
|
294
|
+
details.workflowReceiptPath ? `Workflow receipt: ${details.workflowReceiptPath}` : undefined,
|
|
285
295
|
"",
|
|
286
296
|
scheduleLine,
|
|
287
297
|
scheduleLine ? "" : undefined,
|
|
@@ -302,7 +312,11 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
|
|
|
302
312
|
const lines = content.split("\n");
|
|
303
313
|
const match = (lines[0] ?? "").match(/^(Background task|Detached foreground task) (completed|failed|paused|stopped): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
|
|
304
314
|
if (!match) return undefined;
|
|
305
|
-
|
|
315
|
+
// Only the header slot before the blank separator can carry a receipt.
|
|
316
|
+
// Identical lines anywhere in model output remain preview text.
|
|
317
|
+
const receiptHeader = lines[1]?.startsWith("Workflow receipt: ") && lines[2] === "";
|
|
318
|
+
const workflowReceiptPath = receiptHeader ? lines[1]!.slice("Workflow receipt: ".length) : undefined;
|
|
319
|
+
let body = lines.slice(receiptHeader ? 3 : 2);
|
|
306
320
|
// Restore the schedule origin so a re-rendered notice keeps its attribution and
|
|
307
321
|
// does not fold the line into the result preview.
|
|
308
322
|
const scheduleMatch = (body[0] ?? "").match(/^Scheduled run from \*\*(.+?)\*\* \(schedule (.+?)\)\.$/);
|
|
@@ -358,6 +372,7 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
|
|
|
358
372
|
...(match[1] === "Detached foreground task" ? { source: "foreground" as const } : {}),
|
|
359
373
|
...(match[4] ? { taskInfo: match[4] } : {}),
|
|
360
374
|
...(parsedScheduleOrigin ? { scheduleOrigin: parsedScheduleOrigin } : {}),
|
|
375
|
+
...(workflowReceiptPath ? { workflowReceiptPath } : {}),
|
|
361
376
|
resultPreview,
|
|
362
377
|
...(handoffPath ? { handoffPath } : {}),
|
|
363
378
|
...(workflowRunId ? { workflowRunId } : {}),
|
|
@@ -376,6 +391,7 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
|
|
|
376
391
|
if (!detail) continue;
|
|
377
392
|
const sessionLine = formatSessionLine(detail);
|
|
378
393
|
blocks.push(`${index + 1}. ${detail.agent}${detail.taskInfo ?? ""}${detail.scheduleOrigin ? ` — scheduled run from ${detail.scheduleOrigin.name ?? detail.scheduleOrigin.id} (schedule ${detail.scheduleOrigin.id})` : ""}`);
|
|
394
|
+
if (detail.workflowReceiptPath) blocks.push(`Workflow receipt: ${detail.workflowReceiptPath}`);
|
|
379
395
|
blocks.push(formatResultPreview(detail));
|
|
380
396
|
blocks.push(...formatWatchdogBlockerLines(detail));
|
|
381
397
|
if (detail.handoffPath) blocks.push(`Parallel handoff: ${detail.handoffPath}`);
|
|
@@ -386,7 +402,31 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
|
|
|
386
402
|
return blocks.join("\n").trimEnd();
|
|
387
403
|
}
|
|
388
404
|
|
|
405
|
+
const notificationDebug = debuglog("pi-subagents-notify");
|
|
406
|
+
type TraceIdentity = Pick<CompletionNotification, "id" | "runId" | "source">;
|
|
407
|
+
type NotificationReason = "disposed" | "missing_session" | "foreground_session_mismatch" | "not_owned"
|
|
408
|
+
| "intercom_delivered" | "deduped_ttl" | "deduped_pending" | "batch_deferred"
|
|
409
|
+
| "emit_foreground_session_mismatch" | "emit_not_owned" | "send_accepted" | "send_failed" | "dispose_pending";
|
|
410
|
+
|
|
411
|
+
// Slice before sanitizing: diagnostic work and each identity are bounded even for
|
|
412
|
+
// malformed result metadata. Never include task/output, paths, or error bodies.
|
|
413
|
+
function traceIdentity(result: TraceIdentity): TraceIdentity {
|
|
414
|
+
const identity = (value: unknown) => typeof value === "string"
|
|
415
|
+
? value.slice(0, 128).replace(/[^a-zA-Z0-9_.:-]/g, "_") : undefined;
|
|
416
|
+
return { id: identity(result.id), runId: identity(result.runId), source: result.source === "foreground" ? "foreground" : "async" };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function traceNotification(reason: NotificationReason, result?: TraceIdentity): void {
|
|
420
|
+
if (!notificationDebug.enabled) return;
|
|
421
|
+
try {
|
|
422
|
+
notificationDebug("%s", JSON.stringify({ reason, ...(result ? traceIdentity(result) : {}) }));
|
|
423
|
+
} catch {
|
|
424
|
+
// Diagnostics must never change delivery acknowledgement.
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
389
428
|
interface PendingCompletion {
|
|
429
|
+
trace?: TraceIdentity;
|
|
390
430
|
key: string;
|
|
391
431
|
details: SubagentNotifyDetails;
|
|
392
432
|
sessionId: string;
|
|
@@ -447,6 +487,10 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
|
|
|
447
487
|
? result.parallelHandoff as { path?: unknown }
|
|
448
488
|
: undefined;
|
|
449
489
|
const handoffPath = typeof parallelHandoff?.path === "string" ? parallelHandoff.path : undefined;
|
|
490
|
+
const receipt = result.workflowReceipt;
|
|
491
|
+
const receiptPath = receipt && typeof receipt === "object" && !Array.isArray(receipt)
|
|
492
|
+
? (receipt as Record<string, unknown>).path : undefined;
|
|
493
|
+
const workflowReceiptPath = typeof receiptPath === "string" && receiptPath ? receiptPath : undefined;
|
|
450
494
|
const rawRunId = typeof result.runId === "string" ? result.runId : typeof result.id === "string" ? result.id : undefined;
|
|
451
495
|
const workflowRunId = (result.mode === "workflow" || agent === "workflow") && rawRunId ? rawRunId : undefined;
|
|
452
496
|
const directChild = !workflowRunId && result.results?.length === 1 ? result.results[0]! : undefined;
|
|
@@ -455,10 +499,17 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
|
|
|
455
499
|
: undefined;
|
|
456
500
|
const directSummary = summary.trim();
|
|
457
501
|
const directAgent = typeof directChild?.agent === "string" ? directChild.agent : agent;
|
|
502
|
+
const directSummaryBody = directAgent && summary.trimStart().startsWith(`${directAgent}:\n`)
|
|
503
|
+
? summary.trimStart().slice(directAgent.length + 2)
|
|
504
|
+
: directSummary;
|
|
505
|
+
const directDegenerateSummary = directChild
|
|
506
|
+
&& isDegenerateOutput(typeof directChild.output === "string" ? directChild.output : "")
|
|
507
|
+
&& isDegenerateOutput(directSummaryBody)
|
|
508
|
+
&& structuredOutputText(directChild.structuredOutput) !== undefined;
|
|
458
509
|
const directNoOutputSummary = directChild && (!directSummary
|
|
459
510
|
|| directSummary === "(no output)"
|
|
460
511
|
|| (directAgent && directSummary === `${directAgent}:\n(no output)`));
|
|
461
|
-
const resultPreview = directStructuredPreview && directNoOutputSummary
|
|
512
|
+
const resultPreview = directStructuredPreview && (directNoOutputSummary || directDegenerateSummary)
|
|
462
513
|
? `Structured output:\n${directStructuredPreview}`
|
|
463
514
|
: summary;
|
|
464
515
|
const childRuns = result.results?.flatMap((child) => {
|
|
@@ -516,6 +567,7 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
|
|
|
516
567
|
agent,
|
|
517
568
|
status,
|
|
518
569
|
...(scheduleOrigin ? { scheduleOrigin } : {}),
|
|
570
|
+
...(workflowReceiptPath ? { workflowReceiptPath } : {}),
|
|
519
571
|
...(result.source ? { source: result.source } : {}),
|
|
520
572
|
...(taskInfo ? { taskInfo } : {}),
|
|
521
573
|
resultPreview,
|
|
@@ -547,8 +599,9 @@ export default function registerSubagentNotify(
|
|
|
547
599
|
&& typeof completionOwnerId === "string"
|
|
548
600
|
&& completionOwnerId === state.completionOwnerId);
|
|
549
601
|
|
|
550
|
-
const settle = (items: PendingCompletion[], accepted: boolean) => {
|
|
602
|
+
const settle = (items: PendingCompletion[], accepted: boolean, reason?: NotificationReason) => {
|
|
551
603
|
for (const item of items) {
|
|
604
|
+
if (reason) traceNotification(reason, item.trace);
|
|
552
605
|
pending.delete(item.key);
|
|
553
606
|
if (accepted) markSeenWithTtl(seen, item.key, now(), ttlMs);
|
|
554
607
|
item.resolve(accepted);
|
|
@@ -561,10 +614,12 @@ export default function registerSubagentNotify(
|
|
|
561
614
|
const owned = item.details.source === "foreground"
|
|
562
615
|
? item.sessionId === state.currentSessionId
|
|
563
616
|
: ownsResult(item.sessionId, item.completionOwnerId);
|
|
617
|
+
if (!owned) traceNotification(item.details.source === "foreground" ? "emit_foreground_session_mismatch" : "emit_not_owned", item.trace);
|
|
564
618
|
(owned ? accepted : rejected).push(item);
|
|
565
619
|
}
|
|
566
620
|
settle(rejected, false);
|
|
567
|
-
|
|
621
|
+
const sent = sendCompletion(pi, accepted);
|
|
622
|
+
settle(accepted, sent, sent ? "send_accepted" : "send_failed");
|
|
568
623
|
};
|
|
569
624
|
const getBatcher = (result: CompletionNotification) => {
|
|
570
625
|
const key = completionBatchKey(result);
|
|
@@ -582,17 +637,35 @@ export default function registerSubagentNotify(
|
|
|
582
637
|
};
|
|
583
638
|
|
|
584
639
|
const deliver = (result: CompletionNotification): Promise<boolean> => {
|
|
585
|
-
if (disposed || typeof result.sessionId !== "string")
|
|
640
|
+
if (disposed || typeof result.sessionId !== "string") {
|
|
641
|
+
traceNotification(disposed ? "disposed" : "missing_session", result);
|
|
642
|
+
return Promise.resolve(false);
|
|
643
|
+
}
|
|
586
644
|
if (result.source === "foreground") {
|
|
587
|
-
if (result.sessionId !== state.currentSessionId)
|
|
588
|
-
|
|
589
|
-
|
|
645
|
+
if (result.sessionId !== state.currentSessionId) {
|
|
646
|
+
traceNotification("foreground_session_mismatch", result);
|
|
647
|
+
return Promise.resolve(false);
|
|
648
|
+
}
|
|
649
|
+
} else if (!ownsResult(result.sessionId, result.completionOwnerId)) {
|
|
650
|
+
traceNotification("not_owned", result);
|
|
651
|
+
return Promise.resolve(false);
|
|
652
|
+
}
|
|
653
|
+
if (result.intercomDelivered === true) {
|
|
654
|
+
traceNotification("intercom_delivered", result);
|
|
655
|
+
return Promise.resolve(true);
|
|
656
|
+
}
|
|
590
657
|
const key = buildCompletionKey(result, "notify");
|
|
591
658
|
const seenAt = seen.get(key);
|
|
592
|
-
if (seenAt !== undefined && now() - seenAt <= ttlMs)
|
|
659
|
+
if (seenAt !== undefined && now() - seenAt <= ttlMs) {
|
|
660
|
+
traceNotification("deduped_ttl", result);
|
|
661
|
+
return Promise.resolve(true);
|
|
662
|
+
}
|
|
593
663
|
if (seenAt !== undefined) seen.delete(key);
|
|
594
664
|
const inFlight = pending.get(key);
|
|
595
|
-
if (inFlight)
|
|
665
|
+
if (inFlight) {
|
|
666
|
+
traceNotification("deduped_pending", result);
|
|
667
|
+
return inFlight;
|
|
668
|
+
}
|
|
596
669
|
const details = buildCompletionDetails(result);
|
|
597
670
|
let resolve!: (accepted: boolean) => void;
|
|
598
671
|
const completion = new Promise<boolean>((settleCompletion) => { resolve = settleCompletion; });
|
|
@@ -605,6 +678,7 @@ export default function registerSubagentNotify(
|
|
|
605
678
|
triggerTurn: result.triggerTurn !== false,
|
|
606
679
|
resolve,
|
|
607
680
|
};
|
|
681
|
+
if (notificationDebug.enabled) item.trace = traceIdentity(result);
|
|
608
682
|
if (details.source === "foreground") {
|
|
609
683
|
emit([item]);
|
|
610
684
|
return completion;
|
|
@@ -615,6 +689,7 @@ export default function registerSubagentNotify(
|
|
|
615
689
|
emit([item]);
|
|
616
690
|
return completion;
|
|
617
691
|
}
|
|
692
|
+
if (batchConfig.enabled) traceNotification("batch_deferred", item.trace);
|
|
618
693
|
batcher.push(item);
|
|
619
694
|
return completion;
|
|
620
695
|
};
|
|
@@ -628,10 +703,11 @@ export default function registerSubagentNotify(
|
|
|
628
703
|
|
|
629
704
|
return {
|
|
630
705
|
deliver,
|
|
706
|
+
hasPendingDelivery: () => pending.size > 0,
|
|
631
707
|
dispose() {
|
|
632
708
|
if (disposed) return;
|
|
633
709
|
disposed = true;
|
|
634
|
-
for (const batcher of batchers.values()) settle(batcher.dispose(), false);
|
|
710
|
+
for (const batcher of batchers.values()) settle(batcher.dispose(), false, "dispose_pending");
|
|
635
711
|
batchers.clear();
|
|
636
712
|
for (const unsubscribe of [unsubscribeAsync, unsubscribeForeground]) {
|
|
637
713
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ProcessTreeTerminal } from "../../shared/types.ts";
|
|
3
3
|
|
|
4
4
|
const DEFAULT_TERM_GRACE_MS = 3000;
|
|
5
5
|
const DEFAULT_KILL_VERIFY_MS = 1000;
|
|
@@ -52,25 +52,25 @@ async function waitUntilGroupTerminal(
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
function observed(processGroupId: number):
|
|
55
|
+
function observed(processGroupId: number): ProcessTreeTerminal {
|
|
56
56
|
return { state: "observed", mechanism: "posix-process-group", processGroupId, verifiedAt: Date.now() };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/** Owns one writer process group and arbitrates its cleanup exactly once. */
|
|
60
60
|
export interface OwnedProcessTreeController {
|
|
61
|
-
terminate(): Promise<
|
|
62
|
-
finishAfterWriterClose(): Promise<
|
|
61
|
+
terminate(): Promise<ProcessTreeTerminal>;
|
|
62
|
+
finishAfterWriterClose(): Promise<ProcessTreeTerminal>;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export function createOwnedProcessTreeController(
|
|
66
66
|
pid: number,
|
|
67
67
|
options: { termGraceMs?: number; killVerifyMs?: number } = {},
|
|
68
68
|
): OwnedProcessTreeController {
|
|
69
|
-
let termination: Promise<
|
|
69
|
+
let termination: Promise<ProcessTreeTerminal> | undefined;
|
|
70
70
|
const posixGroupOwned = process.platform !== "win32";
|
|
71
71
|
const target = posixGroupOwned ? -pid : pid;
|
|
72
72
|
|
|
73
|
-
const terminate = (): Promise<
|
|
73
|
+
const terminate = (): Promise<ProcessTreeTerminal> => {
|
|
74
74
|
if (termination) return termination;
|
|
75
75
|
termination = (async () => {
|
|
76
76
|
if (!posixGroupOwned) {
|