@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 +52 -8
- package/agents.ts +13 -2
- package/constants.ts +3 -0
- package/delegate.ts +13 -1
- package/dispatch.ts +33 -3
- package/extension.ts +66 -2
- package/host-compat.ts +47 -12
- package/host.ts +93 -16
- package/lifecycle.ts +220 -85
- package/manual.ts +10 -3
- package/model.ts +3 -4
- package/package.json +22 -20
- package/pool.ts +169 -51
- package/render-branches.ts +21 -3
- package/runner.ts +18 -9
- package/schema.ts +58 -26
- package/status.ts +203 -0
- package/task-resolution.ts +127 -44
- package/types.ts +8 -0
package/README.md
CHANGED
|
@@ -11,13 +11,32 @@ as a standalone repo (full history preserved).
|
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Install a reviewed Git commit or release tag as a Pi package:
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
55
|
-
|
|
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
|
|
92
|
-
appended to subagent system prompts
|
|
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 {
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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
|