oh-pi 0.1.69 → 0.1.70

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.
@@ -12,6 +12,36 @@ const PROVIDER_API_URLS = {
12
12
  xai: "https://api.x.ai",
13
13
  mistral: "https://api.mistral.ai",
14
14
  };
15
+ /** Block internal/private IPs to prevent SSRF */
16
+ function isUnsafeUrl(urlStr) {
17
+ try {
18
+ const u = new URL(urlStr);
19
+ const host = u.hostname;
20
+ // Allow localhost for local dev servers (Ollama, vLLM, etc.)
21
+ if (host === "localhost" || host === "127.0.0.1" || host === "::1")
22
+ return false;
23
+ // Block private IP ranges
24
+ if (/^10\./.test(host))
25
+ return true;
26
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host))
27
+ return true;
28
+ if (/^192\.168\./.test(host))
29
+ return true;
30
+ if (/^0\./.test(host) || host === "0.0.0.0")
31
+ return true;
32
+ if (host.includes(":") || host.startsWith("["))
33
+ return true;
34
+ if (/^169\.254\./.test(host))
35
+ return true;
36
+ // Block non-https for remote hosts
37
+ if (u.protocol !== "https:")
38
+ return true;
39
+ return false;
40
+ }
41
+ catch {
42
+ return true;
43
+ }
44
+ }
15
45
  /**
16
46
  * 动态获取模型列表,依次尝试 Anthropic、Google、OpenAI 兼容 API 风格。
17
47
  * @param provider - 提供商名称
@@ -85,7 +115,7 @@ async function fetchModels(provider, baseUrl, apiKey) {
85
115
  api: "openai-completions",
86
116
  models: data.map((m) => ({
87
117
  id: m.id,
88
- reasoning: m.thinking_enabled ?? m.id.includes("o3") ?? false,
118
+ reasoning: m.thinking_enabled ?? m.id.includes("o3"),
89
119
  input: ["text", "image"],
90
120
  contextWindow: m.context_window ?? m.max_tokens ?? 128000,
91
121
  maxTokens: m.max_output ?? 16384,
@@ -158,7 +188,7 @@ export async function setupProviders(env) {
158
188
  const url = await p.text({
159
189
  message: t("provider.baseUrl", { label: info.label }),
160
190
  placeholder: "https://proxy.example.com",
161
- validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : undefined,
191
+ validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : isUnsafeUrl(v) ? "URL must use HTTPS for remote hosts (private IPs blocked)" : undefined,
162
192
  });
163
193
  if (p.isCancel(url)) {
164
194
  p.cancel(t("cancelled"));
@@ -203,7 +233,7 @@ async function setupCustomProvider() {
203
233
  const baseUrl = await p.text({
204
234
  message: t("provider.baseUrlCustom"),
205
235
  placeholder: t("provider.baseUrlCustomPlaceholder"),
206
- validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : undefined,
236
+ validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : isUnsafeUrl(v) ? "URL must use HTTPS for remote hosts (private IPs blocked)" : undefined,
207
237
  });
208
238
  if (p.isCancel(baseUrl)) {
209
239
  p.cancel(t("cancelled"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-pi",
3
- "version": "0.1.69",
3
+ "version": "0.1.70",
4
4
  "description": "One-click setup for pi-coding-agent. Like oh-my-zsh for pi.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -76,18 +76,27 @@ export function adapt(config: ConcurrencyConfig, pendingTasks: number): Concurre
76
76
  const latest = samples[samples.length - 1];
77
77
  const prev = samples[samples.length - 2];
78
78
 
79
- // 硬约束:系统过载立即减少
80
- if (latest.cpuLoad > 0.85 || latest.memFree < 500 * 1024 * 1024) {
79
+ // CPU 滑动窗口:最近 3 次采样平均值
80
+ const recentCpuSamples = samples.slice(-3);
81
+ const avgCpu = recentCpuSamples.reduce((s, x) => s + x.cpuLoad, 0) / recentCpuSamples.length;
82
+
83
+ // 429 冷却:30s 内不允许增加并发
84
+ const inRateLimitCooldown = config.lastRateLimitAt != null && Date.now() - config.lastRateLimitAt < 30000;
85
+
86
+ // 硬约束:系统过载立即减少(滞回带:>85% 减,60%-85% 保持)
87
+ if (avgCpu > 0.85 || latest.memFree < 500 * 1024 * 1024) {
81
88
  next.current = Math.max(config.min, config.current - 1);
82
89
  return next;
83
90
  }
84
91
 
92
+ // 滞回带:CPU 在 60%-85% 之间保持不变
93
+ const canIncrease = avgCpu < 0.6 && !inRateLimitCooldown;
94
+
85
95
  // 探索期:样本不足,逐步提升
86
96
  if (samples.length < 10) {
87
- if (latest.throughput >= prev.throughput) {
88
- // 吞吐量还在涨,继续探索
97
+ if (latest.throughput >= prev.throughput && canIncrease) {
89
98
  next.current = Math.min(config.current + 1, taskCap);
90
- } else {
99
+ } else if (latest.throughput < prev.throughput) {
91
100
  // 吞吐量下降,找到拐点
92
101
  next.optimal = prev.concurrency;
93
102
  next.current = prev.concurrency;
@@ -99,20 +108,17 @@ export function adapt(config: ConcurrencyConfig, pendingTasks: number): Concurre
99
108
  const recentThroughput = samples.slice(-5).reduce((s, x) => s + x.throughput, 0) / 5;
100
109
  const olderThroughput = samples.slice(-10, -5).reduce((s, x) => s + x.throughput, 0) / 5;
101
110
 
102
- if (recentThroughput > olderThroughput * 1.1 && latest.cpuLoad < 0.7) {
103
- // 吞吐量上升且 CPU 有余量,尝试+1
111
+ if (recentThroughput > olderThroughput * 1.1 && canIncrease) {
104
112
  next.current = Math.min(config.current + 1, taskCap);
105
113
  if (recentThroughput > olderThroughput * 1.2) {
106
- next.optimal = next.current; // 更新最优值
114
+ next.optimal = next.current;
107
115
  }
108
116
  } else if (recentThroughput < olderThroughput * 0.8) {
109
- // 吞吐量下降,回退
110
117
  next.current = Math.max(config.min, config.optimal);
111
118
  }
112
- // 否则保持不变
113
119
 
114
120
  // 429 recovery: restore to optimal when CPU is underutilized (e.g. after backoff)
115
- if (latest.cpuLoad < 0.5 && next.current < config.optimal) {
121
+ if (avgCpu < 0.5 && next.current < config.optimal && !inRateLimitCooldown) {
116
122
  next.current = config.optimal;
117
123
  }
118
124
 
@@ -18,10 +18,19 @@ import { runColony, resumeColony, type QueenCallbacks } from "./queen.js";
18
18
  import { Nest } from "./nest.js";
19
19
  import type { ColonyState, ColonyMetrics, AntStreamEvent } from "./types.js";
20
20
 
21
- import { formatDuration, formatCost, formatTokens, statusIcon, casteIcon } from "./ui.js";
21
+ import { formatDuration, formatCost, formatTokens, statusIcon, casteIcon, buildReport } from "./ui.js";
22
22
 
23
23
  // ═══ Background colony state ═══
24
24
 
25
+ /** Ensure .ant-colony/ is in .gitignore */
26
+ function ensureGitignore(cwd: string) {
27
+ const gitignorePath = join(cwd, ".gitignore");
28
+ const content = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
29
+ if (!content.includes(".ant-colony/")) {
30
+ appendFileSync(gitignorePath, `${content.length && !content.endsWith("\n") ? "\n" : ""}.ant-colony/\n`);
31
+ }
32
+ }
33
+
25
34
  interface AntStreamState {
26
35
  antId: string;
27
36
  caste: string;
@@ -89,11 +98,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
89
98
  cwd: string;
90
99
  modelRegistry?: any;
91
100
  }, signal?: AbortSignal | null) {
92
- const gitignorePath = join(params.cwd, ".gitignore");
93
- const gitContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
94
- if (!gitContent.includes(".ant-colony/")) {
95
- appendFileSync(gitignorePath, `${gitContent.length && !gitContent.endsWith("\n") ? "\n" : ""}.ant-colony/\n`);
96
- }
101
+ ensureGitignore(params.cwd);
97
102
 
98
103
  const callbacks: QueenCallbacks = {};
99
104
 
@@ -110,24 +115,8 @@ export default function antColonyExtension(pi: ExtensionAPI) {
110
115
  modelRegistry: params.modelRegistry,
111
116
  });
