@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.
- package/extensions/ant-colony/budget-planner.ts +355 -0
- package/extensions/ant-colony/index.ts +260 -101
- package/extensions/ant-colony/nest.ts +14 -2
- package/extensions/ant-colony/prompts.ts +4 -0
- package/extensions/ant-colony/queen.ts +85 -3
- package/extensions/ant-colony/spawner.ts +2 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
68
|
-
|
|
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 (
|
|
193
|
+
if (colonies.size === 0) {
|
|
170
194
|
return;
|
|
171
195
|
}
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
parts.push(
|
|
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",
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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 (
|
|
498
|
-
ctx.ui.notify("No
|
|
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 =
|
|
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(
|
|
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 (
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
830
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
|
|
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
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
924
|
-
|
|
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
|
|
930
|
-
async handler(
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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 (
|
|
958
|
-
|
|
959
|
-
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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);
|