oh-pi 0.1.58 → 0.1.60

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-pi",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
4
4
  "description": "One-click setup for pi-coding-agent. Like oh-my-zsh for pi.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,7 +60,6 @@ interface BackgroundColony {
60
60
  abortController: AbortController;
61
61
  state: ColonyState | null;
62
62
  phase: string;
63
- log: string[];
64
63
  antStreams: Map<string, AntStreamState>;
65
64
  promise: Promise<ColonyState>;
66
65
  }
@@ -84,20 +83,13 @@ export default function antColonyExtension(pi: ExtensionAPI) {
84
83
  pi.on("session_start", async (_event, ctx) => {
85
84
  pi.events.on("ant-colony:render", () => {
86
85
  if (!activeColony) return;
87
- const { state, antStreams } = activeColony;
88
- const active = antStreams.size;
86
+ const { state } = activeColony;
89
87
  const elapsed = state ? formatDuration(Date.now() - state.createdAt) : "0s";
90
88
  const m = state?.metrics;
91
89
  const colonyStatus = state?.status || "scouting";
92
- const ants = state?.ants || [];
93
- const turns = ants.reduce((s, a) => s + a.usage.turns, 0);
94
- const outTok = ants.reduce((s, a) => s + a.usage.output, 0);
95
90
 
96
91
  const parts = [`🐜 ${statusIcon(colonyStatus)}`];
97
92
  if (m) parts.push(`${m.tasksDone}/${m.tasksTotal}`);
98
- parts.push(`${active}⚡`);
99
- parts.push(`${turns}↻`);
100
- parts.push(formatTokens(outTok) + "↑");
101
93
  if (m) parts.push(formatCost(m.totalCost));
102
94
  parts.push(elapsed);
103
95
 
@@ -129,18 +121,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
129
121
  appendFileSync(gitignorePath, `${gitContent.length && !gitContent.endsWith("\n") ? "\n" : ""}.ant-colony/\n`);
130
122
  }
131
123
 
132
- const log: string[] = [];
133
- const callbacks: QueenCallbacks = {
134
- onPhase(_phase, detail) { log.push(detail); },
135
- onAntSpawn(ant, task) { log.push(` ${casteIcon(ant.caste)} ${ant.caste} → ${task.title.slice(0, 50)}`); },
136
- onAntDone(ant, task) {
137
- const dur = ant.finishedAt ? formatDuration(ant.finishedAt - ant.startedAt) : "?";
138
- log.push(` ${ant.status === "done" ? "✓" : "✗"} ${ant.caste} (${dur}) → ${task.title.slice(0, 50)}`);
139
- },
140
- onAntStream() {},
141
- onProgress() {},
142
- onComplete() {},
143
- };
124
+ const callbacks: QueenCallbacks = {};
144
125
 
145
126
  try {
146
127
  const state = await runColony({
@@ -160,14 +141,14 @@ export default function antColonyExtension(pi: ExtensionAPI) {
160
141
  const report = [
161
142
  `## 🐜 Ant Colony Report`,
162
143
  `**Goal:** ${state.goal}`,
163
- `**Status:** ${statusIcon(state.status)} ${state.status} │ ${elapsed}`,
164
- `**Metrics:** ${m.tasksDone}/${m.tasksTotal} tasks │ ${m.antsSpawned} ants ${formatTokens(m.totalTokens)} ${formatCost(m.totalCost)}`,
144
+ `**Status:** ${statusIcon(state.status)} ${state.status} │ ${elapsed} │ ${formatCost(m.totalCost)}`,
145
+ `**Tasks:** ${m.tasksDone}/${m.tasksTotal} done${m.tasksFailed > 0 ? `, ${m.tasksFailed} failed` : ""}`,
165
146
  ``,
166
147
  ...state.tasks.filter(t => t.status === "done").map(t =>
167
- `- ✓ **${t.title}**${t.result ? `: ${t.result.split("\n")[0]?.slice(0, 100)}` : ""}`
148
+ `- ✓ **${t.title}**`
168
149
  ),
169
150
  ...state.tasks.filter(t => t.status === "failed").map(t =>
170
- `- ✗ **${t.title}** — ${t.error?.slice(0, 100) || "unknown"}`
151
+ `- ✗ **${t.title}** — ${t.error?.slice(0, 80) || "unknown"}`
171
152
  ),
172
153
  ].join("\n");
173
154
 
@@ -205,32 +186,27 @@ export default function antColonyExtension(pi: ExtensionAPI) {
205
186
  abortController,
206
187
  state: null,
207
188
  phase: "initializing",
208
- log: [],
209
189
  antStreams: new Map(),
210
190
  promise: null as any, // set below
211
191
  };
212
192
 
213
193
  const callbacks: QueenCallbacks = {
194
+ onSignal(signal) {
195
+ colony.phase = signal.message;
196
+ throttledRender();
197
+ },
214
198
  onPhase(phase, detail) {
215
199
  colony.phase = detail;
216
- colony.log.push(`[${new Date().toLocaleTimeString()}] ${statusIcon(phase)} ${detail}`);
217
200
  throttledRender();
218
201
  },
219
202
  onAntSpawn(ant, task) {
220
203
  colony.antStreams.set(ant.id, {
221
- antId: ant.id,
222
- caste: ant.caste,
223
- lastLine: "starting...",
224
- tokens: 0,
204
+ antId: ant.id, caste: ant.caste, lastLine: "starting...", tokens: 0,
225
205
  });
226
- colony.log.push(` ${casteIcon(ant.caste)} ${ant.caste} ant dispatched → ${task.title.slice(0, 50)}`);
227
206
  throttledRender();
228
207
  },
229
- onAntDone(ant, task) {
208
+ onAntDone(ant) {
230
209
  colony.antStreams.delete(ant.id);
231
- const dur = ant.finishedAt ? formatDuration(ant.finishedAt - ant.startedAt) : "?";
232
- const icon = ant.status === "done" ? "✓" : "✗";
233
- colony.log.push(` ${icon} ${ant.caste} finished (${dur}, ${formatCost(ant.usage.cost)}) → ${task.title.slice(0, 50)}`);
234
210
  throttledRender();
235
211
  },
236
212
  onAntStream(event: AntStreamEvent) {
@@ -240,7 +216,6 @@ export default function antColonyExtension(pi: ExtensionAPI) {
240
216
  const lines = event.totalText.split("\n").filter(l => l.trim());
241
217
  stream.lastLine = lines[lines.length - 1]?.trim() || "...";
242
218
  }
243
- throttledRender();
244
219
  },
245
220
  onProgress(metrics) {
246
221
  if (colony.state) colony.state.metrics = metrics;
@@ -285,25 +260,15 @@ export default function antColonyExtension(pi: ExtensionAPI) {
285
260
 
286
261
  const report = [
287
262
  `## 🐜 Ant Colony Report`,
288
- ``,
289
263
  `**Goal:** ${state.goal}`,
290
- `**Status:** ${statusIcon(state.status)} ${state.status}`,
291
- `**Duration:** ${elapsed}`,
292
- ...(state.maxCost != null ? [`**Budget:** ${formatCost(m.totalCost)} / ${formatCost(state.maxCost)}`] : []),
293
- ``,
294
- `### Metrics`,
295
- `- Tasks: ${m.tasksDone}/${m.tasksTotal} done, ${m.tasksFailed} failed`,
296
- `- Ants spawned: ${m.antsSpawned}`,
297
- `- Tokens: ${formatTokens(m.totalTokens)}`,
298
- `- Cost: ${formatCost(m.totalCost)}`,
299
- `- Peak concurrency: ${state.concurrency.optimal}`,
264
+ `**Status:** ${statusIcon(state.status)} ${state.status} │ ${elapsed} │ ${formatCost(m.totalCost)}`,
265
+ `**Tasks:** ${m.tasksDone}/${m.tasksTotal} done${m.tasksFailed > 0 ? `, ${m.tasksFailed} failed` : ""}`,
300
266
  ``,
301
- `### Task Results`,
302
267
  ...state.tasks.filter(t => t.status === "done").map(t =>
303
- `- ✓ **${t.title}** (${t.caste})${t.result ? `\n ${t.result.split("\n")[0]?.slice(0, 100)}` : ""}`
268
+ `- ✓ **${t.title}**`
304
269
  ),
305
270
  ...state.tasks.filter(t => t.status === "failed").map(t =>
306
- `- ✗ **${t.title}** — ${t.error?.slice(0, 100) || "unknown error"}`
271
+ `- ✗ **${t.title}** — ${t.error?.slice(0, 80) || "unknown"}`
307
272
  ),
308
273
  ].join("\n");
309
274
 
@@ -420,22 +385,10 @@ export default function antColonyExtension(pi: ExtensionAPI) {
420
385
  // ── Active Ants ──
421
386
  const streams = Array.from(c.antStreams.values());
422
387
  if (streams.length > 0) {
423
- lines.push(theme.fg("accent", " Active Ants"));
424
- for (const s of streams) {
425
- const line = s.lastLine.length > w - 20 ? s.lastLine.slice(0, w - 23) + "..." : s.lastLine;
426
- lines.push(` ${casteIcon(s.caste)} ${theme.fg("accent", s.antId.slice(0, 14))} ${theme.fg("dim", `${s.tokens}tok`)} ${theme.fg("muted", "▸")} ${line}`);
427
- }
388
+ lines.push(theme.fg("accent", ` Active: ${streams.length} ants working`));
428
389
  lines.push("");
429
390
  }
430
391
 
431
- // ── Log (last 8) ──
432
- if (c.log.length > 0) {
433
- lines.push(theme.fg("accent", " Log"));
434
- for (const l of c.log.slice(-8)) {
435
- lines.push(theme.fg("dim", ` ${l.slice(0, w - 2)}`));
436
- }
437
- }
438
-
439
392
  lines.push("");
440
393
  lines.push(theme.fg("muted", " esc close"));
441
394
  return lines;
@@ -566,38 +519,14 @@ export default function antColonyExtension(pi: ExtensionAPI) {
566
519
  const state = c.state;
567
520
  const elapsed = state ? formatDuration(Date.now() - state.createdAt) : "0s";
568
521
  const m = state?.metrics;
569
- const tasks = state?.tasks || [];
570
- const ants = state?.ants || [];
571
- const streams = Array.from(c.antStreams.values());
522
+ const phase = state?.status || "scouting";
572
523
 
573
524
  const lines: string[] = [
574
- `## 🐜 Colony Status`,
575
- `**Goal:** ${c.goal}`,
576
- `**Phase:** ${c.phase}`,
577
- `**Duration:** ${elapsed}`,
525
+ `🐜 ${statusIcon(phase)} ${c.goal.slice(0, 80)}`,
526
+ `${phase} ${m ? `${m.tasksDone}/${m.tasksTotal} tasks` : "starting"} │ ${m ? formatCost(m.totalCost) : "$0"} │ ${elapsed}`,
578
527
  ];
579
528
 
580
- if (m) {
581
- lines.push(`**Tasks:** ${m.tasksDone}/${m.tasksTotal} done, ${m.tasksFailed} failed`);
582
- lines.push(`**Ants spawned:** ${m.antsSpawned} | **Active:** ${streams.length}`);
583
- lines.push(`**Cost:** ${formatCost(m.totalCost)} | **Tokens:** ${formatTokens(m.totalTokens)}`);
584
- }
585
-
586
- if (tasks.length > 0) {
587
- lines.push("", "### Tasks");
588
- for (const t of tasks) {
589
- const icon = t.status === "done" ? "✓" : t.status === "failed" ? "✗" : t.status === "active" ? "●" : "○";
590
- const dur = t.finishedAt && t.startedAt ? ` (${formatDuration(t.finishedAt - t.startedAt)})` : "";
591
- lines.push(`- ${icon} [${t.caste}] ${t.title}${dur}`);
592
- }
593
- }
594
-
595
- if (streams.length > 0) {
596
- lines.push("", "### Active Ants");
597
- for (const s of streams) {
598
- lines.push(`- ${casteIcon(s.caste)} ${s.antId.slice(0, 14)} | ${s.tokens}tok | ${s.lastLine.slice(0, 60)}`);
599
- }
600
- }
529
+ if (m && m.tasksFailed > 0) lines.push(`⚠ ${m.tasksFailed} failed`);
601
530
 
602
531
  return lines.join("\n");
603
532
  }
@@ -15,7 +15,7 @@
15
15
  import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import type {
18
- ColonyState, Task, Ant, AntCaste, ColonyMetrics,
18
+ ColonyState, Task, Ant, AntCaste, ColonyMetrics, ColonySignal,
19
19
  ConcurrencyConfig, TaskPriority, ModelOverrides, AntStreamEvent,
20
20
  } from "./types.js";
21
21
  import { DEFAULT_ANT_CONFIGS } from "./types.js";
@@ -26,12 +26,15 @@ import { buildImportGraph, taskDependsOn, type ImportGraph } from "./deps.js";
26
26
  import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
27
27
 
28
28
  export interface QueenCallbacks {
29
- onPhase(phase: ColonyState["status"], detail: string): void;
30
- onAntSpawn(ant: Ant, task: Task): void;
31
- onAntDone(ant: Ant, task: Task, output: string): void;
29
+ /** 抽象信号 观察者只需实现这一个 */
30
+ onSignal?(signal: ColonySignal): void;
31
+ /** 以下为细粒度回调(verbose 模式,可选) */
32
+ onPhase?(phase: ColonyState["status"], detail: string): void;
33
+ onAntSpawn?(ant: Ant, task: Task): void;
34
+ onAntDone?(ant: Ant, task: Task, output: string): void;
32
35
  onAntStream?(event: AntStreamEvent): void;
33
- onProgress(metrics: ColonyMetrics): void;
34
- onComplete(state: ColonyState): void;
36
+ onProgress?(metrics: ColonyMetrics): void;
37
+ onComplete?(state: ColonyState): void;
35
38
  }
36
39
 
37
40
  export interface QueenOptions {
@@ -156,7 +159,7 @@ interface WaveOptions {
156
159
  /**
157
160
  * 并发执行一批蚂蚁,自适应调节并发度
158
161
  */
159
- async function runAntWave(opts: WaveOptions): Promise<"ok"> {
162
+ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
160
163
  const { nest, cwd, caste, signal, callbacks, currentModel } = opts;
161
164
  const casteModel = opts.modelOverrides?.[caste] || currentModel;
162
165
  const config = { ...DEFAULT_ANT_CONFIGS[caste], model: casteModel };
@@ -165,7 +168,19 @@ async function runAntWave(opts: WaveOptions): Promise<"ok"> {
165
168
  let consecutiveRateLimits = 0; // 连续限流计数
166
169
  const retriedTasks = new Set<string>(); // 防止重复重试
167
170
 
168
- const runOne = async (): Promise<"done" | "empty" | "rate_limited"> => {
171
+ const runOne = async (): Promise<"done" | "empty" | "rate_limited" | "budget"> => {
172
+ // Budget 刹车:剩余预算不够一只蚂蚁的预估成本就不出发
173
+ const state = nest.getState();
174
+ if (state.maxCost != null && caste !== "drone") {
175
+ const spent = state.ants.reduce((s, a) => s + a.usage.cost, 0);
176
+ const remaining = state.maxCost - spent;
177
+ const doneAnts = state.ants.filter(a => a.status === "done" && a.usage.cost > 0);
178
+ const avgCost = doneAnts.length > 0
179
+ ? doneAnts.reduce((s, a) => s + a.usage.cost, 0) / doneAnts.length
180
+ : 0.05;
181
+ if (remaining < avgCost * 1.5) return "budget";
182
+ }
183
+
169
184
  const task = nest.nextPendingTask(caste);
170
185
  if (!task) return "empty";
171
186
  if (!nest.claimTask(task.id, "queen")) return "empty";
@@ -176,13 +191,13 @@ async function runAntWave(opts: WaveOptions): Promise<"ok"> {
176
191
  usage: { input: 0, output: 0, cost: 0, turns: 0 },
177
192
  startedAt: Date.now(), finishedAt: null,
178
193
  };
179
- callbacks.onAntSpawn(ant, task);
194
+ callbacks.onAntSpawn?.(ant, task);
180
195
 
181
196
  try {
182
197
  const result = caste === "drone"
183
198
  ? await runDrone(cwd, nest, task)
184
199
  : await spawnAnt(cwd, nest, task, config, signal, callbacks.onAntStream, opts.authStorage, opts.modelRegistry);
185
- callbacks.onAntDone(result.ant, task, result.output);
200
+ callbacks.onAntDone?.(result.ant, task, result.output);
186
201
 
187
202
  if (result.rateLimited) {
188
203
  return "rate_limited";
@@ -207,7 +222,8 @@ async function runAntWave(opts: WaveOptions): Promise<"ok"> {
207
222
 
208
223
  // 更新指标
209
224
  const metrics = updateMetrics(nest);
210
- callbacks.onProgress(metrics);
225
+ callbacks.onProgress?.(metrics);
226
+ emitSignal("working", `${metrics.tasksDone}/${metrics.tasksTotal} tasks done`);
211
227
 
212
228
  return "done";
213
229
  } catch (e) {
@@ -230,7 +246,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok"> {
230
246
 
231
247
  // 429 退避:短暂等待后恢复,连续限流才逐步加长
232
248
  if (backoffMs > 0) {
233
- callbacks.onPhase("working", `Rate limited (429). Waiting ${Math.round(backoffMs / 1000)}s...`);
249
+ callbacks.onPhase?.("working", `Rate limited (429). Waiting ${Math.round(backoffMs / 1000)}s...`);
234
250
  await new Promise(r => setTimeout(r, backoffMs));
235
251
  }
236
252
 
@@ -276,12 +292,16 @@ async function runAntWave(opts: WaveOptions): Promise<"ok"> {
276
292
  }
277
293
 
278
294
  const batch = Math.min(slotsAvailable, pending.length);
279
- const promises: Promise<"done" | "empty" | "rate_limited">[] = [];
295
+ const promises: Promise<"done" | "empty" | "rate_limited" | "budget">[] = [];
280
296
  for (let i = 0; i < batch; i++) {
281
297
  promises.push(runOne());
282
298
  }
283
299
  const results = await Promise.all(promises);
284
300
 
301
+ if (results.includes("budget")) {
302
+ return "budget";
303
+ }
304
+
285
305
  // 429 处理:降低并发 + 渐进退避(2s → 5s → 10s,上限 10s)
286
306
  if (results.includes("rate_limited")) {
287
307
  consecutiveRateLimits++;
@@ -349,9 +369,17 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
349
369
  } catch { /* ignore */ }
350
370
  };
351
371
 
372
+ const emitSignal = (phase: ColonyState["status"], message: string) => {
373
+ const m = nest.getState().metrics;
374
+ const active = nest.getState().ants.filter(a => a.status === "working").length;
375
+ const progress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
376
+ callbacks.onSignal?.({ phase, progress, active, cost: m.totalCost, message });
377
+ };
378
+
352
379
  try {
353
380
  // ═══ Phase 1: 侦察(快速单次,不再多轮接力) ═══
354
- callbacks.onPhase("scouting", "Dispatching scout ant to explore codebase...");
381
+ callbacks.onPhase?.("scouting", "Dispatching scout ant to explore codebase...");
382
+ emitSignal("scouting", "Exploring codebase...");
355
383
  await runAntWave({ ...waveBase, caste: "scout" });
356
384
 
357
385
  let workerTasks = nest.getAllTasks().filter(t => (t.caste === "worker" || t.caste === "drone") && t.status === "pending");
@@ -380,7 +408,8 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
380
408
  finishedAt: null,
381
409
  };
382
410
  nest.writeTask(relayTask);
383
- callbacks.onPhase("scouting", "Scout relay: generating worker tasks...");
411
+ callbacks.onPhase?.("scouting", "Scout relay: generating worker tasks...");
412
+ emitSignal("scouting", "Retrying scout...");
384
413
  await runAntWave({ ...waveBase, caste: "scout" });
385
414
  workerTasks = nest.getAllTasks().filter(t => (t.caste === "worker" || t.caste === "drone") && t.status === "pending");
386
415
  }
@@ -388,7 +417,8 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
388
417
  if (workerTasks.length === 0) {
389
418
  nest.updateState({ status: "failed", finishedAt: Date.now() });
390
419
  const finalState = nest.getState();
391
- callbacks.onComplete(finalState);
420
+ callbacks.onComplete?.(finalState);
421
+ emitSignal("failed", "No tasks generated");
392
422
  return finalState;
393
423
  }
394
424
 
@@ -408,16 +438,17 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
408
438
  // 先执行 drone 任务(零 LLM 成本)
409
439
  const droneTasks = nest.getAllTasks().filter(t => t.caste === "drone" && t.status === "pending");
410
440
  if (droneTasks.length > 0) {
411
- callbacks.onPhase("working", `${droneTasks.length} drone tasks. Executing rules...`);
441
+ callbacks.onPhase?.("working", `${droneTasks.length} drone tasks. Executing rules...`);
442
+ emitSignal("working", `${droneTasks.length} drone tasks`);
412
443
  await runAntWave({ ...waveBase, caste: "drone" });
413
444
  }
414
445
 
415
- callbacks.onPhase("working", `${workerTasks.length} tasks discovered. Dispatching worker ants...`);
446
+ callbacks.onPhase?.("working", `${workerTasks.length} tasks discovered. Dispatching worker ants...`);
447
+ emitSignal("working", `${workerTasks.length} tasks to do`);
416
448
  await runAntWave({ ...waveBase, caste: "worker" });
417
449
 
418
- // 处理工蚁产生的子任务(可能有多轮)
419
- let rounds = 0;
420
- while (rounds < 3) {
450
+ // 处理工蚁产生的子任务(budget 驱动,无硬限制)
451
+ while (true) {
421
452
  // 先跑 drone 子任务
422
453
  const pendingDrones = nest.getAllTasks().filter(t => t.caste === "drone" && t.status === "pending");
423
454
  if (pendingDrones.length > 0) await runAntWave({ ...waveBase, caste: "drone" });
@@ -426,9 +457,15 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
426
457
  t.caste === "worker" && (t.status === "pending" || t.status === "blocked")
427
458
  );
428
459
  if (remaining.length === 0) break;
429
- rounds++;
430
- callbacks.onPhase("working", `Round ${rounds + 1}: ${remaining.length} sub-tasks from workers...`);
431
- await runAntWave({ ...waveBase, caste: "worker" });
460
+ callbacks.onPhase?.("working", `${remaining.length} sub-tasks from workers...`);
461
+ const result = await runAntWave({ ...waveBase, caste: "worker" });
462
+ if (result === "budget") {
463
+ nest.updateState({ status: "budget_exceeded", finishedAt: Date.now() });
464
+ emitSignal("budget_exceeded", "Budget exhausted");
465
+ const budgetState = nest.getState();
466
+ callbacks.onComplete?.(budgetState);
467
+ return budgetState;
468
+ }
432
469
  }
433
470
 
434
471
  // ═══ Auto-check: run tsc before soldier review ═══
@@ -444,7 +481,8 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
444
481
  const completedWorkerTasks = nest.getAllTasks().filter(t => t.caste === "worker" && t.status === "done");
445
482
  if (completedWorkerTasks.length > 0 && (!tscPassed || completedWorkerTasks.length > 3)) {
446
483
  nest.updateState({ status: "reviewing" });
447
- callbacks.onPhase("reviewing", "Dispatching soldier ants to review changes...");
484
+ callbacks.onPhase?.("reviewing", "Dispatching soldier ants to review changes...");
485
+ emitSignal("reviewing", "Reviewing changes...");
448
486
  const reviewTask = makeReviewTask(completedWorkerTasks);
449
487
  nest.writeTask(reviewTask);
450
488
  await runAntWave({ ...waveBase, caste: "soldier" });
@@ -455,7 +493,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
455
493
  );
456
494
  if (fixTasks.length > 0) {
457
495
  nest.updateState({ status: "working" });
458
- callbacks.onPhase("working", `${fixTasks.length} fix tasks from review. Dispatching workers...`);
496
+ callbacks.onPhase?.("working", `${fixTasks.length} fix tasks from review. Dispatching workers...`);
459
497
  await runAntWave({ ...waveBase, caste: "worker" });
460
498
  }
461
499
  }
@@ -464,13 +502,15 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
464
502
  const finalMetrics = updateMetrics(nest);
465
503
  nest.updateState({ status: "done", finishedAt: Date.now(), metrics: finalMetrics });
466
504
  const finalState = nest.getState();
467
- callbacks.onComplete(finalState);
505
+ callbacks.onComplete?.(finalState);
506
+ emitSignal("done", `${finalMetrics.tasksDone}/${finalMetrics.tasksTotal} tasks done`);
468
507
  return finalState;
469
508
 
470
509
  } catch (e) {
471
510
  nest.updateState({ status: "failed", finishedAt: Date.now() });
472
511
  const failState = nest.getState();
473
- callbacks.onComplete(failState);
512
+ callbacks.onComplete?.(failState);
513
+ emitSignal("failed", String(e).slice(0, 100));
474
514
  return failState;
475
515
  } finally {
476
516
  cleanup();
@@ -132,3 +132,12 @@ export interface ColonyMetrics {
132
132
  startTime: number;
133
133
  throughputHistory: number[]; // tasks/min 滑动窗口
134
134
  }
135
+
136
+ /** 蚁群抽象信号 — 观察者只需关注这一个 */
137
+ export interface ColonySignal {
138
+ phase: ColonyState["status"];
139
+ progress: number; // 0-1
140
+ active: number; // 当前工作中的蚂蚁数
141
+ cost: number;
142
+ message: string; // 一句话描述
143
+ }