112
117
 
113
- const m = state.metrics;
114
- const elapsed = state.finishedAt ? formatDuration(state.finishedAt - state.createdAt) : "?";
115
- const report = [
116
- `## 🐜 Ant Colony Report`,
117
- `**Goal:** ${state.goal}`,
118
- `**Status:** ${statusIcon(state.status)} ${state.status} │ ${elapsed} │ ${formatCost(m.totalCost)}`,
119
- `**Tasks:** ${m.tasksDone}/${m.tasksTotal} done${m.tasksFailed > 0 ? `, ${m.tasksFailed} failed` : ""}`,
120
- ``,
121
- ...state.tasks.filter(t => t.status === "done").map(t =>
122
- `- ✓ **${t.title}**`
123
- ),
124
- ...state.tasks.filter(t => t.status === "failed").map(t =>
125
- `- ✗ **${t.title}** — ${t.error?.slice(0, 80) || "unknown"}`
126
- ),
127
- ].join("\n");
128
-
129
118
  return {
130
- content: [{ type: "text" as const, text: report }],
119
+ content: [{ type: "text" as const, text: buildReport(state) }],
131
120
  isError: state.status === "failed" || state.status === "budget_exceeded",
132
121
  };
133
122
  } catch (e) {
@@ -226,11 +215,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
226
215
  };
227
216
 
228
217
  // Ensure .ant-colony/ is in .gitignore
