@bermudi/pi-delegate 0.1.0 → 0.1.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/README.md CHANGED
@@ -11,13 +11,32 @@ as a standalone repo (full history preserved).
11
11
 
12
12
  ## Install
13
13
 
14
- Symlink the **bundled** output into Pi's global extensions dir:
14
+ Install a reviewed Git commit or release tag as a Pi package:
15
15
 
16
16
  ```bash
17
- ln -s "$PWD/delegate.bundle.ts" ~/.pi/agent/extensions/delegate.ts
17
+ # Replace this with a reviewed commit or release tag; do not use a moving branch.
18
+ pi install git:github.com/bermudi/pi-delegate@<reviewed-commit-or-tag>
18
19
  ```
19
20
 
20
- Then `/reload` in Pi.
21
+ Remove any old `delegate.ts` symlink from `~/.pi/agent/extensions/` before
22
+ starting Pi. Pi loads `delegate.ts` from the isolated Git package checkout; do
23
+ not point a running Pi at this repository or at `.build/delegate.bundle.ts`.
24
+ Start a fresh Pi process after updating the installed ref.
25
+
26
+ ## Usage
27
+
28
+ Use the built-in `default` profile to run a subagent with the live parent's
29
+ model, thinking level, delegatable native tools, and base system prompt:
30
+
31
+ ```ts
32
+ delegate({
33
+ tasks: [{ agent: "default", prompt: "Investigate the auth module" }],
34
+ });
35
+ ```
36
+
37
+ Parent extension/MCP tools are not copied, and project instructions are rebuilt
38
+ for the task's `cwd`. Omit `agent` when you want an ad-hoc task using delegate's
39
+ normal inline defaults instead.
21
40
 
22
41
  ### Token accounting
23
42
 
@@ -30,6 +49,25 @@ which has no usage slot. The per-call aggregate (`Nk tokens`) is still shown in
30
49
  the delegate header for both modes. Use sync delegation when totals must roll
31
50
  into the session.
32
51
 
52
+ ### Background-work visibility
53
+
54
+ Async tickets keep running after the parent's turn settles, and pi renders an
55
+ idle session — so delegate adds three signals:
56
+
57
+ - **Footer status** — while any ticket is active, the footer shows
58
+ `⏳ 2 subagents · t5042v19`, updated live as subagents start and finish.
59
+ - **Settle warning** — the first time a turn settles with a ticket still
60
+ active, a warning notification names the ticket and reminds you that
61
+ quitting aborts it. Once per ticket; the footer carries it from there.
62
+ - **Switch/fork guard** — `/new`, `/resume`, and forking ask for confirmation
63
+ before killing live subagents (pi lets extensions cancel those paths).
64
+
65
+ Quitting (Ctrl+C×2 / Ctrl+D / `/quit`) and `/reload` **cannot be intercepted**
66
+ by an extension — pi's `session_shutdown` is advisory. The footer status is
67
+ the mitigation there; on quit, delegate also prints a trace line to the
68
+ terminal naming the aborted tickets and agents, and on `/reload` it shows a
69
+ warning notification.
70
+
33
71
  ### Stall detection and cancellation
34
72
 
35
73
  `stallTimeoutMs` is an inactivity watchdog, not a hard execution deadline. When
@@ -46,13 +84,15 @@ task result. Set `stallTimeoutMs` to `0` to disable the watchdog.
46
84
 
47
85
  ```bash
48
86
  bun install
49
- bun run build # regenerate delegate.bundle.ts
50
87
  bun run typecheck
51
88
  bun test
89
+ bun run build # optional disposable bundle smoke test
52
90
  ```
53
91
 
54
- The unbundled entry is `delegate.ts`; `extension.ts` holds the tool implementation.
55
- `delegate.bundle.ts` is generated by `bun run build` — do not edit by hand.
92
+ The package entry point is `delegate.ts`; `extension.ts` holds the tool
93
+ implementation. `.build/delegate.bundle.ts` is generated by `bun run build` and
94
+ is only a verification artifact. Never symlink it into a running Pi or build
95
+ over an installed extension.
56
96
 
57
97
  ## Glossary
58
98
 
@@ -61,6 +101,9 @@ The unbundled entry is `delegate.ts`; `extension.ts` holds the tool implementati
61
101
  `systemPrompt`, `thinking`, `cwd`, `context`, `sessionId`, or `resumeFrom`.
62
102
  `model` is also accepted but should be rare — subagents inherit the parent
63
103
  model by default.
