pi-subagents-lite 0.2.0 → 0.3.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.
@@ -17,13 +17,27 @@ import type { Model } from "@earendil-works/pi-ai";
17
17
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
18
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
19
19
  import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
20
- import type { AgentInvocation, AgentRecord, SubagentType, ThinkingLevel } from "./types.js";
21
- import { addUsage, getLifetimeTotal } from "./usage.js";
20
+ import type {
21
+ AgentInvocation,
22
+ AgentRecord,
23
+ CompactionInfo,
24
+ SubagentType,
25
+ ThinkingLevel,
26
+ } from "./types.js";
27
+ import { addUsage, getLifetimeTotal, type LifetimeUsage } from "./usage.js";
28
+ import { errorMessage } from "./utils.js";
22
29
 
23
- /** Safely extract a human-readable error message from an unknown exception. */
24
- function errorMessage(err: unknown): string {
25
- return err instanceof Error ? err.message : String(err);
26
- }
30
+ /** How often to check for expired agent records (milliseconds). */
31
+ const CLEANUP_INTERVAL_MS = 60_000;
32
+
33
+ /** Age after which a completed agent record is evicted (milliseconds). */
34
+ const CLEANUP_AGE_CUTOFF_MS = 10 * 60_000;
35
+
36
+ /** Length of short agent ID (UUID prefix). */
37
+ const AGENT_ID_LENGTH = 17;
38
+
39
+ /** Default per-model concurrency limit when not specified in config. */
40
+ const DEFAULT_CONCURRENCY_LIMIT = 4;
27
41
 
28
42
  /**
29
43
  * Create a cleanup function for the output file stream.
@@ -35,12 +49,13 @@ function createOutputCleanup(
35
49
  path: string,
36
50
  record: AgentRecord,
37
51
  ): () => void {
38
- const outputStats = { turnCount: 0, toolUseCount: 0, totalTokens: 0 };
52
+ const outputStats = { turnCount: 0, toolUseCount: 0, totalTokens: 0, cost: 0 };
39
53
  const cleanup = streamToOutputFile(session, path, outputStats);
40
54
  return () => {
41
55
  outputStats.turnCount = record.turnCount ?? 0;
42
56
  outputStats.toolUseCount = record.toolUses;
43
57
  outputStats.totalTokens = getLifetimeTotal(record.lifetimeUsage);
58
+ outputStats.cost = record.lifetimeUsage.cost;
44
59
  cleanup();
45
60
  };
46
61
  }
@@ -57,7 +72,7 @@ export interface ConcurrencyConfig {
57
72
  /** Per-provider concurrency limits keyed by provider name (e.g. "llamacpp"). */
58
73
  providers?: Record<string, number>;
59
74
  /** Per-model concurrency limits keyed by "provider/modelId". */
60
- models: Record<string, number>;
75
+ models?: Record<string, number>;
61
76
  }
62
77
 
63
78
  type OnAgentComplete = (record: AgentRecord) => void;
@@ -103,9 +118,9 @@ export interface SpawnOptions {
103
118
  /** Called at the end of each agentic turn with the cumulative count. */
104
119
  onTurnEnd?: (turnCount: number) => void;
105
120
  /** Called once per assistant message_end with that message's usage delta. */
106
- onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
121
+ onAssistantUsage?: (usage: LifetimeUsage) => void;
107
122
  /** Called when the session successfully compacts. */
108
- onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
123
+ onCompaction?: (info: CompactionInfo) => void;
109
124
  }
110
125
 
