@bermudi/pi-delegate 0.1.9 → 0.1.11

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
@@ -1,4 +1,6 @@
1
1
  import {
2
+ BUILTIN_AGENT_NAMES,
3
+ DEFAULT_AGENT_NAME,
2
4
  DEFAULT_TOOLS,
3
5
  OUTPUT_SPILL_THRESHOLD_CHARS,
4
6
  OUTPUT_SPILL_TAIL_CHARS,
@@ -7,6 +9,7 @@ import { getMaxAsyncTickets, getMaxConcurrent } from "./config.ts";
7
9
  import type { TSchema } from "@sinclair/typebox";
8
10
  import { delegateArgumentsSchema, delegateTaskSchema } from "./schema.ts";
9
11
  import type { AgentConfig } from "./types.ts";
12
+ import { BUILTIN_AGENT_CONFIGS } from "./agents.ts";
10
13
 
11
14
  function schemaType(schema: TSchema): string {
12
15
  if (Array.isArray(schema.enum)) {
@@ -39,7 +42,41 @@ function schemaTable(properties: Record<string, TSchema>): string {
39
42
  export function getSubagentManualMarkdown(
40
43
  agents: Map<string, AgentConfig>,
41
44
  ): string {
42
- const entries = [...agents].filter(([, a]) => !a.builtin);
45
+ const builtinNames = new Set<string>(
46
+ BUILTIN_AGENT_NAMES as readonly string[],
47
+ );
48
+ const entries = [...agents].filter(
49
+ ([name, a]) => !a.builtin && !builtinNames.has(name),
50
+ );
51
+ const builtinLines = (BUILTIN_AGENT_NAMES as readonly string[]).map(
52
+ (name) => {
53
+ const cfg = agents.get(name) ?? BUILTIN_AGENT_CONFIGS[name]!;
54
+ const isDefault = name === DEFAULT_AGENT_NAME;
55
+ // `default` normally mirrors the parent's native tools; only show a
56
+ // fixed list when the file explicitly overrode them. This avoids
57
+ // advertising `read, write, edit, bash` when runtime will actually use
58
+ // the parent's active set. A deny-only `default` (deniedTools with no
59
+ // explicit allowlist) filters the parent at runtime and must surface.
60
+ let toolsPart: string;
61
+ if (isDefault && cfg.deniedTools?.length && !cfg.explicitTools) {
62
+ toolsPart = ` Tools: parent tools minus \`${cfg.deniedTools.join(", ")}\`.`;
63
+ } else {
64
+ const showTools = !isDefault || !!cfg.explicitTools;
65
+ toolsPart = showTools ? ` Tools: \`${cfg.tools.join(", ")}\`.` : "";
66
+ }
67
+ const modelPart =
68
+ cfg.explicitModel && cfg.model ? ` Model: \`${cfg.model}\`.` : "";
69
+ const thinkingPart =
70
+ cfg.explicitThinking && cfg.thinking
71
+ ? ` Thinking: \`${cfg.thinking}\`.`
72
+ : "";
73
+ const workspace =
74
+ cfg.workspace === "scratch"
75
+ ? `Defaults to a disposable scratch workspace; set \`workspace: "shared"\` for a persistent ${name} with \`sessionId\`.`
76
+ : "Shared workspace.";
77
+ return `- **${name}**: ${cfg.description}${toolsPart}${modelPart}${thinkingPart} ${workspace}`;
78
+ },
79
+ );
43
80
  const agentList = entries.length
44
81
  ? entries
45
82
  .map(([n, a]) => {
@@ -97,12 +134,9 @@ export function getSubagentManualMarkdown(
97
134
  "",
98
135
  "## Built-in Agents",
99
136
  "",
100
- "- **default**: mirrors the live parent model, thinking level, delegatable native tools, and base prompt. It uses the shared workspace.",
101
- "- **scout**: investigates without modifying files. Tools: `read`, `grep`, `find`, `ls`. Shared workspace.",
102
- "- **coder**: implements and verifies changes. Tools: `read`, `write`, `edit`, `bash`. Shared workspace.",
103
- '- **reviewer**: reviews the current snapshot and reports findings. Tools: `read`, `bash`. Defaults to a disposable scratch workspace; set `workspace: "shared"` for a persistent reviewer with `sessionId`.',
137
+ ...builtinLines,
104
138
  "",
105
- "Fresh built-ins inherit the parent's exact model object and thinking level unless task fields or settings override 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`, 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.",
106
140
  "",
107
141
  "## Available Custom Agents",
108
142
  "",
@@ -208,10 +242,6 @@ export function getSubagentManualMarkdown(
208
242
  `- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
209
243
  "- `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.",
210
244
  "",
211
- "## Legacy `action` compatibility",
212
- "",
213
- "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`.",
214
- "",
215
245
  "## Config",
216
246
  "",
217
247
  "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.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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
+ }