@bermudi/pi-delegate 0.1.0 → 0.1.2
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/README.md +55 -10
- package/agents.ts +176 -19
- package/concurrency.ts +70 -7
- package/constants.ts +3 -0
- package/delegate.ts +26 -1
- package/dispatch.ts +65 -9
- package/extension.ts +86 -6
- package/file-tracking.ts +27 -5
- package/format.ts +46 -7
- package/host-compat.ts +47 -12
- package/host.ts +93 -16
- package/leaf.ts +48 -0
- package/lifecycle.ts +292 -95
- package/manual.ts +45 -11
- package/model.ts +3 -4
- package/package.json +25 -20
- package/patches/@marcfargas%2Fpi-test-harness@0.6.1.patch +13 -0
- package/pool.ts +169 -51
- package/render-branches.ts +52 -16
- package/render-result.ts +12 -0
- package/runner.ts +255 -51
- package/schema.ts +252 -63
- package/status.ts +269 -0
- package/task-resolution.ts +196 -77
- package/tickets.ts +173 -62
- package/tools.ts +16 -15
- package/types.ts +62 -13
package/host.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
*
|
|
6
6
|
* `DefaultResourceLoader.reload()` is the one expensive step (~1.2s cold — it
|
|
7
7
|
* scans for skills, prompts, agents.md files, system prompts). It is a
|
|
8
|
-
* read-only cache for the parts we care about: skills, AGENTS.md/context
|
|
9
|
-
* and the system prompt.
|
|
8
|
+
* read-only cache for the parts we care about: skills, project AGENTS.md/context
|
|
9
|
+
* files, and the system prompt. Global context is filtered at this seam so a
|
|
10
|
+
* child cannot inherit the parent's user-global instructions. `_buildRuntime`
|
|
11
|
+
* reads `resourceLoader.getExtensions()`
|
|
10
12
|
* and the prompt/skill getters.
|
|
11
13
|
*
|
|
12
14
|
* **Extensions are disabled for subagents by default** (`noExtensions: true`).
|
|
@@ -78,7 +80,7 @@ export interface HostDepsOptions {
|
|
|
78
80
|
* Custom system prompt for a named agent. When set, it overrides the default
|
|
79
81
|
* system prompt the resource loader would otherwise discover. Extension-free
|
|
80
82
|
* host deps are cached per (agentDir + cwd + systemPrompt): the expensive
|
|
81
|
-
* `reload()` (skills, AGENTS.md discovery) runs once per distinct combo, then
|
|
83
|
+
* `reload()` (skills, project AGENTS.md discovery) runs once per distinct combo, then
|
|
82
84
|
* is reused across concurrent subagents. Provider-configured or
|
|
83
85
|
* allowlisted-extension tasks always receive fresh host deps. For ad-hoc
|
|
84
86
|
* tasks (no named agent) pass undefined to use the discovered prompt.
|
|
@@ -89,6 +91,8 @@ export interface HostDepsOptions {
|
|
|
89
91
|
const hostDepsCache = new Map<string, HostDeps>();
|
|
90
92
|
/** In-flight builds, so concurrent calls for the same key share one reload(). */
|
|
91
93
|
const hostDepsInflight = new Map<string, Promise<HostDeps>>();
|
|
94
|
+
/** Prevent a pre-invalidation build from repopulating or clearing newer state. */
|
|
95
|
+
let hostDepsCacheGeneration = 0;
|
|
92
96
|
|
|
93
97
|
function canonicalPath(candidate: string): string {
|
|
94
98
|
try {
|
|
@@ -113,6 +117,33 @@ function isPathWithinDirectory(directory: string, candidate: string): boolean {
|
|
|
113
117
|
);
|
|
114
118
|
}
|
|
115
119
|
|
|
120
|
+
const CONTEXT_FILE_NAMES = new Set([
|
|
121
|
+
"agents.override.md",
|
|
122
|
+
"agents.md",
|
|
123
|
+
"claude.override.md",
|
|
124
|
+
"claude.md",
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Delegate workers deliberately do not inherit user-global context files.
|
|
129
|
+
* Pi's standard global file lives under `agentDir`; `.agents/AGENTS.md` is a
|
|
130
|
+
* legacy convention used by other coding-agent harnesses. Compare lexical
|
|
131
|
+
* paths here rather than canonical paths: a user's global file is commonly a
|
|
132
|
+
* symlink, and the ResourceLoader reports the path it discovered, not its
|
|
133
|
+
* symlink target.
|
|
134
|
+
*/
|
|
135
|
+
function isExcludedGlobalContextFile(
|
|
136
|
+
filePath: string,
|
|
137
|
+
agentDir: string,
|
|
138
|
+
): boolean {
|
|
139
|
+
const resolvedFilePath = resolve(filePath);
|
|
140
|
+
const roots = [resolve(agentDir), resolve(homedir(), ".agents")];
|
|
141
|
+
return roots.some((root) => {
|
|
142
|
+
const relativePath = relative(root, resolvedFilePath);
|
|
143
|
+
return CONTEXT_FILE_NAMES.has(relativePath.toLowerCase());
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
116
147
|
/**
|
|
117
148
|
* Whether a managed package's canonical target remains in a user install root.
|
|
118
149
|
*
|
|
@@ -301,6 +332,7 @@ function getConfiguredGitSource(
|
|
|
301
332
|
host?: unknown;
|
|
302
333
|
path?: unknown;
|
|
303
334
|
ref?: unknown;
|
|
335
|
+
pinned?: unknown;
|
|
304
336
|
};
|
|
305
337
|
if (parsedSource.type !== "git") {
|
|
306
338
|
throw new Error(
|
|
@@ -324,7 +356,22 @@ function getConfiguredGitSource(
|
|
|
324
356
|
"A configured provider extension Git identity could not be verified; delegation stopped.",
|
|
325
357
|
);
|
|
326
358
|
}
|
|
327
|
-
|
|
359
|
+
// `ref` and `pinned` are a security contract from Pi's private parser. If a
|
|
360
|
+
// host upgrade drops either field, never reinterpret a pinned source as an
|
|
361
|
+
// unpinned checkout and silently skip commit validation.
|
|
362
|
+
if (typeof parsedSource.pinned !== "boolean") {
|
|
363
|
+
throw new Error(
|
|
364
|
+
"A configured provider extension Git pin state could not be verified; delegation stopped.",
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (!parsedSource.pinned) {
|
|
368
|
+
if (parsedSource.ref !== undefined) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
"A configured provider extension Git ref could not be verified; delegation stopped.",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return repository;
|
|
374
|
+
}
|
|
328
375
|
if (typeof parsedSource.ref !== "string" || parsedSource.ref.length === 0) {
|
|
329
376
|
throw new Error(
|
|
330
377
|
"A configured provider extension Git ref could not be verified; delegation stopped.",
|
|
@@ -579,8 +626,10 @@ let testModelRuntimeFactory: (() => Promise<ModelRuntime>) | undefined;
|
|
|
579
626
|
|
|
580
627
|
/**
|
|
581
628
|
* Lazily build the host deps for a task. Extension-free, provider-independent
|
|
582
|
-
* deps are cached by (agentDir, cwd, systemPrompt)
|
|
583
|
-
*
|
|
629
|
+
* deps are cached by (agentDir, cwd, systemPrompt) within one delegate dispatch;
|
|
630
|
+
* the extension invalidates the generation before the next dispatch so file
|
|
631
|
+
* edits become visible. Provider registrations and allowlisted extensions get
|
|
632
|
+
* a private dependency graph for every session.
|
|
584
633
|
*
|
|
585
634
|
* The first cached call pays the `resourceLoader.reload()` cost (~1.2s). An
|
|
586
635
|
* extension-bearing call intentionally pays that cost again: sharing its
|
|
@@ -602,14 +651,13 @@ export async function getHostDeps(options: HostDepsOptions): Promise<HostDeps> {
|
|
|
602
651
|
);
|
|
603
652
|
|
|
604
653
|
// Resolve provider extensions before deciding whether to use the cache. This
|
|
605
|
-
// fails closed for missing sources while keeping package lookup
|
|
606
|
-
//
|
|
654
|
+
// fails closed for missing sources while keeping both package lookup and child
|
|
655
|
+
// resource loading isolated from executable project settings.
|
|
607
656
|
let additionalExtensionPaths: string[] = [];
|
|
608
657
|
if (requestedExtensions.length > 0) {
|
|
609
658
|
// Package lookup is a user-scope trust boundary. Pi's legacy npm fallback
|
|
610
659
|
// may execute the configured npmCommand to discover the global npm root,
|
|
611
|
-
// so project settings must
|
|
612
|
-
// loader below remains project-aware.
|
|
660
|
+
// so project settings must never participate.
|
|
613
661
|
const packageLookupSettingsManager = SettingsManager.create(
|
|
614
662
|
options.cwd,
|
|
615
663
|
agentDir,
|
|
@@ -630,6 +678,7 @@ export async function getHostDeps(options: HostDepsOptions): Promise<HostDeps> {
|
|
|
630
678
|
// extension runtime is mutable and session-owned.
|
|
631
679
|
const cacheable =
|
|
632
680
|
providerConfigs.length === 0 && additionalExtensionPaths.length === 0;
|
|
681
|
+
const cacheGeneration = hostDepsCacheGeneration;
|
|
633
682
|
const key = JSON.stringify({
|
|
634
683
|
agentDir,
|
|
635
684
|
cwd: options.cwd,
|
|
@@ -677,6 +726,7 @@ export async function getHostDeps(options: HostDepsOptions): Promise<HostDeps> {
|
|
|
677
726
|
const resolvedSettingsManager = SettingsManager.create(
|
|
678
727
|
options.cwd,
|
|
679
728
|
agentDir,
|
|
729
|
+
{ projectTrusted: false },
|
|
680
730
|
);
|
|
681
731
|
if (testRetryBaseMs !== undefined) {
|
|
682
732
|
installFastRetry(resolvedSettingsManager, testRetryBaseMs);
|
|
@@ -689,6 +739,16 @@ export async function getHostDeps(options: HostDepsOptions): Promise<HostDeps> {
|
|
|
689
739
|
// interactive extension inventory. The only paths supplied here are the
|
|
690
740
|
// explicitly allowlisted, user-scoped provider extensions.
|
|
691
741
|
noExtensions: true,
|
|
742
|
+
// Global AGENTS.md files describe the parent harness, not the delegated
|
|
743
|
+
// task. Keep cwd/ancestor project context discovery, but remove Pi's
|
|
744
|
+
// global file and the legacy ~/.agents equivalent. This override also
|
|
745
|
+
// handles symlinked global files because it compares discovered paths.
|
|
746
|
+
agentsFilesOverride: ({ agentsFiles }) => ({
|
|
747
|
+
agentsFiles: agentsFiles.filter(
|
|
748
|
+
({ path: contextPath }) =>
|
|
749
|
+
!isExcludedGlobalContextFile(contextPath, agentDir),
|
|
750
|
+
),
|
|
751
|
+
}),
|
|
692
752
|
...(additionalExtensionPaths.length ? { additionalExtensionPaths } : {}),
|
|
693
753
|
// When a named agent supplies a custom prompt, it becomes the loader's
|
|
694
754
|
// customPrompt — overriding the default system prompt AgentSession would
|
|
@@ -742,16 +802,20 @@ export async function getHostDeps(options: HostDepsOptions): Promise<HostDeps> {
|
|
|
742
802
|
if (!cacheable) return build();
|
|
743
803
|
|
|
744
804
|
const promise = build().then((deps) => {
|
|
745
|
-
|
|
805
|
+
if (hostDepsCacheGeneration === cacheGeneration) {
|
|
806
|
+
hostDepsCache.set(key, deps);
|
|
807
|
+
}
|
|
746
808
|
return deps;
|
|
747
809
|
});
|
|
748
810
|
hostDepsInflight.set(key, promise);
|
|
749
811
|
try {
|
|
750
812
|
return await promise;
|
|
751
813
|
} finally {
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
hostDepsInflight.
|
|
814
|
+
// An invalidation can let a newer generation install its own in-flight
|
|
815
|
+
// build for the same key. Never let the older promise delete that marker.
|
|
816
|
+
if (hostDepsInflight.get(key) === promise) {
|
|
817
|
+
hostDepsInflight.delete(key);
|
|
818
|
+
}
|
|
755
819
|
}
|
|
756
820
|
}
|
|
757
821
|
|
|
@@ -764,12 +828,25 @@ function installFastRetry(sm: SettingsManager, baseDelayMs: number): void {
|
|
|
764
828
|
})) as never;
|
|
765
829
|
}
|
|
766
830
|
|
|
767
|
-
/**
|
|
768
|
-
|
|
831
|
+
/**
|
|
832
|
+
* Invalidate cached host dependencies before a new delegate dispatch.
|
|
833
|
+
*
|
|
834
|
+
* A dispatch may still share one expensive resource reload across its parallel
|
|
835
|
+
* tasks, but the next dispatch observes auth, model, settings, and context-file
|
|
836
|
+
* edits made while Pi remains open. Existing sessions retain their already-built
|
|
837
|
+
* dependencies; clearing the maps never mutates live AgentSessions.
|
|
838
|
+
*/
|
|
839
|
+
export function invalidateHostDepsCache(): void {
|
|
840
|
+
hostDepsCacheGeneration++;
|
|
769
841
|
hostDepsCache.clear();
|
|
770
842
|
hostDepsInflight.clear();
|
|
771
843
|
}
|
|
772
844
|
|
|
845
|
+
/** Test-only alias retained for existing test setup. */
|
|
846
|
+
export function _resetHostDepsCacheForTesting(): void {
|
|
847
|
+
invalidateHostDepsCache();
|
|
848
|
+
}
|
|
849
|
+
|
|
773
850
|
/**
|
|
774
851
|
* Test-only: substitute the ModelRuntime factory. Pass a factory returning a
|
|
775
852
|
* pre-authenticated runtime (e.g. the parent session's `modelRuntime`) so
|
package/leaf.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-tree leaf affinity for async tickets.
|
|
3
|
+
*
|
|
4
|
+
* An async ticket outlives the turn that spawned it. `/tree` navigation moves
|
|
5
|
+
* the session to a different leaf **within the same session file**: no
|
|
6
|
+
* `session_shutdown` fires, the extension runtime stays live, and the ticket
|
|
7
|
+
* keeps running. When it finishes, `deliverTicketResults` wakes the agent at
|
|
8
|
+
* whatever leaf is active *now* — which may be a branch that knows nothing
|
|
9
|
+
* about the task. See GitHub issue #30.
|
|
10
|
+
*
|
|
11
|
+
* pi exposes no "what leaf am I on?" query, so the current leaf has to be
|
|
12
|
+
* tracked from the `session_tree` event (`newLeafId`). That event also fires
|
|
13
|
+
* for extension-driven `ctx.navigateTree`, so this tracking covers navigation
|
|
14
|
+
* that never passed the `session_before_tree` confirm guard.
|
|
15
|
+
*
|
|
16
|
+
* State is runtime-scoped: `resetLeafTracking()` on session shutdown, since a
|
|
17
|
+
* replacement session starts on its own (unknown) leaf.
|
|
18
|
+
*/
|
|
19
|
+
import type { AsyncTicket } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
/** Leaf the session is currently on. `undefined` means no navigation has been
|
|
22
|
+
* observed by this runtime — i.e. the leaf the session opened on. */
|
|
23
|
+
let currentLeafId: string | null | undefined;
|
|
24
|
+
|
|
25
|
+
/** Record a completed `/tree` navigation. */
|
|
26
|
+
export function recordTreeNavigation(newLeafId: string | null): void {
|
|
27
|
+
currentLeafId = newLeafId;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Leaf id to stamp on a ticket at spawn time. */
|
|
31
|
+
export function getCurrentLeafId(): string | null | undefined {
|
|
32
|
+
return currentLeafId;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resetLeafTracking(): void {
|
|
36
|
+
currentLeafId = undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True when the session has navigated away from the leaf that spawned this
|
|
40
|
+
* ticket, so delivering its result would wake the agent on a foreign branch.
|
|
41
|
+
*
|
|
42
|
+
* Navigating away and back to the spawn leaf yields a fresh leaf id and is
|
|
43
|
+
* therefore reported as cross-leaf. That false positive is deliberate: the
|
|
44
|
+
* cross-leaf path only downgrades delivery to non-waking, and reconstructing
|
|
45
|
+
* true leaf identity across a round trip is not worth the complexity. */
|
|
46
|
+
export function isCrossLeafTicket(ticket: AsyncTicket): boolean {
|
|
47
|
+
return ticket.spawnLeafId !== currentLeafId;
|
|
48
|
+
}
|