@bermudi/pi-delegate 0.1.10 → 0.1.12

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/manual.ts CHANGED
@@ -110,7 +110,7 @@ export function getSubagentManualMarkdown(
110
110
  'delegate({ tasks: [{ agent: "default", prompt: "Investigate the auth module" }] })',
111
111
  "```",
112
112
  "",
113
- "Delegate subagents to execute tasks in parallel. Each subagent gets an independent conversation but uses the real filesystem at its task `cwd`; tasks sharing a directory can observe and overwrite one another's changes. Fresh prompts must therefore be self-contained, and dependent or shared-file work should run separately.",
113
+ 'Delegate subagents to execute tasks in parallel. Each subagent gets an independent conversation. A batch with multiple write-capable tasks in one shared tree is rejected before dispatch; run them sequentially, use synchronous one-shot `workspace: "isolated"` for Git-backed ordered reconciliation, or use disposable scratch workspaces.',
114
114
  "",
115
115
  "The three handles have different lifetimes:",
116
116
  "",
@@ -136,7 +136,7 @@ export function getSubagentManualMarkdown(
136
136
  "",
137
137
  ...builtinLines,
138
138
  "",
139
- "Fresh built-ins inherit the parent's exact model object and thinking level. A same-named Markdown file can override any built-in (first definition wins); an explicit `model` or `thinking` in that file replaces parent inheritance. Task-level `model`/`thinking`/`tools` always win. For `scout`/`coder`/`reviewer`, settings overrides (`settings.json` `delegate.agentOverrides` and `delegate.agentOverridesByParentModel`) win over the Markdown file; `default` ignores settings and uses only an explicit Markdown `model`/`thinking` when present. A prompt-only Markdown override keeps the built-in's tools and workspace, so `scout` stays read-only and `reviewer` stays scratch unless the file explicitly changes them. Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context and skills are rebuilt for the task's `cwd`; per-task fields remain explicit overrides.",
139
+ "Fresh built-ins inherit the parent's exact model object and thinking level. A same-named Markdown file can override any built-in (first definition wins); an explicit `model` or `thinking` in that file replaces parent inheritance. Task-level `model`/`thinking`/`tools` always win. For `scout`/`coder`/`reviewer`, delegate.json overrides (`agentOverrides`, `agentOverridesByParentModel`) win over the Markdown file; `default` ignores overrides and uses only an explicit Markdown `model`/`thinking` when present. A prompt-only Markdown override keeps the built-in's tools and workspace, so `scout` stays read-only and `reviewer` stays scratch unless the file explicitly changes them. Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context and skills are rebuilt for the task's `cwd`; per-task fields remain explicit overrides.",
140
140
  "",
141
141
  "## Available Custom Agents",
142
142
  "",
@@ -234,6 +234,7 @@ export function getSubagentManualMarkdown(
234
234
  "## Gotchas",
235
235
  "",
236
236
  "- Dispatch validation is batch-wide and runs before spawning: one invalid task rejects the call without starting its siblings.",
237
+ '- Shared writers that overlap tasks in the same call or a running sync/async dispatch are rejected. Unknown tools count as mutating. Run them sequentially, use `workspace: "isolated"` for Git-backed ordered reconciliation, or use `workspace: "scratch"` when changes may be discarded.',
237
238
  "- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
238
239
  '- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
239
240
  '- Use `agent: "default"` for the parent\'s live model/thinking/native tools/base prompt. Built-ins are `default`, `scout`, `coder`, and `reviewer`; omitting `agent` creates an ad-hoc task.',
@@ -242,13 +243,9 @@ export function getSubagentManualMarkdown(
242
243
  `- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
243
244
  "- `deadlineMs` is a per-task wall-clock budget measured from when the task starts running (after queuing). It requests cooperative abort and is not a hard kill; completed writes/commands remain. Omission disables the deadline.",
244
245
  "",
245
- "## Legacy `action` compatibility",
246
- "",
247
- "The overloaded `action` field was split into `ticketAction` (poll/wait/cancel) and `sessionAction` (prompt/close/list). Legacy `action` values are still accepted at runtime through automatic normalization, but new calls should use the canonical fields. Programmatic TypeScript consumers should note the exported type `DelegateAction` is now `TicketAction`.",
248
- "",
249
246
  "## Config",
250
247
  "",
251
- "Tunables live in `~/.pi/agent/delegate.json`: `maxConcurrent` (sync ceiling), `maxAsyncTickets` (background ticket cap), `stallTimeoutMs` (inactivity watchdog; default 900000, 0 disables), per-model/per-provider concurrency limits, and legacy custom-agent model overrides. Built-in model/thinking/tools overrides live in `settings.json`; `agentOverridesByParentModel` uses an exact `provider/model-id` key and project settings take precedence.",
248
+ "Tunables live in `~/.pi/agent/delegate.json` (user scope, global — no project-level config): `maxConcurrent` (sync ceiling), `maxAsyncTickets` (background ticket cap), `stallTimeoutMs` (inactivity watchdog; default 900000, 0 disables), per-model/per-provider concurrency limits, legacy custom-agent model overrides (`agent`), and agent model/thinking/tools overrides `agentOverrides` and `agentOverridesByParentModel` (exact `provider/model-id` key of the parent model; wins over `agentOverrides` on match). Config edits apply from the next delegate call.",
252
249
  "The inactivity watchdog requests cooperative `AgentSession.abort()` cancellation and waits for the subagent to become idle; it is not a hard wall-clock execution deadline.",
253
250
  "",
254
251
  `Output bounding: subagent outputs longer than ${OUTPUT_SPILL_THRESHOLD_CHARS} characters are spilled to a temp file, and only the last ${OUTPUT_SPILL_TAIL_CHARS} characters stay in the LLM-facing result. Adjust with \`output.spillThresholdChars\` and \`output.spillTailChars\`. Spill files are written to the system temp directory with owner-only permissions; the full output is always available in the expanded TUI view and the spilled file.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,293 @@
1
+ /**
2
+ * The single seam onto Pi's package-source grammar.
3
+ *
4
+ * Provider-extension verification needs three facts about a configured source:
5
+ * whether it is a local path, which Git repository and ref it pins, and which
6
+ * npm version range it requires. All three come from
7
+ * `DefaultPackageManager.parseSource`, which is private upstream.
8
+ *
9
+ * Reaching into that private is not the hazard — *duplicating* it is. This
10
+ * module exists because the previous implementation hand-rolled its own
11
+ * `isLocalPath`, its own npm-spec regex, and its own Git URL grammar in order
12
+ * to avoid the cast, and all three drifted from the originals:
13
+ *
14
+ * - the local check lowercased its input, so `NPM:pkg` was "managed" to us
15
+ * and "local" to Pi;
16
+ * - the npm regex was a verbatim copy of Pi's private `parseNpmSpec`, kept in
17
+ * sync by hand;
18
+ * - the Git parser knew nothing of `hosted-git-info`, which Pi's real parser
19
+ * uses for every hosted provider.
20
+ *
21
+ * A second grammar that must track a private one on every upgrade is strictly
22
+ * worse than one narrow cast that fails loudly. It is also not buying any
23
+ * independence: `getInstalledPath` — a *public* method the resolution path
24
+ * already depends on — calls this same private parser internally, so if
25
+ * `parseSource` disappears, path resolution is broken with or without this
26
+ * module.
27
+ *
28
+ * Hence: one cast, one module, one error type. Callers deal in the parsed
29
+ * result and never see Pi's raw shape.
30
+ */
31
+ import type { DefaultPackageManager } from "@earendil-works/pi-coding-agent";
32
+
33
+ /**
34
+ * Pi's private parser is missing, threw, or returned a shape this version of
35
+ * delegate cannot trust.
36
+ *
37
+ * Callers fail closed on this rather than falling back to a guess: an
38
+ * unverifiable source is treated exactly like a source that failed
39
+ * verification. Callers that wrap it in a friendlier message must preserve it
40
+ * as `cause`, so a Pi-version mismatch stays distinguishable from a genuinely
41
+ * mismatched checkout.
42
+ */
43
+ export class PiPackageSourceError extends Error {
44
+ /**
45
+ * Which kind of source Pi had already classified when the contract broke, if
46
+ * it got that far. Callers use it to report the failure in terms of the
47
+ * verification the user configured ("this Git source could not be verified")
48
+ * rather than as an unrelated internal error.
49
+ */
50
+ readonly sourceType?: "git" | "npm";
51
+
52
+ constructor(
53
+ message: string,
54
+ options?: { cause?: unknown; sourceType?: "git" | "npm" },
55
+ ) {
56
+ super(message, { cause: options?.cause });
57
+ this.name = "PiPackageSourceError";
58
+ this.sourceType = options?.sourceType;
59
+ }
60
+ }
61
+
62
+ /** A Git repository identity, normalized for comparison. */
63
+ export interface GitRepositoryIdentity {
64
+ /** Lowercased host, e.g. `github.com`. */
65
+ host: string;
66
+ /** Repository path, e.g. `example/extension`. */
67
+ path: string;
68
+ }
69
+
70
+ export interface GitPackageSource extends GitRepositoryIdentity {
71
+ type: "git";
72
+ /** Configured ref. Present exactly when Pi reports the source as pinned. */
73
+ ref?: string;
74
+ }
75
+
76
+ export interface NpmPackageSource {
77
+ type: "npm";
78
+ /** Version, range, or dist-tag text after the package name, if any. */
79
+ version?: string;
80
+ /** Semver range. Undefined when `version` is a dist-tag such as `latest`. */
81
+ range?: string;
82
+ /**
83
+ * Whether the package installed at `installedPath` satisfies this source,
84
+ * using Pi's own semver comparison rather than a reimplementation. Resolves
85
+ * `true` for an unconstrained source, matching Pi's install-time behavior.
86
+ */
87
+ satisfiedBy(installedPath: string): Promise<boolean>;
88
+ }
89
+
90
+ export interface LocalPackageSource {
91
+ type: "local";
92
+ }
93
+
94
+ export type PiPackageSource =
95
+ GitPackageSource | NpmPackageSource | LocalPackageSource;
96
+
97
+ type PackageManagerInternals = {
98
+ parseSource(source: string): unknown;
99
+ installedNpmMatchesConfiguredVersion(
100
+ parsed: unknown,
101
+ installedPath: string,
102
+ ): Promise<boolean>;
103
+ };
104
+
105
+ function internalsOf(
106
+ packageManager: DefaultPackageManager,
107
+ ): Partial<PackageManagerInternals> {
108
+ return packageManager as unknown as Partial<PackageManagerInternals>;
109
+ }
110
+
111
+ /**
112
+ * Normalize a Pi-parsed Git identity for equality comparison.
113
+ *
114
+ * This is comparison hygiene, not a grammar: both sides of every comparison
115
+ * are produced by Pi's parser, and this only removes differences Pi is
116
+ * indifferent to (host case, a trailing slash or `.git` that Pi strips in some
117
+ * source forms but not others). It never decides what a source *is*.
118
+ */
119
+ function normalizeIdentity(
120
+ host: string,
121
+ repoPath: string,
122
+ ): GitRepositoryIdentity | undefined {
123
+ const path = repoPath.replace(/\/+$/, "").replace(/\.git$/i, "");
124
+ if (!host || !path) return undefined;
125
+ return { host: host.toLowerCase(), path };
126
+ }
127
+
128
+ /**
129
+ * Parse a configured package source with Pi's own parser.
130
+ *
131
+ * Throws `PiPackageSourceError` when the parser is unavailable or returns a
132
+ * result whose security-relevant fields (Git pin state, npm range) cannot be
133
+ * trusted. Unrecognized sources come back as `local`, which is exactly how Pi
134
+ * itself resolves them.
135
+ */
136
+ export function parsePackageSource(
137
+ packageManager: DefaultPackageManager,
138
+ source: string,
139
+ ): PiPackageSource {
140
+ const internals = internalsOf(packageManager);
141
+ if (typeof internals.parseSource !== "function") {
142
+ throw new PiPackageSourceError(
143
+ "This Pi version no longer exposes the package-source parser that delegate uses to verify provider extensions.",
144
+ );
145
+ }
146
+
147
+ let parsed: unknown;
148
+ try {
149
+ parsed = internals.parseSource.call(packageManager, source);
150
+ } catch (error) {
151
+ throw new PiPackageSourceError(
152
+ "Pi could not parse a configured provider extension source.",
153
+ { cause: error },
154
+ );
155
+ }
156
+ if (typeof parsed !== "object" || parsed === null) {
157
+ throw new PiPackageSourceError(
158
+ "Pi's package-source parser returned an unusable result for a configured provider extension.",
159
+ );
160
+ }
161
+
162
+ const record = parsed as {
163
+ type?: unknown;
164
+ host?: unknown;
165
+ path?: unknown;
166
+ ref?: unknown;
167
+ pinned?: unknown;
168
+ version?: unknown;
169
+ range?: unknown;
170
+ };
171
+
172
+ if (record.type === "git") return toGitSource(record);
173
+ if (record.type === "npm") {
174
+ return toNpmSource(record, parsed, packageManager);
175
+ }
176
+ return { type: "local" };
177
+ }
178
+
179
+ function toGitSource(record: {
180
+ host?: unknown;
181
+ path?: unknown;
182
+ ref?: unknown;
183
+ pinned?: unknown;
184
+ }): GitPackageSource {
185
+ if (typeof record.host !== "string" || typeof record.path !== "string") {
186
+ throw new PiPackageSourceError(
187
+ "Pi's package-source parser reported a Git source without a usable repository identity.",
188
+ { sourceType: "git" },
189
+ );
190
+ }
191
+ const identity = normalizeIdentity(record.host, record.path);
192
+ if (!identity) {
193
+ throw new PiPackageSourceError(
194
+ "Pi's package-source parser reported an empty Git repository identity.",
195
+ { sourceType: "git" },
196
+ );
197
+ }
198
+
199
+ // `pinned` and `ref` are a security contract, not decoration. If a Pi
200
+ // upgrade drops either, never reinterpret a pinned source as an unpinned
201
+ // checkout and silently skip commit validation.
202
+ if (typeof record.pinned !== "boolean") {
203
+ throw new PiPackageSourceError(
204
+ "Pi's package-source parser no longer reports Git pin state.",
205
+ { sourceType: "git" },
206
+ );
207
+ }
208
+ if (!record.pinned) {
209
+ if (record.ref !== undefined) {
210
+ throw new PiPackageSourceError(
211
+ "Pi's package-source parser reported an unpinned Git source that still carries a ref.",
212
+ { sourceType: "git" },
213
+ );
214
+ }
215
+ return { type: "git", ...identity };
216
+ }
217
+ if (typeof record.ref !== "string" || record.ref.length === 0) {
218
+ throw new PiPackageSourceError(
219
+ "Pi's package-source parser reported a pinned Git source without a ref.",
220
+ { sourceType: "git" },
221
+ );
222
+ }
223
+ return { type: "git", ...identity, ref: record.ref };
224
+ }
225
+
226
+ function toNpmSource(
227
+ record: { version?: unknown; range?: unknown },
228
+ parsed: unknown,
229
+ packageManager: DefaultPackageManager,
230
+ ): NpmPackageSource {
231
+ if (record.version !== undefined && typeof record.version !== "string") {
232
+ throw new PiPackageSourceError(
233
+ "Pi's package-source parser reported an unusable npm version specifier.",
234
+ { sourceType: "npm" },
235
+ );
236
+ }
237
+ if (record.range !== undefined && typeof record.range !== "string") {
238
+ throw new PiPackageSourceError(
239
+ "Pi's package-source parser reported an unusable npm version range.",
240
+ { sourceType: "npm" },
241
+ );
242
+ }
243
+
244
+ return {
245
+ type: "npm",
246
+ version: record.version,
247
+ range: record.range,
248
+ async satisfiedBy(installedPath: string): Promise<boolean> {
249
+ const compare =
250
+ internalsOf(packageManager).installedNpmMatchesConfiguredVersion;
251
+ if (typeof compare !== "function") {
252
+ throw new PiPackageSourceError(
253
+ "This Pi version no longer exposes the npm version check that delegate uses to verify provider extensions.",
254
+ { sourceType: "npm" },
255
+ );
256
+ }
257
+ // Pi's own parsed object is handed back untouched: it owns the contract
258
+ // between its parser and its comparison, and a reconstructed stand-in
259
+ // would silently change meaning if that contract grew a field.
260
+ return compare.call(packageManager, parsed, installedPath);
261
+ },
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Read the repository identity of an installed checkout from its origin URL,
267
+ * using the same parser the configured source went through.
268
+ *
269
+ * The `git:` prefix is what makes that possible: without it Pi only accepts
270
+ * explicit protocol URLs, while with it Pi accepts every shorthand it accepts
271
+ * in a configured source (SCP `git@host:path`, bare `host/path`). Prefixing a
272
+ * URL that already carries a scheme is harmless — Pi strips the prefix before
273
+ * parsing. Returns undefined for an origin Pi does not recognize as Git, which
274
+ * callers treat as a failed identity match.
275
+ */
276
+ export function parseGitOriginIdentity(
277
+ packageManager: DefaultPackageManager,
278
+ originUrl: string,
279
+ ): GitRepositoryIdentity | undefined {
280
+ const trimmed = originUrl.trim();
281
+ if (!trimmed) return undefined;
282
+ const parsed = parsePackageSource(packageManager, `git:${trimmed}`);
283
+ if (parsed.type !== "git") return undefined;
284
+ return { host: parsed.host, path: parsed.path };
285
+ }
286
+
287
+ /** Whether two Pi-parsed Git identities name the same repository. */
288
+ export function sameRepository(
289
+ a: GitRepositoryIdentity,
290
+ b: GitRepositoryIdentity,
291
+ ): boolean {
292
+ return a.host === b.host && a.path === b.path;
293
+ }
package/pool.ts CHANGED
@@ -18,6 +18,10 @@ export interface FrozenConfig {
18
18
  thinking: ThinkingLevel;
19
19
  tools: string[];
20
20
  cwd: string;
21
+ /** Stable signature of the provider-scoped extension allowlist this session
22
+ * was built with. A change in `delegate.json` providerExtensions must force
23
+ * session recreation so the old executable runtime is not silently reused. */
24
+ providerExtensions?: string;
21
25
  }
22
26
 
23
27
  /** The subset a reuse request supplies for validation. `model` and
@@ -29,12 +33,20 @@ export interface ConfigCandidate {
29
33
  tools: string[];
30
34
  model?: Model<Api>;
31
35
  systemPrompt?: string;
36
+ /** Provider-scoped extension allowlist signature for the current dispatch. */
37
+ providerExtensions?: string;
32
38
  }
33
39
 
34
40
  /** One field-level diff from a reuse that conflicts with the frozen config. The
35
41
  * pool computes these; the caller formats the error string. */
36
42
  export interface ConfigMismatch {
37
- field: "cwd" | "thinking" | "tools" | "model" | "systemPrompt";
43
+ field:
44
+ | "cwd"
45
+ | "thinking"
46
+ | "tools"
47
+ | "model"
48
+ | "systemPrompt"
49
+ | "providerExtensions";
38
50
  frozen: string;
39
51
  requested: string;
40
52
  }
@@ -189,6 +201,16 @@ export function checkout(
189
201
  requested: "<requested>",
190
202
  });
191
203
  }
204
+ if (frozen.providerExtensions !== candidate.providerExtensions) {
205
+ // This field is derived from configured package sources, which may contain
206
+ // credentials. Never expose either the sources or their digest through the
207
+ // public checkout result; a digest can still enable dictionary guessing.
208
+ mismatches.push({
209
+ field: "providerExtensions",
210
+ frozen: "<redacted>",
211
+ requested: "<redacted>",
212
+ });
213
+ }
192
214
  if (mismatches.length) return { status: "mismatch", mismatches };
193
215
 
194
216
  return {