@bermudi/pi-delegate 0.1.0

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/config.ts ADDED
@@ -0,0 +1,358 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import {
5
+ MAX_ASYNC_TICKETS,
6
+ MAX_CONCURRENCY,
7
+ OUTPUT_SPILL_TAIL_CHARS,
8
+ OUTPUT_SPILL_THRESHOLD_CHARS,
9
+ } from "./constants.ts";
10
+
11
+ export interface DelegateConfig {
12
+ agent: {
13
+ /** Global default model for all agent types. */
14
+ default: string | null;
15
+ /** Per-agent-type model overrides. Keys are agent names or "default". */
16
+ [agentType: string]: string | null | undefined;
17
+ };
18
+ /** Per-model and per-provider concurrency limits. */
19
+ concurrency: {
20
+ /** Default concurrency limit for unspecified models. */
21
+ default: number;
22
+ /** Per-provider limits (e.g. "llamacpp": 2). */
23
+ providers?: Record<string, number>;
24
+ /** Per-model limits keyed by "provider/modelId". */
25
+ models?: Record<string, number>;
26
+ };
27
+ /** Hard ceiling on total concurrent agents across all models. */
28
+ maxConcurrent?: number;
29
+ /** Max concurrent async tickets. */
30
+ maxAsyncTickets?: number;
31
+ /** Maximum inactivity before cooperative stall cancellation is requested (0 disables). */
32
+ stallTimeoutMs?: number;
33
+ /** Whole-task transient-error retry settings. */
34
+ retry?: {
35
+ /** Max whole-task retries after the initial attempt (0 = no retry). */
36
+ wholeTaskMaxRetries?: number;
37
+ /** Base delay (ms) for exponential backoff between whole-task retries. */
38
+ wholeTaskBaseDelayMs?: number;
39
+ };
40
+ /** User-scope extension sources to load for subagents by provider. */
41
+ providerExtensions?: {
42
+ [provider: string]: readonly string[];
43
+ };
44
+ /** LLM-facing output bounding: over-threshold final output is spilled to a
45
+ * temp file with a tail kept in-context. See `spill.ts`. */
46
+ output?: {
47
+ /** Over this many chars (strictly), spill. Default 8000. */
48
+ spillThresholdChars?: number;
49
+ /** Tail length (chars) kept in-context when spilled. Default 2000. */
50
+ spillTailChars?: number;
51
+ };
52
+ }
53
+
54
+ const DELEGATE_CONFIG_DIR = path.join(os.homedir(), ".pi", "agent");
55
+ const DELEGATE_CONFIG_PATH = path.join(DELEGATE_CONFIG_DIR, "delegate.json");
56
+
57
+ function normalizeProviderExtensions(
58
+ raw: unknown,
59
+ ): Record<string, readonly string[]> {
60
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
61
+
62
+ // A null-prototype map keeps config keys such as `constructor` and
63
+ // `__proto__` from resolving to Object.prototype members or mutating the
64
+ // map's prototype during normalization.
65
+ const out = Object.create(null) as Record<string, readonly string[]>;
66
+ for (const [provider, entries] of Object.entries(
67
+ raw as Record<string, unknown>,
68
+ )) {
69
+ const normalizedProvider = provider.trim().toLowerCase();
70
+ if (!normalizedProvider || !Array.isArray(entries)) continue;
71
+ const normalizedEntries = entries
72
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
73
+ .filter((entry): entry is string => entry.length > 0);
74
+ if (normalizedEntries.length === 0) continue;
75
+ out[normalizedProvider] = [...new Set(normalizedEntries)];
76
+ }
77
+
78
+ return out;
79
+ }
80
+
81
+ // Provider-scoped opt-in extension map for subagents. Keep this aligned with
82
+ // the currently shipped codex remote-compaction integration. `delegate.json`
83
+ // `providerExtensions` replaces a provider's entries (it does not append); an
84
+ // empty array is ignored so the default persists.
85
+ const DEFAULT_PROVIDER_EXTENSIONS: Record<string, readonly string[]> = {
86
+ "openai-codex": ["npm:@ogulcancelik/pi-codex-compaction"],
87
+ };
88
+
89
+ function resolveProviderExtensions(
90
+ raw: unknown,
91
+ ): Record<string, readonly string[]> {
92
+ const out = Object.create(null) as Record<string, readonly string[]>;
93
+ Object.assign(
94
+ out,
95
+ DEFAULT_PROVIDER_EXTENSIONS,
96
+ normalizeProviderExtensions(raw),
97
+ );
98
+ return out;
99
+ }
100
+
101
+ const DEFAULT_DELEGATE_CONFIG: DelegateConfig = {
102
+ agent: { default: null },
103
+ concurrency: { default: MAX_CONCURRENCY },
104
+ maxConcurrent: MAX_CONCURRENCY,
105
+ stallTimeoutMs: 15 * 60 * 1000,
106
+ retry: {
107
+ wholeTaskMaxRetries: 3,
108
+ wholeTaskBaseDelayMs: 1_000,
109
+ },
110
+ providerExtensions: DEFAULT_PROVIDER_EXTENSIONS,
111
+ output: {
112
+ spillThresholdChars: OUTPUT_SPILL_THRESHOLD_CHARS,
113
+ spillTailChars: OUTPUT_SPILL_TAIL_CHARS,
114
+ },
115
+ };
116
+
117
+ /** Module-level config singleton. Loaded lazily, mutated by setters. */
118
+ let __delegateConfig: DelegateConfig = {
119
+ ...DEFAULT_DELEGATE_CONFIG,
120
+ agent: { ...DEFAULT_DELEGATE_CONFIG.agent },
121
+ concurrency: { ...DEFAULT_DELEGATE_CONFIG.concurrency },
122
+ };
123
+ let stallTimeoutOverrideForTesting: number | undefined;
124
+
125
+ /** Read delegate config from disk. Returns defaults if file missing or corrupt. */
126
+ export function loadDelegateConfig(): DelegateConfig {
127
+ try {
128
+ const raw = fs.readFileSync(DELEGATE_CONFIG_PATH, "utf-8");
129
+ const parsed = JSON.parse(raw);
130
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
131
+ return structuredClone(DEFAULT_DELEGATE_CONFIG);
132
+ // Merge with defaults so new fields are always present
133
+ return {
134
+ ...DEFAULT_DELEGATE_CONFIG,
135
+ ...parsed,
136
+ agent: { ...DEFAULT_DELEGATE_CONFIG.agent, ...(parsed.agent ?? {}) },
137
+ concurrency: {
138
+ ...DEFAULT_DELEGATE_CONFIG.concurrency,
139
+ ...(parsed.concurrency ?? {}),
140
+ },
141
+ retry: { ...DEFAULT_DELEGATE_CONFIG.retry, ...(parsed.retry ?? {}) },
142
+ providerExtensions: resolveProviderExtensions(parsed.providerExtensions),
143
+ output: { ...DEFAULT_DELEGATE_CONFIG.output, ...(parsed.output ?? {}) },
144
+ } as DelegateConfig;
145
+ } catch {
146
+ return structuredClone(DEFAULT_DELEGATE_CONFIG);
147
+ }
148
+ }
149
+
150
+ /** Initialize module config from disk. Called once at extension load. */
151
+ function initDelegateConfig(): void {
152
+ __delegateConfig = loadDelegateConfig();
153
+ }
154
+
155
+ // Auto-init on module load
156
+ initDelegateConfig();
157
+
158
+ /**
159
+ * Test-only seam: reset the module config singleton to compiled defaults.
160
+ *
161
+ * `__delegateConfig` is auto-initialized from `~/.pi/agent/delegate.json` at
162
+ * import time, so any test that exercises the config-dependent renderers
163
+ * (e.g. the `queued (N running)` label, which reads `getMaxConcurrent()`) is
164
+ * otherwise at the mercy of the developer's on-disk `maxConcurrent`. There are
165
+ * no production mutators (the file is the only write path); this restores the
166
+ * deterministic default baseline for tests, mirroring `_resetPoolForTesting` /
167
+ * `_resetGlobalConcurrencyForTesting`.
168
+ */
169
+ export function _resetDelegateConfigForTesting(): void {
170
+ __delegateConfig = structuredClone(DEFAULT_DELEGATE_CONFIG);
171
+ stallTimeoutOverrideForTesting = undefined;
172
+ }
173
+
174
+ /** @internal Test-only override; production configuration is file-only. */
175
+ export function _setStallTimeoutForTesting(
176
+ timeoutMs: number | undefined,
177
+ ): void {
178
+ stallTimeoutOverrideForTesting = timeoutMs;
179
+ }
180
+
181
+ /**
182
+ * Test-only seam: replace delegate config at runtime.
183
+ *
184
+ * This is only for deterministic tests; production config is file-only and not
185
+ * expected to mutate at runtime.
186
+ */
187
+ export function _setDelegateConfigForTesting(
188
+ config: Partial<DelegateConfig> = {},
189
+ ): void {
190
+ __delegateConfig = {
191
+ ...DEFAULT_DELEGATE_CONFIG,
192
+ ...config,
193
+ agent: {
194
+ ...DEFAULT_DELEGATE_CONFIG.agent,
195
+ ...(config.agent ?? {}),
196
+ },
197
+ concurrency: {
198
+ ...DEFAULT_DELEGATE_CONFIG.concurrency,
199
+ ...(config.concurrency ?? {}),
200
+ },
201
+ retry: {
202
+ ...DEFAULT_DELEGATE_CONFIG.retry,
203
+ ...(config.retry ?? {}),
204
+ },
205
+ providerExtensions: resolveProviderExtensions(config.providerExtensions),
206
+ output: {
207
+ ...DEFAULT_DELEGATE_CONFIG.output,
208
+ ...(config.output ?? {}),
209
+ },
210
+ };
211
+ stallTimeoutOverrideForTesting = undefined;
212
+ }
213
+
214
+ /**
215
+ * Get the configured provider-scoped extension allowlist for subagents.
216
+ * Explicit configs are normalized here too, so callers using the injected
217
+ * config form get the same case-insensitive and replace-per-provider
218
+ * semantics as the file-backed singleton.
219
+ */
220
+ export function getSubagentProviderExtensionMap(
221
+ config: DelegateConfig = __delegateConfig,
222
+ ): Readonly<Record<string, readonly string[]>> {
223
+ return config.providerExtensions
224
+ ? resolveProviderExtensions(config.providerExtensions)
225
+ : DEFAULT_PROVIDER_EXTENSIONS;
226
+ }
227
+
228
+ /**
229
+ * Get extension sources for a specific provider's subagents. Provider matching is
230
+ * case-insensitive.
231
+ */
232
+ export function getSubagentProviderExtensionsForProvider(
233
+ provider: string | undefined,
234
+ config: DelegateConfig = __delegateConfig,
235
+ ): readonly string[] {
236
+ const normalized = provider?.trim().toLowerCase();
237
+ if (!normalized) return [];
238
+ const extensions = getSubagentProviderExtensionMap(config);
239
+ return Object.prototype.hasOwnProperty.call(extensions, normalized)
240
+ ? (extensions[normalized] ?? [])
241
+ : [];
242
+ }
243
+
244
+ // ── Config Getters ───────────────────────────────────────────────────────
245
+
246
+ /**
247
+ * Get the effective concurrency limit for a model key.
248
+ *
249
+ * Precedence: per-model → per-provider → default. Accepts an injected config
250
+ * so the precedence chain is testable without touching the module singleton
251
+ * (there are no config mutators — `delegate.json` is the only write path).
252
+ */
253
+ export function getConcurrencyLimit(
254
+ modelKey: string,
255
+ config: DelegateConfig = __delegateConfig,
256
+ ): number {
257
+ // 1. Per-model
258
+ const perModel = config.concurrency.models?.[modelKey];
259
+ if (perModel != null) return perModel;
260
+ // 2. Per-provider
261
+ const provider = modelKey.split("/")[0];
262
+ const perProvider = config.concurrency.providers?.[provider];
263
+ if (perProvider != null) return perProvider;
264
+ // 3. Default
265
+ return config.concurrency.default;
266
+ }
267
+
268
+ /** Get the effective max async tickets limit. */
269
+ export function getMaxAsyncTickets(): number {
270
+ return __delegateConfig.maxAsyncTickets ?? MAX_ASYNC_TICKETS;
271
+ }
272
+
273
+ /** Get the hard ceiling on total concurrent agents. */
274
+ export function getMaxConcurrent(): number {
275
+ return __delegateConfig.maxConcurrent ?? MAX_CONCURRENCY;
276
+ }
277
+
278
+ /** Maximum inactivity before cooperative stall cancellation is requested.
279
+ * `0` disables detection; malformed values fall back to the compiled default. */
280
+ export function getStallTimeoutMs(
281
+ config: DelegateConfig = __delegateConfig,
282
+ ): number {
283
+ const configured = stallTimeoutOverrideForTesting ?? config.stallTimeoutMs;
284
+ if (
285
+ typeof configured === "number" &&
286
+ Number.isFinite(configured) &&
287
+ configured >= 0
288
+ ) {
289
+ return configured;
290
+ }
291
+ return DEFAULT_DELEGATE_CONFIG.stallTimeoutMs!;
292
+ }
293
+
294
+ /** Get the max whole-task retries after the initial attempt. */
295
+ export function getWholeTaskMaxRetries(): number {
296
+ return __delegateConfig.retry?.wholeTaskMaxRetries ?? 3;
297
+ }
298
+
299
+ /** Get the base delay (ms) for whole-task retry exponential backoff. */
300
+ export function getWholeTaskBaseDelayMs(): number {
301
+ return __delegateConfig.retry?.wholeTaskBaseDelayMs ?? 1_000;
302
+ }
303
+
304
+ /** Get the output-spill threshold (chars). Over this, final output is spilled. */
305
+ export function getOutputSpillThreshold(): number {
306
+ return (
307
+ __delegateConfig.output?.spillThresholdChars ?? OUTPUT_SPILL_THRESHOLD_CHARS
308
+ );
309
+ }
310
+
311
+ /** Get the output-spill tail length (chars) kept in-context when spilled. */
312
+ export function getOutputSpillTail(): number {
313
+ return __delegateConfig.output?.spillTailChars ?? OUTPUT_SPILL_TAIL_CHARS;
314
+ }
315
+
316
+ /**
317
+ * Resolve an *explicit* model spec string using the precedence chain.
318
+ * Returns the first non-null, non-empty string value, or `undefined` when no
319
+ * explicit spec is set — in which case the caller inherits the parent
320
+ * session's model object directly (see extension.ts).
321
+ *
322
+ * Parent inheritance is intentionally NOT a tier here: the parent model is
323
+ * already a resolved, authenticated Model object. Re-resolving its id string
324
+ * through the registry would be both redundant and lossy for composite ids
325
+ * such as OpenRouter's "provider/upstream/model", whose first segment is an
326
+ * upstream provider name, not a configured pi provider — that misroutes auth
327
+ * lookup to the wrong provider.
328
+ *
329
+ * Precedence (highest to lowest):
330
+ * 1. taskModel — per-task explicit override (from API call)
331
+ * 2. config.agent[agentType] — config per-type (delegate.json)
332
+ * 3. config.agent["default"] — config global
333
+ * 4. frontmatterModel — agent .md frontmatter
334
+ */
335
+ export function resolveModelSpec(options: {
336
+ taskModel?: string;
337
+ agentType: string;
338
+ frontmatterModel?: string;
339
+ config?: DelegateConfig;
340
+ }): string | undefined {
341
+ const {
342
+ taskModel,
343
+ agentType,
344
+ frontmatterModel,
345
+ config = __delegateConfig,
346
+ } = options;
347
+
348
+ const candidates: Array<string | null | undefined> = [
349
+ taskModel,
350
+ config.agent[agentType] as string | null | undefined,
351
+ config.agent["default"],
352
+ frontmatterModel,
353
+ ];
354
+
355
+ return candidates.find(
356
+ (v): v is string => typeof v === "string" && v.length > 0,
357
+ );
358
+ }
package/constants.ts ADDED
@@ -0,0 +1,41 @@
1
+ /** Full-capability agent set. Inline-task default and the `*` shorthand.
2
+ * Bash subsumes search, so the dedicated grep/find/ls tools are excluded. */
3
+ export const DEFAULT_TOOLS = ["read", "write", "edit", "bash"];
4
+
5
+ /** Read-only scout set: search without a shell. The `ro` shorthand. */
6
+ export const READONLY_TOOLS = ["read", "grep", "find", "ls"];
7
+
8
+ /** Maximum concurrent subagent tasks. Prevents rate-limit thundering herds. */
9
+ export const MAX_CONCURRENCY = 3;
10
+
11
+ /** Maximum concurrent background async tickets. */
12
+ export const MAX_ASYNC_TICKETS = 5;
13
+
14
+ /** Completed tickets cleaned up after 30 minutes. */
15
+ export const ASYNC_TICKET_TTL_MS = 30 * 60 * 1000;
16
+
17
+ /** Subagent final output longer than this (chars) is spilled to a temp file,
18
+ * keeping only a tail in the LLM-facing context. See `spill.ts`. */
19
+ export const OUTPUT_SPILL_THRESHOLD_CHARS = 8000;
20
+
21
+ /** Tail length (chars) kept in-context when an output is spilled. */
22
+ export const OUTPUT_SPILL_TAIL_CHARS = 2000;
23
+
24
+ /** Literal union of valid thinking levels. Source of truth for both the
25
+ * runtime Set (below) and the TypeBox schema's static type — the `as const`
26
+ * tuple keeps `StringEnum`'s `T[number]` narrow so `DelegateArguments["tasks"]`
27
+ * projects `thinking` to the literal union rather than `string`. */
28
+ export const VALID_THINKING_LEVELS = [
29
+ "off",
30
+ "minimal",
31
+ "low",
32
+ "medium",
33
+ "high",
34
+ "xhigh",
35
+ "max",
36
+ ] as const;
37
+
38
+ /** Runtime membership check for thinking levels. Kept as `Set<string>` (not
39
+ * `Set<VALID_THINKING_LEVELS[number]>`) so the `.has(string)` call sites in
40
+ * task-resolution.ts and agents.ts stay ergonomic under strict typing. */
41
+ export const VALID_THINKING: Set<string> = new Set(VALID_THINKING_LEVELS);
package/delegate.ts ADDED
@@ -0,0 +1,115 @@
1
+ export { default } from "./extension.ts";
2
+
3
+ export type {
4
+ AgentConfig,
5
+ SessionAction,
6
+ DelegateAction,
7
+ DelegateArguments,
8
+ TaskDef,
9
+ AsyncTicket,
10
+ ResolvedTask,
11
+ ToolActivity,
12
+ TaskProgress,
13
+ DelegateDetails,
14
+ TaskResult,
15
+ TaskFailureKind,
16
+ ReuseIntent,
17
+ AgentRunConfig,
18
+ TaskRunEnv,
19
+ } from "./types.ts";
20
+ export type { DelegateConfig } from "./config.ts";
21
+
22
+ export {
23
+ DEFAULT_TOOLS,
24
+ READONLY_TOOLS,
25
+ MAX_CONCURRENCY,
26
+ VALID_THINKING,
27
+ } from "./constants.ts";
28
+ export { TOOL_FACTORIES, resolveToolGroups } from "./tools.ts";
29
+ export {
30
+ loadDelegateConfig,
31
+ getConcurrencyLimit,
32
+ getMaxAsyncTickets,
33
+ getMaxConcurrent,
34
+ getStallTimeoutMs,
35
+ resolveModelSpec,
36
+ getOutputSpillThreshold,
37
+ getOutputSpillTail,
38
+ } from "./config.ts";
39
+ export {
40
+ checkout,
41
+ commit,
42
+ configFor,
43
+ closePooledAgent,
44
+ closeAllPooledAgents,
45
+ listPooledAgents,
46
+ withSessionLock,
47
+ } from "./pool.ts";
48
+ export type {
49
+ FrozenConfig,
50
+ ConfigCandidate,
51
+ ConfigMismatch,
52
+ CheckoutResult,
53
+ CommitPayload,
54
+ } from "./pool.ts";
55
+ export {
56
+ ticketRegistry,
57
+ sweepTickets,
58
+ cancelTicketForShutdown,
59
+ isSessionBusy,
60
+ handlePoll,
61
+ handleCancel,
62
+ handleWait,
63
+ notifyWaiters,
64
+ deliverTicketResults,
65
+ resolveFinalTicketStatus,
66
+ formatCompletedTicket,
67
+ } from "./tickets.ts";
68
+ export { runAgentSession } from "./runner.ts";
69
+ export { getHostDeps } from "./host.ts";
70
+ export type { HostDeps, HostDepsOptions } from "./host.ts";
71
+ export {
72
+ emptyUsage,
73
+ snapshotSessionUsage,
74
+ usageDelta,
75
+ addUsage,
76
+ sumUsage,
77
+ } from "./usage.ts";
78
+ export type { SessionUsageSnapshot } from "./usage.ts";
79
+ export {
80
+ truncLine,
81
+ shortenPath,
82
+ getActivityAge,
83
+ fmtDuration,
84
+ fmtTokens,
85
+ trunc,
86
+ tree,
87
+ indent,
88
+ formatFailedTask,
89
+ formatCompletedTask,
90
+ } from "./format.ts";
91
+ export {
92
+ parseFrontmatter,
93
+ findProjectRoot,
94
+ loadAgentFile,
95
+ loadClaudeAgentFile,
96
+ discoverAgents,
97
+ buildSubagentSystemPrompt,
98
+ DEFAULT_SUBAGENT_SYSTEM_PROMPT,
99
+ } from "./agents.ts";
100
+ export { buildParentTranscript, extractTextContent } from "./parent-context.ts";
101
+ export { extractTouchedFromActivities } from "./file-tracking.ts";
102
+ export {
103
+ resolveModel,
104
+ resolveModelRequest,
105
+ findAvailableAlternative,
106
+ } from "./model.ts";
107
+ export { readDelegateSettingsFile, loadDelegateSettings } from "./settings.ts";
108
+ export { resolveCwd, extractOutput, extractUsage } from "./utils.ts";
109
+ export {
110
+ decideSpill,
111
+ spillToTempFile,
112
+ renderOutputForLLM,
113
+ renderOutputForPoll,
114
+ } from "./spill.ts";
115
+ export type { SpillDecision } from "./spill.ts";