pi-cohort 7.1.2 → 7.2.1
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 +12 -0
- package/README.md +6 -0
- package/package.json +3 -3
- package/src/execution-backend/child-host-controller.ts +10 -8
- package/src/extension/doctor.ts +6 -2
- package/src/extension/fanout-child.ts +2 -2
- package/src/extension/index.ts +10 -45
- package/src/runs/background/async-execution.ts +154 -87
- package/src/runs/background/async-job-tracker.ts +4 -1
- package/src/runs/background/async-resume.ts +93 -17
- package/src/runs/background/async-status.ts +12 -9
- package/src/runs/background/external-attempt.ts +6 -2
- package/src/runs/background/notify.ts +2 -13
- package/src/runs/background/run-id-resolver.ts +15 -18
- package/src/runs/background/run-status.ts +22 -11
- package/src/runs/background/stale-run-reconciler.ts +9 -1
- package/src/runs/background/subagent-runner.ts +54 -79
- package/src/runs/foreground/chain-execution.ts +7 -0
- package/src/runs/foreground/execution.ts +30 -13
- package/src/runs/foreground/external-single-attempt.ts +3 -2
- package/src/runs/foreground/subagent-executor.ts +142 -63
- package/src/runs/shared/external-execution.ts +2 -0
- package/src/runs/shared/jiti-cli.ts +17 -48
- package/src/runs/shared/nested-events.ts +103 -30
- package/src/runs/shared/pi-spawn.ts +75 -67
- package/src/shared/agent-dir.ts +12 -0
- package/src/shared/atomic-json.ts +12 -1
- package/src/shared/fork-context.ts +15 -3
- package/src/shared/types.ts +11 -1
- package/src/shared/utils.ts +1 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [7.2.1] - 2026-10-16
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- A second pi-cohort instance loaded into the same Node process no longer stops the first instance's result watcher or drops its event subscriptions. The extension factory and `registerSubagentNotify` used to call cleanup callbacks a previous instance stored on `globalThis` (`__piSubagentRuntimeCleanup`, `__piSubagentEventUnsubscribes`, `__pi_subagents_notify_unsubscribe__`), which also retired a live session in another SDK runtime; those hooks are gone and each instance now tears down only itself in its own `session_shutdown` handler, which pi emits on reload, new, resume, fork, and quit. (#20)
|
|
8
|
+
|
|
9
|
+
## [7.2.0] - 2026-10-11
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Children and detached coordinators launch with the originating parent's Node/Pi paths instead of `PATH`; revival keeps that runtime across parent installations. Run records and nested state use durable profile storage without automatic age expiry. Legacy records remain inspectable in their original paths, but revival requires a recorded original runtime and an available session file; missing installations require restoration or a new run. (#19)
|
|
14
|
+
|
|
3
15
|
## [7.1.2] - 2026-09-29
|
|
4
16
|
|
|
5
17
|
### Fixed
|
package/README.md
CHANGED
|
@@ -39,6 +39,8 @@ pi install npm:pi-cohort
|
|
|
39
39
|
|
|
40
40
|
That is the only required step.
|
|
41
41
|
|
|
42
|
+
Children and detached coordinators use the originating parent's resolved Node executable and Pi entry path directly, bypassing `PATH`. Revival keeps that original runtime even when requested from a different parent installation. Custom hosts whose entry cannot be identified as the installed Pi package's declared CLI are unsupported. These paths are not a version lock: replacing Node or Pi in place changes what runs. If the original installation is removed, restore it at the recorded paths or start a new run; there is no fallback to the current parent's installation. Legacy records without an original runtime identity remain inspectable but cannot be revived - start a new run.
|
|
43
|
+
|
|
42
44
|
## Execution backend extension API
|
|
43
45
|
|
|
44
46
|
External execution backends are currently unsupported on Windows, for both foreground and background runs. Native execution remains supported.
|
|
@@ -155,6 +157,10 @@ Full reference: agent/chain authoring in [doc/agents-and-chains.md](doc/agents-a
|
|
|
155
157
|
|
|
156
158
|
Most installs need zero configuration. Full env/JSON reference: [doc/configuration.md](doc/configuration.md).
|
|
157
159
|
|
|
160
|
+
Run history lives under the pinned profile's `pi-cohort/` root and has no automatic age expiry. Status listings can grow without bound as history accumulates. To remove history manually, delete only a terminal top-level run directory after confirming its entire subtree has stopped; never delete queued or running state, individual nested records, or shared route files. This removes that directory's status and resume lookup, not separately stored session files or shared routes. Keep those sessions if you need their context; deleting a run directory is not a complete session purge. Storage details: [doc/observability.md](doc/observability.md#files-logs-and-observability).
|
|
161
|
+
|
|
162
|
+
A queued start that never publishes running status stays unresolved: pi-cohort does not automatically recover or revive it. Start a new run instead.
|
|
163
|
+
|
|
158
164
|
Parent extension CLI flags (e.g. pi-lens's `--no-autofix`) are forwarded into spawned children by default, so subagents inherit the same tooling behavior you launched with; toggle with [`forwardParentFlags`](doc/configuration.md#forwardparentflags).
|
|
159
165
|
|
|
160
166
|
Optional companions:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cohort",
|
|
3
|
-
"version": "7.1
|
|
3
|
+
"version": "7.2.1",
|
|
4
4
|
"description": "Delegate Pi work to focused child agents: code review, scouting, implementation, parallel audits, saved chains, and background jobs.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"scripts": {
|
|
41
41
|
"check:agents-core": "node scripts/check-agents-core.mjs",
|
|
42
42
|
"test": "npm run test:unit",
|
|
43
|
-
"test:unit": "node --test test/unit/*.test.ts",
|
|
44
|
-
"test:integration": "node --import ./test/support/register-loader.mjs --test test/integration/*.test.ts",
|
|
43
|
+
"test:unit": "node --import ./test/support/isolate-test-env.mjs --test test/unit/*.test.ts",
|
|
44
|
+
"test:integration": "node --import ./test/support/isolate-test-env.mjs --import ./test/support/register-loader.mjs --test test/integration/*.test.ts",
|
|
45
45
|
"test:all": "npm run check:agents-core && npm run test:unit && npm run test:integration"
|
|
46
46
|
},
|
|
47
47
|
"pi": {
|
|
@@ -7,18 +7,20 @@ import { ChildHostFrameDecoder, encodeChildHostMessage, type ChildHostMessage }
|
|
|
7
7
|
import { PI_COHORT_CHILD_HOST_CONFIG } from "./child-host-runtime.ts";
|
|
8
8
|
import { ensureJitiCliPath } from "../runs/shared/jiti-cli.ts";
|
|
9
9
|
import type { ExecutionBackend, ExecutionBackendLease } from "./types.ts";
|
|
10
|
+
import { getPiSpawnCommand, PiRuntimeLaunchError, type PiRuntimeIdentity } from "../runs/shared/pi-spawn.ts";
|
|
10
11
|
|
|
11
12
|
export interface ChildHostAttempt { readonly attemptId: string; readonly command: string; readonly args: readonly string[]; readonly cwd: string; readonly environment: Readonly<Record<string, string>>; }
|
|
12
13
|
export interface ChildHostExit { readonly status: number | null; readonly signal: string | null; }
|
|
13
14
|
export interface ChildHostController { readonly lease: ExecutionBackendLease; readonly ready: Promise<void>; startAttempt(attempt: ChildHostAttempt): Promise<ChildHostExit>; shutdown(): Promise<void>; releaseObserver(): Promise<void>; }
|
|
14
|
-
export interface ChildHostControllerOptions { readonly backend: ExecutionBackend; readonly runId: string; readonly childId: string; readonly cwd: string; readonly title?: string; readonly signal: AbortSignal; }
|
|
15
|
-
export interface ChildHostControllerDependencies { readonly fs?: typeof fs; readonly ensureJiti?: () => string | undefined; readonly childHostCommand?: () => { command: string; args: readonly string[] }; readonly platform?: NodeJS.Platform; readonly createServer?: typeof net.createServer; }
|
|
15
|
+
export interface ChildHostControllerOptions { readonly runtime: PiRuntimeIdentity; readonly backend: ExecutionBackend; readonly runId: string; readonly childId: string; readonly cwd: string; readonly title?: string; readonly signal: AbortSignal; }
|
|
16
|
+
export interface ChildHostControllerDependencies { readonly fs?: typeof fs; readonly ensureJiti?: (root: string) => string | undefined; readonly childHostCommand?: () => { command: string; args: readonly string[] }; readonly platform?: NodeJS.Platform; readonly createServer?: typeof net.createServer; }
|
|
16
17
|
|
|
17
18
|
const HOST_ENVIRONMENT_KEYS = ["PATH", "HOME", "TMPDIR", "SystemRoot"] as const;
|
|
18
|
-
function childHostCommand(ensureJiti: () => string | undefined): { command: string; args: readonly string[] } {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
function childHostCommand(runtime: PiRuntimeIdentity, ensureJiti: (root: string) => string | undefined): { command: string; args: readonly string[] } {
|
|
20
|
+
getPiSpawnCommand([], runtime);
|
|
21
|
+
const jiti = ensureJiti(runtime.piPackageRoot);
|
|
22
|
+
if (!jiti) throw new PiRuntimeLaunchError(`jiti for selected Pi installation ${runtime.piPackageRoot} could not be found`);
|
|
23
|
+
return { command: runtime.nodeExecPath, args: [jiti, path.resolve(path.dirname(fileURLToPath(import.meta.url)), "child-host-runtime.ts")] };
|
|
22
24
|
}
|
|
23
25
|
function disconnected(phase: string): Error { return new Error(`child host disconnected ${phase}`); }
|
|
24
26
|
function aborted(): Error { return new Error("child host launch aborted"); }
|
|
@@ -90,7 +92,7 @@ export async function createChildHostController(options: ChildHostControllerOpti
|
|
|
90
92
|
}
|
|
91
93
|
if (message.kind === "attempt_started" && active?.attemptId === message.attemptId && !active.started) { active.started = true; continue; }
|
|
92
94
|
if (message.kind === "attempt_spawn_error" && active?.attemptId === message.attemptId) {
|
|
93
|
-
const pending = active; active = undefined; pending.reject(new
|
|
95
|
+
const pending = active; active = undefined; pending.reject(new PiRuntimeLaunchError(`child host attempt spawn error for ${options.runtime.nodeExecPath} ${options.runtime.piEntryPath}`)); continue;
|
|
94
96
|
}
|
|
95
97
|
if (message.kind === "attempt_exited" && active?.attemptId === message.attemptId && active.started) {
|
|
96
98
|
const pending = active; active = undefined; pending.resolve({ status: message.status, signal: message.signal }); continue;
|
|
@@ -115,9 +117,9 @@ export async function createChildHostController(options: ChildHostControllerOpti
|
|
|
115
117
|
await new Promise<void>((resolve, reject) => server!.listen(socketPath!, () => resolve()).once("error", reject));
|
|
116
118
|
fileSystem.chmodSync(socketPath, 0o600);
|
|
117
119
|
if (options.signal.aborted) throw aborted();
|
|
118
|
-
const host = dependencies.childHostCommand?.() ?? childHostCommand(dependencies.ensureJiti ?? ensureJitiCliPath);
|
|
119
120
|
const environment: Record<string, string> = { [PI_COHORT_CHILD_HOST_CONFIG]: configPath };
|
|
120
121
|
for (const key of HOST_ENVIRONMENT_KEYS) if (process.env[key]) environment[key] = process.env[key]!;
|
|
122
|
+
const host = dependencies.childHostCommand?.() ?? childHostCommand(options.runtime, dependencies.ensureJiti ?? ensureJitiCliPath);
|
|
121
123
|
lease = await options.backend.launch({
|
|
122
124
|
command: host.command,
|
|
123
125
|
args: host.args,
|
package/src/extension/doctor.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { discoverAgentsAll, type AgentSource } from "../agents/agents.ts";
|
|
4
|
+
import type { PiRuntimeIdentity } from "../runs/shared/pi-spawn.ts";
|
|
4
5
|
import { isAsyncAvailable } from "../runs/background/async-execution.ts";
|
|
5
6
|
import { discoverAvailableSkills, type SkillSource } from "../agents/skills.ts";
|
|
6
7
|
import {
|
|
@@ -20,12 +21,14 @@ interface DoctorPaths {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
interface DoctorDeps {
|
|
23
|
-
isAsyncAvailable: () => boolean;
|
|
24
|
+
isAsyncAvailable: (runtime: PiRuntimeIdentity) => boolean;
|
|
24
25
|
discoverAgentsAll: typeof discoverAgentsAll;
|
|
25
26
|
discoverAvailableSkills: typeof discoverAvailableSkills;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
interface DoctorReportInput {
|
|
30
|
+
runtime?: PiRuntimeIdentity;
|
|
31
|
+
runtimeError?: string;
|
|
29
32
|
cwd: string;
|
|
30
33
|
config: ExtensionConfig;
|
|
31
34
|
state: SubagentState;
|
|
@@ -189,7 +192,8 @@ export function buildDoctorReport(input: DoctorReportInput): string {
|
|
|
189
192
|
"",
|
|
190
193
|
"Runtime",
|
|
191
194
|
`- cwd: ${input.cwd}`,
|
|
192
|
-
|
|
195
|
+
...(input.runtimeError ? [`- runtime capture: failed - ${input.runtimeError}`] : input.runtime ? [] : ["- runtime capture: not available"]),
|
|
196
|
+
lineFromCheck("async support", () => `- async support: ${input.runtime && deps.isAsyncAvailable(input.runtime) ? "available" : "unavailable"}`),
|
|
193
197
|
...formatSessionLines(input),
|
|
194
198
|
"",
|
|
195
199
|
"Filesystem",
|
|
@@ -10,7 +10,7 @@ import { readNestedControlRequests, resolveNestedRouteFromEnv, writeNestedContro
|
|
|
10
10
|
import { SubagentParams } from "./schemas.ts";
|
|
11
11
|
import { loadConfig } from "./config.ts";
|
|
12
12
|
import { deriveForwardedFlags } from "../runs/shared/forward-flags.ts";
|
|
13
|
-
import type
|
|
13
|
+
import { RUN_STATE_ROOT_DIR, type Details, type SubagentState } from "../shared/types.ts";
|
|
14
14
|
import { emptyGrandTotal } from "./grand-total.ts";
|
|
15
15
|
|
|
16
16
|
function getSubagentSessionRoot(parentSessionFile: string | null): string {
|
|
@@ -19,7 +19,7 @@ function getSubagentSessionRoot(parentSessionFile: string | null): string {
|
|
|
19
19
|
const sessionsDir = path.dirname(parentSessionFile);
|
|
20
20
|
return path.join(sessionsDir, baseName);
|
|
21
21
|
}
|
|
22
|
-
return
|
|
22
|
+
return path.join(RUN_STATE_ROOT_DIR, "sessions");
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
function expandTilde(p: string): string {
|
package/src/extension/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
type Details,
|
|
44
44
|
type SubagentState,
|
|
45
45
|
ASYNC_DIR,
|
|
46
|
+
RUN_STATE_ROOT_DIR,
|
|
46
47
|
DEFAULT_ARTIFACT_CONFIG,
|
|
47
48
|
GRAND_TOTAL_STATUS_KEY,
|
|
48
49
|
RESULTS_DIR,
|
|
@@ -68,7 +69,7 @@ export { loadConfig } from "./config.ts";
|
|
|
68
69
|
* If parent session is ~/.pi/agent/sessions/abc123.jsonl,
|
|
69
70
|
* returns ~/.pi/agent/sessions/abc123/ as the base.
|
|
70
71
|
* Callers add runId to create the actual session root: abc123/{runId}/
|
|
71
|
-
*
|
|
72
|
+
* Without a parent, uses the durable sessions base; callers append the run UUID.
|
|
72
73
|
*/
|
|
73
74
|
function getSubagentSessionRoot(parentSessionFile: string | null): string {
|
|
74
75
|
if (parentSessionFile) {
|
|
@@ -76,19 +77,19 @@ function getSubagentSessionRoot(parentSessionFile: string | null): string {
|
|
|
76
77
|
const sessionsDir = path.dirname(parentSessionFile);
|
|
77
78
|
return path.join(sessionsDir, baseName);
|
|
78
79
|
}
|
|
79
|
-
return
|
|
80
|
+
return path.join(RUN_STATE_ROOT_DIR, "sessions");
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
function expandTilde(p: string): string {
|
|
83
84
|
return p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
function ensureAccessibleDir(dirPath: string): void {
|
|
87
|
+
function ensureAccessibleDir(dirPath: string, durable = false): void {
|
|
88
88
|
mkdirWithEpermRetry(dirPath);
|
|
89
89
|
try {
|
|
90
90
|
fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
|
|
91
|
-
} catch {
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (durable) throw error;
|
|
92
93
|
try {
|
|
93
94
|
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
94
95
|
} catch {
|
|
@@ -245,18 +246,10 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
|
245
246
|
return;
|
|
246
247
|
}
|
|
247
248
|
const globalStore = globalThis as Record<string, unknown>;
|
|
248
|
-
const runtimeCleanupStoreKey = "__piSubagentRuntimeCleanup";
|
|
249
|
-
const previousRuntimeCleanup = globalStore[runtimeCleanupStoreKey];
|
|
250
|
-
if (typeof previousRuntimeCleanup === "function") {
|
|
251
|
-
try {
|
|
252
|
-
previousRuntimeCleanup();
|
|
253
|
-
} catch {
|
|
254
|
-
// Best effort cleanup for stale timers from an older reload.
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
249
|
|
|
250
|
+
ensureAccessibleDir(RUN_STATE_ROOT_DIR, true);
|
|
258
251
|
ensureAccessibleDir(RESULTS_DIR);
|
|
259
|
-
ensureAccessibleDir(ASYNC_DIR);
|
|
252
|
+
ensureAccessibleDir(ASYNC_DIR, true);
|
|
260
253
|
cleanupOldChainDirs();
|
|
261
254
|
|
|
262
255
|
const config = loadConfig();
|
|
@@ -294,16 +287,6 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
|
294
287
|
startResultWatcher();
|
|
295
288
|
primeExistingResults();
|
|
296
289
|
|
|
297
|
-
const runtimeCleanup = () => {
|
|
298
|
-
stopResultWatcher();
|
|
299
|
-
clearPendingForegroundControlNotices(state);
|
|
300
|
-
if (state.poller) {
|
|
301
|
-
clearInterval(state.poller);
|
|
302
|
-
state.poller = null;
|
|
303
|
-
}
|
|
304
|
-
};
|
|
305
|
-
globalStore[runtimeCleanupStoreKey] = runtimeCleanup;
|
|
306
|
-
|
|
307
290
|
const { ensurePoller, handleStarted, handleComplete, resetJobs } = createAsyncJobTracker(pi, state, ASYNC_DIR);
|
|
308
291
|
const executor = createSubagentExecutor({
|
|
309
292
|
pi,
|
|
@@ -489,20 +472,8 @@ DIAGNOSTICS:
|
|
|
489
472
|
pi.registerTool(tool);
|
|
490
473
|
registerSlashCommands(pi, state);
|
|
491
474
|
|
|
492
|
-
const eventUnsubscribeStoreKey = "__piSubagentEventUnsubscribes";
|
|
493
475
|
const controlNoticeSeenStoreKey = "__piSubagentVisibleControlNotices";
|
|
494
|
-
const
|
|
495
|
-
if (Array.isArray(previousEventUnsubscribes)) {
|
|
496
|
-
for (const unsubscribe of previousEventUnsubscribes) {
|
|
497
|
-
if (typeof unsubscribe !== "function") continue;
|
|
498
|
-
try {
|
|
499
|
-
unsubscribe();
|
|
500
|
-
} catch {
|
|
501
|
-
// Best effort cleanup for stale handlers from an older reload.
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
registerSubagentNotify(pi);
|
|
476
|
+
const unsubscribeNotify = registerSubagentNotify(pi);
|
|
506
477
|
|
|
507
478
|
const existingVisibleControlNotices = globalStore[controlNoticeSeenStoreKey];
|
|
508
479
|
const visibleControlNotices = existingVisibleControlNotices instanceof Set ? existingVisibleControlNotices as Set<string> : new Set<string>();
|
|
@@ -516,6 +487,7 @@ DIAGNOSTICS:
|
|
|
516
487
|
});
|
|
517
488
|
};
|
|
518
489
|
const eventUnsubscribes = [
|
|
490
|
+
unsubscribeNotify,
|
|
519
491
|
pi.events.on(SUBAGENT_ASYNC_STARTED_EVENT, handleStarted),
|
|
520
492
|
pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, handleComplete),
|
|
521
493
|
pi.events.on(SUBAGENT_CONTROL_EVENT, controlEventHandler),
|
|
@@ -525,7 +497,6 @@ DIAGNOSTICS:
|
|
|
525
497
|
}
|
|
526
498
|
}),
|
|
527
499
|
];
|
|
528
|
-
globalStore[eventUnsubscribeStoreKey] = eventUnsubscribes;
|
|
529
500
|
|
|
530
501
|
pi.on("message_end", (event, ctx) => {
|
|
531
502
|
if (event.message.role !== "assistant") return;
|
|
@@ -589,9 +560,6 @@ DIAGNOSTICS:
|
|
|
589
560
|
// Best effort cleanup during shutdown.
|
|
590
561
|
}
|
|
591
562
|
}
|
|
592
|
-
if (globalStore[eventUnsubscribeStoreKey] === eventUnsubscribes) {
|
|
593
|
-
delete globalStore[eventUnsubscribeStoreKey];
|
|
594
|
-
}
|
|
595
563
|
stopResultWatcher();
|
|
596
564
|
if (state.poller) clearInterval(state.poller);
|
|
597
565
|
state.poller = null;
|
|
@@ -606,9 +574,6 @@ DIAGNOSTICS:
|
|
|
606
574
|
slashBridge.dispose();
|
|
607
575
|
promptTemplateBridge.cancelAll();
|
|
608
576
|
promptTemplateBridge.dispose();
|
|
609
|
-
if (globalStore[runtimeCleanupStoreKey] === runtimeCleanup) {
|
|
610
|
-
delete globalStore[runtimeCleanupStoreKey];
|
|
611
|
-
}
|
|
612
577
|
try {
|
|
613
578
|
if (state.lastUiContext?.hasUI) {
|
|
614
579
|
state.lastUiContext.ui.setWidget(WIDGET_KEY, undefined);
|