oh-pi 0.1.29 → 0.1.31

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.
@@ -1,10 +1,9 @@
1
1
  import { writeFileSync, mkdirSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
2
- import { join, dirname } from "node:path";
3
- import { fileURLToPath } from "node:url";
2
+ import { join } from "node:path";
4
3
  import { homedir } from "node:os";
5
4
  import { execSync } from "node:child_process";
6
5
  import { KEYBINDING_SCHEMES, MODEL_CAPABILITIES, PROVIDERS } from "../types.js";
7
- const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
6
+ import { resources } from "./resources.js";
8
7
  /**
9
8
  * 确保目录存在,若不存在则递归创建
10
9
  * @param dir - 目标目录路径
@@ -123,7 +122,7 @@ export function applyConfig(config) {
123
122
  writeFileSync(join(agentDir, "keybindings.json"), JSON.stringify(kb, null, 2));
124
123
  }
125
124
  // 5. AGENTS.md
126
- const agentsSrc = join(PKG_ROOT, "pi-package", "agents", `${config.agents}.md`);
125
+ const agentsSrc = resources.agent(config.agents);
127
126
  try {
128
127
  let content = readFileSync(agentsSrc, "utf8");
129
128
  if (config.locale && config.locale !== "en") {
@@ -138,8 +137,8 @@ export function applyConfig(config) {
138
137
  const extDir = join(agentDir, "extensions");
139
138
  cleanDir(extDir);
140
139
  for (const ext of config.extensions) {
141
- const dirSrc = join(PKG_ROOT, "pi-package", "extensions", ext);
142
- const fileSrc = join(PKG_ROOT, "pi-package", "extensions", `${ext}.ts`);
140
+ const dirSrc = resources.extension(ext);
141
+ const fileSrc = resources.extensionFile(ext);
143
142
  if (existsSync(dirSrc) && statSync(dirSrc).isDirectory()) {
144
143
  copyDir(dirSrc, join(extDir, ext));
145
144
  }
@@ -154,7 +153,7 @@ export function applyConfig(config) {
154
153
  const promptDir = join(agentDir, "prompts");
155
154
  cleanDir(promptDir);
156
155
  for (const p of config.prompts) {
157
- const src = join(PKG_ROOT, "pi-package", "prompts", `${p}.md`);
156
+ const src = resources.prompt(p);
158
157
  try {
159
158
  copyFileSync(src, join(promptDir, `${p}.md`));
160
159
  }
@@ -164,7 +163,7 @@ export function applyConfig(config) {
164
163
  const skillDir = join(agentDir, "skills");
165
164
  cleanDir(skillDir);
166
165
  for (const s of config.skills) {
167
- const srcDir = join(PKG_ROOT, "pi-package", "skills", s);
166
+ const srcDir = resources.skill(s);
168
167
  const destDir = join(skillDir, s);
169
168
  ensureDir(destDir);
170
169
  try {
@@ -175,7 +174,7 @@ export function applyConfig(config) {
175
174
  // 9. Copy themes (only custom ones)
176
175
  const themeDir = join(agentDir, "themes");
177
176
  cleanDir(themeDir);
178
- const themeSrc = join(PKG_ROOT, "pi-package", "themes", `${config.theme}.json`);
177
+ const themeSrc = resources.theme(config.theme);
179
178
  try {
180
179
  copyFileSync(themeSrc, join(themeDir, `${config.theme}.json`));
181
180
  }
@@ -0,0 +1,8 @@
1
+ export declare const resources: {
2
+ agent: (name: string) => string;
3
+ extension: (name: string) => string;
4
+ extensionFile: (name: string) => string;
5
+ prompt: (name: string) => string;
6
+ skill: (name: string) => string;
7
+ theme: (name: string) => string;
8
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 资源路径解析 — 解耦 install.ts 对 pi-package/ 目录的硬编码引用
3
+ */
4
+ import { join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
7
+ const RESOURCES = join(PKG_ROOT, "pi-package");
8
+ export const resources = {
9
+ agent: (name) => join(RESOURCES, "agents", `${name}.md`),
10
+ extension: (name) => join(RESOURCES, "extensions", name),
11
+ extensionFile: (name) => join(RESOURCES, "extensions", `${name}.ts`),
12
+ prompt: (name) => join(RESOURCES, "prompts", `${name}.md`),
13
+ skill: (name) => join(RESOURCES, "skills", name),
14
+ theme: (name) => join(RESOURCES, "themes", `${name}.json`),
15
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-pi",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "description": "One-click setup for pi-coding-agent. Like oh-my-zsh for pi.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,11 +10,10 @@
10
10
  import { readFileSync, appendFileSync, existsSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
13
- import { Text, Container, Spacer } from "@mariozechner/pi-tui";
13
+ import { Text, Container, Spacer, Box } from "@mariozechner/pi-tui";
14
14
  import { Type } from "@sinclair/typebox";
15
15
  import { runColony, type QueenCallbacks } from "./queen.js";
16
- import type { ColonyState, ColonyMetrics, Ant, Task, AvailableModel } from "./types.js";
17
- import { resolveModelForCaste } from "./types.js";
16
+ import type { ColonyState, ColonyMetrics, Ant, Task } from "./types.js";
18
17
 
19
18
  interface ColonyDetails {
20
19
  state: ColonyState | null;
@@ -49,20 +48,34 @@ function casteIcon(caste: string): string {
49
48
  return caste === "scout" ? "🔍" : caste === "soldier" ? "🛡️" : "⚒️";
50
49
  }
51
50
 
51
+ /** 渲染进度条 ▓░ */
52
+ function progressBar(done: number, total: number, width: number, theme: any): string {
53
+ if (total === 0) return "";
54
+ const pct = Math.min(done / total, 1);
55
+ const filled = Math.round(pct * width);
56
+ const empty = width - filled;
57
+ const bar = theme.fg("success", "█".repeat(filled)) + theme.fg("muted", "░".repeat(empty));
58
+ return `${bar} ${theme.fg("accent", `${done}/${total}`)}`;
59
+ }
52
60
 
53
- function resolveModels(ctx: { modelRegistry: { getAvailable(): AvailableModel[] }; model?: { id: string } | undefined }): { overrides: Record<string, string>; available: AvailableModel[]; currentModel?: string } {
54
- const overrides: Record<string, string> = {};
55
- const current = ctx.model?.id;
56
- const available = ctx.modelRegistry.getAvailable();
57
-
58
- for (const caste of ["scout", "worker", "soldier"] as const) {
59
- const resolved = resolveModelForCaste(caste, available, current);
60
- if (resolved) overrides[caste] = resolved;
61
- }
62
-
63
- return { overrides, available, currentModel: current };
61
+ /** 渲染阶段流水线 scout work review done */
62
+ function phasePipeline(status: string, theme: any): string {
63
+ const phases = [
64
+ { key: "scouting", icon: "🔍", label: "Scout" },
65
+ { key: "working", icon: "⚒️", label: "Work" },
66
+ { key: "reviewing", icon: "🛡️", label: "Review" },
67
+ { key: "done", icon: "✅", label: "Done" },
68
+ ];
69
+ const idx = phases.findIndex(p => p.key === status);
70
+ return phases.map((p, i) => {
71
+ const label = `${p.icon} ${p.label}`;
72
+ if (i < idx) return theme.fg("success", label);
73
+ if (i === idx) return theme.fg("accent", theme.bold(label));
74
+ return theme.fg("muted", label);
75
+ }).join(theme.fg("muted", " → "));
64
76
  }
65
77
 
78
+
66
79
  export default function antColonyExtension(pi: ExtensionAPI) {
67
80
 
68
81
  // ═══ Auto-trigger: 注入蚁群意识,LLM 自动判断何时启动 ═══
@@ -102,8 +115,14 @@ For simple single-file tasks, work directly without the colony.`,
102
115
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
103
116
  const details: ColonyDetails = { state: null, phase: "initializing", log: [] };
104
117
 
105
- // Resolve models: smart discovery from registry
106
- const { overrides: modelOverrides, available, currentModel } = resolveModels(ctx);
118
+ // 所有蚂蚁统一使用当前会话模型
119
+ const currentModel = ctx.model?.id;
120
+ if (!currentModel) {
121
+ return {
122
+ content: [{ type: "text", text: "Colony failed: no model available in current session" }],
123
+ isError: true,
124
+ };
125
+ }
107
126
 
108
127
  const emit = () => {
109
128
  const summary = details.state
@@ -155,8 +174,6 @@ For simple single-file tasks, work directly without the colony.`,
155
174
  goal: params.goal,
156
175
  maxAnts: params.maxAnts,
157
176
  maxCost: params.maxCost,
158
- modelOverrides,
159
- availableModels: available,
160
177
  currentModel,
161
178
  signal: signal ?? undefined,
162
179
  callbacks,
@@ -212,108 +229,136 @@ For simple single-file tasks, work directly without the colony.`,
212
229
  // ═══ TUI Rendering ═══
213
230
 
214
231
  renderCall(args, theme) {
215
- let text = theme.fg("toolTitle", theme.bold("ant_colony "));
216
- text += theme.fg("accent", "🐜");
217
- const goal = args.goal?.length > 60 ? args.goal.slice(0, 57) + "..." : args.goal;
218
- text += "\n " + theme.fg("dim", goal || "...");
219
- if (args.maxAnts) text += theme.fg("muted", ` (max: ${args.maxAnts})`);
220
- if (args.maxCost) text += theme.fg("warning", ` (budget: $${args.maxCost})`);
232
+ const goal = args.goal?.length > 70 ? args.goal.slice(0, 67) + "..." : args.goal;
233
+ let text = theme.fg("toolTitle", theme.bold("🐜 ant_colony"));
234
+ if (args.maxAnts) text += theme.fg("muted", ` ×${args.maxAnts}`);
235
+ if (args.maxCost) text += theme.fg("warning", ` $${args.maxCost}`);
236
+ text += "\n" + theme.fg("dim", ` ${goal || "..."}`);
221
237
  return new Text(text, 0, 0);
222
238
  },
223
239
 
224
240
  renderResult(result, { expanded }, theme) {
225
241
  const details = result.details as ColonyDetails | undefined;
242
+
243
+ // ─── 运行中 ───
226
244
  if (!details?.state) {
227
- // Still running or no state
228
245
  const log = details?.log ?? [];
229
- let text = theme.fg("warning", "🐜 ") + theme.fg("toolTitle", details?.phase || "initializing...");
230
- const recent = log.slice(expanded ? -30 : -8);
246
+ const container = new Container();
247
+ container.addChild(new Text(
248
+ theme.fg("warning", "🐜 ") + theme.fg("toolTitle", theme.bold("Colony ")) +
249
+ theme.fg("accent", details?.phase || "initializing..."),
250
+ 0, 0,
251
+ ));
252
+ const recent = log.slice(expanded ? -20 : -5);
231
253
  if (recent.length > 0) {
232
- text += "\n" + recent.map(l => theme.fg("dim", l)).join("\n");
254
+ container.addChild(new Text(recent.map(l => theme.fg("dim", ` ${l}`)).join("\n"), 0, 0));
233
255
  }
234
- if (!expanded && log.length > 8) {
235
- text += "\n" + theme.fg("muted", `... ${log.length - 8} more (Ctrl+O to expand)`);
256
+ if (!expanded && log.length > 5) {
257
+ container.addChild(new Text(theme.fg("muted", ` ⋯ ${log.length - 5} more`), 0, 0));
236
258
  }
237
- return new Text(text, 0, 0);
259
+ return container;
238
260
  }
239
261
 
240
262
  const state = details.state;
241
263
  const m = state.metrics;
242
- const icon = state.status === "done" ? theme.fg("success", "✓") : theme.fg("error", "✗");
243
264
  const elapsed = state.finishedAt ? formatDuration(state.finishedAt - state.createdAt) : "?";
265
+ const ok = state.status === "done";
244
266
 
267
+ // ─── 折叠视图 ───
245
268
  if (!expanded) {
246
- let text = `${icon} ${theme.fg("toolTitle", theme.bold("ant colony "))}`;
247
- text += theme.fg("accent", `${m.tasksDone}/${m.tasksTotal} tasks`);
248
- text += theme.fg("muted", ` | ${m.antsSpawned} ants | ${elapsed} | ${formatCost(m.totalCost)}`);
249
- text += theme.fg("muted", ` | peak ×${state.concurrency.optimal}`);
250
-
251
- // Compact task list
252
- for (const t of state.tasks.slice(0, 5)) {
253
- const ti = t.status === "done" ? theme.fg("success", "✓") : t.status === "failed" ? theme.fg("error", "✗") : theme.fg("muted", "○");
254
- text += `\n ${ti} ${theme.fg("dim", `[${t.caste}]`)} ${t.title.slice(0, 60)}`;
269
+ const container = new Container();
270
+
271
+ // 标题行:状态 + 统计
272
+ const icon = ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
273
+ container.addChild(new Text(
274
+ `${icon} ${theme.fg("toolTitle", theme.bold("ant colony "))}` +
275
+ theme.fg("muted", `${elapsed} `) +
276
+ theme.fg("accent", `${m.antsSpawned} ants`) +
277
+ theme.fg("muted", `${formatTokens(m.totalTokens)} ${formatCost(m.totalCost)}`),
278
+ 0, 0,
279
+ ));
280
+
281
+ // 进度条
282
+ container.addChild(new Text(` ${progressBar(m.tasksDone, m.tasksTotal, 20, theme)} ${theme.fg("muted", `(${m.tasksFailed} failed)`)}`, 0, 0));
283
+
284
+ // 任务列表(最多6条)
285
+ for (const t of state.tasks.slice(0, 6)) {
286
+ const ti = t.status === "done" ? theme.fg("success", "✓")
287
+ : t.status === "failed" ? theme.fg("error", "✗")
288
+ : theme.fg("muted", "○");
289
+ container.addChild(new Text(
290
+ ` ${ti} ${theme.fg("dim", `${casteIcon(t.caste)}`)} ${t.title.slice(0, 60)}`,
291
+ 0, 0,
292
+ ));
255
293
  }
256
- if (state.tasks.length > 5) text += `\n ${theme.fg("muted", `... +${state.tasks.length - 5} more`)}`;
257
- text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
258
- return new Text(text, 0, 0);
294
+ if (state.tasks.length > 6) {
295
+ container.addChild(new Text(theme.fg("muted", ` ⋯ +${state.tasks.length - 6} more (Ctrl+O)`), 0, 0));
296
+ }
297
+
298
+ return container;
259
299
  }
260
300
 
261
- // Expanded view
301
+ // ─── 展开视图 ───
262
302
  const container = new Container();
303
+
304
+ // 标题 + 阶段流水线
305
+ const icon = ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
263
306
  container.addChild(new Text(
264
307
  `${icon} ${theme.fg("toolTitle", theme.bold("ant colony "))}` +
265
308
  theme.fg("accent", state.status) +
266
- theme.fg("muted", ` | ${elapsed} | ${formatCost(m.totalCost)} | ${formatTokens(m.totalTokens)} tokens | peak ×${state.concurrency.optimal}`),
309
+ theme.fg("muted", ` ${elapsed} ${formatCost(m.totalCost)} ${formatTokens(m.totalTokens)} tokens`),
267
310
  0, 0,
268
311
  ));
269
- container.addChild(new Text(theme.fg("dim", state.goal), 0, 0));
312
+ container.addChild(new Text(` ${phasePipeline(state.status, theme)}`, 0, 0));
313
+ container.addChild(new Text(theme.fg("dim", ` ${state.goal}`), 0, 0));
314
+
315
+ // 进度条
270
316
  container.addChild(new Spacer(1));
317
+ container.addChild(new Text(` ${progressBar(m.tasksDone, m.tasksTotal, 30, theme)}`, 0, 0));
271
318
 
272
- // Tasks
273
- container.addChild(new Text(theme.fg("muted", `─── Tasks (${m.tasksDone}/${m.tasksTotal}) ───`), 0, 0));
319
+ // 任务区
320
+ container.addChild(new Spacer(1));
321
+ container.addChild(new Text(theme.fg("muted", ` ─── Tasks (${m.tasksDone}/${m.tasksTotal}) ───`), 0, 0));
274
322
  for (const t of state.tasks) {
275
323
  const ti = t.status === "done" ? theme.fg("success", "✓")
276
324
  : t.status === "failed" ? theme.fg("error", "✗")
277
- : t.status === "active" ? theme.fg("warning", "")
325
+ : t.status === "active" ? theme.fg("warning", "")
278
326
  : theme.fg("muted", "○");
279
- let line = `${ti} ${theme.fg("accent", `[${t.caste}]`)} ${t.title}`;
280
- if (t.finishedAt && t.startedAt) line += theme.fg("dim", ` (${formatDuration(t.finishedAt - t.startedAt)})`);
281
- container.addChild(new Text(line, 0, 0));
327
+ const dur = (t.finishedAt && t.startedAt) ? theme.fg("dim", ` ${formatDuration(t.finishedAt - t.startedAt)}`) : "";
328
+ container.addChild(new Text(` ${ti} ${casteIcon(t.caste)} ${t.title}${dur}`, 0, 0));
282
329
  if (t.status === "done" && t.result) {
283
- const preview = t.result.split("\n").slice(0, 2).join("\n").slice(0, 120);
284
- container.addChild(new Text(theme.fg("dim", ` ${preview}`), 0, 0));
330
+ container.addChild(new Text(theme.fg("dim", ` ${t.result.split("\n")[0]?.slice(0, 100)}`), 0, 0));
285
331
  }
286
332
  if (t.status === "failed" && t.error) {
287
- container.addChild(new Text(theme.fg("error", ` ${t.error.slice(0, 120)}`), 0, 0));
333
+ container.addChild(new Text(theme.fg("error", ` ${t.error.slice(0, 100)}`), 0, 0));
288
334
  }
289
335
  }
290
336
 
291
- // Ants
337
+ // 蚂蚁区
292
338
  container.addChild(new Spacer(1));
293
- container.addChild(new Text(theme.fg("muted", `─── Ants (${m.antsSpawned}) ───`), 0, 0));
339
+ container.addChild(new Text(theme.fg("muted", ` ─── Ants (${m.antsSpawned}) ───`), 0, 0));
294
340
  for (const a of state.ants) {
295
- const ai = a.status === "done" ? theme.fg("success", "✓") : a.status === "failed" ? theme.fg("error", "✗") : theme.fg("warning", "");
341
+ const ai = a.status === "done" ? theme.fg("success", "✓") : a.status === "failed" ? theme.fg("error", "✗") : theme.fg("warning", "");
296
342
  const dur = a.finishedAt ? formatDuration(a.finishedAt - a.startedAt) : "...";
297
343
  container.addChild(new Text(
298
- `${ai} ${casteIcon(a.caste)} ${theme.fg("accent", a.id)} ${theme.fg("dim", `${dur} ${formatCost(a.usage.cost)} ${a.usage.turns}t`)}`,
344
+ ` ${ai} ${casteIcon(a.caste)} ${theme.fg("accent", a.id)} ${theme.fg("dim", `${dur} ${formatCost(a.usage.cost)} ${a.usage.turns}t`)}`,
299
345
  0, 0,
300
346
  ));
301
347
  }
302
348
 
303
- // Concurrency
349
+ // 并发 + 日志
304
350
  container.addChild(new Spacer(1));
305
351
  const c = state.concurrency;
306
352
  container.addChild(new Text(
307
- theme.fg("muted", `─── Concurrency ───`) + `\n` +
308
- theme.fg("dim", `current: ${c.current} | optimal: ${c.optimal} | range: ${c.min}-${c.max} | samples: ${c.history.length}`),
353
+ theme.fg("muted", ` ─── Concurrency ───`) + "\n" +
354
+ theme.fg("dim", ` current: ${c.current} optimal: ${c.optimal} range: ${c.min}-${c.max}`),
309
355
  0, 0,
310
356
  ));
311
357
 
312
- // Activity log
313
358
  container.addChild(new Spacer(1));
314
- container.addChild(new Text(theme.fg("muted", "─── Log ───"), 0, 0));
315
- for (const l of details.log.slice(-20)) {
316
- container.addChild(new Text(theme.fg("dim", l), 0, 0));
359
+ container.addChild(new Text(theme.fg("muted", ` ─── Log ───`), 0, 0));
360
+ for (const l of details.log.slice(-15)) {
361
+ container.addChild(new Text(theme.fg("dim", ` ${l}`), 0, 0));
317
362
  }
318
363
 
319
364
  return container;
@@ -80,7 +80,7 @@ export class Nest {
80
80
  .map(f => this.readJson<Task>(path.join(this.tasksDir, f)));
81
81
  for (const t of tasks) this.taskCache.set(t.id, t);
82
82
  return tasks;
83
- } catch { return []; }
83
+ } catch (e) { console.error("[nest] failed to read tasks dir:", e); return []; }
84
84
  }
85
85
 
86
86
  claimTask(taskId: string, antId: string): boolean {
@@ -217,7 +217,7 @@ export class Nest {
217
217
 
218
218
  private withStateLock<T>(fn: () => T): T {
219
219
  const MAX_WAIT = 5000;
220
- const SPIN_MS = 10;
220
+ const SPIN_MS = 15;
221
221
  const start = Date.now();
222
222
  while (true) {
223
223
  try {
@@ -225,18 +225,22 @@ export class Nest {
225
225
  break;
226
226
  } catch {
227
227
  if (Date.now() - start > MAX_WAIT) {
228
- // 超时强制清除死锁
229
- try { fs.unlinkSync(this.lockFile); } catch { /* ignore */ }
228
+ // 超时:检查锁持有者是否存活
229
+ try {
230
+ const holder = parseInt(fs.readFileSync(this.lockFile, "utf-8"), 10);
231
+ try { process.kill(holder, 0); } catch { /* 进程已死,清除死锁 */ fs.unlinkSync(this.lockFile); continue; }
232
+ } catch { /* 读取失败,强制清除 */ try { fs.unlinkSync(this.lockFile); } catch {} }
230
233
  continue;
231
234
  }
232
- const wait = SPIN_MS + Math.random() * SPIN_MS;
233
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, wait);
235
+ // 简单 busy-wait,避免 SharedArrayBuffer 依赖
236
+ const until = Date.now() + SPIN_MS + Math.random() * SPIN_MS;
237
+ while (Date.now() < until) { /* spin */ }
234
238
  }
235
239
  }
236
240
  try {
237
241
  return fn();
238
242
  } finally {
239
- try { fs.unlinkSync(this.lockFile); } catch { /* ignore */ }
243
+ try { fs.unlinkSync(this.lockFile); } catch { /* lock already removed */ }
240
244
  }
241
245
  }
242
246
 
@@ -16,9 +16,9 @@ import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import type {
18
18
  ColonyState, Task, Ant, AntCaste, ColonyMetrics,
19
- ConcurrencyConfig, TaskPriority, ModelOverrides, AvailableModel,
19
+ ConcurrencyConfig, TaskPriority, ModelOverrides,
20
20
  } from "./types.js";
21
- import { DEFAULT_ANT_CONFIGS, resolveModelForCaste } from "./types.js";
21
+ import { DEFAULT_ANT_CONFIGS } from "./types.js";
22
22
  import { Nest } from "./nest.js";
23
23
  import { spawnAnt, makeTaskId } from "./spawner.js";
24
24
  import { adapt, sampleSystem, defaultConcurrency } from "./concurrency.js";
@@ -36,9 +36,7 @@ export interface QueenOptions {
36
36
  goal: string;
37
37
  maxAnts?: number;
38
38
  maxCost?: number;
39
- modelOverrides?: ModelOverrides;
40
- availableModels?: AvailableModel[];
41
- currentModel?: string;
39
+ currentModel: string;
42
40
  signal?: AbortSignal;
43
41
  callbacks: QueenCallbacks;
44
42
  }
@@ -139,25 +137,17 @@ interface WaveOptions {
139
137
  nest: Nest;
140
138
  cwd: string;
141
139
  caste: AntCaste;
140
+ currentModel: string;
142
141
  signal?: AbortSignal;
143
142
  callbacks: QueenCallbacks;
144
- modelOverrides?: ModelOverrides;
145
- maxCost?: number;
146
- availableModels?: AvailableModel[];
147
- currentModel?: string;
148
143
  }
149
144
 
150
145
  /**
151
146
  * 并发执行一批蚂蚁,自适应调节并发度
152
147
  */
153
- async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget_exceeded"> {
154
- const { nest, cwd, caste, signal, callbacks, modelOverrides, maxCost, availableModels, currentModel } = opts;
155
- const config = { ...DEFAULT_ANT_CONFIGS[caste] };
156
- if (modelOverrides?.[caste]) {
157
- config.model = modelOverrides[caste];
158
- } else if (!config.model && availableModels?.length) {
159
- config.model = resolveModelForCaste(caste, availableModels, currentModel) ?? "";
160
- }
148
+ async function runAntWave(opts: WaveOptions): Promise<"ok"> {
149
+ const { nest, cwd, caste, signal, callbacks, currentModel } = opts;
150
+ const config = { ...DEFAULT_ANT_CONFIGS[caste], model: currentModel };
161
151
 
162
152
  let backoffMs = 0; // 429 退避时间
163
153
  let consecutiveRateLimits = 0; // 连续限流计数
@@ -217,26 +207,6 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget_exceeded">
217
207
  // 调度循环:持续派蚂蚁直到没有待处理任务
218
208
  let lastSampleTime = 0;
219
209
  while (!signal?.aborted) {
220
- // 预算检查:派蚂蚁前预估,基于已完成蚂蚁的平均 cost
221
- if (maxCost != null) {
222
- const ants = nest.getState().ants;
223
- const currentCost = ants.reduce((s, a) => s + a.usage.cost, 0);
224
- if (currentCost >= maxCost) {
225
- callbacks.onPhase("working", `Budget exceeded ($${currentCost.toFixed(3)} >= $${maxCost.toFixed(2)}). Stopping.`);
226
- return "budget_exceeded";
227
- }
228
- // 预估:如果剩余预算不够一只蚂蚁的平均花费,也停止
229
- const doneAnts = ants.filter(a => a.status === "done" && a.usage.cost > 0);
230
- if (doneAnts.length > 0) {
231
- const avgCost = doneAnts.reduce((s, a) => s + a.usage.cost, 0) / doneAnts.length;
232
- const remaining = maxCost - currentCost;
233
- if (remaining < avgCost * 0.5) {
234
- callbacks.onPhase("working", `Budget nearly exhausted ($${currentCost.toFixed(3)}, avg/ant: $${avgCost.toFixed(3)}). Stopping.`);
235
- return "budget_exceeded";
236
- }
237
- }
238
- }
239
-
240
210
  const state = nest.getState();
241
211
  const pending = state.tasks.filter(t => t.status === "pending" && t.caste === caste);
242
212
  if (pending.length === 0) break;
@@ -327,7 +297,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
327
297
  startTime: Date.now(), throughputHistory: [],
328
298
  },
329
299
  maxCost: opts.maxCost ?? null,
330
- modelOverrides: opts.modelOverrides ?? {},
300
+ modelOverrides: {},
331
301
  createdAt: Date.now(),
332
302
  finishedAt: null,
333
303
  };
@@ -340,8 +310,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
340
310
  const { signal, callbacks } = opts;
341
311
  const waveBase: Omit<WaveOptions, "caste"> = {
342
312
  nest, cwd: opts.cwd, signal, callbacks,
343
- modelOverrides: opts.modelOverrides, maxCost: opts.maxCost,
344
- availableModels: opts.availableModels, currentModel: opts.currentModel,
313
+ currentModel: opts.currentModel,
345
314
  };
346
315
 
347
316
  const cleanup = () => {
@@ -353,26 +322,32 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
353
322
  } catch { /* ignore */ }
354
323
  };
355
324
 
356
- const budgetStop = (phase: string) => {
357
- nest.updateState({ status: "budget_exceeded", finishedAt: Date.now() });
358
- callbacks.onPhase("budget_exceeded" as any, phase);
359
- const s = nest.getState();
360
- callbacks.onComplete(s);
361
- cleanup();
362
- return s;
363
- };
364
-
365
325
  try {
366
- // ═══ Phase 1: 侦察 ═══
367
- callbacks.onPhase("scouting", "Dispatching scout ants to explore codebase...");
368
- if (await runAntWave({ ...waveBase, caste: "scout" }) === "budget_exceeded")
369
- return budgetStop("Budget exceeded during scouting");
370
-
371
- // 检查侦察结果,如果没有产生工蚁任务,用侦察结果让女王自己拆
372
- const postScout = nest.getAllTasks();
373
- const workerTasks = postScout.filter(t => t.caste === "worker" && t.status === "pending");
326
+ // ═══ Phase 1: 侦察(最多重试2次) ═══
327
+ let scoutAttempt = 0;
328
+ const MAX_SCOUT_RETRIES = 2;
329
+ let workerTasks: Task[] = [];
330
+
331
+ while (scoutAttempt <= MAX_SCOUT_RETRIES) {
332
+ callbacks.onPhase("scouting", scoutAttempt === 0
333
+ ? "Dispatching scout ants to explore codebase..."
334
+ : `Scout retry ${scoutAttempt}/${MAX_SCOUT_RETRIES}...`);
335
+
336
+ await runAntWave({ ...waveBase, caste: "scout" });
337
+
338
+ workerTasks = nest.getAllTasks().filter(t => t.caste === "worker" && t.status === "pending");
339
+ if (workerTasks.length > 0) break;
340
+
341
+ scoutAttempt++;
342
+ if (scoutAttempt <= MAX_SCOUT_RETRIES) {
343
+ // 重置失败的 scout 任务为 pending 以便重试
344
+ for (const t of nest.getAllTasks().filter(t => t.caste === "scout" && (t.status === "done" || t.status === "failed"))) {
345
+ nest.updateTaskStatus(t.id, "pending");
346
+ }
347
+ }
348
+ }
349
+
374
350
  if (workerTasks.length === 0) {
375
- // 侦察蚁没产生任务,标记失败
376
351
  nest.updateState({ status: "failed", finishedAt: Date.now() });
377
352
  const finalState = nest.getState();
378
353
  callbacks.onComplete(finalState);
@@ -383,8 +358,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
383
358
  // ═══ Phase 2: 工作 ═══
384
359
  nest.updateState({ status: "working" });
385
360
  callbacks.onPhase("working", `${workerTasks.length} tasks discovered. Dispatching worker ants...`);
386
- if (await runAntWave({ ...waveBase, caste: "worker" }) === "budget_exceeded")
387
- return budgetStop("Budget exceeded during working");
361
+ await runAntWave({ ...waveBase, caste: "worker" });
388
362
 
389
363
  // 处理工蚁产生的子任务(可能有多轮)
390
364
  let rounds = 0;
@@ -395,8 +369,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
395
369
  if (remaining.length === 0) break;
396
370
  rounds++;
397
371
  callbacks.onPhase("working", `Round ${rounds + 1}: ${remaining.length} sub-tasks from workers...`);
398
- if (await runAntWave({ ...waveBase, caste: "worker" }) === "budget_exceeded")
399
- return budgetStop("Budget exceeded during sub-tasks");
372
+ await runAntWave({ ...waveBase, caste: "worker" });
400
373
  }
401
374
 
402
375
  // ═══ Phase 3: 审查 ═══
@@ -406,8 +379,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
406
379
  callbacks.onPhase("reviewing", "Dispatching soldier ants to review changes...");
407
380
  const reviewTask = makeReviewTask(completedWorkerTasks);
408
381
  nest.writeTask(reviewTask);
409
- if (await runAntWave({ ...waveBase, caste: "soldier" }) === "budget_exceeded")
410
- return budgetStop("Budget exceeded during review");
382
+ await runAntWave({ ...waveBase, caste: "soldier" });
411
383
 
412
384
  // 兵蚁产生的修复任务
413
385
  const fixTasks = nest.getAllTasks().filter(t =>
@@ -416,8 +388,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
416
388
  if (fixTasks.length > 0) {
417
389
  nest.updateState({ status: "working" });
418
390
  callbacks.onPhase("working", `${fixTasks.length} fix tasks from review. Dispatching workers...`);
419
- if (await runAntWave({ ...waveBase, caste: "worker" }) === "budget_exceeded")
420
- return budgetStop("Budget exceeded during fixes");
391
+ await runAntWave({ ...waveBase, caste: "worker" });
421
392
  }
422
393
  }
423
394
 
@@ -114,8 +114,11 @@ Output format (MUST follow exactly):
114
114
  PASS or FAIL with summary.`,
115
115
  };
116
116
 
117
- function buildPrompt(task: Task, pheromoneContext: string, castePrompt: string): string {
117
+ function buildPrompt(task: Task, pheromoneContext: string, castePrompt: string, maxTurns?: number): string {
118
118
  let prompt = castePrompt + "\n\n";
119
+ if (maxTurns) {
120
+ 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`;
121
+ }
119
122
  if (pheromoneContext) {
120
123
  prompt += `## Colony Pheromone Trail (intelligence from other ants)\n${pheromoneContext}\n\n`;
121
124
  }
@@ -223,7 +226,7 @@ export async function spawnAnt(
223
226
  // 构建 prompt
224
227
  const pheromoneCtx = nest.getPheromoneContext(task.files);
225
228
  const castePrompt = CASTE_PROMPTS[antConfig.caste];
226
- const fullPrompt = buildPrompt(task, pheromoneCtx, castePrompt);
229
+ const fullPrompt = buildPrompt(task, pheromoneCtx, castePrompt, antConfig.maxTurns);
227
230
  const tmpFile = writePromptFile(nest.dir, antId, fullPrompt);
228
231
 
229
232
  const args = [
@@ -246,6 +249,7 @@ export async function spawnAnt(
246
249
  nest.updateAnt(ant);
247
250
 
248
251
  let buffer = "";
252
+ let turnCount = 0;
249
253
 
250
254
  proc.stdout.on("data", (data) => {
251
255
  buffer += data.toString();
@@ -255,6 +259,15 @@ export async function spawnAnt(
255
259
  if (!line.trim()) continue;
256
260
  try {
257
261
  const event = JSON.parse(line);
262
+ if (event.type === "turn_end") {
263
+ turnCount++;
264
+ if (antConfig.maxTurns && turnCount === antConfig.maxTurns) {
265
+ stderr += `[ant-colony] Warning: ant reached maxTurns (${antConfig.maxTurns}), 1 grace turn remaining\n`;
266
+ } else if (antConfig.maxTurns && turnCount > antConfig.maxTurns) {
267
+ proc.kill("SIGTERM");
268
+ setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, 3000);
269
+ }
270
+ }
258
271
  if (event.type === "message_end" && event.message) {
259
272
  messages.push(event.message);
260
273
  if (event.message.role === "assistant") {
@@ -288,6 +301,7 @@ export async function spawnAnt(
288
301
 
289
302
  if (signal) {
290
303
  const kill = () => {
304
+ try { fs.unlinkSync(tmpFile); } catch { /* already cleaned */ }
291
305
  proc.kill("SIGTERM");
292
306
  setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, 3000);
293
307
  };
@@ -19,79 +19,9 @@ export const DEFAULT_ANT_CONFIGS: Record<AntCaste, Omit<AntConfig, "systemPrompt
19
19
  soldier: { caste: "soldier", model: "", tools: ["read", "bash", "grep", "find", "ls"], maxTurns: 8 },
20
20
  };
21
21
 
22
- // ═══ 模型层级 ═══
23
- export type ModelTier = "fast" | "balanced" | "powerful";
24
-
25
- export const CASTE_MODEL_TIER: Record<AntCaste, ModelTier> = {
26
- scout: "fast",
27
- worker: "powerful",
28
- soldier: "balanced",
29
- };
30
-
31
- export const MODEL_TIER_KEYWORDS: Record<ModelTier, string[]> = {
32
- fast: ["haiku", "mini", "flash", "nano", "small"],
33
- balanced: ["sonnet", "gpt-4o", "pro"],
34
- powerful: ["opus", "o1", "o3", "deepthink"],
35
- };
36
-
37
22
  /** Per-caste model overrides from user config */
38
23
  export type ModelOverrides = Partial<Record<AntCaste, string>>;
39
24
 
40
- export interface AvailableModel {
41
- id: string;
42
- cost?: { input: number };
43
- }
44
-
45
- /** Extract version score from model name: "claude-sonnet-4-0" → 4.0, "claude-3-5-haiku" → 3.5 */
46
- function modelVersionScore(id: string): number {
47
- // Match patterns like "4-0", "3-5", "4.5", "4o" etc.
48
- const nums = id.match(/(\d+)[-.](\d+)/g);
49
- if (!nums) {
50
- const single = id.match(/(\d+)/g);
51
- return single ? Math.max(...single.map(Number)) : 0;
52
- }
53
- // Take the highest version-like number pair
54
- return Math.max(...nums.map(n => {
55
- const [a, b] = n.split(/[-.]/).map(Number);
56
- return a + (b ?? 0) / 10;
57
- }));
58
- }
59
-
60
- /**
61
- * 根据 caste 的 ModelTier 从可用模型中匹配最合适的模型
62
- *
63
- * 策略:从模型名提取版本号排序
64
- * - fast(侦察蚁):选版本号最低的(轻量便宜)
65
- * - powerful(工蚁):选版本号最高的(最强)
66
- * - balanced(兵蚁):优先当前会话模型
67
- * - 所有 tier 最终 fallback 到 currentModel
68
- */
69
- export function resolveModelForCaste(
70
- caste: AntCaste,
71
- available: AvailableModel[],
72
- currentModel?: string,
73
- ): string | undefined {
74
- if (available.length === 0) return currentModel;
75
- const tier = CASTE_MODEL_TIER[caste];
76
-
77
- // 工蚁/兵蚁优先使用当前会话模型
78
- if (tier !== "fast" && currentModel) return currentModel;
79
-
80
- // 按版本号排序
81
- const scored = available.map(m => ({ id: m.id, score: modelVersionScore(m.id) }));
82
- scored.sort((a, b) => a.score - b.score);
83
-
84
- // 优先同 provider
85
- const provider = currentModel?.split("/")[0] ?? currentModel?.split("-")[0];
86
- const sameProvider = provider ? scored.filter(m => m.id.toLowerCase().includes(provider.toLowerCase())) : [];
87
- const pool = sameProvider.length > 0 ? sameProvider : scored;
88
-
89
- if (tier === "fast") return pool[pool.length - 1]?.id ?? currentModel;
90
- if (tier === "powerful") return pool[pool.length - 1]?.id ?? currentModel;
91
- // balanced: middle
92
- return pool[Math.floor(pool.length / 2)]?.id ?? currentModel;
93
- }
94
-
95
25
  // ═══ 任务 (Food Source) ═══
96
26
  export type TaskStatus = "pending" | "claimed" | "active" | "done" | "failed" | "blocked";
97
27
  export type TaskPriority = 1 | 2 | 3 | 4 | 5; // 1=highest