229
- const gitignorePath = join(params.cwd, ".gitignore");
230
- const gitContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
231
- if (!gitContent.includes(".ant-colony/")) {
232
- appendFileSync(gitignorePath, `${gitContent.length && !gitContent.endsWith("\n") ? "\n" : ""}.ant-colony/\n`);
233
- }
218
+ ensureGitignore(params.cwd);
234
219
 
235
220
  const colonyOpts = {
236
221
  cwd: params.cwd,
@@ -251,23 +236,9 @@ export default function antColonyExtension(pi: ExtensionAPI) {
251
236
 
252
237
  // 后台等待完成,注入结果
253
238
  colony.promise.then((state) => {
254
- const m = state.metrics;
255
- const elapsed = state.finishedAt ? formatDuration(state.finishedAt - state.createdAt) : "?";
256
239
  const ok = state.status === "done";
257
-
258
- const report = [
259
- `## 🐜 Ant Colony Report`,
260
- `**Goal:** ${state.goal}`,
261
- `**Status:** ${statusIcon(state.status)} ${state.status} │ ${elapsed} │ ${formatCost(m.totalCost)}`,
262
- `**Tasks:** ${m.tasksDone}/${m.tasksTotal} done${m.tasksFailed > 0 ? `, ${m.tasksFailed} failed` : ""}`,
263
- ``,
264
- ...state.tasks.filter(t => t.status === "done").map(t =>
265
- `- ✓ **${t.title}**`
266
- ),
267
- ...state.tasks.filter(t => t.status === "failed").map(t =>
268
- `- ✗ **${t.title}** — ${t.error?.slice(0, 80) || "unknown"}`
269
- ),
270
- ].join("\n");
240
+ const report = buildReport(state);
241
+ const m = state.metrics;
271
242
 
272
243
  // 清理 UI
273
244
  pi.events.emit("ant-colony:clear-ui");
@@ -594,6 +565,14 @@ export default function antColonyExtension(pi: ExtensionAPI) {
594
565
  pi.on("session_shutdown", async () => {
595
566
  if (activeColony) {
596
567
  activeColony.abortController.abort();
568
+ // Wait for colony to finish gracefully (max 5s)
569
+ try {
570
+ await Promise.race([
571
+ activeColony.promise,
572
+ new Promise(r => setTimeout(r, 5000)),
573
+ ]);
574
+ } catch { /* ignore */ }
575
+ pi.events.emit("ant-colony:clear-ui");
597
576
  activeColony = null;
598
577
  }
599
578
  });
@@ -22,6 +22,8 @@ export class Nest {
22
22
  private pheromoneOffset: number = 0;
23
23
  private taskCache: Map<string, Task> = new Map();
24
24
  private stateCache: ColonyState | null = null;
25
+ private gcCounter: number = 0;
26
+ private pheromoneByFile: Map<string, Pheromone[]> = new Map();
25
27
 
26
28
  constructor(private cwd: string, private colonyId: string) {
27
29
  this.dir = path.join(cwd, ".ant-colony", colonyId);
@@ -90,12 +92,50 @@ export class Nest {
90
92
  }
91
93
 
92
94
  claimTask(taskId: string, antId: string): boolean {
93
- const task = this.getTask(taskId);
94
- if (!task || task.status !== "pending") return false;
95
- task.status = "claimed";
96
- task.claimedBy = antId;
97
- this.writeTask(task);
98
- return true;
95
+ return this.withStateLock(() => {
96
+ const task = this.getTask(taskId);
97
+ if (!task || task.status !== "pending") return false;
98
+ task.status = "claimed";
99
+ task.claimedBy = antId;
100
+ this.writeTask(task);
101
+ return true;
102
+ });
103
+ }
104
+
105
+ /** 原子选取并 claim 下一个任务,消灭 nextPendingTask+claimTask 之间的竞态窗口 */
106
+ claimNextTask(caste: "scout" | "worker" | "soldier" | "drone", antId: string): Task | null {
107
+ return this.withStateLock(() => {
108
+ const tasks = this.getAllTasks().filter(t => t.status === "pending" && t.caste === caste);
109
+ if (tasks.length === 0) return null;
110
+
111
+ this.getAllPheromones();
112
+ let chosen: Task;
113
+ if (tasks.length > 1 && Math.random() < 0.1) {
114
+ chosen = tasks[Math.floor(Math.random() * tasks.length)];
115
+ } else {
116
+ const scored = tasks.map(t => {
117
+ let pScore = 0;
118
+ const seen = new Set<Pheromone>();
119
+ for (const f of t.files) {
120
+ for (const p of this.pheromoneByFile.get(f) ?? []) {
121
+ if (seen.has(p) || p.strength <= 0.1) continue;
122
+ seen.add(p);
123
+ if (p.type === "discovery" || p.type === "completion") pScore += p.strength;
124
+ else if (p.type === "repellent") pScore -= p.strength * 3;
125
+ else if (p.type === "warning") pScore -= p.strength;
126
+ }
127
+ }
128
+ return { task: t, score: (6 - t.priority) + pScore };
129
+ });
130
+ scored.sort((a, b) => b.score - a.score);
131
+ chosen = scored[0].task;
132
+ }
133
+
134
+ chosen.status = "claimed";
135
+ chosen.claimedBy = antId;
136
+ this.writeTask(chosen);
137
+ return chosen;
138
+ });
99
139
  }
100
140
 
101
141
  updateTaskStatus(taskId: string, status: TaskStatus, result?: string, error?: string): void {
@@ -129,15 +169,21 @@ export class Nest {
129
169
  return tasks[Math.floor(Math.random() * tasks.length)];
130
170
  }
131
171
 
132
- // 信息素加权:discovery/completion 加分,warning/repellent 减分(负信息素)
133
- const pheromones = this.getAllPheromones();
172
+ // 信息素加权:用索引查询而非全量扫描
173
+ this.getAllPheromones(); // 确保索引已建立
134
174
  const scored = tasks.map(t => {
135
175
  let pScore = 0;
136
- for (const p of pheromones) {
137
- if (!p.files.some(f => t.files.includes(f)) || p.strength <= 0.1) continue;
138
- if (p.type === "discovery" || p.type === "completion") pScore += p.strength;
139
- else if (p.type === "repellent") pScore -= p.strength * 3; // repellent 负信息素惩罚最重
140
- else if (p.type === "warning") pScore -= p.strength;
176
+ const seen = new Set<Pheromone>();
177
+ for (const f of t.files) {
178
+ const related = this.pheromoneByFile.get(f);
179
+ if (!related) continue;
180
+ for (const p of related) {
181
+ if (seen.has(p) || p.strength <= 0.1) continue;
182
+ seen.add(p);
183
+ if (p.type === "discovery" || p.type === "completion") pScore += p.strength;
184
+ else if (p.type === "repellent") pScore -= p.strength * 3;
185
+ else if (p.type === "warning") pScore -= p.strength;
186
+ }
141
187
  }
142
188
  return { task: t, score: (6 - t.priority) + pScore };
143
189
  });
@@ -171,10 +217,32 @@ export class Nest {
171
217
  }
172
218
 
173
219
  // 衰减 + 过滤弱信息素
220
+ const beforeLen = this.pheromoneCache.length;
174
221
  this.pheromoneCache = this.pheromoneCache.filter(p => {
175
- p.strength = p.strength * Math.pow(0.5, (now - p.createdAt) / HALF_LIFE);
222
+ p.strength = Math.pow(0.5, (now - p.createdAt) / HALF_LIFE);
176
223
  return p.strength > 0.05;
177
224
  });
225
+ const hadGarbage = this.pheromoneCache.length < beforeLen;
226
+
227
+ // 重建文件索引
228
+ this.pheromoneByFile.clear();
229
+ for (const p of this.pheromoneCache) {
230
+ for (const f of p.files) {
231
+ let arr = this.pheromoneByFile.get(f);
232
+ if (!arr) { arr = []; this.pheromoneByFile.set(f, arr); }
233
+ arr.push(p);
234
+ }
235
+ }
236
+
237
+ // GC:每 10 次调用,若有弱条目被过滤则重写文件
238
+ this.gcCounter++;
239
+ if (this.gcCounter >= 10 && hadGarbage) {
240
+ this.gcCounter = 0;
241
+ const tmp = this.pheromoneFile + ".tmp";
242
+ fs.writeFileSync(tmp, this.pheromoneCache.map(p => JSON.stringify(p)).join("\n") + (this.pheromoneCache.length ? "\n" : ""));
243
+ fs.renameSync(tmp, this.pheromoneFile);
244
+ this.pheromoneOffset = fs.statSync(this.pheromoneFile).size;
245
+ }
178
246
 
179
247
  return this.pheromoneCache;
180
248
  }
@@ -234,16 +302,22 @@ export class Nest {
234
302
  const start = Date.now();
235
303
  while (true) {
236
304
  try {
237
- fs.writeFileSync(this.lockFile, `${process.pid}`, { flag: "wx" });
305
+ fs.writeFileSync(this.lockFile, `${process.pid}:${Date.now()}`, { flag: "wx" });
238
306
  break;
239
307
  } catch {
240
308
  if (Date.now() - start > MAX_WAIT) {
241
- // 超时:检查锁持有者是否存活
242
309
  try {
243
- const holder = parseInt(fs.readFileSync(this.lockFile, "utf-8"), 10);
244
- try { process.kill(holder, 0); } catch { /* 进程已死,清除死锁 */ fs.unlinkSync(this.lockFile); continue; }
245
- } catch { /* 读取失败,强制清除 */ try { fs.unlinkSync(this.lockFile); } catch {} }
246
- continue;
310
+ const content = fs.readFileSync(this.lockFile, "utf-8");
311
+ const [pidStr, tsStr] = content.split(":");
312
+ const holder = parseInt(pidStr, 10);
313
+ const lockTime = parseInt(tsStr, 10);
314
+ // 超过 30s 的锁视为过期
315
+ if (lockTime && Date.now() - lockTime > 30_000) { fs.unlinkSync(this.lockFile); continue; }
316
+ // 进程存活检查作为第二道防线
317
+ try { process.kill(holder, 0); } catch { fs.unlinkSync(this.lockFile); continue; }
318
+ } catch { try { fs.unlinkSync(this.lockFile); } catch {} }
319
+ // 进程存活且锁未过期,放弃等待
320
+ throw new Error(`[Nest] withStateLock timeout after ${MAX_WAIT}ms`);
247
321
  }
248
322
  // 简单 busy-wait,避免 SharedArrayBuffer 依赖
249
323
  const until = Date.now() + SPIN_MS + Math.random() * SPIN_MS;
@@ -294,5 +368,13 @@ export class Nest {
294
368
  this.writeTask(task);
295
369
  }
296
370
  }
371
+ // 将 orphaned working/idle ants 标记为 failed
372
+ for (const ant of this.stateCache.ants) {
373
+ if (ant.status === "working" || ant.status === "idle") {
374
+ ant.status = "failed";
375
+ ant.finishedAt = Date.now();
376
+ }
377
+ }
378
+ this.writeJson(this.stateFile, this.stateCache);
297
379
  }
298
380
  }
@@ -1,6 +1,8 @@
1
1
  import type { AntCaste, Pheromone, PheromoneType } from "./types.js";
2
2
  import { makePheromoneId } from "./spawner.js";
3
3
 
4
+ const VALID_CASTES = new Set(["scout", "worker", "soldier", "drone"]);
5
+
4
6
  export interface ParsedSubTask {
5
7
  title: string;
6
8
  description: string;
@@ -11,21 +13,41 @@ export interface ParsedSubTask {
11
13
  }
12
14
 
13
15
  export function parseSubTasks(output: string): ParsedSubTask[] {
16
+ // Try JSON block first
17
+ const jsonMatch = output.match(/```json\s*([\s\S]*?)```/);
18
+ if (jsonMatch?.[1]) {
19
+ try {
20
+ const parsed = JSON.parse(jsonMatch[1].trim());
21
+ const arr = Array.isArray(parsed) ? parsed : [parsed];
22
+ return arr.map((t: Record<string, unknown>) => ({
23
+ title: String(t.title || "Untitled"),
24
+ description: String(t.description || t.title || ""),
25
+ files: Array.isArray(t.files) ? t.files.map(String) : String(t.files || "").split(",").map((f: string) => f.trim()).filter(Boolean),
26
+ caste: (VALID_CASTES.has(String(t.caste)) ? String(t.caste) : "worker") as AntCaste,
27
+ priority: (Math.min(5, Math.max(1, parseInt(String(t.priority || "3")))) as 1 | 2 | 3 | 4 | 5),
28
+ context: t.context ? String(t.context) : undefined,
29
+ }));
30
+ } catch { /* fallback to regex */ }
31
+ }
32
+
33
+ // Fallback: regex parsing with per-task try-catch
14
34
  const tasks: ParsedSubTask[] = [];
15
35
  const regex = /### TASK:\s*(.+)\n(?:- description:\s*(.+)\n)?(?:- files:\s*(.+)\n)?(?:- caste:\s*(\w+)\n)?(?:- priority:\s*(\d))?/g;
16
36
  const taskBlocks = output.split(/(?=### TASK:)/);
17
37
  for (const m of output.matchAll(regex)) {
18
- const block = taskBlocks.find(b => b.includes(`### TASK: ${m[1]?.trim()}`)) || "";
19
- const ctxMatch = block.match(/- context:\s*([\s\S]*?)(?=### TASK:|## |\n\n|$)/);
20
- const context = ctxMatch?.[1]?.trim() || undefined;
21
- tasks.push({
22
- title: m[1]?.trim() || "Untitled",
23
- description: m[2]?.trim() || m[1]?.trim() || "",
24
- files: (m[3]?.trim() || "").split(",").map((f: string) => f.trim()).filter(Boolean),
25
- caste: (m[4]?.trim() as AntCaste) || "worker",
26
- priority: (parseInt(m[5] || "3") as 1 | 2 | 3 | 4 | 5) || 3,
27
- context,
28
- });
38
+ try {
39
+ const block = taskBlocks.find(b => b.includes(`### TASK: ${m[1]?.trim()}`)) || "";
40
+ const ctxMatch = block.match(/- context:\s*([\s\S]*?)(?=### TASK:|## |\n\n|$)/);
41
+ const context = ctxMatch?.[1]?.trim() || undefined;
42
+ tasks.push({
43
+ title: m[1]?.trim() || "Untitled",
44
+ description: m[2]?.trim() || m[1]?.trim() || "",
45
+ files: (m[3]?.trim() || "").split(",").map((f: string) => f.trim()).filter(Boolean),
46
+ caste: (VALID_CASTES.has(m[4]?.trim() ?? "") ? m[4]!.trim() : "worker") as AntCaste,
47
+ priority: (parseInt(m[5] || "3") as 1 | 2 | 3 | 4 | 5) || 3,
48
+ context,
49
+ });
50
+ } catch { /* skip malformed task, continue */ }
29
51
  }
30
52
  return tasks;
31
53
  }
@@ -35,7 +57,7 @@ export function extractPheromones(antId: string, caste: AntCaste, taskId: string
35
57
  const now = Date.now();
36
58
  const sections = ["Discoveries", "Pheromone", "Files Changed", "Warnings", "Review"];
37
59
  for (const section of sections) {
38
- const regex = new RegExp(`## ${section}\\n([\\s\\S]*?)(?=\\n## |$)`, "i");
60
+ const regex = new RegExp(`#{1,2} ${section}\\n([\\s\\S]*?)(?=\\n#{1,2} |$)`, "i");
39
61
  const match = output.match(regex);
40
62
  if (match?.[1]?.trim()) {
41
63
  const type: PheromoneType =
@@ -167,7 +167,8 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
167
167
 
168
168
  let backoffMs = 0; // 429 退避时间
169
169
  let consecutiveRateLimits = 0; // 连续限流计数
170
- const retriedTasks = new Set<string>(); // 防止重复重试
170
+ const retryCount = new Map<string, number>(); // taskId → retry count
171
+ const MAX_RETRIES = 2;
171
172
 
172
173
  const runOne = async (): Promise<"done" | "empty" | "rate_limited" | "budget"> => {
173
174
  // Budget 刹车:预算用完就不出发(drone 免费,不检查)
@@ -177,9 +178,8 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
177
178
  if (spent >= state.maxCost) return "budget";
178
179
  }
179
180
 
180
- const task = nest.nextPendingTask(caste);
181
+ const task = nest.claimNextTask(caste, "queen");
181
182
  if (!task) return "empty";
182
- if (!nest.claimTask(task.id, "queen")) return "empty";
183
183
 
184
184
  const ant: Ant = {
185
185
  id: "", caste, status: "idle", taskId: task.id,
@@ -190,15 +190,35 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
190
190
  callbacks.onAntSpawn?.(ant, task);
191
191
 
192
192
  try {
193
- const result = caste === "drone"
194
- ? await runDrone(cwd, nest, task)
195
- : await spawnAnt(cwd, nest, task, config, signal, callbacks.onAntStream, opts.authStorage, opts.modelRegistry);
193
+ const ANT_TIMEOUT = 5 * 60 * 1000; // 5 min hard timeout per ant
194
+ const antAbort = new AbortController();
195
+ signal?.addEventListener("abort", () => antAbort.abort(), { once: true });
196
+ const antSignal = antAbort.signal;
197
+ const antPromise = caste === "drone"
198
+ ? runDrone(cwd, nest, task)
199
+ : spawnAnt(cwd, nest, task, config, antSignal, callbacks.onAntStream, opts.authStorage, opts.modelRegistry);
200
+ let timeoutId: ReturnType<typeof setTimeout>;
201
+ const result = await Promise.race([
202
+ antPromise.finally(() => clearTimeout(timeoutId)),
203
+ new Promise<never>((_, reject) => {
204
+ timeoutId = setTimeout(() => { antAbort.abort(); reject(new Error("Ant timeout (5min)")); }, ANT_TIMEOUT);
205
+ }),
206
+ ]);
196
207
  callbacks.onAntDone?.(result.ant, task, result.output);
197
208
 
198
209
  if (result.rateLimited) {
199
210
  return "rate_limited";
200
211
  }
201
212
 
213
+ // 成本预警:超 80% 预算时发信号
214
+ const curState = nest.getState();
215
+ if (curState.maxCost != null) {
216
+ const spent = curState.ants.reduce((s, a) => s + a.usage.cost, 0);
217
+ if (spent >= curState.maxCost * 0.8) {
218
+ emitSignal("working", `Budget warning: ${(spent / curState.maxCost * 100).toFixed(0)}% used`);
219
+ }
220
+ }
221
+
202
222
  // 蚂蚁产生的子任务加入巢穴(限制繁殖上限,防止任务膨胀)
203
223
  const MAX_TOTAL_TASKS = 30;
204
224
  const MAX_SUB_PER_TASK = 5;
@@ -237,8 +257,11 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
237
257
 
238
258
  return "done";
239
259
  } catch (e) {
240
- if (!retriedTasks.has(task.id)) {
241
- retriedTasks.add(task.id);
260
+ const errStr = String(e);
261
+ const isRetryable = errStr.includes("timeout") || errStr.includes("Timeout") || errStr.includes("ECONNRESET") || errStr.includes("429");
262
+ const count = retryCount.get(task.id) ?? 0;
263
+ if (isRetryable && count < MAX_RETRIES) {
264
+ retryCount.set(task.id, count + 1);
242
265
  nest.updateTaskStatus(task.id, "pending");
243
266
  } else {
244
267
  // 负信息素:失败任务释放 warning,阻止后续蚂蚁走同一条路
@@ -270,7 +293,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
270
293
  }
271
294
 
272
295
  // 解除 blocked 任务(如果锁定文件和依赖文件都已释放)
273
- const activeTasks = state.tasks.filter(t => t.status === "active");
296
+ const activeTasks = state.tasks.filter(t => t.status === "active" || t.status === "claimed");
274
297
  const activeFiles = new Set(activeTasks.flatMap(t => t.files));
275
298
  for (const t of state.tasks.filter(t => t.status === "blocked" && t.caste === caste)) {
276
299
  const fileConflict = t.files.some(f => activeFiles.has(f));
@@ -321,12 +344,12 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
321
344
  return "budget";
322
345
  }
323
346
 
324
- // 429 处理:降低并发 + 渐进退避(2s → 5s → 10s,上限 10s
347
+ // 429 处理:降低并发 + 渐进退避(2s → 5s → 10s,上限 10s)+ 记录时间戳
325
348
  if (results.includes("rate_limited")) {
326
349
  consecutiveRateLimits++;
327
350
  const cur = nest.getState().concurrency;
328
351
  const reduced = Math.max(cur.min, cur.current - 1); // 每次只减 1,不砍半
329
- nest.updateState({ concurrency: { ...cur, current: reduced } });
352
+ nest.updateState({ concurrency: { ...cur, current: reduced, lastRateLimitAt: Date.now() } });
330
353
  backoffMs = Math.min(consecutiveRateLimits * 2000, 10000);
331
354
  } else {
332
355
  consecutiveRateLimits = 0;
@@ -371,13 +394,6 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
371
394
 
372
395
  nest.init(initialState);
373
396
  const { signal, callbacks } = opts;
374
- const waveBase: Omit<WaveOptions, "caste"> & { importGraph?: ImportGraph } = {
375
- nest, cwd: opts.cwd, signal, callbacks, emitSignal,
376
- currentModel: opts.currentModel,
377
- modelOverrides: opts.modelOverrides,
378
- authStorage: opts.authStorage,
379
- modelRegistry: opts.modelRegistry,
380
- };
381
397
 
382
398
  const cleanup = () => {
383
399
  nest.destroy();
@@ -389,12 +405,21 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
389
405
  };
390
406
 
391
407
  const emitSignal = (phase: ColonyState["status"], message: string) => {
392
- const m = nest.getState().metrics;
393
- const active = nest.getState().ants.filter(a => a.status === "working").length;
408
+ const state = nest.getState();
409
+ const m = state.metrics;
410
+ const active = state.ants.filter(a => a.status === "working").length;
394
411
  const progress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
395
412
  callbacks.onSignal?.({ phase, progress, active, cost: m.totalCost, message });
396
413
  };
397
414
 
415
+ const waveBase: Omit<WaveOptions, "caste"> & { importGraph?: ImportGraph } = {
416
+ nest, cwd: opts.cwd, signal, callbacks, emitSignal,
417
+ currentModel: opts.currentModel,
418
+ modelOverrides: opts.modelOverrides,
419
+ authStorage: opts.authStorage,
420
+ modelRegistry: opts.modelRegistry,
421
+ };
422
+
398
423
  try {
399
424
  // ═══ Phase 1: 侦察(快速单次,不再多轮接力) ═══
400
425
  callbacks.onPhase?.("scouting", "Dispatching scout ant to explore codebase...");
@@ -490,7 +515,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
490
515
  // ═══ 持续探索:Worker 完成后检查是否有新发现,有则再派 Scout ═══
491
516
  const discoveries = nest.getAllPheromones().filter(p => p.type === "discovery");
492
517
  const allDone = nest.getAllTasks().filter(t => t.status === "done");
493
- if (discoveries.length > allDone.length && nest.getState().maxCost != null) {
518
+ if (discoveries.length > allDone.length) {
494
519
  const spent = nest.getState().ants.reduce((s, a) => s + a.usage.cost, 0);
495
520
  if (spent < (nest.getState().maxCost ?? Infinity)) {
496
521
  callbacks.onPhase?.("scouting", "Re-exploring based on new discoveries...");
@@ -563,7 +588,8 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
563
588
  emitSignal("failed", String(e).slice(0, 100));
564
589
  return failState;
565
590
  } finally {
566
- cleanup();
591
+ const finalStatus = nest.getState().status;
592
+ if (finalStatus === "done") cleanup();
567
593
  }
568
594
  }
569
595
 
@@ -580,8 +606,9 @@ export async function resumeColony(opts: QueenOptions): Promise<ColonyState> {
580
606
  const { signal, callbacks } = opts;
581
607
 
582
608
  const emitSignal = (phase: ColonyState["status"], message: string) => {
583
- const m = nest.getState().metrics;
584
- const active = nest.getState().ants.filter(a => a.status === "working").length;
609
+ const state = nest.getState();
610
+ const m = state.metrics;
611
+ const active = state.ants.filter(a => a.status === "working").length;
585
612
  const progress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
586
613
  callbacks.onSignal?.({ phase, progress, active, cost: m.totalCost, message });
587
614
  };
@@ -623,6 +650,26 @@ export async function resumeColony(opts: QueenOptions): Promise<ColonyState> {
623
650
  }
624
651
  }
625
652
 
653
+ // Soldier review for resumed colony(条件与 runColony 对齐)
654
+ let tscPassed = true;
655
+ try {
656
+ const { execSync } = await import("node:child_process");
657
+ execSync("npx tsc --noEmit", { cwd: opts.cwd, timeout: 30000, stdio: "pipe" });
658
+ } catch { tscPassed = false; }
659
+
660
+ const completedWorkerTasks = nest.getAllTasks().filter(t => t.caste === "worker" && t.status === "done");
661
+ if (completedWorkerTasks.length > 0 && (!tscPassed || completedWorkerTasks.length > 3)) {
662
+ nest.updateState({ status: "reviewing" });
663
+ const reviewTask = makeReviewTask(completedWorkerTasks);
664
+ nest.writeTask(reviewTask);
665
+ await runAntWave({ ...waveBase, caste: "soldier" });
666
+ const fixTasks = nest.getAllTasks().filter(t => t.caste === "worker" && t.status === "pending" && t.parentId !== null);
667
+ if (fixTasks.length > 0) {
668
+ nest.updateState({ status: "working" });
669
+ await runAntWave({ ...waveBase, caste: "worker" });
670
+ }
671
+ }
672
+
626
673
  const finalMetrics = updateMetrics(nest);
627
674
  nest.updateState({ status: "done", finishedAt: Date.now(), metrics: finalMetrics });
628
675
  const finalState = nest.getState();
@@ -637,6 +684,7 @@ export async function resumeColony(opts: QueenOptions): Promise<ColonyState> {
637
684
  emitSignal("failed", String(e).slice(0, 100));
638
685
  return failState;
639
686
  } finally {
640
- cleanup();
687
+ const finalStatus = nest.getState().status;
688
+ if (finalStatus === "done") cleanup();
641
689
  }
642
690
  }
@@ -120,7 +120,14 @@ export async function runDrone(
120
120
 
121
121
  try {
122
122
  const { execSync } = await import("node:child_process");
123
- const cmd = task.description;
123
+ // 优先从 context 代码块提取 bash 命令,fallback 到 description
124
+ const ctxMatch = task.context?.match(/```(?:bash|sh)?\s*\n?([\s\S]*?)```/);
125
+ const cmd = ctxMatch?.[1]?.trim() || task.description;
126
+ // 基本危险命令校验
127
+ const DANGEROUS = /\brm\s+-rf\s+\/|mkfs\b|dd\s+if=|chmod\s+777\s+\/|>\s*\/dev\/sd/i;
128
+ if (DANGEROUS.test(cmd)) {
129
+ throw new Error(`Drone refused dangerous command: ${cmd.slice(0, 80)}`);
130
+ }
124
131
  const output = execSync(cmd, { cwd, encoding: "utf-8", timeout: 30000, stdio: "pipe" }).trim();
125
132
 
126
133
  ant.status = "done";
@@ -191,9 +198,10 @@ export async function spawnAnt(
191
198
 
192
199
  let accumulatedText = "";
193
200
  let rateLimited = false;
201
+ let session: any = null;
194
202
 
195
203
  try {
196
- const { session } = await createAgentSession({
204
+ const created = await createAgentSession({
197
205
  cwd,
198
206
  model,
199
207
  thinkingLevel: "off",
@@ -204,6 +212,7 @@ export async function spawnAnt(
204
212
  sessionManager: SessionManager.inMemory(),
205
213
  settingsManager,
206
214
  });
215
+ session = created.session;
207
216
 
208
217
  session.subscribe((event: AgentSessionEvent) => {
209
218
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
@@ -241,8 +250,9 @@ export async function spawnAnt(
241
250
 
242
251
  const userPrompt = `Execute this task: ${task.title}\n\n${task.description}`;
243
252
 
253
+ let onAbort: (() => void) | undefined;
244
254
  if (signal) {
245
- const onAbort = () => session.abort();
255
+ onAbort = () => session.abort();
246
256
  if (signal.aborted) {
247
257
  await session.abort();
248
258
  } else {
@@ -250,7 +260,13 @@ export async function spawnAnt(
250
260
  }
251
261
  }
252
262
 
253
- await session.prompt(userPrompt);
263
+ try {
264
+ await session.prompt(userPrompt);
265
+ } finally {
266
+ if (signal && onAbort) {
267
+ signal.removeEventListener("abort", onAbort);
268
+ }
269
+ }
254
270
 
255
271
  const messages = session.messages;
256
272
  let finalOutput = accumulatedText;
@@ -278,7 +294,6 @@ export async function spawnAnt(
278
294
  for (const p of pheromones) nest.dropPheromone(p);
279
295
 
280
296
  nest.updateTaskStatus(task.id, "done", finalOutput);
281
- session.dispose();
282
297
 
283
298
  return { ant, output: finalOutput, newTasks, pheromones, rateLimited: false };
284
299
 
@@ -305,5 +320,7 @@ export async function spawnAnt(
305
320
  nest.updateTaskStatus(task.id, "failed", accumulatedText, errStr);
306
321
 
307
322
  return { ant, output: accumulatedText, newTasks, pheromones, rateLimited: false };
323
+ } finally {
324
+ try { session?.dispose(); } catch { /* ignore dispose errors */ }
308
325
  }
309
326
  }
@@ -113,6 +113,7 @@ export interface ConcurrencyConfig {
113
113
  max: number;
114
114
  optimal: number; // 自适应计算的最优值
115
115
  history: ConcurrencySample[];
116
+ lastRateLimitAt?: number; // 最近一次 429 的时间戳
116
117
  }
117
118
 
118
119
  export interface ConcurrencySample {