@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/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 files,
9
- * and the system prompt. `_buildRuntime` reads `resourceLoader.getExtensions()`
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
- if (parsedSource.ref === undefined) return repository;
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); provider registrations and
583
- * allowlisted extensions get a private dependency graph for every session.
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's user-only
606
- // settings isolated from the project-aware session settings built below.
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 not participate even though the normal resource
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
- hostDepsCache.set(key, deps);
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
- // Clear the in-flight marker whether it succeeded or threw; the cache holds
753
- // the result on success, and a failure leaves nothing for a retry to reuse.
754
- hostDepsInflight.delete(key);
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
- /** Test-only: clear the cache so a fresh (cwd, prompt) gets re-built. */
768
- export function _resetHostDepsCacheForTesting(): void {
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
+ }