oh-pi 0.1.72 → 0.1.74

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.72",
3
+ "version": "0.1.74",
4
4
  "description": "One-click setup for pi-coding-agent. Like oh-my-zsh for pi.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,7 +51,6 @@ export default function antColonyExtension(pi: ExtensionAPI) {
51
51
 
52
52
  // 当前运行中的后台蚁群(同时只允许一个)
53
53
  let activeColony: BackgroundColony | null = null;
54
- let uiListenersRegistered = false;
55
54
 
56
55
  // ─── Status 渲染 ───
57
56
 
@@ -63,12 +62,18 @@ export default function antColonyExtension(pi: ExtensionAPI) {
63
62
  pi.events.emit("ant-colony:render");
64
63
  };
65
64
 
66
- // 监听事件来更新 UI(确保在有 ctx 的上下文中)
65
+ // 每次 session_start 重新绑定事件,确保 ctx 始终是最新的
66
+ let renderHandler: (() => void) | null = null;
67
+ let clearHandler: (() => void) | null = null;
68
+ let notifyHandler: ((data: { msg: string; level: "info" | "success" | "warning" | "error" }) => void) | null = null;
69
+
67
70
  pi.on("session_start", async (_event, ctx) => {
68
- if (uiListenersRegistered) return;
69
- uiListenersRegistered = true;
71
+ // 移除旧监听器(session 重启 / /reload 时 ctx 已失效)
72
+ if (renderHandler) pi.events.off("ant-colony:render", renderHandler);
73
+ if (clearHandler) pi.events.off("ant-colony:clear-ui", clearHandler);
74
+ if (notifyHandler) pi.events.off("ant-colony:notify", notifyHandler);
70
75
 
71
- pi.events.on("ant-colony:render", () => {
76
+ renderHandler = () => {
72
77
  if (!activeColony) return;
73
78
  const { state } = activeColony;
74
79
  const elapsed = state ? formatDuration(Date.now() - state.createdAt) : "0s";
@@ -81,14 +86,17 @@ export default function antColonyExtension(pi: ExtensionAPI) {
81
86
  parts.push(elapsed);
82
87
 
83
88
  ctx.ui.setStatus("ant-colony", parts.join(" │ "));
84
- });
85
-
86
- pi.events.on("ant-colony:clear-ui", () => {
89
+ };
90
+ clearHandler = () => {
87
91
  ctx.ui.setStatus("ant-colony", undefined);
88
- });
89
- pi.events.on("ant-colony:notify", (data: { msg: string; level: "info" | "success" | "warning" | "error" }) => {
92
+ };
93
+ notifyHandler = (data) => {
90
94
  ctx.ui.notify(data.msg, data.level);
91
- });
95
+ };
96
+
97
+ pi.events.on("ant-colony:render", renderHandler);
98
+ pi.events.on("ant-colony:clear-ui", clearHandler);
99
+ pi.events.on("ant-colony:notify", notifyHandler);
92
100
  });
93
101
 
94
102
  // ─── 同步模式(print mode):阻塞等待蚁群完成 ───
@@ -162,14 +170,14 @@ export default function antColonyExtension(pi: ExtensionAPI) {
162
170
  const callbacks: QueenCallbacks = {
163
171
  onSignal(signal) {
164
172
  colony.phase = signal.message;
165
- // 阶段切换时注入消息到主进程对话流
173
+ // 阶段切换时注入消息到主进程对话流(display: true 让 LLM 下次可见,无需轮询)
166
174
  if (signal.phase !== lastPhase) {
167
175
  lastPhase = signal.phase;
168
176
  const pct = Math.round(signal.progress * 100);
169
177
  pi.sendMessage({
170
178
  customType: "ant-colony-progress",
171
179
  content: `[COLONY_SIGNAL:${signal.phase.toUpperCase()}] 🐜 ${signal.message} (${pct}%, ${formatCost(signal.cost)})`,
172
- display: false,
180
+ display: true,
173
181
  }, { triggerTurn: false, deliverAs: "followUp" });
174
182
  }
175
183
  throttledRender();
@@ -194,7 +202,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
194
202
  pi.sendMessage({
195
203
  customType: "ant-colony-progress",
196
204
  content: `[COLONY_SIGNAL:TASK_DONE] 🐜 ${icon} ${task.title.slice(0, 60)} (${progress}, ${cost})`,
197
- display: false,
205
+ display: true,
198
206
  }, { triggerTurn: false, deliverAs: "followUp" });
199
207
  throttledRender();
200
208
  },
@@ -367,12 +375,14 @@ export default function antColonyExtension(pi: ExtensionAPI) {
367
375
  };
368
376
 
369
377
  // 定时刷新
370
- const timer = setInterval(() => {
378
+ let timer: ReturnType<typeof setInterval> | null = setInterval(() => {
371
379
  cachedWidth = undefined;
372
380
  cachedLines = undefined;
373
381
  tui.requestRender();
374
382
  }, 1000);
375
383
 
384
+ const cleanup = () => { if (timer) { clearInterval(timer); timer = null; } };
385
+
376
386
  return {
377
387
  render(width: number): string[] {
378
388
  if (cachedLines && cachedWidth === width) return cachedLines;
@@ -380,10 +390,10 @@ export default function antColonyExtension(pi: ExtensionAPI) {
380
390
  cachedWidth = width;
381
391
  return cachedLines;
382
392
  },
383
- invalidate() { cachedWidth = undefined; cachedLines = undefined; },
393
+ invalidate() { cachedWidth = undefined; cachedLines = undefined; cleanup(); },
384
394
  handleInput(data: string) {
385
395
  if (matchesKey(data, "escape")) {
386
- clearInterval(timer);
396
+ cleanup();
387
397
  done(undefined);
388
398
  }
389
399
  },
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import { Nest } from "./nest.js";
6
+ import type { ColonyState, Pheromone } from "./types.js";
7
+
8
+ const mkState = (overrides: Partial<ColonyState> = {}): ColonyState => ({
9
+ id: "test-colony", goal: "test", status: "working",
10
+ tasks: [], ants: [], pheromones: [],
11
+ concurrency: { current: 2, min: 1, max: 4, optimal: 3, history: [] },
12
+ metrics: { tasksTotal: 0, tasksDone: 0, tasksFailed: 0, antsSpawned: 0, totalCost: 0, totalTokens: 0, startTime: Date.now(), throughputHistory: [] },
13
+ maxCost: null, modelOverrides: {}, createdAt: Date.now(), finishedAt: null,
14
+ ...overrides,
15
+ });
16
+
17
+ const mkPheromone = (overrides: Partial<Pheromone> = {}): Pheromone => ({
18
+ id: `p-${Math.random().toString(36).slice(2)}`, type: "warning", antId: "ant-1", antCaste: "worker",
19
+ taskId: "t-1", content: "test", files: ["a.ts"], strength: 1.0, createdAt: Date.now(),
20
+ ...overrides,
21
+ });
22
+
23
+ let tmpDir: string;
24
+ let nest: Nest;
25
+
26
+ beforeEach(() => {
27
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nest-test-"));
28
+ nest = new Nest(tmpDir, "test-colony");
29
+ nest.init(mkState());
30
+ });
31
+
32
+ afterEach(() => {
33
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
34
+ });
35
+
36
+ describe("getStateLight", () => {
37
+ it("returns state without triggering pheromone read", () => {
38
+ nest.dropPheromone(mkPheromone());
39
+ const light = nest.getStateLight();
40
+ expect(light.id).toBe("test-colony");
41
+ expect(light.tasks).toEqual([]);
42
+ // pheromones should not be populated by getStateLight
43
+ // (it returns stateCache which has empty pheromones from init)
44
+ });
45
+
46
+ it("includes tasks from cache", () => {
47
+ nest.writeTask({
48
+ id: "t-1", parentId: null, title: "Test", description: "desc",
49
+ caste: "worker", status: "pending", priority: 3, files: [],
50
+ claimedBy: null, result: null, error: null, spawnedTasks: [],
51
+ createdAt: Date.now(), startedAt: null, finishedAt: null,
52
+ });
53
+ const light = nest.getStateLight();
54
+ expect(light.tasks).toHaveLength(1);
55
+ expect(light.tasks[0].id).toBe("t-1");
56
+ });
57
+ });
58
+
59
+ describe("countWarnings", () => {
60
+ it("returns 0 when no pheromones", () => {
61
+ expect(nest.countWarnings(["a.ts"])).toBe(0);
62
+ });
63
+
64
+ it("counts warning pheromones for matching files", () => {
65
+ nest.dropPheromone(mkPheromone({ type: "warning", files: ["a.ts"] }));
66
+ nest.dropPheromone(mkPheromone({ type: "warning", files: ["a.ts"] }));
67
+ nest.dropPheromone(mkPheromone({ type: "completion", files: ["a.ts"] }));
68
+ expect(nest.countWarnings(["a.ts"])).toBe(2);
69
+ });
70
+
71
+ it("counts repellent pheromones", () => {
72
+ nest.dropPheromone(mkPheromone({ type: "repellent", files: ["b.ts"] }));
73
+ expect(nest.countWarnings(["b.ts"])).toBe(1);
74
+ });
75
+
76
+ it("returns 0 for unrelated files", () => {
77
+ nest.dropPheromone(mkPheromone({ type: "warning", files: ["a.ts"] }));
78
+ expect(nest.countWarnings(["c.ts"])).toBe(0);
79
+ });
80
+ });
81
+
82
+ describe("pheromone dirty flag", () => {
83
+ it("rebuilds index after dropPheromone", () => {
84
+ nest.dropPheromone(mkPheromone({ type: "discovery", files: ["x.ts"] }));
85
+ const pheromones = nest.getAllPheromones();
86
+ expect(pheromones.length).toBe(1);
87
+ expect(pheromones[0].type).toBe("discovery");
88
+ });
89
+
90
+ it("does not rebuild index when nothing changed", () => {
91
+ nest.dropPheromone(mkPheromone({ files: ["x.ts"] }));
92
+ nest.getAllPheromones(); // builds index, clears dirty
93
+ // Second call should use cached index (no new data, no GC)
94
+ const p2 = nest.getAllPheromones();
95
+ expect(p2.length).toBe(1);
96
+ });
97
+ });
98
+
99
+ describe("claimNextTask", () => {
100
+ it("claims highest scored pending task", () => {
101
+ nest.writeTask({
102
+ id: "t-low", parentId: null, title: "Low", description: "",
103
+ caste: "worker", status: "pending", priority: 5, files: [],
104
+ claimedBy: null, result: null, error: null, spawnedTasks: [],
105
+ createdAt: Date.now(), startedAt: null, finishedAt: null,
106
+ });
107
+ nest.writeTask({
108
+ id: "t-high", parentId: null, title: "High", description: "",
109
+ caste: "worker", status: "pending", priority: 1, files: [],
110
+ claimedBy: null, result: null, error: null, spawnedTasks: [],
111
+ createdAt: Date.now(), startedAt: null, finishedAt: null,
112
+ });
113
+ const claimed = nest.claimNextTask("worker", "ant-1");
114
+ expect(claimed).not.toBeNull();
115
+ expect(claimed!.id).toBe("t-high");
116
+ expect(claimed!.status).toBe("claimed");
117
+ });
118
+
119
+ it("returns null when no pending tasks", () => {
120
+ expect(nest.claimNextTask("worker", "ant-1")).toBeNull();
121
+ });
122
+ });
123
+
124
+ describe("withStateLock spin", () => {
125
+ it("updateState works under normal conditions", () => {
126
+ nest.updateState({ status: "reviewing" });
127
+ const state = nest.getStateLight();
128
+ expect(state.status).toBe("reviewing");
129
+ });
130
+ });
@@ -54,4 +54,46 @@ describe("buildPrompt", () => {
54
54
  const r = buildPrompt(mkTask({ description: "修复登录问题" }), "", "System");
55
55
  expect(r).toContain("Chinese");
56
56
  });
57
+
58
+ // Bio 3: 串联觅食 — tandem context
59
+ it("includes tandem parent result when provided", () => {
60
+ const r = buildPrompt(mkTask(), "", "System", 10, { parentResult: "Parent found auth module at src/auth.ts" });
61
+ expect(r).toContain("Tandem Context");
62
+ expect(r).toContain("Parent found auth module");
63
+ });
64
+
65
+ it("includes tandem prior error when provided", () => {
66
+ const r = buildPrompt(mkTask(), "", "System", 10, { priorError: "TypeError: cannot read property" });
67
+ expect(r).toContain("Prior Attempt Failed");
68
+ expect(r).toContain("TypeError: cannot read property");
69
+ });
70
+
71
+ it("includes both tandem fields when provided", () => {
72
+ const r = buildPrompt(mkTask(), "", "System", 10, {
73
+ parentResult: "Scout found files",
74
+ priorError: "Build failed",
75
+ });
76
+ expect(r).toContain("Tandem Context");
77
+ expect(r).toContain("Prior Attempt Failed");
78
+ });
79
+
80
+ it("omits tandem sections when not provided", () => {
81
+ const r = buildPrompt(mkTask(), "", "System", 10);
82
+ expect(r).not.toContain("Tandem Context");
83
+ expect(r).not.toContain("Prior Attempt Failed");
84
+ });
85
+
86
+ it("omits tandem sections when empty object", () => {
87
+ const r = buildPrompt(mkTask(), "", "System", 10, {});
88
+ expect(r).not.toContain("Tandem Context");
89
+ expect(r).not.toContain("Prior Attempt Failed");
90
+ });
91
+
92
+ it("truncates long parent result", () => {
93
+ const longResult = "x".repeat(5000);
94
+ const r = buildPrompt(mkTask(), "", "System", 10, { parentResult: longResult });
95
+ expect(r).toContain("Tandem Context");
96
+ // Should be truncated to 3000 chars
97
+ expect(r.length).toBeLessThan(longResult.length);
98
+ });
57
99
  });
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+
6
+ vi.mock("@mariozechner/pi-coding-agent", () => ({
7
+ AuthStorage: class {},
8
+ createAgentSession: vi.fn(),
9
+ createReadTool: vi.fn(), createBashTool: vi.fn(), createEditTool: vi.fn(),
10
+ createWriteTool: vi.fn(), createGrepTool: vi.fn(), createFindTool: vi.fn(),
11
+ createLsTool: vi.fn(), ModelRegistry: class {}, SessionManager: { inMemory: vi.fn() },
12
+ SettingsManager: { inMemory: vi.fn() }, createExtensionRuntime: vi.fn(),
13
+ }));
14
+ vi.mock("@mariozechner/pi-ai", () => ({ getModel: vi.fn() }));
15
+
16
+ import { classifyError, quorumMergeTasks } from "./queen.js";
17
+ import { Nest } from "./nest.js";
18
+ import type { ColonyState, Task } from "./types.js";
19
+
20
+ // ═══ classifyError ═══
21
+
22
+ describe("classifyError", () => {
23
+ it("classifies TypeError", () => {
24
+ expect(classifyError("TypeError: cannot read property 'x'")).toBe("type_error");
25
+ });
26
+
27
+ it("classifies TS errors", () => {
28
+ expect(classifyError("TS2345: Argument of type")).toBe("type_error");
29
+ });
30
+
31
+ it("classifies permission errors", () => {
32
+ expect(classifyError("EACCES: permission denied")).toBe("permission");
33
+ });
34
+
35
+ it("classifies 401", () => {
36
+ expect(classifyError("Error: 401 Unauthorized")).toBe("permission");
37
+ });
38
+
39
+ it("classifies timeout", () => {
40
+ expect(classifyError("Error: Timeout after 5000ms")).toBe("timeout");
41
+ });
42
+
43
+ it("classifies ETIMEDOUT", () => {
44
+ expect(classifyError("connect ETIMEDOUT 1.2.3.4")).toBe("timeout");
45
+ });
46
+
47
+ it("classifies ENOENT", () => {
48
+ expect(classifyError("ENOENT: no such file or directory")).toBe("not_found");
49
+ });
50
+
51
+ it("classifies Cannot find module", () => {
52
+ expect(classifyError("Cannot find module './foo'")).toBe("not_found");
53
+ });
54
+
55
+ it("classifies syntax errors", () => {
56
+ expect(classifyError("SyntaxError: Unexpected token")).toBe("syntax");
57
+ });
58
+
59
+ it("classifies rate limit", () => {
60
+ expect(classifyError("Error: 429 Too Many Requests")).toBe("rate_limit");
61
+ });
62
+
63
+ it("returns unknown for unrecognized errors", () => {
64
+ expect(classifyError("Something completely different")).toBe("unknown");
65
+ });
66
+
67
+ it("handles empty string", () => {
68
+ expect(classifyError("")).toBe("unknown");
69
+ });
70
+ });
71
+
72
+ // ═══ quorumMergeTasks ═══
73
+
74
+ const mkState = (overrides: Partial<ColonyState> = {}): ColonyState => ({
75
+ id: "test-colony", goal: "test", status: "working",
76
+ tasks: [], ants: [], pheromones: [],
77
+ concurrency: { current: 2, min: 1, max: 4, optimal: 3, history: [] },
78
+ metrics: { tasksTotal: 0, tasksDone: 0, tasksFailed: 0, antsSpawned: 0, totalCost: 0, totalTokens: 0, startTime: Date.now(), throughputHistory: [] },
79
+ maxCost: null, modelOverrides: {}, createdAt: Date.now(), finishedAt: null,
80
+ ...overrides,
81
+ });
82
+
83
+ const mkTask = (overrides: Partial<Task> = {}): Task => ({
84
+ id: `t-${Math.random().toString(36).slice(2)}`, parentId: null,
85
+ title: "Test task", description: "Do something",
86
+ caste: "worker", status: "pending", priority: 3, files: [],
87
+ claimedBy: null, result: null, error: null, spawnedTasks: [],
88
+ createdAt: Date.now(), startedAt: null, finishedAt: null,
89
+ ...overrides,
90
+ });
91
+
92
+ let tmpDir: string;
93
+ let nest: Nest;
94
+
95
+ beforeEach(() => {
96
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "queen-test-"));
97
+ nest = new Nest(tmpDir, "test-colony");
98
+ nest.init(mkState());
99
+ });
100
+
101
+ afterEach(() => {
102
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
103
+ });
104
+
105
+ describe("quorumMergeTasks", () => {
106
+ it("does nothing with 0-1 tasks", () => {
107
+ const t1 = mkTask({ files: ["a.ts"], priority: 3 });
108
+ nest.writeTask(t1);
109
+ quorumMergeTasks(nest);
110
+ const tasks = nest.getAllTasks().filter(t => t.status === "pending");
111
+ expect(tasks).toHaveLength(1);
112
+ });
113
+
114
+ it("merges duplicate tasks with same files", () => {
115
+ const t1 = mkTask({ id: "t-1", title: "Fix auth", files: ["a.ts", "b.ts"], priority: 3 });
116
+ const t2 = mkTask({ id: "t-2", title: "Fix auth v2", files: ["a.ts", "b.ts"], priority: 3 });
117
+ nest.writeTask(t1);
118
+ nest.writeTask(t2);
119
+ quorumMergeTasks(nest);
120
+ const pending = nest.getAllTasks().filter(t => t.status === "pending");
121
+ const done = nest.getAllTasks().filter(t => t.status === "done");
122
+ expect(pending).toHaveLength(1);
123
+ expect(done).toHaveLength(1);
124
+ expect(done[0].result).toContain("quorum");
125
+ });
126
+
127
+ it("boosts priority of merged task", () => {
128
+ const t1 = mkTask({ id: "t-1", files: ["x.ts"], priority: 3 });
129
+ const t2 = mkTask({ id: "t-2", files: ["x.ts"], priority: 3 });
130
+ nest.writeTask(t1);
131
+ nest.writeTask(t2);
132
+ quorumMergeTasks(nest);
133
+ const pending = nest.getAllTasks().filter(t => t.status === "pending");
134
+ expect(pending[0].priority).toBe(2); // boosted from 3 to 2
135
+ });
136
+
137
+ it("does not merge tasks with different files", () => {
138
+ const t1 = mkTask({ id: "t-1", files: ["a.ts"] });
139
+ const t2 = mkTask({ id: "t-2", files: ["b.ts"] });
140
+ nest.writeTask(t1);
141
+ nest.writeTask(t2);
142
+ quorumMergeTasks(nest);
143
+ const pending = nest.getAllTasks().filter(t => t.status === "pending");
144
+ expect(pending).toHaveLength(2);
145
+ });
146
+
147
+ it("merges context from duplicate tasks", () => {
148
+ const t1 = mkTask({ id: "t-1", files: ["a.ts"], context: "context A" });
149
+ const t2 = mkTask({ id: "t-2", files: ["a.ts"], context: "context B" });
150
+ nest.writeTask(t1);
151
+ nest.writeTask(t2);
152
+ quorumMergeTasks(nest);
153
+ const pending = nest.getAllTasks().filter(t => t.status === "pending");
154
+ expect(pending[0].context).toContain("context A");
155
+ expect(pending[0].context).toContain("context B");
156
+ });
157
+
158
+ it("handles three duplicates", () => {
159
+ const t1 = mkTask({ id: "t-1", files: ["a.ts"], priority: 4 });
160
+ const t2 = mkTask({ id: "t-2", files: ["a.ts"], priority: 4 });
161
+ const t3 = mkTask({ id: "t-3", files: ["a.ts"], priority: 4 });
162
+ nest.writeTask(t1);
163
+ nest.writeTask(t2);
164
+ nest.writeTask(t3);
165
+ quorumMergeTasks(nest);
166
+ const pending = nest.getAllTasks().filter(t => t.status === "pending");
167
+ const done = nest.getAllTasks().filter(t => t.status === "done");
168
+ expect(pending).toHaveLength(1);
169
+ expect(done).toHaveLength(2);
170
+ expect(pending[0].priority).toBe(3); // boosted from 4 to 3
171
+ });
172
+
173
+ it("skips non-pending tasks", () => {
174
+ const t1 = mkTask({ id: "t-1", files: ["a.ts"], status: "done" });
175
+ const t2 = mkTask({ id: "t-2", files: ["a.ts"], status: "pending" });
176
+ nest.writeTask(t1);
177
+ nest.writeTask(t2);
178
+ quorumMergeTasks(nest);
179
+ // Only 1 pending, so no merge
180
+ const pending = nest.getAllTasks().filter(t => t.status === "pending");
181
+ expect(pending).toHaveLength(1);
182
+ });
183
+
184
+ it("does not boost priority below 1", () => {
185
+ const t1 = mkTask({ id: "t-1", files: ["a.ts"], priority: 1 });
186
+ const t2 = mkTask({ id: "t-2", files: ["a.ts"], priority: 1 });
187
+ nest.writeTask(t1);
188
+ nest.writeTask(t2);
189
+ quorumMergeTasks(nest);
190
+ const pending = nest.getAllTasks().filter(t => t.status === "pending");
191
+ expect(pending[0].priority).toBe(1); // stays at 1
192
+ });
193
+ });
@@ -102,7 +102,7 @@ function childTaskFromParsed(
102
102
  * Bio 5: 蚁群投票 — 合并多 Scout 产生的重复任务
103
103
  * 相同文件集合的任务合并,被多 Scout 提及的任务 priority 提升
104
104
  */
105
- function quorumMergeTasks(nest: Nest): void {
105
+ export function quorumMergeTasks(nest: Nest): void {
106
106
  const tasks = nest.getAllTasks().filter(t =>
107
107
  (t.caste === "worker" || t.caste === "drone") && t.status === "pending"
108
108
  );
@@ -198,7 +198,7 @@ interface WaveOptions {
198
198
  /**
199
199
  * Bio 6: 尸体清理 — 错误模式分类
200
200
  */
201
- function classifyError(errStr: string): string {
201
+ export function classifyError(errStr: string): string {
202
202
  if (errStr.includes("TypeError") || errStr.includes("type") || errStr.includes("TS")) return "type_error";
203
203
  if (errStr.includes("permission") || errStr.includes("401") || errStr.includes("EACCES")) return "permission";
204
204
  if (errStr.includes("timeout") || errStr.includes("Timeout") || errStr.includes("ETIMEDOUT")) return "timeout";
@@ -33,7 +33,8 @@ export function buildReport(state: ColonyState): string {
33
33
  return [
34
34
  `## 🐜 Ant Colony Report`,
35
35
  `**Goal:** ${state.goal}`,
36
- `**Status:** ${statusIcon(state.status)} ${state.status} │ ${elapsed} │ ${formatCost(m.totalCost)}`,
36
+ `**Status:** ${statusIcon(state.status)} ${state.status} │ ${formatCost(m.totalCost)}`,
37
+ `**Duration:** ${elapsed}`,
37
38
  `**Tasks:** ${m.tasksDone}/${m.tasksTotal} done${m.tasksFailed > 0 ? `, ${m.tasksFailed} failed` : ""}`,
38
39
  ``,
39
40
  ...state.tasks.filter(t => t.status === "done").map(t =>
@@ -2,13 +2,14 @@
2
2
  * oh-pi Background Process Extension
3
3
  *
4
4
  * 任何 bash 命令超时未完成时,自动送到后台执行。
5
+ * 进程完成后自动通过 sendMessage 通知 LLM,无需轮询。
5
6
  * 提供 bg_status 工具让 LLM 查看/停止后台进程。
6
7
  */
7
8
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
9
  import { Type } from "@sinclair/typebox";
9
10
  import { StringEnum } from "@mariozechner/pi-ai";
10
11
  import { spawn, execSync } from "node:child_process";
11
- import { writeFileSync, readFileSync, existsSync } from "node:fs";
12
+ import { writeFileSync, readFileSync, appendFileSync, existsSync } from "node:fs";
12
13
 
13
14
  /** 超时阈值(毫秒),超过此时间自动后台化 */
14
15
  const BG_TIMEOUT_MS = 10_000;
@@ -18,6 +19,8 @@ interface BgProcess {
18
19
  command: string;
19
20
  logFile: string;
20
21
  startedAt: number;
22
+ finished: boolean;
23
+ exitCode: number | null;
21
24
  }
22
25
 
23
26
  export default function (pi: ExtensionAPI) {
@@ -41,6 +44,7 @@ export default function (pi: ExtensionAPI) {
41
44
  let stdout = "";
42
45
  let stderr = "";
43
46
  let settled = false;
47
+ let backgrounded = false;
44
48
 
45
49
  const child = spawn("bash", ["-c", command], {
46
50
  cwd: process.cwd(),
@@ -48,36 +52,58 @@ export default function (pi: ExtensionAPI) {
48
52
  stdio: ["ignore", "pipe", "pipe"],
49
53
  });
50
54
 
51
- child.stdout?.on("data", (d: Buffer) => { stdout += d.toString(); });
52
- child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
55
+ child.stdout?.on("data", (d: Buffer) => {
56
+ const chunk = d.toString();
57
+ stdout += chunk;
58
+ // 后台化后追加写入日志
59
+ if (backgrounded) {
60
+ try { appendFileSync(bgProcesses.get(child.pid!)?.logFile ?? "", chunk); } catch {}
61
+ }
62
+ });
63
+ child.stderr?.on("data", (d: Buffer) => {
64
+ const chunk = d.toString();
65
+ stderr += chunk;
66
+ if (backgrounded) {
67
+ try { appendFileSync(bgProcesses.get(child.pid!)?.logFile ?? "", chunk); } catch {}
68
+ }
69
+ });
53
70
 
54
- // 超时处理:分离进程,送到后台
71
+ // 超时处理:保持管道,标记为后台
55
72
  const timer = setTimeout(() => {
56
73
  if (settled) return;
57
74
  settled = true;
75
+ backgrounded = true;
58
76
 
59
- // 分离子进程,让它继续运行
60
- child.stdout?.removeAllListeners();
61
- child.stderr?.removeAllListeners();
62
- child.removeAllListeners();
63
77
  child.unref();
64
78
 
65
79
  const logFile = `/tmp/oh-pi-bg-${Date.now()}.log`;
66
80
  const pid = child.pid!;
67
81
 
68
- // 启动一个 tail 进程把后续输出写入日志
69
- try {
70
- const tailCmd = `(echo ${JSON.stringify(stdout + stderr)}; tail --pid=${pid} -f /proc/${pid}/fd/1 2>/dev/null) > ${logFile} 2>&1 &`;
71
- spawn("bash", ["-c", tailCmd], { detached: true, stdio: "ignore" }).unref();
72
- } catch {
73
- // fallback: 至少把已有输出写入日志
74
- writeFileSync(logFile, stdout + stderr);
75
- }
76
-
77
- bgProcesses.set(pid, { pid, command, logFile, startedAt: Date.now() });
82
+ // 把已有输出写入日志
83
+ writeFileSync(logFile, stdout + stderr);
84
+
85
+ const proc: BgProcess = { pid, command, logFile, startedAt: Date.now(), finished: false, exitCode: null };
86
+ bgProcesses.set(pid, proc);
87
+
88
+ // 监听完成事件,自动通知 LLM
89
+ child.on("close", (code) => {
90
+ proc.finished = true;
91
+ proc.exitCode = code;
92
+ const tail = (stdout + stderr).slice(-3000);
93
+ const truncated = (stdout + stderr).length > 3000 ? "[...truncated]\n" + tail : tail;
94
+ // 最终输出写入日志
95
+ try { writeFileSync(logFile, stdout + stderr); } catch {}
96
+
97
+ pi.sendMessage({
98
+ content: `[BG_PROCESS_DONE] PID ${pid} finished (exit ${code ?? "?"})\nCommand: ${command}\n\nOutput (last 3000 chars):\n${truncated}`,
99
+ display: true,
100
+ triggerTurn: true,
101
+ deliverAs: "followUp",
102
+ });
103
+ });
78
104
 
79
105
  const preview = (stdout + stderr).slice(0, 500);
80
- const text = `Command still running after ${effectiveTimeout / 1000}s, moved to background.\nPID: ${pid}\nLog: ${logFile}\nView output: tail -f ${logFile}\nStop: kill ${pid}\n\nOutput so far:\n${preview}`;
106
+ const text = `Command still running after ${effectiveTimeout / 1000}s, moved to background.\nPID: ${pid}\nLog: ${logFile}\nStop: kill ${pid}\n\nOutput so far:\n${preview}\n\n⏳ You will be notified automatically when it finishes. No need to poll.`;
81
107
 
82
108
  resolve({
83
109
  content: [{ type: "text", text }],
@@ -85,7 +111,7 @@ export default function (pi: ExtensionAPI) {
85
111
  });
86
112
  }, effectiveTimeout);
87
113
 
88
- // 正常结束
114
+ // 正常结束(超时前)
89
115
  child.on("close", (code) => {
90
116
  if (settled) return;
91
117
  settled = true;
@@ -146,8 +172,7 @@ export default function (pi: ExtensionAPI) {
146
172
  return { content: [{ type: "text", text: "No background processes." }], details: {} };
147
173
  }
148
174
  const lines = [...bgProcesses.values()].map((p) => {
149
- const alive = isAlive(p.pid);
150
- const status = alive ? "🟢 running" : "⚪ stopped";
175
+ const status = p.finished ? `⚪ stopped (exit ${p.exitCode ?? "?"})` : (isAlive(p.pid) ? "🟢 running" : "⚪ stopped");
151
176
  return `PID: ${p.pid} | ${status} | Log: ${p.logFile}\n Cmd: ${p.command}`;
152
177
  });
153
178
  return { content: [{ type: "text", text: lines.join("\n\n") }], details: {} };
@@ -171,13 +196,7 @@ export default function (pi: ExtensionAPI) {
171
196
  return { content: [{ type: "text", text: `Error reading log: ${e.message}` }], details: {}, isError: true };
172
197
  }
173
198
  }
174
- // fallback: 直接读 /proc
175
- try {
176
- const out = execSync(`tail -20 /proc/${pid}/fd/1 2>/dev/null || echo "(cannot read output)"`, { timeout: 3000 }).toString();
177
- return { content: [{ type: "text", text: out }], details: {} };
178
- } catch {
179
- return { content: [{ type: "text", text: "No log available for this PID." }], details: {} };
180
- }
199
+ return { content: [{ type: "text", text: "No log available for this PID." }], details: {} };
181
200
  }
182
201
 
183
202
  if (action === "stop") {
@@ -197,8 +216,10 @@ export default function (pi: ExtensionAPI) {
197
216
 
198
217
  // 清理:退出时杀掉所有后台进程
199
218
  pi.on("session_shutdown", async () => {
200
- for (const [pid] of bgProcesses) {
201
- try { process.kill(pid, "SIGTERM"); } catch {}
219
+ for (const [pid, proc] of bgProcesses) {
220
+ if (!proc.finished) {
221
+ try { process.kill(pid, "SIGTERM"); } catch {}
222
+ }
202
223
  }
203
224
  bgProcesses.clear();
204
225
  });