104
+ - **Default subagent** — The reserved built-in `agent: "default"` profile. It
105
+ mirrors the live parent's model, thinking level, delegatable native tools, and
106
+ base system prompt while preserving delegate's extension/context isolation.
64
107
  - **Custom agent** — A subagent profile defined by the parent, either inline in
65
108
  a delegate task (`systemPrompt`, `tools`, and `thinking`) or persisted as a
66
109
  Markdown file. The subagent inherits the parent model by default; `model` is a
@@ -88,5 +131,6 @@ The unbundled entry is `delegate.ts`; `extension.ts` holds the tool implementati
88
131
  - **Skill** — A `SKILL.md` instruction bundle injected into the subagent system
89
132
  prompt. Skills are text instructions only; they do not unlock additional
90
133
  tools.
91
- - **AGENTS.md context** — Project and global guidance files automatically
92
- appended to subagent system prompts, separate from named agent definitions.
134
+ - **AGENTS.md context** — Project and ancestor guidance files are automatically
135
+ appended to subagent system prompts. User-global AGENTS.md files are excluded;
136
+ they describe the parent harness, not the delegated task.
package/agents.ts CHANGED
@@ -6,7 +6,11 @@ import type {
6
6
  ThinkingLevel,
7
7
  } from "@earendil-works/pi-agent-core";
8
8
  import { parseFrontmatter as parsePiFrontmatter } from "@earendil-works/pi-coding-agent";
9
- import { DEFAULT_TOOLS, VALID_THINKING } from "./constants.ts";
9
+ import {
10
+ DEFAULT_AGENT_NAME,
11
+ DEFAULT_TOOLS,
12
+ VALID_THINKING,
13
+ } from "./constants.ts";
10
14
  import { resolveToolGroups } from "./tools.ts";
11
15
  import type { AgentConfig } from "./types.ts";
12
16
 
@@ -294,7 +298,14 @@ export function discoverAgents(cwd: string): Map<string, AgentConfig> {
294
298
  }
