@ifi/oh-pi-ant-colony 0.2.3 → 0.2.7

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.
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Budget Planner — Usage-aware resource allocation for the ant colony.
3
+ *
4
+ * Integrates with the usage-tracker extension via `pi.events` to access
5
+ * real-time provider rate limits (Claude session/weekly %, Codex 5h/weekly %)
6
+ * and session cost data. Uses this information to:
7
+ *
8
+ * 1. **Allocate per-caste budgets** — scouts get less (exploration is cheap),
9
+ * workers get the bulk, soldiers get a review slice.
10
+ * 2. **Cap concurrency** — when rate limits are low, reduce parallel ants
11
+ * to avoid 429s.
12
+ * 3. **Set per-ant cost ceilings** — individual ants get a maxCost derived
13
+ * from the remaining budget and rate limit headroom.
14
+ * 4. **Inject budget context into prompts** — ants know how tight the budget
15
+ * is and can adjust their behavior (e.g. skip low-priority work).
16
+ *
17
+ * The planner is purely functional: it takes usage data and colony state,
18
+ * returns an allocation. No side effects.
19
+ */
20
+
21
+ import type { AntCaste, ColonyMetrics, ConcurrencyConfig } from "./types.js";
22
+
23
+ // ═══ Types ═══
24
+
25
+ /** Rate limit window from a provider (mirrors usage-tracker's RateWindow). */
26
+ export interface RateWindow {
27
+ label: string;
28
+ percentLeft: number;
29
+ resetDescription: string | null;
30
+ }
31
+
32
+ /** Rate limit snapshot from a provider (mirrors usage-tracker's ProviderRateLimits). */
33
+ export interface ProviderRateLimits {
34
+ provider: string;
35
+ windows: RateWindow[];
36
+ credits: number | null;
37
+ probedAt: number;
38
+ error: string | null;
39
+ }
40
+
41
+ /** Per-model usage data from the usage-tracker (mirrors usage-tracker's ModelUsage). */
42
+ export interface ModelUsageSnapshot {
43
+ model: string;
44
+ provider: string;
45
+ turns: number;
46
+ input: number;
47
+ output: number;
48
+ costTotal: number;
49
+ }
50
+
51
+ /** Aggregate usage data broadcast by the usage-tracker extension via pi.events. */
52
+ export interface UsageLimitsEvent {
53
+ providers: Map<string, ProviderRateLimits> | Record<string, ProviderRateLimits>;
54
+ sessionCost: number;
55
+ perModel: Map<string, ModelUsageSnapshot> | Record<string, ModelUsageSnapshot>;
56
+ }
57
+
58
+ /** Budget allocation for a single caste. */
59
+ export interface CasteBudget {
60
+ /** Maximum total cost this caste may spend (USD). */
61
+ maxCost: number;
62
+ /** Maximum cost per individual ant (USD). */
63
+ maxCostPerAnt: number;
64
+ /** Maximum recommended concurrent ants for this caste. */
65
+ maxConcurrency: number;
66
+ /** Maximum turns per ant (tighter budget → fewer turns). */
67
+ maxTurns: number;
68
+ }
69
+
70
+ /** Full budget plan for a colony run. */
71
+ export interface BudgetPlan {
72
+ /** Per-caste allocations. */
73
+ castes: Record<AntCaste, CasteBudget>;
74
+ /** Recommended global max concurrency (overrides adaptive controller upper bound). */
75
+ recommendedMaxConcurrency: number;
76
+ /** Overall severity: how constrained the budget is. */
77
+ severity: "comfortable" | "moderate" | "tight" | "critical";
78
+ /** Lowest rate limit percentage across all providers/windows. */
79
+ lowestRateLimitPct: number;
80
+ /** Human-readable summary for prompt injection. */
81
+ summary: string;
82
+ }
83
+
84
+ // ═══ Constants ═══
85
+
86
+ /** Default turn counts per caste when budget is unconstrained. */
87
+ const DEFAULT_TURNS: Record<AntCaste, number> = {
88
+ scout: 8,
89
+ worker: 15,
90
+ soldier: 8,
91
+ drone: 1,
92
+ };
93
+
94
+ /** Budget share per caste (must sum to 1.0). */
95
+ const BUDGET_SHARES: Record<AntCaste, number> = {
96
+ scout: 0.1,
97
+ worker: 0.7,
98
+ soldier: 0.2,
99
+ drone: 0.0, // drones are free (execSync, no LLM)
100
+ };
101
+
102
+ /** Severity thresholds based on lowest rate limit %. */
103
+ const SEVERITY_THRESHOLDS = {
104
+ critical: 10,
105
+ tight: 25,
106
+ moderate: 50,
107
+ } as const;
108
+
109
+ /** Concurrency caps per severity level. */
110
+ const CONCURRENCY_CAPS: Record<BudgetPlan["severity"], number> = {
111
+ critical: 1,
112
+ tight: 2,
113
+ moderate: 3,
114
+ comfortable: 6,
115
+ };
116
+
117
+ /** Per-ant cost caps per severity level (USD). */
118
+ const PER_ANT_COST_CAPS: Record<BudgetPlan["severity"], number> = {
119
+ critical: 0.05,
120
+ tight: 0.15,
121
+ moderate: 0.3,
122
+ comfortable: 0.5,
123
+ };
124
+
125
+ /** Turn multipliers per severity level. */
126
+ const TURN_MULTIPLIERS: Record<BudgetPlan["severity"], number> = {
127
+ critical: 0.5,
128
+ tight: 0.7,
129
+ moderate: 0.85,
130
+ comfortable: 1.0,
131
+ };
132
+
133
+ // ═══ Core logic ═══
134
+
135
+ /**
136
+ * Extract the lowest remaining percentage across all provider rate limit windows.
137
+ * Returns 100 if no rate limit data is available (assume unconstrained).
138
+ */
139
+ export function getLowestRateLimitPct(
140
+ providers: Map<string, ProviderRateLimits> | Record<string, ProviderRateLimits> | null | undefined,
141
+ ): number {
142
+ if (!providers) {
143
+ return 100;
144
+ }
145
+
146
+ const entries = providers instanceof Map ? providers.values() : Object.values(providers);
147
+ let lowest = 100;
148
+
149
+ for (const provider of entries) {
150
+ if (provider.error || provider.windows.length === 0) {
151
+ continue;
152
+ }
153
+ for (const window of provider.windows) {
154
+ if (window.percentLeft < lowest) {
155
+ lowest = window.percentLeft;
156
+ }
157
+ }
158
+ }
159
+
160
+ return lowest;
161
+ }
162
+
163
+ /**
164
+ * Determine budget severity from the lowest rate limit percentage
165
+ * and the fraction of maxCost already spent.
166
+ */
167
+ export function classifySeverity(
168
+ lowestRateLimitPct: number,
169
+ costSpent: number,
170
+ maxCost: number | null,
171
+ ): BudgetPlan["severity"] {
172
+ // Rate-limit severity
173
+ let rateSeverity: BudgetPlan["severity"] = "comfortable";
174
+ if (lowestRateLimitPct < SEVERITY_THRESHOLDS.critical) {
175
+ rateSeverity = "critical";
176
+ } else if (lowestRateLimitPct < SEVERITY_THRESHOLDS.tight) {
177
+ rateSeverity = "tight";
178
+ } else if (lowestRateLimitPct < SEVERITY_THRESHOLDS.moderate) {
179
+ rateSeverity = "moderate";
180
+ }
181
+
182
+ // Cost severity (only if a budget cap is set)
183
+ let costSeverity: BudgetPlan["severity"] = "comfortable";
184
+ if (maxCost != null && maxCost > 0) {
185
+ const costPctUsed = (costSpent / maxCost) * 100;
186
+ const costPctRemaining = 100 - costPctUsed;
187
+ if (costPctRemaining < SEVERITY_THRESHOLDS.critical) {
188
+ costSeverity = "critical";
189
+ } else if (costPctRemaining < SEVERITY_THRESHOLDS.tight) {
190
+ costSeverity = "tight";
191
+ } else if (costPctRemaining < SEVERITY_THRESHOLDS.moderate) {
192
+ costSeverity = "moderate";
193
+ }
194
+ }
195
+
196
+ // Return the worse of the two
197
+ const order: BudgetPlan["severity"][] = ["critical", "tight", "moderate", "comfortable"];
198
+ const rateIdx = order.indexOf(rateSeverity);
199
+ const costIdx = order.indexOf(costSeverity);
200
+ return order[Math.min(rateIdx, costIdx)];
201
+ }
202
+
203
+ /**
204
+ * Build a budget summary string for injection into ant prompts.
205
+ */
206
+ export function buildBudgetSummary(
207
+ severity: BudgetPlan["severity"],
208
+ lowestRateLimitPct: number,
209
+ costSpent: number,
210
+ maxCost: number | null,
211
+ tasksDone: number,
212
+ tasksTotal: number,
213
+ ): string {
214
+ const parts: string[] = [];
215
+
216
+ // Rate limit info
217
+ if (lowestRateLimitPct < 100) {
218
+ parts.push(`Provider rate limit: ~${lowestRateLimitPct}% remaining.`);
219
+ }
220
+
221
+ // Cost info
222
+ if (maxCost != null && maxCost > 0) {
223
+ const remaining = Math.max(0, maxCost - costSpent);
224
+ parts.push(
225
+ `Budget: $${costSpent.toFixed(2)} spent of $${maxCost.toFixed(2)} ($${remaining.toFixed(2)} remaining).`,
226
+ );
227
+ } else if (costSpent > 0) {
228
+ parts.push(`Session cost so far: $${costSpent.toFixed(2)}.`);
229
+ }
230
+
231
+ // Progress
232
+ if (tasksTotal > 0) {
233
+ parts.push(`Progress: ${tasksDone}/${tasksTotal} tasks completed.`);
234
+ }
235
+
236
+ // Severity-specific guidance
237
+ switch (severity) {
238
+ case "critical":
239
+ parts.push(
240
+ "⚠️ CRITICAL: Resources nearly exhausted. Only execute essential high-priority tasks. Skip exploration, be extremely concise, minimize tool calls.",
241
+ );
242
+ break;
243
+ case "tight":
244
+ parts.push(
245
+ "⚠️ Budget is tight. Be efficient — prefer targeted edits over broad exploration. Skip low-priority or nice-to-have tasks.",
246
+ );
247
+ break;
248
+ case "moderate":
249
+ parts.push("Budget is moderate. Be reasonably efficient — avoid unnecessary exploration but don't cut corners.");
250
+ break;
251
+ case "comfortable":
252
+ // No extra guidance needed
253
+ break;
254
+ }
255
+
256
+ return parts.join(" ");
257
+ }
258
+
259
+ /**
260
+ * Plan the budget allocation for a colony based on current usage data.
261
+ *
262
+ * @param usageLimits - Rate limit and cost data from the usage-tracker extension (may be null if unavailable).
263
+ * @param metrics - Current colony metrics (cost spent, tasks done, etc.).
264
+ * @param maxCost - Colony-level cost cap (null = unlimited).
265
+ * @param concurrency - Current concurrency config for max bounds.
266
+ * @returns A complete budget plan with per-caste allocations.
267
+ */
268
+ export function planBudget(
269
+ usageLimits: UsageLimitsEvent | null,
270
+ metrics: ColonyMetrics,
271
+ maxCost: number | null,
272
+ concurrency: ConcurrencyConfig,
273
+ ): BudgetPlan {
274
+ const lowestRateLimitPct = getLowestRateLimitPct(usageLimits?.providers ?? null);
275
+ const costSpent = metrics.totalCost;
276
+ const severity = classifySeverity(lowestRateLimitPct, costSpent, maxCost);
277
+
278
+ // Remaining budget for allocation
279
+ const remainingBudget = maxCost != null ? Math.max(0, maxCost - costSpent) : Number.POSITIVE_INFINITY;
280
+
281
+ // Recommended max concurrency (min of severity cap and hardware cap)
282
+ const recommendedMaxConcurrency = Math.min(CONCURRENCY_CAPS[severity], concurrency.max);
283
+
284
+ // Per-caste allocation
285
+ const castes = {} as Record<AntCaste, CasteBudget>;
286
+
287
+ for (const caste of ["scout", "worker", "soldier", "drone"] as AntCaste[]) {
288
+ const share = BUDGET_SHARES[caste];
289
+ const casteMaxCost = Number.isFinite(remainingBudget) ? remainingBudget * share : Number.POSITIVE_INFINITY;
290
+
291
+ const baseTurns = DEFAULT_TURNS[caste];
292
+ const adjustedTurns = Math.max(1, Math.floor(baseTurns * TURN_MULTIPLIERS[severity]));
293
+
294
+ const maxCostPerAnt = caste === "drone" ? 0 : Math.min(PER_ANT_COST_CAPS[severity], casteMaxCost);
295
+
296
+ // Concurrency: scouts and soldiers typically need fewer slots than workers
297
+ let casteConcurrency: number;
298
+ if (caste === "drone") {
299
+ casteConcurrency = recommendedMaxConcurrency; // drones are free
300
+ } else if (caste === "scout" || caste === "soldier") {
301
+ casteConcurrency = Math.max(1, Math.ceil(recommendedMaxConcurrency * 0.5));
302
+ } else {
303
+ casteConcurrency = recommendedMaxConcurrency;
304
+ }
305
+
306
+ castes[caste] = {
307
+ maxCost: casteMaxCost,
308
+ maxCostPerAnt,
309
+ maxConcurrency: casteConcurrency,
310
+ maxTurns: adjustedTurns,
311
+ };
312
+ }
313
+
314
+ const summary = buildBudgetSummary(
315
+ severity,
316
+ lowestRateLimitPct,
317
+ costSpent,
318
+ maxCost,
319
+ metrics.tasksDone,
320
+ metrics.tasksTotal,
321
+ );
322
+
323
+ return {
324
+ castes,
325
+ recommendedMaxConcurrency,
326
+ severity,
327
+ lowestRateLimitPct,
328
+ summary,
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Apply a budget plan's concurrency constraints to the adaptive concurrency config.
334
+ * Returns a new config with `max` capped by the budget plan.
335
+ */
336
+ export function applyConcurrencyCap(config: ConcurrencyConfig, plan: BudgetPlan): ConcurrencyConfig {
337
+ const cappedMax = Math.min(config.max, plan.recommendedMaxConcurrency);
338
+ return {
339
+ ...config,
340
+ max: cappedMax,
341
+ current: Math.min(config.current, cappedMax),
342
+ optimal: Math.min(config.optimal, cappedMax),
343
+ };
344
+ }
345
+
346
+ /**
347
+ * Build the budget-awareness section for ant system prompts.
348
+ * Returns empty string if budget is comfortable (no need to distract the ant).
349
+ */
350
+ export function buildBudgetPromptSection(plan: BudgetPlan): string {
351
+ if (plan.severity === "comfortable") {
352
+ return "";
353
+ }
354
+ return `\n## ⚠️ Budget Awareness\n${plan.summary}\n`;
355
+ }
@@ -54,6 +54,8 @@ interface ColonyLogEntry {
54
54
  }
55
55
 
56
56
  interface BackgroundColony {
57
+ /** Short identifier for this colony (c1, c2, ...). */
58
+ id: string;
57
59
  goal: string;
58
60
  abortController: AbortController;
59
61
  state: ColonyState | null;
@@ -64,8 +66,30 @@ interface BackgroundColony {
64
66
  }
65
67
 
66
68
  export default function antColonyExtension(pi: ExtensionAPI) {
67
- // Currently running background colony (only one at a time)
68
- let activeColony: BackgroundColony | null = null;
69
+ /** All running background colonies, keyed by short ID. */
70
+ const colonies = new Map<string, BackgroundColony>();
71
+ /** Auto-incrementing colony counter for generating IDs. */
72
+ let colonyCounter = 0;
73
+
74
+ /** Generate a short colony ID like c1, c2, ... */
75
+ function nextColonyId(): string {
76
+ colonyCounter++;
77
+ return `c${colonyCounter}`;
78
+ }
79
+
80
+ /**
81
+ * Resolve a colony by ID. If no ID given and exactly one colony is running,
82
+ * returns that one. Returns null if no match or ambiguous.
83
+ */
84
+ function resolveColony(idArg?: string): BackgroundColony | null {
85
+ if (idArg) {
86
+ return colonies.get(idArg) ?? null;
87
+ }
88
+ if (colonies.size === 1) {
89
+ return colonies.values().next().value ?? null;
90
+ }
91
+ return null;
92
+ }
69
93
 
70
94
  // Prevent main process polling from blocking: only allow explicit manual snapshots with cooldown
71
95
  let lastBgStatusSnapshotAt = 0;
@@ -166,26 +190,30 @@ export default function antColonyExtension(pi: ExtensionAPI) {
166
190
  }
167
191
 
168
192
  renderHandler = () => {
169
- if (!activeColony) {
193
+ if (colonies.size === 0) {
170
194
  return;
171
195
  }
172
- const { state } = activeColony;
173
- const elapsed = state ? formatDuration(Date.now() - state.createdAt) : "0s";
174
- const m = state?.metrics;
175
- const phase = state?.status || "scouting";
176
- const progress = calcProgress(m);
177
- const pct = `${Math.round(progress * 100)}%`;
178
- const active = activeColony.antStreams.size;
179
-
180
- const parts = [`🐜 ${statusIcon(phase)} ${statusLabel(phase)}`];
181
- parts.push(m ? `${m.tasksDone}/${m.tasksTotal} (${pct})` : `0/0 (${pct})`);
182
- parts.push(`⚡${active}`);
183
- if (m) {
184
- parts.push(formatCost(m.totalCost));
196
+ const statusParts: string[] = [];
197
+ for (const colony of colonies.values()) {
198
+ const { state } = colony;
199
+ const elapsed = state ? formatDuration(Date.now() - state.createdAt) : "0s";
200
+ const m = state?.metrics;
201
+ const phase = state?.status || "scouting";
202
+ const progress = calcProgress(m);
203
+ const pct = `${Math.round(progress * 100)}%`;
204
+ const active = colony.antStreams.size;
205
+
206
+ const parts = [`🐜[${colony.id}] ${statusIcon(phase)} ${statusLabel(phase)}`];
207
+ parts.push(m ? `${m.tasksDone}/${m.tasksTotal} (${pct})` : `0/0 (${pct})`);
208
+ parts.push(`⚡${active}`);
209
+ if (m) {
210
+ parts.push(formatCost(m.totalCost));
211
+ }
212
+ parts.push(elapsed);
213
+ statusParts.push(parts.join(" │ "));
185
214
  }
186
- parts.push(elapsed);
187
215
 
188
- ctx.ui.setStatus("ant-colony", parts.join(""));
216
+ ctx.ui.setStatus("ant-colony", statusParts.join(" · "));
189
217
  };
190
218
  clearHandler = () => {
191
219
  ctx.ui.setStatus("ant-colony", undefined);
@@ -228,6 +256,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
228
256
  signal: signal ?? undefined,
229
257
  callbacks,
230
258
  modelRegistry: params.modelRegistry,
259
+ eventBus: pi.events, // Usage-tracker integration for budget-aware planning
231
260
  });
232
261
 
233
262
  return {
@@ -255,17 +284,11 @@ export default function antColonyExtension(pi: ExtensionAPI) {
255
284
  modelRegistry?: any;
256
285
  },
257
286
  resume = false,
258
- ) {
259
- if (activeColony) {
260
- pi.events.emit("ant-colony:notify", {
261
- msg: "A colony is already running. Use /colony-stop first.",
262
- level: "warning",
263
- });
264
- return;
265
- }
266
-
287
+ ): string {
288
+ const colonyId = nextColonyId();
267
289
  const abortController = new AbortController();
268
290
  const colony: BackgroundColony = {
291
+ id: colonyId,
269
292
  goal: params.goal,
270
293
  abortController,
271
294
  state: null,
@@ -275,7 +298,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
275
298
  promise: null as any, // set below
276
299
  };
277
300
 
278
- pushLog(colony, { level: "info", text: "INITIALIZING · Colony launched in background" });
301
+ pushLog(colony, { level: "info", text: `INITIALIZING · Colony [${colonyId}] launched in background` });
279
302
 
280
303
  let lastPhase = "";
281
304
 
@@ -290,7 +313,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
290
313
  pi.sendMessage(
291
314
  {
292
315
  customType: "ant-colony-progress",
293
- content: `[COLONY_SIGNAL:${signal.phase.toUpperCase()}] 🐜 ${signal.message} (${pct}%, ${formatCost(signal.cost)})`,
316
+ content: `[COLONY_SIGNAL:${signal.phase.toUpperCase()}] 🐜[${colonyId}] ${signal.message} (${pct}%, ${formatCost(signal.cost)})`,
294
317
  display: true,
295
318
  },
296
319
  { triggerTurn: false, deliverAs: "followUp" },
@@ -326,7 +349,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
326
349
  pi.sendMessage(
327
350
  {
328
351
  customType: "ant-colony-progress",
329
- content: `[COLONY_SIGNAL:TASK_DONE] 🐜 ${icon} ${task.title.slice(0, 60)} (${progress}, ${cost})`,
352
+ content: `[COLONY_SIGNAL:TASK_DONE] 🐜[${colonyId}] ${icon} ${task.title.slice(0, 60)} (${progress}, ${cost})`,
330
353
  display: true,
331
354
  },
332
355
  { triggerTurn: false, deliverAs: "followUp" },
@@ -371,10 +394,11 @@ export default function antColonyExtension(pi: ExtensionAPI) {
371
394
  callbacks,
372
395
  authStorage: undefined,
373
396
  modelRegistry: params.modelRegistry,
397
+ eventBus: pi.events, // Usage-tracker integration for budget-aware planning
374
398
  };
375
399
  colony.promise = resume ? resumeColony(colonyOpts) : runColony(colonyOpts);
376
400
 
377
- activeColony = colony;
401
+ colonies.set(colonyId, colony);
378
402
  lastBgStatusSnapshotAt = 0;
379
403
  throttledRender();
380
404
 
@@ -389,39 +413,44 @@ export default function antColonyExtension(pi: ExtensionAPI) {
389
413
  text: `${ok ? "COMPLETE" : "FAILED"} · ${m.tasksDone}/${m.tasksTotal} · ${formatCost(m.totalCost)}`,
390
414
  });
391
415
 
392
- // Clear UI
393
- pi.events.emit("ant-colony:clear-ui");
394
- activeColony = null;
416
+ colonies.delete(colonyId);
417
+ if (colonies.size === 0) {
418
+ pi.events.emit("ant-colony:clear-ui");
419
+ }
395
420
 
396
421
  // Inject results into conversation
397
422
  pi.sendMessage(
398
423
  {
399
424
  customType: "ant-colony-report",
400
- content: `[COLONY_SIGNAL:COMPLETE]\n${report}`,
425
+ content: `[COLONY_SIGNAL:COMPLETE] [${colonyId}]\n${report}`,
401
426
  display: true,
402
427
  },
403
428
  { triggerTurn: true, deliverAs: "followUp" },
404
429
  );
405
430
 
406
431
  pi.events.emit("ant-colony:notify", {
407
- msg: `🐜 Colony ${ok ? "completed" : "failed"}: ${m.tasksDone}/${m.tasksTotal} tasks │ ${formatCost(m.totalCost)}`,
432
+ msg: `🐜[${colonyId}] Colony ${ok ? "completed" : "failed"}: ${m.tasksDone}/${m.tasksTotal} tasks │ ${formatCost(m.totalCost)}`,
408
433
  level: ok ? "success" : "error",
409
434
  });
410
435
  })
411
436
  .catch((e) => {
412
437
  pushLog(colony, { level: "error", text: `CRASHED · ${String(e).slice(0, 120)}` });
413
- pi.events.emit("ant-colony:clear-ui");
414
- activeColony = null;
415
- pi.events.emit("ant-colony:notify", { msg: `🐜 Colony crashed: ${e}`, level: "error" });
438
+ colonies.delete(colonyId);
439
+ if (colonies.size === 0) {
440
+ pi.events.emit("ant-colony:clear-ui");
441
+ }
442
+ pi.events.emit("ant-colony:notify", { msg: `🐜[${colonyId}] Colony crashed: ${e}`, level: "error" });
416
443
  pi.sendMessage(
417
444
  {
418
445
  customType: "ant-colony-report",
419
- content: `[COLONY_SIGNAL:FAILED]\n## 🐜 Colony Crashed\n${e}`,
446
+ content: `[COLONY_SIGNAL:FAILED] [${colonyId}]\n## 🐜 Colony Crashed\n${e}`,
420
447
  display: true,
421
448
  },
422
449
  { triggerTurn: true, deliverAs: "followUp" },
423
450
  );
424
451
  });
452
+
453
+ return colonyId;
425
454
  }
426
455
 
427
456
  // ═══ Custom message renderer for colony progress signals ═══
@@ -494,8 +523,8 @@ export default function antColonyExtension(pi: ExtensionAPI) {
494
523
  pi.registerShortcut("ctrl+shift+a", {
495
524
  description: "Show ant colony details",
496
525
  async handler(ctx) {
497
- if (!activeColony) {
498
- ctx.ui.notify("No colony is currently running.", "info");
526
+ if (colonies.size === 0) {
527
+ ctx.ui.notify("No colonies are currently running.", "info");
499
528
  return;
500
529
  }
501
530
 
@@ -505,9 +534,18 @@ export default function antColonyExtension(pi: ExtensionAPI) {
505
534
  let cachedLines: string[] | undefined;
506
535
  let currentTab: "tasks" | "streams" | "log" = "tasks";
507
536
  let taskFilter: "all" | "active" | "done" | "failed" = "all";
537
+ /** Which colony to display (cycles with 'n'). */
538
+ let selectedColonyIdx = 0;
539
+
540
+ const getSelectedColony = (): BackgroundColony | null => {
541
+ const ids = [...colonies.keys()];
542
+ if (ids.length === 0) return null;
543
+ const idx = selectedColonyIdx % ids.length;
544
+ return colonies.get(ids[idx]) ?? null;
545
+ };
508
546
 
509
547
  const buildLines = (width: number): string[] => {
510
- const c = activeColony;
548
+ const c = getSelectedColony();
511
549
  if (!c) return [theme.fg("muted", " No colony running.")];
512
550
 
513
551
  const lines: string[] = [];
@@ -523,8 +561,18 @@ export default function antColonyExtension(pi: ExtensionAPI) {
523
561
  const activeAnts = c.antStreams.size;
524
562
  const barWidth = Math.max(10, Math.min(24, w - 28));
525
563
 
564
+ // Show colony selector if multiple are running
565
+ if (colonies.size > 1) {
566
+ const ids = [...colonies.keys()];
567
+ const idx = selectedColonyIdx % ids.length;
568
+ const selector = ids
569
+ .map((id, i) => (i === idx ? theme.fg("accent", theme.bold(`[${id}]`)) : theme.fg("muted", id)))
570
+ .join(" ");
571
+ lines.push(` ${selector} ${theme.fg("dim", "(n = next colony)")}`);
572
+ }
573
+
526
574
  lines.push(
527
- theme.fg("accent", theme.bold(" 🐜 Colony Details")) + theme.fg("muted", ` │ ${elapsed} │ ${cost}`),
575
+ theme.fg("accent", theme.bold(` 🐜 Colony [${c.id}]`)) + theme.fg("muted", ` │ ${elapsed} │ ${cost}`),
528
576
  );
529
577
  lines.push(theme.fg("muted", ` Goal: ${trim(c.goal, w - 8)}`));
530
578
  lines.push(
@@ -703,6 +751,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
703
751
  else if (data.toLowerCase() === "a") taskFilter = "active";
704
752
  else if (data.toLowerCase() === "d") taskFilter = "done";
705
753
  else if (data.toLowerCase() === "f") taskFilter = "failed";
754
+ else if (data.toLowerCase() === "n") selectedColonyIdx++;
706
755
  else return;
707
756
 
708
757
  cachedWidth = undefined;
@@ -745,18 +794,6 @@ export default function antColonyExtension(pi: ExtensionAPI) {
745
794
  }),
746
795
 
747
796
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
748
- if (activeColony) {
749
- return {
750
- content: [
751
- {
752
- type: "text",
753
- text: "A colony is already running in the background. Use /colony-stop to cancel it first.",
754
- },
755
- ],
756
- isError: true,
757
- };
758
- }
759
-
760
797
  const currentModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null;
761
798
  if (!currentModel) {
762
799
  return {
@@ -786,13 +823,13 @@ export default function antColonyExtension(pi: ExtensionAPI) {
786
823
  }
787
824
 
788
825
  // Interactive mode: run in background
789
- launchBackgroundColony(colonyParams);
826
+ const launchedId = launchBackgroundColony(colonyParams);
790
827
 
791
828
  return {
792
829
  content: [
793
830
  {
794
831
  type: "text",
795
- text: `[COLONY_SIGNAL:LAUNCHED]\n🐜 Colony launched in background.\nGoal: ${params.goal}\n\nThe colony runs autonomously in passive mode. Progress is pushed via [COLONY_SIGNAL:*] follow-up messages. Do not poll bg_colony_status unless the user explicitly asks for a manual snapshot.`,
832
+ text: `[COLONY_SIGNAL:LAUNCHED] [${launchedId}]\n🐜 Colony [${launchedId}] launched in background (${colonies.size} active).\nGoal: ${params.goal}\n\nThe colony runs autonomously in passive mode. Progress is pushed via [COLONY_SIGNAL:*] follow-up messages. Do not poll bg_colony_status unless the user explicitly asks for a manual snapshot.`,
796
833
  },
797
834
  ],
798
835
  };
@@ -816,9 +853,17 @@ export default function antColonyExtension(pi: ExtensionAPI) {
816
853
  container.addChild(
817
854
  new Text(theme.fg("success", "✓ ") + theme.fg("toolTitle", theme.bold("Colony launched in background")), 0, 0),
818
855
  );
819
- if (activeColony) {
820
- container.addChild(new Text(theme.fg("muted", ` Goal: ${activeColony.goal.slice(0, 70)}`), 0, 0));
821
- container.addChild(new Text(theme.fg("muted", " Ctrl+Shift+A for details │ /colony-stop to cancel"), 0, 0));
856
+ if (colonies.size > 0) {
857
+ for (const colony of colonies.values()) {
858
+ container.addChild(new Text(theme.fg("muted", ` [${colony.id}] ${colony.goal.slice(0, 65)}`), 0, 0));
859
+ }
860
+ container.addChild(
861
+ new Text(
862
+ theme.fg("muted", ` ${colonies.size} active │ Ctrl+Shift+A for details │ /colony-stop to cancel`),
863
+ 0,
864
+ 0,
865
+ ),
866
+ );
822
867
  }
823
868
  return container;
824
869
  },
@@ -826,9 +871,8 @@ export default function antColonyExtension(pi: ExtensionAPI) {
826
871
 
827
872
  // ═══ Helper: build status summary ═══
828
873
 
829
- function buildStatusText(): string {
830
- if (!activeColony) return "No colony is currently running.";
831
- const c = activeColony;
874
+ /** Build a status summary for a single colony. */
875
+ function buildColonyStatusText(c: BackgroundColony): string {
832
876
  const state = c.state;
833
877
  const elapsed = state ? formatDuration(Date.now() - state.createdAt) : "0s";
834
878
  const m = state?.metrics;
@@ -851,15 +895,29 @@ export default function antColonyExtension(pi: ExtensionAPI) {
851
895
  return lines.join("\n");
852
896
  }
853
897
 
898
+ /** Build a status summary for all running colonies. */
899
+ function buildStatusText(): string {
900
+ if (colonies.size === 0) return "No colonies are currently running.";
901
+ if (colonies.size === 1) {
902
+ const colony = colonies.values().next().value;
903
+ return colony ? buildColonyStatusText(colony) : "No colonies are currently running.";
904
+ }
905
+ const parts: string[] = [`${colonies.size} colonies running:\n`];
906
+ for (const colony of colonies.values()) {
907
+ parts.push(`── [${colony.id}] ──\n${buildColonyStatusText(colony)}\n`);
908
+ }
909
+ return parts.join("\n");
910
+ }
911
+
854
912
  // ═══ Tool: bg_colony_status ═══
855
913
  pi.registerTool({
856
914
  name: "bg_colony_status",
857
915
  label: "Colony Status",
858
916
  description:
859
- "Optional manual snapshot for a running colony. Progress is pushed passively via COLONY_SIGNAL follow-up messages; call this only when the user explicitly asks.",
917
+ "Optional manual snapshot for running colonies. Progress is pushed passively via COLONY_SIGNAL follow-up messages; call this only when the user explicitly asks.",
860
918
  parameters: Type.Object({}),
861
919
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
862
- if (!activeColony) {
920
+ if (colonies.size === 0) {
863
921
  return {
864
922
  content: [{ type: "text" as const, text: "No colony is currently running." }],
865
923
  };
@@ -900,70 +958,171 @@ export default function antColonyExtension(pi: ExtensionAPI) {
900
958
  },
901
959
  });
902
960
 
961
+ // ═══ Command: /colony ═══
962
+ pi.registerCommand("colony", {
963
+ description: "Launch an ant colony swarm to accomplish a goal",
964
+ async handler(args, ctx) {
965
+ const goal = args.trim();
966
+ if (!goal) {
967
+ ctx.ui.notify("Usage: /colony <goal> — describe what the colony should accomplish", "warning");
968
+ return;
969
+ }
970
+
971
+ const currentModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null;
972
+ if (!currentModel) {
973
+ ctx.ui.notify("Colony failed: no model available in current session.", "error");
974
+ return;
975
+ }
976
+
977
+ const id = launchBackgroundColony({
978
+ cwd: ctx.cwd,
979
+ goal,
980
+ currentModel,
981
+ modelOverrides: {},
982
+ modelRegistry: ctx.modelRegistry ?? undefined,
983
+ });
984
+ ctx.ui.notify(
985
+ `🐜[${id}] Colony launched (${colonies.size} active): ${goal.slice(0, 70)}${goal.length > 70 ? "..." : ""}`,
986
+ "info",
987
+ );
988
+ },
989
+ });
990
+
991
+ // ═══ Command: /colony-count ═══
992
+ pi.registerCommand("colony-count", {
993
+ description: "Show how many colonies are currently running",
994
+ async handler(_args, ctx) {
995
+ if (colonies.size === 0) {
996
+ ctx.ui.notify("No colonies running.", "info");
997
+ } else {
998
+ const ids = [...colonies.values()].map((c) => `[${c.id}] ${c.goal.slice(0, 50)}`).join("\n ");
999
+ ctx.ui.notify(`${colonies.size} active ${colonies.size === 1 ? "colony" : "colonies"}:\n ${ids}`, "info");
1000
+ }
1001
+ },
1002
+ });
1003
+
903
1004
  // ═══ Command: /colony-status ═══
904
1005
  pi.registerCommand("colony-status", {
905
- description: "Show current colony progress",
906
- async handler(_args, ctx) {
907
- if (!activeColony) {
908
- ctx.ui.notify("No colony is currently running.", "info");
1006
+ description: "Show current colony progress (optionally specify ID: /colony-status c1)",
1007
+ getArgumentCompletions(prefix) {
1008
+ const items = [...colonies.keys()]
1009
+ .filter((id) => id.startsWith(prefix))
1010
+ .map((id) => {
1011
+ const c = colonies.get(id);
1012
+ return { value: id, label: `${id} — ${c?.goal.slice(0, 50) ?? ""}` };
1013
+ });
1014
+ return items.length > 0 ? items : null;
1015
+ },
1016
+ async handler(args, ctx) {
1017
+ const idArg = args.trim() || undefined;
1018
+ if (colonies.size === 0) {
1019
+ ctx.ui.notify("No colonies are currently running.", "info");
909
1020
  return;
910
1021
  }
911
- ctx.ui.notify(buildStatusText(), "info");
1022
+ if (idArg) {
1023
+ const colony = resolveColony(idArg);
1024
+ if (!colony) {
1025
+ ctx.ui.notify(`Colony "${idArg}" not found. Active: ${[...colonies.keys()].join(", ")}`, "warning");
1026
+ return;
1027
+ }
1028
+ ctx.ui.notify(buildColonyStatusText(colony), "info");
1029
+ } else {
1030
+ ctx.ui.notify(buildStatusText(), "info");
1031
+ }
912
1032
  },
913
1033
  });
914
1034
 
915
1035
  // ═══ Command: /colony-stop ═══
916
1036
  pi.registerCommand("colony-stop", {
917
- description: "Stop the running background colony",
918
- async handler(_args, ctx) {
919
- if (!activeColony) {
920
- ctx.ui.notify("No colony is currently running.", "info");
1037
+ description: "Stop a colony (specify ID, or stops all if none given)",
1038
+ getArgumentCompletions(prefix) {
1039
+ const items = [
1040
+ { value: "all", label: "all Stop all running colonies" },
1041
+ ...[...colonies.keys()]
1042
+ .filter((id) => id.startsWith(prefix))
1043
+ .map((id) => {
1044
+ const c = colonies.get(id);
1045
+ return { value: id, label: `${id} — ${c?.goal.slice(0, 50) ?? ""}` };
1046
+ }),
1047
+ ].filter((i) => i.value.startsWith(prefix));
1048
+ return items.length > 0 ? items : null;
1049
+ },
1050
+ async handler(args, ctx) {
1051
+ const idArg = args.trim() || undefined;
1052
+ if (colonies.size === 0) {
1053
+ ctx.ui.notify("No colonies are currently running.", "info");
921
1054
  return;
922
1055
  }
923
- activeColony.abortController.abort();
924
- ctx.ui.notify("🐜 Colony abort signal sent. Waiting for ants to finish...", "warning");
1056
+ if (!idArg || idArg === "all") {
1057
+ const count = colonies.size;
1058
+ for (const colony of colonies.values()) {
1059
+ colony.abortController.abort();
1060
+ }
1061
+ ctx.ui.notify(`🐜 Abort signal sent to ${count} ${count === 1 ? "colony" : "colonies"}.`, "warning");
1062
+ } else {
1063
+ const colony = resolveColony(idArg);
1064
+ if (!colony) {
1065
+ ctx.ui.notify(`Colony "${idArg}" not found. Active: ${[...colonies.keys()].join(", ")}`, "warning");
1066
+ return;
1067
+ }
1068
+ colony.abortController.abort();
1069
+ ctx.ui.notify(`🐜[${colony.id}] Abort signal sent. Waiting for ants to finish...`, "warning");
1070
+ }
925
1071
  },
926
1072
  });
927
1073
 
928
1074
  pi.registerCommand("colony-resume", {
929
- description: "Resume a colony from its last checkpoint",
930
- async handler(_args, ctx) {
931
- if (activeColony) {
932
- ctx.ui.notify("A colony is already running.", "warning");
1075
+ description: "Resume colonies from their last checkpoint (resumes all resumable by default)",
1076
+ async handler(args, ctx) {
1077
+ const all = Nest.findAllResumable(ctx.cwd);
1078
+ if (all.length === 0) {
1079
+ ctx.ui.notify("No resumable colonies found.", "info");
933
1080
  return;
934
1081
  }
935
- const found = Nest.findResumable(ctx.cwd);
936
- if (!found) {
937
- ctx.ui.notify("No resumable colony found.", "info");
1082
+
1083
+ // If an argument is given, try to match a specific colony ID
1084
+ const target = args.trim();
1085
+ const toResume = target ? all.filter((r) => r.colonyId === target) : [all[0]];
1086
+
1087
+ if (toResume.length === 0) {
1088
+ ctx.ui.notify(`Colony "${target}" not found. Resumable: ${all.map((r) => r.colonyId).join(", ")}`, "warning");
938
1089
  return;
939
1090
  }
940
- ctx.ui.notify(`🐜 Resuming colony: ${found.state.goal.slice(0, 60)}...`, "info");
941
- launchBackgroundColony(
942
- {
943
- cwd: ctx.cwd,
944
- goal: found.state.goal,
945
- maxCost: found.state.maxCost ?? undefined,
946
- currentModel: ctx.currentModel,
947
- modelOverrides: {},
948
- modelRegistry: ctx.modelRegistry,
949
- },
950
- true,
951
- );
1091
+
1092
+ for (const found of toResume) {
1093
+ const id = launchBackgroundColony(
1094
+ {
1095
+ cwd: ctx.cwd,
1096
+ goal: found.state.goal,
1097
+ maxCost: found.state.maxCost ?? undefined,
1098
+ currentModel: ctx.currentModel,
1099
+ modelOverrides: {},
1100
+ modelRegistry: ctx.modelRegistry,
1101
+ },
1102
+ true,
1103
+ );
1104
+ ctx.ui.notify(`🐜[${id}] Resuming: ${found.state.goal.slice(0, 60)}...`, "info");
1105
+ }
952
1106
  },
953
1107
  });
954
1108
 
955
1109
  // ═══ Cleanup on shutdown ═══
956
1110
  pi.on("session_shutdown", async () => {
957
- if (activeColony) {
958
- activeColony.abortController.abort();
959
- // Wait for colony to finish gracefully (max 5s)
1111
+ if (colonies.size > 0) {
1112
+ for (const colony of colonies.values()) {
1113
+ colony.abortController.abort();
1114
+ }
1115
+ // Wait for all colonies to finish gracefully (max 5s)
960
1116
  try {
961
- await Promise.race([activeColony.promise, new Promise((r) => setTimeout(r, 5000))]);
1117
+ await Promise.race([
1118
+ Promise.all([...colonies.values()].map((c) => c.promise)),
1119
+ new Promise((r) => setTimeout(r, 5000)),
1120
+ ]);
962
1121
  } catch {
963
1122
  /* ignore */
964
1123
  }
965
1124
  pi.events.emit("ant-colony:clear-ui");
966
- activeColony = null;
1125
+ colonies.clear();
967
1126
  }
968
1127
  });
969
1128
  }
@@ -494,7 +494,18 @@ export class Nest {
494
494
  * scouting, working, or reviewing and has no `finishedAt` timestamp).
495
495
  */
496
496
  static findResumable(cwd: string): { colonyId: string; state: ColonyState } | null {
497
+ const all = Nest.findAllResumable(cwd);
498
+ return all.length > 0 ? all[0] : null;
499
+ }
500
+
501
+ /**
502
+ * Find all resumable colonies in the working directory.
503
+ * Returns colonies whose state is incomplete (not done/failed/budget_exceeded).
504
+ * Sorted by `createdAt` descending so the most recent colony is first.
505
+ */
506
+ static findAllResumable(cwd: string): Array<{ colonyId: string; state: ColonyState }> {
497
507
  const parentDir = path.join(cwd, ".ant-colony");
508
+ const results: Array<{ colonyId: string; state: ColonyState }> = [];
498
509
  try {
499
510
  for (const dir of fs.readdirSync(parentDir)) {
500
511
  const stateFile = path.join(parentDir, dir, "state.json");
@@ -508,13 +519,14 @@ export class Nest {
508
519
  state.status !== "failed" &&
509
520
  state.status !== "budget_exceeded"
510
521
  ) {
511
- return { colonyId: dir, state };
522
+ results.push({ colonyId: dir, state });
512
523
  }
513
524
  }
514
525
  } catch {
515
526
  // No .ant-colony directory — nothing to resume
516
527
  }
517
- return null;
528
+ results.sort((a, b) => (b.state.createdAt ?? 0) - (a.state.createdAt ?? 0));
529
+ return results;
518
530
  }
519
531
 
520
532
  /**
@@ -82,11 +82,15 @@ export function buildPrompt(
82
82
  castePrompt: string,
83
83
  maxTurns?: number,
84
84
  tandem?: { parentResult?: string; priorError?: string },
85
+ budgetSection?: string,
85
86
  ): string {
86
87
  let prompt = `${castePrompt}\n\n`;
87
88
  if (maxTurns) {
88
89
  prompt += `## ⚠️ Turn Limit\nYou have a MAXIMUM of ${maxTurns} turns. Plan accordingly — reserve your LAST turn to output the structured result format above. Do NOT waste turns on unnecessary exploration.\n\n`;
89
90
  }
91
+ if (budgetSection) {
92
+ prompt += budgetSection;
93
+ }
90
94
  if (pheromoneContext) {
91
95
  prompt += `## Colony Pheromone Trail (intelligence from other ants)\n${pheromoneContext}\n\n`;
92
96
  }
@@ -15,6 +15,13 @@
15
15
  import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
18
+ import {
19
+ applyConcurrencyCap,
20
+ type BudgetPlan,
21
+ buildBudgetPromptSection,
22
+ planBudget,
23
+ type UsageLimitsEvent,
24
+ } from "./budget-planner.js";
18
25
  import { adapt, defaultConcurrency, sampleSystem } from "./concurrency.js";
19
26
  import { buildImportGraph, type ImportGraph, taskDependsOn } from "./deps.js";
20
27
  import { Nest } from "./nest.js";
@@ -44,6 +51,13 @@ export interface QueenCallbacks {
44
51
  onComplete?(state: ColonyState): void;
45
52
  }
46
53
 
54
+ /** Event emitter interface for inter-extension communication. */
55
+ export interface ColonyEventBus {
56
+ emit(event: string, data?: unknown): void;
57
+ on(event: string, handler: (data: unknown) => void): void;
58
+ off(event: string, handler: (data: unknown) => void): void;
59
+ }
60
+
47
61
  export interface QueenOptions {
48
62
  cwd: string;
49
63
  goal: string;
@@ -55,6 +69,8 @@ export interface QueenOptions {
55
69
  callbacks: QueenCallbacks;
56
70
  authStorage?: AuthStorage;
57
71
  modelRegistry?: ModelRegistry;
72
+ /** Event bus for cross-extension communication (usage-tracker integration). */
73
+ eventBus?: ColonyEventBus;
58
74
  }
59
75
 
60
76
  function makeColonyId(): string {
@@ -305,6 +321,8 @@ interface WaveOptions {
305
321
  authStorage?: AuthStorage;
306
322
  modelRegistry?: ModelRegistry;
307
323
  importGraph?: ImportGraph;
324
+ /** Budget plan from the usage-aware planner (may be null if no data available). */
325
+ budgetPlan?: BudgetPlan | null;
308
326
  }
309
327
 
310
328
  /**
@@ -341,6 +359,14 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
341
359
  const casteModel = opts.modelOverrides?.[caste] || currentModel;
342
360
  const baseConfig = { ...DEFAULT_ANT_CONFIGS[caste], model: casteModel };
343
361
 
362
+ // Budget-aware turn cap: if the budget planner recommends fewer turns, use that
363
+ if (opts.budgetPlan) {
364
+ const casteBudget = opts.budgetPlan.castes[caste];
365
+ if (casteBudget && casteBudget.maxTurns < baseConfig.maxTurns) {
366
+ baseConfig.maxTurns = casteBudget.maxTurns;
367
+ }
368
+ }
369
+
344
370
  let backoffMs = 0; // 429 backoff duration
345
371
  let consecutiveRateLimits = 0; // Consecutive rate limit counter
346
372
  const retryCount = new Map<string, number>(); // taskId → retry count
@@ -400,10 +426,22 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
400
426
  } else if (progress > 0.7) {
401
427
  config.maxTurns = Math.max(baseConfig.maxTurns - 5, 5); // Late convergence, only cleanup/fixes
402
428
  }
429
+ // Build budget-awareness prompt section for non-drone ants
430
+ const budgetSection = opts.budgetPlan ? buildBudgetPromptSection(opts.budgetPlan) : undefined;
403
431
  const antPromise =
404
432
  caste === "drone"
405
433
  ? runDrone(cwd, nest, task)
406
- : spawnAnt(cwd, nest, task, config, antSignal, callbacks.onAntStream, opts.authStorage, opts.modelRegistry);
434
+ : spawnAnt(
435
+ cwd,
436
+ nest,
437
+ task,
438
+ config,
439
+ antSignal,
440
+ callbacks.onAntStream,
441
+ opts.authStorage,
442
+ opts.modelRegistry,
443
+ budgetSection,
444
+ );
407
445
  let timeoutId: ReturnType<typeof setTimeout>;
408
446
  const result = await Promise.race([
409
447
  antPromise.finally(() => clearTimeout(timeoutId)),
@@ -596,7 +634,11 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
596
634
  nest.recordSample(sample);
597
635
  }
598
636
 
599
- const concurrency = adapt(state.concurrency, pending.length);
637
+ let concurrency = adapt(state.concurrency, pending.length);
638
+ // Apply budget-aware concurrency cap (rate limits / cost constraints)
639
+ if (opts.budgetPlan) {
640
+ concurrency = applyConcurrencyCap(concurrency, opts.budgetPlan);
641
+ }
600
642
  nest.updateState({ concurrency });
601
643
 
602
644
  // Dispatch ants (concurrency determined by adapt())
@@ -711,7 +753,32 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
711
753
  modelRegistry: opts.modelRegistry,
712
754
  };
713
755
 
756
+ // ═══ Usage-aware budget planning ═══
757
+ // Query the usage-tracker extension for rate limit / cost data via the event bus.
758
+ // The result is used to cap concurrency, limit turns, and inject budget context into prompts.
759
+ const refreshBudgetPlan = (): BudgetPlan | null => {
760
+ if (!opts.eventBus) {
761
+ // No event bus → plan based on colony metrics alone (no rate limit awareness)
762
+ return planBudget(null, nest.getStateLight().metrics, opts.maxCost ?? null, nest.getStateLight().concurrency);
763
+ }
764
+
765
+ // Request fresh data from usage-tracker (fire-and-forget, they respond via "usage:limits")
766
+ let latestLimits: UsageLimitsEvent | null = null;
767
+ const handler = (data: unknown) => {
768
+ latestLimits = data as UsageLimitsEvent;
769
+ };
770
+ opts.eventBus.on("usage:limits", handler);
771
+ opts.eventBus.emit("usage:query");
772
+ opts.eventBus.off("usage:limits", handler);
773
+
774
+ const state = nest.getStateLight();
775
+ return planBudget(latestLimits, state.metrics, opts.maxCost ?? null, state.concurrency);
776
+ };
777
+
714
778
  try {
779
+ // Initial budget plan
780
+ waveBase.budgetPlan = refreshBudgetPlan();
781
+
715
782
  // ═══ Phase 1: Scouting (Bio 5: Colony voting — complex goals get multiple scouts) ═══
716
783
  const scoutCountBase = opts.goal.length > 500 ? 3 : opts.goal.length > 200 ? 2 : 1;
717
784
  const scoutCount = shouldUseScoutQuorum(opts.goal) ? Math.max(2, scoutCountBase) : scoutCountBase;
@@ -785,6 +852,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
785
852
  }
786
853
 
787
854
  // ═══ Phase 2: Working ═══
855
+ waveBase.budgetPlan = refreshBudgetPlan(); // Refresh budget before work phase
788
856
  nest.updateState({ status: "working" });
789
857
 
790
858
  // Build import graph for dependency-aware scheduling
@@ -885,6 +953,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
885
953
  }
886
954
 
887
955
  // ═══ Phase 3: Review ═══
956
+ waveBase.budgetPlan = refreshBudgetPlan(); // Refresh budget before review phase
888
957
  const completedWorkerTasks = nest.getAllTasks().filter((t) => t.caste === "worker" && t.status === "done");
889
958
  if (completedWorkerTasks.length > 0 && (!tscPassed || completedWorkerTasks.length > 3)) {
890
959
  nest.updateState({ status: "reviewing" });
@@ -949,7 +1018,7 @@ export async function resumeColony(opts: QueenOptions): Promise<ColonyState> {
949
1018
  callbacks.onSignal?.({ phase, progress, active, cost: m.totalCost, message });
950
1019
  };
951
1020
 
952
- const waveBase: Omit<WaveOptions, "caste"> = {
1021
+ const waveBase: Omit<WaveOptions, "caste"> & { budgetPlan?: BudgetPlan | null } = {
953
1022
  nest,
954
1023
  cwd: opts.cwd,
955
1024
  signal,
@@ -961,6 +1030,19 @@ export async function resumeColony(opts: QueenOptions): Promise<ColonyState> {
961
1030
  modelRegistry: opts.modelRegistry,
962
1031
  };
963
1032
 
1033
+ // Budget plan for resumed colony
1034
+ if (opts.eventBus) {
1035
+ let latestLimits: UsageLimitsEvent | null = null;
1036
+ const handler = (data: unknown) => {
1037
+ latestLimits = data as UsageLimitsEvent;
1038
+ };
1039
+ opts.eventBus.on("usage:limits", handler);
1040
+ opts.eventBus.emit("usage:query");
1041
+ opts.eventBus.off("usage:limits", handler);
1042
+ const state = nest.getStateLight();
1043
+ waveBase.budgetPlan = planBudget(latestLimits, state.metrics, opts.maxCost ?? null, state.concurrency);
1044
+ }
1045
+
964
1046
  const cleanup = () => {
965
1047
  nest.destroy();
966
1048
  const parentDir = path.join(opts.cwd, ".ant-colony");
@@ -182,6 +182,7 @@ export async function spawnAnt(
182
182
  onStream?: (event: AntStreamEvent) => void,
183
183
  authStorage?: AuthStorage,
184
184
  modelRegistry?: ModelRegistry,
185
+ budgetPromptSection?: string,
185
186
  ): Promise<AntResult> {
186
187
  if (!antConfig.model) {
187
188
  throw new Error("No model resolved for ant");
@@ -221,7 +222,7 @@ export async function spawnAnt(
221
222
 
222
223
  const pheromoneCtx = nest.getPheromoneContext(task.files);
223
224
  const castePrompt = CASTE_PROMPTS[antConfig.caste];
224
- const systemPrompt = buildPrompt(task, pheromoneCtx, castePrompt, effectiveMaxTurns, tandem);
225
+ const systemPrompt = buildPrompt(task, pheromoneCtx, castePrompt, effectiveMaxTurns, tandem, budgetPromptSection);
225
226
 
226
227
  const auth = authStorage ?? new AuthStorage();
227
228
  const registry = modelRegistry ?? new ModelRegistry(auth);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ifi/oh-pi-ant-colony",
3
- "version": "0.2.3",
3
+ "version": "0.2.7",
4
4
  "description": "Autonomous multi-agent swarm extension for pi — adaptive concurrency, pheromone communication.",
5
5
  "keywords": [
6
6
  "pi-package"