111
126
  export class AgentManager {
@@ -133,24 +148,19 @@ export class AgentManager {
133
148
  ) {
134
149
  this.onComplete = onComplete;
135
150
  this.onStart = onStart;
136
- this.defaultConcurrency = concurrency?.default ?? 4;
151
+ this.defaultConcurrency = concurrency?.default ?? DEFAULT_CONCURRENCY_LIMIT;
137
152
 
138
153
  // Initialize per-provider slots from config (shared pool)
139
- if (concurrency?.providers) {
140
- for (const [provider, limit] of Object.entries(concurrency.providers)) {
141
- this.providerSlots.set(provider, { limit: Math.max(1, limit), running: 0 });
142
- }
154
+ for (const [provider, limit] of Object.entries(concurrency?.providers ?? {})) {
155
+ this.applyConcurrencyEntry(this.providerSlots, provider, limit);
143
156
  }
144
157
 
145
158
  // Initialize per-model slots from config
146
- if (concurrency?.models) {
147
- for (const [modelKey, limit] of Object.entries(concurrency.models)) {
148
- this.concurrencySlots.set(modelKey, { limit: Math.max(1, limit), running: 0 });
149
- }
159
+ for (const [modelKey, limit] of Object.entries(concurrency?.models ?? {})) {
160
+ this.applyConcurrencyEntry(this.concurrencySlots, modelKey, limit);
150
161
  }
151
162
 
152
- // Cleanup completed agents after 10 minutes (but keep sessions for resume)
153
- this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
163
+ this.cleanupInterval = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
154
164
  this.cleanupInterval.unref();
155
165
  }
156
166
 
@@ -164,50 +174,52 @@ export class AgentManager {
164
174
  this.defaultConcurrency = config.default;
165
175
 
166
176
  // Update per-provider slots (shared pool)
167
- if (config.providers) {
168
- for (const [provider, limit] of Object.entries(config.providers)) {
169
- const existing = this.providerSlots.get(provider);
170
- if (existing) {
171
- existing.limit = Math.max(1, limit);
172
- } else {
173
- this.providerSlots.set(provider, { limit: Math.max(1, limit), running: 0 });
174
- }
175
- }
177
+ for (const [provider, limit] of Object.entries(config.providers ?? {})) {
178
+ this.applyConcurrencyEntry(this.providerSlots, provider, limit);
176
179
  }
177
180
 
178
181
  // Update existing slots and create new ones
179
- for (const [modelKey, limit] of Object.entries(config.models)) {
180
- const existing = this.concurrencySlots.get(modelKey);
181
- if (existing) {
182
- existing.limit = Math.max(1, limit);
183
- } else {
184
- this.concurrencySlots.set(modelKey, { limit: Math.max(1, limit), running: 0 });
185
- }
182
+ for (const [modelKey, limit] of Object.entries(config.models ?? {})) {
183
+ this.applyConcurrencyEntry(this.concurrencySlots, modelKey, limit);
186
184
  }
187
185
 
188
186
  // Start queued agents if the new limits allow
189
187
  this.drainQueue();
190
188
  }
191
189
 
190
+ /**
191
+ * Update or create a concurrency slot entry.
192
+ * If the key already exists in the map, updates its limit.
193
+ * Otherwise, creates a new slot with the given limit and running=0.
194
+ */
195
+ private applyConcurrencyEntry(map: Map<string, ConcurrencySlot>, key: string, limit: number): void {
196
+ const safeLimit = Math.max(1, limit);
197
+ const existing = map.get(key);
198
+ if (existing) {
199
+ existing.limit = safeLimit;
200
+ } else {
201
+ map.set(key, { limit: safeLimit, running: 0 });
202
+ }
203
+ }
204
+
192
205
  /**
193
206
  * Get or create a concurrency slot for a model key.
194
207
  * Precedence: per-model slot > per-provider shared slot > default (per-model).
195
- * Returns { slot, isProviderSlot } so caller knows which slot to decrement.
196
208
  */
197
- private getSlot(modelKey: string): { slot: ConcurrencySlot; isProviderSlot: boolean } {
209
+ private getSlot(modelKey: string): ConcurrencySlot {
198
210
  // 1. Check per-model slot
199
211
  let slot = this.concurrencySlots.get(modelKey);
200
- if (slot) return { slot, isProviderSlot: false };
212
+ if (slot) return slot;
201
213
 
202
214
  // 2. Check per-provider shared slot
203
215
  const provider = modelKey.split("/")[0];
204
216
  const providerSlot = this.providerSlots.get(provider);
205
- if (providerSlot) return { slot: providerSlot, isProviderSlot: true };
217
+ if (providerSlot) return providerSlot;
206
218
 
207
219
  // 3. Create per-model slot with default limit
208
220
  slot = { limit: Math.max(1, this.defaultConcurrency), running: 0 };
209
221
  this.concurrencySlots.set(modelKey, slot);
210
- return { slot, isProviderSlot: false };
222
+ return slot;
211
223
  }
212
224
 
213
225
  /**
@@ -221,7 +233,7 @@ export class AgentManager {
221
233
  prompt: string,
222
234
  options: SpawnOptions,
223
235
  ): string {
224
- const id = randomUUID().slice(0, 17);
236
+ const id = randomUUID().slice(0, AGENT_ID_LENGTH);
225
237
  const abortController = new AbortController();
226
238
  const args: SpawnArgs = { pi, ctx, type, prompt, options };
227
239
 
@@ -229,7 +241,7 @@ export class AgentManager {
229
241
  let queued = false;
230
242
  let concurrencySlot: ConcurrencySlot | undefined;
231
243
  if (options.modelKey) {
232
- const { slot } = this.getSlot(options.modelKey);
244
+ const slot = this.getSlot(options.modelKey);
233
245
  if (slot.running >= slot.limit) {
234
246
  queued = true;
235
247
  this.queue.push({ id, modelKey: options.modelKey, args });
@@ -246,7 +258,7 @@ export class AgentManager {
246
258
  toolUses: 0,
247
259
  startedAt: Date.now(),
248
260
  abortController,
249
- lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
261
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
250
262
  compactionCount: 0,
251
263
  invocation: options.invocation,
252
264
  maxTurns: options.maxTurns,
@@ -300,29 +312,21 @@ export class AgentManager {
300
312
  isolated: options.isolated,
301
313
  thinkingLevel: options.thinkingLevel,
302
314
  signal: record.abortController!.signal,
303
- onToolActivity: (activity) => {
304
- if (activity.type === "end") record.toolUses++;
305
- options.onToolActivity?.(activity);
306
- },
315
+ ...this.createRecordCallbacks(record, options),
307
316
  onTurnEnd: (turnCount) => {
308
317
  record.turnCount = turnCount;
309
318
  options.onTurnEnd?.(turnCount);
310
319
  },
311
320
  onTextDelta: options.onTextDelta,
312
- onAssistantUsage: (usage) => {
313
- addUsage(record.lifetimeUsage, usage);
314
- options.onAssistantUsage?.(usage);
315
- },
316
- onCompaction: (info) => {
317
- record.compactionCount++;
318
- options.onCompaction?.(info);
319
- },
320
321
  onSessionCreated: (session) => {
321
322
  record.session = session;
322
323
  // Flush any steers that arrived before the session was ready
323
324
  if (record.pendingSteers?.length) {
324
325
  for (const msg of record.pendingSteers) {
325
- session.steer(msg).catch(() => {});
326
+ session.steer(msg).catch(() => {
327
+ // Steer is advisory — a failure here (e.g. session already aborting)
328
+ // is fine; the user can re-send if needed.
329
+ });
326
330
  }
327
331
  record.pendingSteers = undefined;
328
332
  }
@@ -364,13 +368,47 @@ export class AgentManager {
364
368
  // Decrement per-model concurrency count
365
369
  if (concurrencySlot) concurrencySlot.running--;
366
370
 
367
- try { this.onComplete?.(record); } catch { /* ignore */ }
371
+ this.safeNotifyComplete(record);
368
372
  this.drainQueue();
369
373
  });
370
374
 
371
375
  record.promise = promise;
372
376
  }
373
377
 
378
+ /** Notify completion callback, ignoring any errors. */
379
+ private safeNotifyComplete(record: AgentRecord): void {
380
+ try { this.onComplete?.(record); } catch { /* ignore */ }
381
+ }
382
+
383
+ /**
384
+ * Build common record-tracking callbacks shared by startAgent and resume.
385
+ * Updates the record's toolUses, lifetimeUsage, and compactionCount.
386
+ * When options are provided, also forwards events to the caller.
387
+ */
388
+ private createRecordCallbacks(
389
+ record: AgentRecord,
390
+ options?: Pick<SpawnOptions, "onToolActivity" | "onAssistantUsage" | "onCompaction">,
391
+ ): {
392
+ onToolActivity: (activity: ToolActivity) => void;
393
+ onAssistantUsage: (usage: LifetimeUsage) => void;
394
+ onCompaction: (info: CompactionInfo) => void;
395
+ } {
396
+ return {
397
+ onToolActivity: (activity) => {
398
+ if (activity.type === "end") record.toolUses++;
399
+ options?.onToolActivity?.(activity);
400
+ },
401
+ onAssistantUsage: (usage) => {
402
+ addUsage(record.lifetimeUsage, usage);
403
+ options?.onAssistantUsage?.(usage);
404
+ },
405
+ onCompaction: (info) => {
406
+ record.compactionCount++;
407
+ options?.onCompaction?.(info);
408
+ },
409
+ };
410
+ }
411
+
374
412
  /** Start queued agents up to the per-model concurrency limits. */
375
413
  private drainQueue() {
376
414
  const started = new Set<string>();
@@ -390,29 +428,12 @@ export class AgentManager {
390
428
  record.error = errorMessage(err);
391
429
  record.completedAt = Date.now();
392
430
  started.add(entry.id);
393
- this.onComplete?.(record);
431
+ this.safeNotifyComplete(record);
394
432
  }
395
433
  }
396
434
  this.queue = this.queue.filter(e => !started.has(e.id));
397
435
  }
398
436
 
399
- /**
400
- * Spawn an agent and wait for completion (foreground use).
401
- * Respects per-model concurrency limits — queued if at capacity, awaited on completion.
402
- */
403
- async spawnAndWait(
404
- pi: ExtensionAPI,
405
- ctx: ExtensionContext,
406
- type: SubagentType,
407
- prompt: string,
408
- options: Omit<SpawnOptions, "isBackground">,
409
- ): Promise<AgentRecord> {
410
- const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
411
- const record = this.agents.get(id)!;
412
- await record.promise;
413
- return record;
414
- }
415
-
416
437
  /**
417
438
  * Resume an existing agent session with a new prompt.
418
439
  */
@@ -432,15 +453,7 @@ export class AgentManager {
432
453
 
433
454
  try {
434
455
  const responseText = await resumeAgent(record.session, prompt, {
435
- onToolActivity: (activity) => {
436
- if (activity.type === "end") record.toolUses++;
437
- },
438
- onAssistantUsage: (usage) => {
439
- addUsage(record.lifetimeUsage, usage);
440
- },
441
- onCompaction: (info) => {
442
- record.compactionCount++;
443
- },
456
+ ...this.createRecordCallbacks(record),
444
457
  signal,
445
458
  });
446
459
  record.status = "completed";
@@ -476,6 +489,7 @@ export class AgentManager {
476
489
  await record.session.steer(message);
477
490
  return true;
478
491
  } catch {
492
+ // steer failures are surfaced to the caller via the boolean return value
479
493
  return false;
480
494
  }
481
495
  }
@@ -526,7 +540,7 @@ export class AgentManager {
526
540
  }
527
541
 
528
542
  private cleanup() {
529
- const cutoff = Date.now() - 10 * 60_000;
543
+ const cutoff = Date.now() - CLEANUP_AGE_CUTOFF_MS;
530
544
  for (const [id, record] of this.agents) {
531
545
  if (!isTerminalStatus(record.status)) continue;
532
546
  if ((record.completedAt ?? 0) >= cutoff) continue;
@@ -25,10 +25,12 @@ import {
25
25
  } from "@earendil-works/pi-coding-agent";
26
26
  import { getAgentConfig, getConfig, getToolNamesForType } from "./agent-types.js";
27
27
  import { extractText } from "./context.js";
28
+ import type { LifetimeUsage } from "./usage.js";
29
+ import { findModelInRegistry } from "./utils.js";
28
30
  import { DEFAULT_AGENTS } from "./default-agents.js";
29
31
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
30
32
  import { preloadSkills } from "./skill-loader.js";
31
- import type { EnvInfo, SubagentType, ThinkingLevel } from "./types.js";
33
+ import type { CompactionInfo, EnvInfo, SubagentType, ThinkingLevel } from "./types.js";
32
34
 
33
35
  /** Names of tools registered by this extension that subagents must NOT inherit. */
34
36
  export const EXCLUDED_TOOL_NAMES = ["Agent"];
@@ -36,34 +38,15 @@ export const EXCLUDED_TOOL_NAMES = ["Agent"];
36
38
  /** Additional turns allowed after the soft limit steer message. */
37
39
  const GRACE_TURNS = 5;
38
40
 
41
+ /** Timeout for quick git commands (branch detection, repo check). */
42
+ const GIT_EXEC_TIMEOUT_MS = 5000;
43
+
39
44
  /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
40
45
  function normalizeMaxTurns(n: number | undefined): number | undefined {
41
46
  if (n == null || n === 0) return undefined;
42
47
  return Math.max(1, n);
43
48
  }
44
49
 
45
- /**
46
- * Try to find the right model for an agent type.
47
- * Priority: explicit option > config.model > parent model.
48
- */
49
- function resolveDefaultModel(
50
- parentModel: Model<any> | undefined,
51
- registry: { find(provider: string, modelId: string): Model<any> | undefined },
52
- configModel?: string,
53
- ): Model<any> | undefined {
54
- if (configModel) {
55
- const slashIdx = configModel.indexOf("/");
56
- if (slashIdx !== -1) {
57
- const provider = configModel.slice(0, slashIdx);
58
- const modelId = configModel.slice(slashIdx + 1);
59
- const found = registry.find(provider, modelId);
60
- if (found) return found;
61
- }
62
- }
63
-
64
- return parentModel;
65
- }
66
-
67
50
  /** Info about a tool event in the subagent. */
68
51
  export interface ToolActivity {
69
52
  type: "start" | "end";
@@ -95,12 +78,12 @@ interface RunOptions {
95
78
  * Lets callers maintain a lifetime accumulator that survives compaction
96
79
  * (which replaces session.state.messages and resets stats-derived sums).
97
80
  */
98
- onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
81
+ onAssistantUsage?: (usage: LifetimeUsage) => void;
99
82
  /**
100
83
  * Called when the session successfully compacts. `tokensBefore` is upstream's
101
84
  * pre-compaction context size estimate. Aborted compactions don't fire.
102
85
  */
103
- onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
86
+ onCompaction?: (info: CompactionInfo) => void;
104
87
  }
105
88
 
106
89
  interface RunResult {
@@ -155,11 +138,28 @@ function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () =>
155
138
  return () => signal.removeEventListener("abort", onAbort);
156
139
  }
157
140
 
141
+ /**
142
+ * Extract a LifetimeUsage from a runtime assistant message_end event.
143
+ * pi-ai attaches `usage: { input, output, cacheWrite, cost: { total } }` to
144
+ * assistant messages at runtime, but this shape isn't reflected in the
145
+ * AgentSessionEvent public types.
146
+ */
147
+ function usageFromAssistantMessage(msg: Record<string, unknown>): LifetimeUsage | undefined {
148
+ const usage = msg.usage as Record<string, unknown> | undefined;
149
+ if (!usage) return undefined;
150
+ return {
151
+ input: (usage.input as number) ?? 0,
152
+ output: (usage.output as number) ?? 0,
153
+ cacheWrite: (usage.cacheWrite as number) ?? 0,
154
+ cost: ((usage.cost as Record<string, unknown>)?.total as number) ?? 0,
155
+ };
156
+ }
157
+
158
158
  /**
159
159
  * Subscribe to shared session events (tool activity, usage, compaction)
160
160
  * used by both runAgent and resumeAgent. Returns an unsubscribe function.
161
161
  */
162
- function subscribeToSessionEvents(
162
+ export function subscribeToSessionEvents(
163
163
  session: AgentSession,
164
164
  options: Pick<RunOptions, "onToolActivity" | "onAssistantUsage" | "onCompaction">,
165
165
  ): () => void {
@@ -174,13 +174,10 @@ function subscribeToSessionEvents(
174
174
  options.onToolActivity?.({ type: "end", toolName: event.toolName });
175
175
  }
176
176
  if (event.type === "message_end" && event.message.role === "assistant") {
177
- const u = (event.message as any).usage;
178
- if (u) {
179
- options.onAssistantUsage?.({
180
- input: u.input ?? 0,
181
- output: u.output ?? 0,
182
- cacheWrite: u.cacheWrite ?? 0,
183
- });
177
+ const msg = event.message as unknown as Record<string, unknown>;
178
+ const usage = usageFromAssistantMessage(msg);
179
+ if (usage) {
180
+ options.onAssistantUsage?.(usage);
184
181
  }
185
182
  }
186
183
  if (event.type === "compaction_end" && !event.aborted && event.result) {
@@ -210,7 +207,7 @@ function filterActiveTools(
210
207
  }
211
208
 
212
209
  const builtinToolNameSet = new Set(builtinToolNames);
213
- return activeTools.filter((t) => {
210
+ const filtered = activeTools.filter((t) => {
214
211
  if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
215
212
  if (disallowedSet?.has(t)) return false;
216
213
  if (builtinToolNameSet.has(t)) return true;
@@ -219,12 +216,13 @@ function filterActiveTools(
219
216
  }
220
217
  return true;
221
218
  });
219
+ return filtered.length !== activeTools.length ? filtered : null;
222
220
  }
223
221
 
224
222
  /** Run a git command via pi.exec, returning stdout on success or null on failure. */
225
223
  async function execGit(pi: ExtensionAPI, args: string[], cwd: string): Promise<string | null> {
226
224
  try {
227
- const result = await pi.exec("git", args, { cwd, timeout: 5000 });
225
+ const result = await pi.exec("git", args, { cwd, timeout: GIT_EXEC_TIMEOUT_MS });
228
226
  return result.code === 0 ? result.stdout.trim() : null;
229
227
  } catch {
230
228
  return null;
@@ -262,8 +260,10 @@ export async function runAgent(
262
260
  const env = await detectEnv(options.pi, effectiveCwd);
263
261
 
264
262
  // Resolve extensions/skills: isolated overrides to false
265
- const extensions = options.isolated ? false : config.extensions;
266
- const skills = options.isolated ? false : config.skills;
263
+ // Falls back to agent config (frontmatter) when not set via options (tool injection)
264
+ const effectiveIsolated = options.isolated ?? agentConfig?.isolated;
265
+ const extensions = effectiveIsolated ? false : config.extensions;
266
+ const skills = effectiveIsolated ? false : config.skills;
267
267
 
268
268
  // Build prompt extras (no memoryBlock — skills only).
269
269
  // When skills is string[], preload their content into the prompt.
@@ -305,8 +305,8 @@ export async function runAgent(
305
305
  await loader.reload();
306
306
 
307
307
  // Resolve model: explicit option > config.model > parent model
308
- const model = options.model ?? resolveDefaultModel(
309
- ctx.model, ctx.modelRegistry, agentConfig?.model,
308
+ const model = options.model ?? findModelInRegistry(
309
+ agentConfig?.model, ctx.modelRegistry, ctx.model,
310
310
  );
311
311
 
312
312
  // Resolve thinking level: explicit option > agent config > undefined (inherit)
@@ -419,16 +419,7 @@ export async function resumeAgent(
419
419
  return collector.getText().trim() || getLastAssistantText(session);
420
420
  }
421
421
 
422
- /**
423
- * Send a steering message to a running subagent.
424
- * The message will interrupt the agent after its current tool execution.
425
- */
426
- export async function steerAgent(
427
- session: AgentSession,
428
- message: string,
429
- ): Promise<void> {
430
- await session.steer(message);
431
- }
422
+
432
423
 
433
424
 
434
425
 
@@ -12,7 +12,7 @@ import { DEFAULT_AGENTS } from "./default-agents.js";
12
12
  import type { AgentConfig } from "./types.js";
13
13
 
14
14
  /** All known built-in tool names. */
15
- export const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
15
+ const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
16
16
 
17
17
  /** Unified runtime registry of all agents (defaults + user-defined). */
18
18
  const agents = new Map<string, AgentConfig>();
@@ -36,8 +36,8 @@ export function registerAgents(userAgents: Map<string, AgentConfig>): void {
36
36
  }
37
37
  }
38
38
 
39
- /** Case-insensitive key resolution, also matches displayName. */
40
- function resolveKey(name: string): string | undefined {
39
+ /** Resolve a type name case-insensitively. Also matches displayName. Returns the canonical key or undefined. */
40
+ export function resolveType(name: string): string | undefined {
41
41
  if (!name) return undefined;
42
42
  if (agents.has(name)) return name;
43
43
  const lower = name.toLowerCase();
@@ -48,14 +48,9 @@ function resolveKey(name: string): string | undefined {
48
48
  return undefined;
49
49
  }
50
50
 
51
- /** Resolve a type name case-insensitively. Returns the canonical key or undefined. */
52
- export function resolveType(name: string): string | undefined {
53
- return resolveKey(name);
54
- }
55
-
56
51
  /** Get the agent config for a type (case-insensitive). */
57
52
  export function getAgentConfig(name: string): AgentConfig | undefined {
58
- const key = resolveKey(name);
53
+ const key = resolveType(name);
59
54
  return key ? agents.get(key) : undefined;
60
55
  }
61
56
 
@@ -71,26 +66,6 @@ export function getAllTypes(): string[] {
71
66
  return [...agents.keys()];
72
67
  }
73
68
 
74
- /** Get names of default agents currently in the registry. */
75
- export function getDefaultAgentNames(): string[] {
76
- return [...agents.entries()]
77
- .filter(([_, config]) => config.isDefault === true)
78
- .map(([name]) => name);
79
- }
80
-
81
- /** Get names of user-defined agents (non-defaults) currently in the registry. */
82
- export function getUserAgentNames(): string[] {
83
- return [...agents.entries()]
84
- .filter(([_, config]) => config.isDefault !== true)
85
- .map(([name]) => name);
86
- }
87
-
88
- /** Check if a type is valid and enabled (case-insensitive). */
89
- export function isValidType(type: string): boolean {
90
- const config = getAgentConfig(type);
91
- return config !== undefined && config.enabled !== false;
92
- }
93
-
94
69
  /** Get built-in tool names for a type (case-insensitive). */
95
70
  export function getToolNamesForType(type: string): string[] {
96
71
  const config = getAgentConfig(type);
@@ -99,14 +74,16 @@ export function getToolNamesForType(type: string): string[] {
99
74
  : [...BUILTIN_TOOL_NAMES];
100
75
  }
101
76
 
102
- /** Convert an AgentConfig to the SubagentTypeConfig shape used by getConfig. */
103
- function toSubagentTypeConfig(config: AgentConfig): {
77
+ /** Resolved config shape returned by getConfig. */
78
+ interface ResolvedAgentConfig {
104
79
  displayName: string;
105
80
  description: string;
106
81
  builtinToolNames: string[];
107
82
  extensions: true | string[] | false;
108
83
  skills: true | string[] | false;
109
- } {
84
+ }
85
+
86
+ function toResolved(config: AgentConfig): ResolvedAgentConfig {
110
87
  return {
111
88
  displayName: config.displayName ?? config.name,
112
89
  description: config.description,
@@ -117,16 +94,17 @@ function toSubagentTypeConfig(config: AgentConfig): {
117
94
  }
118
95
 
119
96
  /** Get config for a type (case-insensitive). Falls back to general-purpose. */
120
- export function getConfig(type: string): ReturnType<typeof toSubagentTypeConfig> {
121
- const key = resolveKey(type);
122
- const config = key ? agents.get(key) : undefined;
97
+ export function getConfig(type: string): ResolvedAgentConfig {
98
+ const resolvedKey = resolveType(type);
99
+ const config = resolvedKey ? agents.get(resolvedKey) : undefined;
123
100
 
124
- const activeConfig = (config?.enabled !== false)
101
+ // If config exists and is enabled, use it; otherwise fall back to general-purpose
102
+ const activeConfig = config?.enabled !== false
125
103
  ? config
126
104
  : agents.get("general-purpose");
127
105
 
128
106
  if (activeConfig && activeConfig.enabled !== false) {
129
- return toSubagentTypeConfig(activeConfig);
107
+ return toResolved(activeConfig);
130
108
  }
131
109
 
132
110
  // Absolute fallback — general-purpose was disabled or missing
@@ -0,0 +1,40 @@
1
+ /**
2
+ * config-io.ts — Config persistence (read/write).
3
+ *
4
+ * Atomic writes: write to .tmp then rename.
5
+ * Loaded at session_start; saved on every /agents menu mutation.
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import type { SubagentsConfig } from "./model-precedence.js";
11
+
12
+ const CONFIG_DIR = path.join(process.env.HOME || "", ".pi", "agent");
13
+ const CONFIG_PATH = path.join(CONFIG_DIR, "subagents-lite.json");
14
+
15
+ /** Read config from disk. Returns defaults if file doesn't exist or is invalid. */
16
+ export function loadConfig(): SubagentsConfig {
17
+ try {
18
+ const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
19
+ return JSON.parse(raw) as SubagentsConfig;
20
+ } catch {
21
+ // File doesn't exist or is invalid — return defaults
22
+ }
23
+
24
+ return {
25
+ agent: { default: null, forceBackground: false },
26
+ concurrency: { default: 4 },
27
+ };
28
+ }
29
+
30
+ /** Write config to disk with atomic rename. */
31
+ export function saveConfigAtomic(config: SubagentsConfig): void {
32
+ const tmpPath = CONFIG_PATH + ".tmp";
33
+ try {
34
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
35
+ fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), "utf-8");
36
+ fs.renameSync(tmpPath, CONFIG_PATH);
37
+ } catch (err) {
38
+ console.error(`[subagents] Failed to save config: ${err}`);
39
+ }
40
+ }