295
299
  for (const e of entries) {
296
300
  if (!e.name.endsWith(".md") || e.name.endsWith(".chain.md")) continue;
297
- const cfg = loader(path.join(dir, e.name));
301
+ const filePath = path.join(dir, e.name);
302
+ const cfg = loader(filePath);
303
+ if (cfg?.name === DEFAULT_AGENT_NAME) {
304
+ console.warn(
305
+ `[delegate] ignoring agent profile '${DEFAULT_AGENT_NAME}' from ${filePath}: the name is reserved for the built-in parent-mirroring profile.`,
306
+ );
307
+ continue;
308
+ }
298
309
  if (cfg && !agents.has(cfg.name)) {
299
310
  cfg.scope = scope;
300
311
  agents.set(cfg.name, cfg);
package/constants.ts CHANGED
@@ -1,3 +1,6 @@
1
+ /** Reserved built-in profile that mirrors the live parent configuration. */
2
+ export const DEFAULT_AGENT_NAME = "default";
3
+
1
4
  /** Full-capability agent set. Inline-task default and the `*` shorthand.
2
5
  * Bash subsumes search, so the dedicated grep/find/ls tools are excluded. */
3
6
  export const DEFAULT_TOOLS = ["read", "write", "edit", "bash"];
package/delegate.ts CHANGED
@@ -14,12 +14,14 @@ export type {
14
14
  TaskResult,
15
15
  TaskFailureKind,
16
16
  ReuseIntent,
17
+ ParentAgentDefaults,
17
18
  AgentRunConfig,
18
19
  TaskRunEnv,
19
20
  } from "./types.ts";
20
21
  export type { DelegateConfig } from "./config.ts";
21
22
 
22
23
  export {
24
+ DEFAULT_AGENT_NAME,
23
25
  DEFAULT_TOOLS,
24
26
  READONLY_TOOLS,
25
27
  MAX_CONCURRENCY,
@@ -66,7 +68,17 @@ export {
66
68
  formatCompletedTicket,
67
69
  } from "./tickets.ts";
68
70
  export { runAgentSession } from "./runner.ts";
69
- export { getHostDeps } from "./host.ts";
71
+ export {
72
+ activeTicketSummary,
73
+ buildStatusText,
74
+ clearDelegateStatusContext,
75
+ describeActiveTickets,
76
+ syncDelegateStatus,
77
+ notifyActiveTicketsOnSettled,
78
+ guardSessionReplacement,
79
+ } from "./status.ts";
80
+ export type { ActiveTicketSummary } from "./status.ts";
81
+ export { getHostDeps, invalidateHostDepsCache } from "./host.ts";
70
82
  export type { HostDeps, HostDepsOptions } from "./host.ts";
71
83
  export {
72
84
  emptyUsage,
package/dispatch.ts CHANGED
@@ -15,6 +15,7 @@ import { sumUsage } from "./usage.ts";
15
15
  import { runResolvedTask, updateProgressFromRun } from "./lifecycle.ts";
16
16
  import { fmtDuration, formatCompletedTask, trunc } from "./format.ts";
17
17
  import { validateDelegateOperation } from "./schema.ts";
18
+ import { syncDelegateStatus } from "./status.ts";
18
19
  import { validateTasks, resolveTasks } from "./task-resolution.ts";
19
20
  import type {
20
21
  AgentConfig,
@@ -23,6 +24,7 @@ import type {
23
24
  DelegateDetails,
24
25
  DelegateToolCtx,
25
26
  DelegateToolResult,
27
+ ParentAgentDefaults,
26
28
  ResolvedTask,
27
29
  TaskDef,
28
30
  TaskProgress,
@@ -123,6 +125,7 @@ export interface DelegateDispatchInput {
123
125
  ctx: DelegateToolCtx;
124
126
  agents: Map<string, AgentConfig>;
125
127
  parentModelId: string | undefined;
128
+ parentDefaults: ParentAgentDefaults;
126
129
  signal: AbortSignal | undefined;
127
130
  onUpdate: AgentToolUpdateCallback<DelegateDetails> | undefined;
128
131
  }
@@ -131,13 +134,22 @@ export interface DelegateDispatchInput {
131
134
  export async function dispatchDelegate(
132
135
  input: DelegateDispatchInput,
133
136
  ): Promise<DelegateToolResult> {
134
- const { pi, params, ctx, agents, parentModelId, signal, onUpdate } = input;
137
+ const {
138
+ pi,
139
+ params,
140
+ ctx,
141
+ agents,
142
+ parentModelId,
143
+ parentDefaults,
144
+ signal,
145
+ onUpdate,
146
+ } = input;
135
147
  const tasks = params.tasks ?? [];
136
148
 
137
149
  const validationError = validateTasks(tasks, agents, parentModelId);
138
150
  if (validationError) return validationError;
139
151
 
140
- const resolved = resolveTasks(tasks, ctx, agents);
152
+ const resolved = resolveTasks(tasks, ctx, agents, parentDefaults);
141
153
  const progress = initProgress(resolved);
142
154
  const fire = makeFireUpdater(
143
155
  onUpdate,
@@ -206,6 +218,10 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
206
218
  parentModelId,
207
219
  };
208
220
  ticketRegistry.set(ticketId, ticket);
221
+ // Footer visibility for the new background work (see status.ts). Uses the
222
+ // ctx cached from the dispatch path in extension.ts — DelegateToolCtx is
223
+ // the intentionally narrowed surface and does not carry `ui`.
224
+ syncDelegateStatus();
209
225
 
210
226
  // Capture values for the closure — do NOT use `signal` from execute()
211
227
  // The parent turn's signal dies when execute() returns.
@@ -221,9 +237,13 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
221
237
  onProgress: (p, u) => {
222
238
  updateProgressFromRun(p, u);
223
239
  notifyWaiters(ticket);
240
+ // Live subagent counts in the footer. Deduped by text, so only
241
+ // running/pending count transitions trigger a render.
242
+ syncDelegateStatus();
224
243
  },
225
244
  onStatusChange: () => {
226
245
  notifyWaiters(ticket);
246
+ syncDelegateStatus();
227
247
  },
228
248
  };
229
249
 
@@ -243,6 +263,12 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
243
263
  ticketSignal,
244
264
  )
245
265
  .then(() => {
266
+ // A ticket that is already terminally "cancelled" at this point was
267
+ // finalized by cancelTicketForShutdown (user cancels pass through
268
+ // "cancelling" first): the extension runtime is being torn down, the
269
+ // captured `pi` is stale or about to be, and a follow-up message has
270
+ // no live session to land in. Skip delivery entirely.
271
+ if (ticket.status === "cancelled") return;
246
272
  // All tasks settled — determine final ticket status.
247
273
  // Use progress (set by runResolvedTask) for settled-ness so the
248
274
  // status reflects work completion, not just result-array density.
@@ -262,10 +288,13 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
262
288
  ticket.completedAt = Date.now();
263
289
  syncTicketBusyIndex(ticket);
264
290
  }
291
+ syncDelegateStatus();
265
292
  deliverTicketResults(pi, ticket);
266
293
  })
267
294
  .catch((err) => {
268
- // Defense-in-depth — should not happen if individual tasks catch properly
295
+ // Defense-in-depth — should not happen if individual tasks catch properly.
296
+ // Same shutdown guard as the .then path above.
297
+ if (ticket.status === "cancelled") return;
269
298
  if (ticket.status === "cancelling") {
270
299
  ticket.status = "cancelled";
271
300
  } else if (ticket.status === "running") {
@@ -274,6 +303,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
274
303
  ticket.error = err instanceof Error ? err.message : String(err);
275
304
  ticket.completedAt = Date.now();
276
305
  syncTicketBusyIndex(ticket);
306
+ syncDelegateStatus();
277
307
  deliverTicketResults(pi, ticket);
278
308
  });
279
309
 
package/extension.ts CHANGED
@@ -18,7 +18,16 @@ import {
18
18
  } from "./dispatch.ts";
19
19
  import { renderDelegateCall, renderDelegateResult } from "./render-result.ts";
20
20
  import { hostCompatError } from "./host-compat.ts";
21
+ import { invalidateHostDepsCache } from "./host.ts";
21
22
  import { closeAllPooledAgents } from "./pool.ts";
23
+ import {
24
+ activeTicketSummary,
25
+ clearDelegateStatusContext,
26
+ describeActiveTickets,
27
+ guardSessionReplacement,
28
+ notifyActiveTicketsOnSettled,
29
+ syncDelegateStatus,
30
+ } from "./status.ts";
22
31
  import type { DelegateArguments } from "./types.ts";
23
32
 
24
33
  /** Register the delegate tool and clean up its parent-session resources. */
@@ -55,7 +64,11 @@ export default function delegateExtension(pi: ExtensionAPI): void {
55
64
 
56
65
  // ── Cancel action ─────────────────────────────────────────────────
57
66
  if (params.action === "cancel") {
58
- return handleCancel(params);
67
+ const result = handleCancel(params);
68
+ // A forced cancel flips the ticket to "cancelling" — keep the
69
+ // footer status in step (deduped; the preview path is a no-op).
70
+ syncDelegateStatus(ctx);
71
+ return result;
59
72
  }
60
73
 
61
74
  // ── Wait action ────────────────────────────────────────────────────
@@ -83,12 +96,26 @@ export default function delegateExtension(pi: ExtensionAPI): void {
83
96
  };
84
97
  }
85
98
 
99
+ // Cache the full ExtensionContext for the footer-status module before
100
+ // dispatch narrows it to DelegateToolCtx (which has no `ui`). The
101
+ // status push itself is a deduped no-op here; dispatchAsync re-syncs
102
+ // after registering its ticket.
103
+ syncDelegateStatus(ctx);
104
+
105
+ // Keep expensive host deps shared within this dispatch, not indefinitely
106
+ // across dispatches: edits to auth/models/settings/context files must be
107
+ // visible without restarting Pi.
108
+ invalidateHostDepsCache();
86
109
  return dispatchDelegate({
87
110
  pi,
88
111
  params,
89
112
  ctx,
90
113
  agents,
91
114
  parentModelId,
115
+ parentDefaults: {
116
+ thinking: pi.getThinkingLevel(),
117
+ tools: pi.getActiveTools(),
118
+ },
92
119
  signal,
93
120
  onUpdate,
94
121
  });
@@ -98,11 +125,48 @@ export default function delegateExtension(pi: ExtensionAPI): void {
98
125
  renderResult: renderDelegateResult,
99
126
  });
100
127
 
128
+ // ── Background-work visibility (see status.ts) ──────────────────────────
129
+ // The turn settling with live tickets is the "looks idle but isn't" moment:
130
+ // warn once per ticket. The footer status carries it from there.
131
+ pi.on("agent_settled", (_event, ctx) => {
132
+ notifyActiveTicketsOnSettled(ctx);
133
+ });
134
+
135
+ // Session replacements are cancellable — confirm before killing live work.
136
+ pi.on("session_before_switch", (_event, ctx) =>
137
+ guardSessionReplacement(ctx, "switch"),
138
+ );
139
+ pi.on("session_before_fork", (_event, ctx) =>
140
+ guardSessionReplacement(ctx, "fork"),
141
+ );
142
+
101
143
  // ── Session shutdown: abort tickets and dispose live pooled sessions ──
102
- pi.on("session_shutdown", async () => {
144
+ pi.on("session_shutdown", async (event, ctx) => {
145
+ // Quit and /reload kill background work with no cancellable hook, so
146
+ // leave a trace. For quit the TUI is already stopped — stderr lands in
147
+ // the scrollback. For reload the TUI survives — warn in place. Switch
148
+ // and fork already passed the confirm guard above.
149
+ const active = activeTicketSummary();
150
+ if (active.tickets.length) {
151
+ if (event.reason === "quit") {
152
+ console.error(
153
+ `[delegate] pi exited with ${describeActiveTickets(active)} — aborted.`,
154
+ );
155
+ } else if (event.reason === "reload") {
156
+ ctx.ui.notify(
157
+ `[delegate] reload aborted ${describeActiveTickets(active)}`,
158
+ "warning",
159
+ );
160
+ }
161
+ }
103
162
  for (const ticket of ticketRegistry.values()) {
104
163
  cancelTicketForShutdown(ticket);
105
164
  }
165
+ syncDelegateStatus(ctx);
166
+ // The runtime is invalidated right after this handler returns; aborted
167
+ // tickets keep unwinding asynchronously and must find no cached ctx (or
168
+ // captured pi) to touch. See the "cancelled"-at-entry guard in dispatch.
169
+ clearDelegateStatusContext();
106
170
  // Do NOT clear the ticket registry here — completed tickets are retained
107
171
  // until their TTL cleanup. Pooled AgentSessions, however, own listeners
108
172
  // and must be disposed before the parent session exits.
package/host-compat.ts CHANGED
@@ -8,16 +8,27 @@ import type { DelegateToolResult } from "./types.ts";
8
8
  * Keep this list in sync with the actual import sites (host.ts, sessions.ts,
9
9
  * lifecycle.ts, agents.ts).
10
10
  */
11
- const REQUIRED_SYMBOLS = [
12
- "ModelRuntime",
13
- "SettingsManager",
14
- "SessionManager",
15
- "DefaultResourceLoader",
16
- "DefaultPackageManager",
17
- "createAgentSession",
18
- "getAgentDir",
19
- "parseFrontmatter",
20
- ] as const;
11
+ type ExportCheck = {
12
+ name: string;
13
+ requiredMember?: string;
14
+ };
15
+
16
+ /**
17
+ * Host symbols actually dereferenced by delegate. Static members are listed by
18
+ * `<symbol>.<member>` so we fail fast when a constructor is present but no longer
19
+ * exposes the required factory methods.
20
+ */
21
+ const REQUIRED_EXPORTS: ExportCheck[] = [
22
+ { name: "ModelRuntime", requiredMember: "create" },
23
+ { name: "SettingsManager", requiredMember: "create" },
24
+ { name: "SessionManager", requiredMember: "create" },
25
+ { name: "SessionManager", requiredMember: "open" },
26
+ { name: "DefaultResourceLoader" },
27
+ { name: "DefaultPackageManager" },
28
+ { name: "createAgentSession" },
29
+ { name: "getAgentDir" },
30
+ { name: "parseFrontmatter" },
31
+ ];
21
32
 
22
33
  /**
23
34
  * Build a tool result describing any required symbols missing from a pi
@@ -27,9 +38,33 @@ const REQUIRED_SYMBOLS = [
27
38
  export function hostCompatResult(
28
39
  ns: Record<string, unknown>,
29
40
  ): DelegateToolResult | null {
30
- const missing = REQUIRED_SYMBOLS.filter((name) => ns[name] === undefined);
41
+ const missing: string[] = [];
42
+ for (const entry of REQUIRED_EXPORTS) {
43
+ const value = ns[entry.name];
44
+ if (value === undefined) {
45
+ missing.push(`'${entry.name}'`);
46
+ continue;
47
+ }
48
+
49
+ if (typeof value !== "function") {
50
+ missing.push(`'${entry.name}'`);
51
+ continue;
52
+ }
53
+
54
+ if (entry.requiredMember) {
55
+ const symbolValue = value as unknown as { [key: string]: unknown };
56
+ if (!(entry.requiredMember in symbolValue)) {
57
+ missing.push(`'${entry.name}.${entry.requiredMember}'`);
58
+ continue;
59
+ }
60
+ if (typeof symbolValue[entry.requiredMember] !== "function") {
61
+ missing.push(`'${entry.name}.${entry.requiredMember}'`);
62
+ }
63
+ }
64
+ }
65
+
31
66
  if (missing.length === 0) return null;
32
- const listed = missing.map((m) => `'${m}'`).join(", ");
67
+ const listed = missing.join(", ");
33
68
  return {
34
69
  content: [
35
70
  {
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