pi-orchestrator-config 4.1.5

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.
Files changed (175) hide show
  1. package/README.md +808 -0
  2. package/agents/api-documenter.md +32 -0
  3. package/agents/bash-expert.md +53 -0
  4. package/agents/code-reviewer-docs.md +145 -0
  5. package/agents/code-reviewer-guidelines.md +97 -0
  6. package/agents/code-reviewer-quality.md +142 -0
  7. package/agents/code-reviewer-security.md +107 -0
  8. package/agents/code-reviewer-spec.md +164 -0
  9. package/agents/debugger.md +63 -0
  10. package/agents/docker-expert.md +40 -0
  11. package/agents/docs-fetcher.md +48 -0
  12. package/agents/git-expert.md +63 -0
  13. package/agents/github-expert.md +83 -0
  14. package/agents/go-expert.md +62 -0
  15. package/agents/issue-reviewer-feasibility.md +60 -0
  16. package/agents/issue-reviewer-scope.md +63 -0
  17. package/agents/issue-reviewer-spec.md +77 -0
  18. package/agents/java-expert.md +31 -0
  19. package/agents/jenkins-expert.md +38 -0
  20. package/agents/kubernetes-expert.md +40 -0
  21. package/agents/planner.md +42 -0
  22. package/agents/python-expert.md +61 -0
  23. package/agents/reviewer.md +26 -0
  24. package/agents/scout.md +40 -0
  25. package/agents/security-auditor.md +254 -0
  26. package/agents/technical-documentation-writer.md +37 -0
  27. package/agents/test-automator.md +55 -0
  28. package/agents/test-runner.md +47 -0
  29. package/agents/ts-expert.md +23 -0
  30. package/agents/worker.md +30 -0
  31. package/extensions/acpx-provider/index.ts +104 -0
  32. package/extensions/acpx-provider/load-runtime.ts +99 -0
  33. package/extensions/acpx-provider/runtime-models.ts +40 -0
  34. package/extensions/cli-provider/agents/claude.ts +128 -0
  35. package/extensions/cli-provider/agents/cursor.ts +84 -0
  36. package/extensions/cli-provider/agents/gemini.ts +100 -0
  37. package/extensions/cli-provider/discover.ts +77 -0
  38. package/extensions/cli-provider/index.ts +57 -0
  39. package/extensions/cli-provider/parsers.ts +304 -0
  40. package/extensions/cli-provider/providers.ts +58 -0
  41. package/extensions/cli-provider/runner.ts +225 -0
  42. package/extensions/cli-provider/runtime-models.ts +32 -0
  43. package/extensions/cli-provider/session-reaper.ts +122 -0
  44. package/extensions/cli-provider/sessions.ts +533 -0
  45. package/extensions/cli-provider/shared/discover-cache.ts +235 -0
  46. package/extensions/cli-provider/types.ts +48 -0
  47. package/extensions/coms/coms-net-server.ts +1642 -0
  48. package/extensions/coms/coms-net-wrapper.ts +521 -0
  49. package/extensions/coms/coms-net.ts +1589 -0
  50. package/extensions/coms/coms-p2p.ts +1879 -0
  51. package/extensions/coms/coms-shared.ts +510 -0
  52. package/extensions/coms/coms-wrapper.ts +138 -0
  53. package/extensions/coms/index.ts +20 -0
  54. package/extensions/coms/themeMap.ts +147 -0
  55. package/extensions/image-gen/image-gen.ts +332 -0
  56. package/extensions/image-gen/index.ts +14 -0
  57. package/extensions/orchestrator/agents.ts +134 -0
  58. package/extensions/orchestrator/ask-user.ts +293 -0
  59. package/extensions/orchestrator/async-agents.ts +1037 -0
  60. package/extensions/orchestrator/async-capability.ts +129 -0
  61. package/extensions/orchestrator/async-runner.ts +324 -0
  62. package/extensions/orchestrator/async-status-parse.ts +31 -0
  63. package/extensions/orchestrator/async-status-ui.ts +201 -0
  64. package/extensions/orchestrator/async-wait.ts +36 -0
  65. package/extensions/orchestrator/btw.ts +210 -0
  66. package/extensions/orchestrator/cron-status-format.ts +126 -0
  67. package/extensions/orchestrator/cron-status-ui.ts +132 -0
  68. package/extensions/orchestrator/cron.ts +730 -0
  69. package/extensions/orchestrator/dreaming.ts +355 -0
  70. package/extensions/orchestrator/enforcement-helpers.ts +530 -0
  71. package/extensions/orchestrator/enforcement-rules.ts +329 -0
  72. package/extensions/orchestrator/enforcement.ts +828 -0
  73. package/extensions/orchestrator/extended-autocomplete.ts +593 -0
  74. package/extensions/orchestrator/git-helpers.ts +420 -0
  75. package/extensions/orchestrator/github-autocomplete.ts +250 -0
  76. package/extensions/orchestrator/icons.ts +17 -0
  77. package/extensions/orchestrator/index.ts +100 -0
  78. package/extensions/orchestrator/memory-embeddings.ts +281 -0
  79. package/extensions/orchestrator/memory-promotion.ts +449 -0
  80. package/extensions/orchestrator/memory-provenance.ts +80 -0
  81. package/extensions/orchestrator/memory-query-class.ts +90 -0
  82. package/extensions/orchestrator/memory-scoring.ts +434 -0
  83. package/extensions/orchestrator/memory-tools.ts +883 -0
  84. package/extensions/orchestrator/memory-tree.ts +212 -0
  85. package/extensions/orchestrator/nvim.ts +169 -0
  86. package/extensions/orchestrator/overlay-dashboard-utils.ts +25 -0
  87. package/extensions/orchestrator/overlay-dashboard.ts +512 -0
  88. package/extensions/orchestrator/pi-config-review-state.ts +303 -0
  89. package/extensions/orchestrator/preference-extractor.ts +106 -0
  90. package/extensions/orchestrator/project-settings.ts +468 -0
  91. package/extensions/orchestrator/promotion-queue.ts +311 -0
  92. package/extensions/orchestrator/resolve-agent-model.ts +32 -0
  93. package/extensions/orchestrator/review-ui.ts +204 -0
  94. package/extensions/orchestrator/rule-placeholders.ts +12 -0
  95. package/extensions/orchestrator/rules.ts +481 -0
  96. package/extensions/orchestrator/session-search.ts +243 -0
  97. package/extensions/orchestrator/session-validation.ts +374 -0
  98. package/extensions/orchestrator/situation-report.ts +370 -0
  99. package/extensions/orchestrator/status-bar.ts +31 -0
  100. package/extensions/orchestrator/status-line.ts +192 -0
  101. package/extensions/orchestrator/status.ts +110 -0
  102. package/extensions/orchestrator/subagent-tool.ts +1748 -0
  103. package/extensions/orchestrator/task-lifecycle.ts +88 -0
  104. package/extensions/orchestrator/utils.ts +227 -0
  105. package/extensions/pidash/index.ts +15 -0
  106. package/extensions/pidash/pidash.ts +941 -0
  107. package/extensions/pidiff/index.ts +13 -0
  108. package/extensions/pidiff/pidiff.ts +369 -0
  109. package/extensions/providers/acpx-driver.ts +479 -0
  110. package/extensions/providers/built-in-drivers.ts +52 -0
  111. package/extensions/providers/claude-driver.ts +319 -0
  112. package/extensions/providers/cursor-acpx-driver.ts +398 -0
  113. package/extensions/providers/cursor-cli-driver.ts +484 -0
  114. package/extensions/providers/gemini-driver.ts +302 -0
  115. package/extensions/providers/index.ts +513 -0
  116. package/extensions/shared/build-system-prompt.ts +57 -0
  117. package/extensions/shared/create-runtime-provider.ts +190 -0
  118. package/extensions/shared/daemon-manager.ts +248 -0
  119. package/extensions/shared/file-logger.ts +115 -0
  120. package/extensions/shared/managed-refresh.ts +211 -0
  121. package/extensions/shared/provider-driver.ts +295 -0
  122. package/extensions/shared/provider-errors.ts +305 -0
  123. package/extensions/shared/provider-registry.ts +388 -0
  124. package/extensions/shared/stream-builder.ts +275 -0
  125. package/extensions/shared/types.ts +20 -0
  126. package/extensions/shared/ui/README.md +10 -0
  127. package/extensions/shared/ui/badge.tsx +52 -0
  128. package/extensions/shared/ui/button.tsx +58 -0
  129. package/extensions/shared/ui/collapsible.tsx +21 -0
  130. package/extensions/shared/ui/dropdown-menu.tsx +266 -0
  131. package/extensions/shared/ui/input.tsx +20 -0
  132. package/extensions/shared/ui/popover.tsx +114 -0
  133. package/extensions/shared/ui/resizable.tsx +48 -0
  134. package/extensions/shared/ui/scroll-area.tsx +53 -0
  135. package/extensions/shared/ui/select.tsx +201 -0
  136. package/extensions/shared/ui/separator.tsx +25 -0
  137. package/extensions/shared/ui/tooltip.tsx +64 -0
  138. package/extensions/shared/ws-client.ts +74 -0
  139. package/package.json +59 -0
  140. package/prompts/coderabbit-rate-limit.md +95 -0
  141. package/prompts/create-coms-feature-manager.md +186 -0
  142. package/prompts/create-skill.md +128 -0
  143. package/prompts/domain-model.md +109 -0
  144. package/prompts/external-ai.md +665 -0
  145. package/prompts/implement-and-review.md +25 -0
  146. package/prompts/implement.md +26 -0
  147. package/prompts/issue-review.md +256 -0
  148. package/prompts/pr-review.md +753 -0
  149. package/prompts/query-db.md +154 -0
  150. package/prompts/refine-review.md +208 -0
  151. package/prompts/release.md +235 -0
  152. package/prompts/remember.md +33 -0
  153. package/prompts/review-handler-status.md +27 -0
  154. package/prompts/review-handler.md +677 -0
  155. package/prompts/review-local.md +159 -0
  156. package/prompts/scout-and-plan.md +21 -0
  157. package/rules/00-orchestrator-core.md +42 -0
  158. package/rules/05-issue-first-workflow.md +119 -0
  159. package/rules/10-agent-routing.md +58 -0
  160. package/rules/15-mcp-launchpad.md +46 -0
  161. package/rules/20-code-review-loop.md +234 -0
  162. package/rules/25-documentation-updates.md +15 -0
  163. package/rules/30-prompt-templates.md +19 -0
  164. package/rules/35-memory.md +240 -0
  165. package/rules/40-critical-rules.md +124 -0
  166. package/rules/45-file-preview.md +29 -0
  167. package/rules/50-agent-bug-reporting.md +90 -0
  168. package/rules/55-coms-protocol.md +63 -0
  169. package/rules/60-task-tracking.md +66 -0
  170. package/skills/conflict-resolver/SKILL.md +213 -0
  171. package/skills/skill-creator/SKILL.md +85 -0
  172. package/skills/systematic-debugging/SKILL.md +353 -0
  173. package/skills/test-driven-development/SKILL.md +335 -0
  174. package/skills/update-agents-md/SKILL.md +282 -0
  175. package/templates/coms-feature-manager-prompt.md +313 -0
@@ -0,0 +1,1037 @@
1
+ /**
2
+ * Async agent infrastructure — background agent spawning, polling, result watching.
3
+ */
4
+
5
+ import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import { createRequire } from "node:module";
10
+
11
+ const require = createRequire(import.meta.url);
12
+
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import type { AgentConfig } from "./agents.js";
15
+ import { discoverAgents } from "./agents.js";
16
+ import { resolveAgentModelProvider } from "./resolve-agent-model.js";
17
+ import { getPiInvocation, getProjectTmpDir, parseProcStartTime, djb2Hash } from "./utils.js";
18
+ import { addReviewerPending, recordReviewerResult, countFindings, readReviewState, markTestsPassed, markTestsFailed } from "./pi-config-review-state.js";
19
+ import { getMainBranch } from "./git-helpers.js";
20
+ import { waitForResultFiles } from "./async-wait.js";
21
+ import { openAsyncStatusOverlay } from "./async-status-ui.js";
22
+ export { autoCompleteTask, autoMarkInProgress } from "./task-lifecycle.js";
23
+ import { autoCompleteTask, autoMarkInProgress } from "./task-lifecycle.js";
24
+ import { setSlot } from "./status-bar.js";
25
+
26
+ // ── Constants ────────────────────────────────────────────────────────────
27
+
28
+
29
+
30
+ const ASYNC_POLL_INTERVAL_MS = 3000;
31
+
32
+ // ── Interfaces ───────────────────────────────────────────────────────────
33
+
34
+ export interface AsyncJob {
35
+ id: string;
36
+ agent: string;
37
+ name?: string;
38
+ task: string;
39
+ status: "queued" | "running" | "complete" | "failed";
40
+ workerDir: string;
41
+ startedAt: number;
42
+ updatedAt: number;
43
+ output?: string;
44
+ exitCode?: number | null;
45
+ durationMs?: number;
46
+ delivered?: boolean;
47
+ fireAndForget?: boolean;
48
+ onComplete?: () => void;
49
+ groupId?: string;
50
+ taskId?: string;
51
+ cwd?: string;
52
+ projectCwd?: string;
53
+ sessionId?: string;
54
+ model?: string;
55
+ }
56
+
57
+ /** Get the effective working directory for a job. */
58
+ function jobCwd(job: { cwd?: string; projectCwd?: string }): string {
59
+ return job.cwd || job.projectCwd || process.cwd();
60
+ }
61
+
62
+ interface AsyncState {
63
+ jobs: Map<string, AsyncJob>;
64
+ poller: ReturnType<typeof setInterval> | null;
65
+ watcher: fs.FSWatcher | null;
66
+ lastCtx: any;
67
+ }
68
+
69
+ // ── Helpers ──────────────────────────────────────────────────────────────
70
+
71
+ function readAsyncStatus(workerDir: string): any | null {
72
+ try {
73
+ const statusPath = path.join(workerDir, "status.json");
74
+ return JSON.parse(fs.readFileSync(statusPath, "utf-8"));
75
+ } catch { return null; }
76
+ }
77
+
78
+ /** Derive a stable directory name from a session file path. */
79
+ function sessionResultsDirName(): string {
80
+ let startTime = "";
81
+ try {
82
+ const stat = fs.readFileSync(`/proc/${process.pid}/stat`, "utf-8");
83
+ startTime = parseProcStartTime(stat) || "";
84
+ } catch {}
85
+ return startTime
86
+ ? `async-results-pid-${process.pid}-${startTime}`
87
+ : `async-results-pid-${process.pid}`;
88
+ }
89
+
90
+ export function formatDuration(ms: number): string {
91
+ if (ms < 1000) return `${ms}ms`;
92
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
93
+ const m = Math.floor(ms / 60000);
94
+ const s = Math.floor((ms % 60000) / 1000);
95
+ return `${m}m${s}s`;
96
+ }
97
+
98
+ // ── Registration ─────────────────────────────────────────────────────────
99
+
100
+ export function registerAsyncAgents(
101
+ pi: ExtensionAPI,
102
+ terminalNotify: (title: string, body: string) => void,
103
+ ): {
104
+ spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: () => void; persistSession?: boolean }) => { id: string; error?: string; model?: string };
105
+ killAsyncAgent: (target: string) => { killed: string[]; errors: string[] };
106
+ getAsyncJobs: () => Array<{ id: string; agent: string; name?: string; task: string; status: string; startedAt: number }>;
107
+ } {
108
+ let PROJECT_TMP_DIR = path.join(process.cwd(), ".pi", "tmp"); // Computed only; created on session_start
109
+ let ASYNC_RESULTS_DIR = ""; // Set on session_start to project-scoped dir
110
+
111
+ const ASYNC_DEBUG = !!process.env.PI_ASYNC_DEBUG;
112
+ const EARLY_LOG_PATH = ASYNC_DEBUG ? path.join(PROJECT_TMP_DIR, `early-debug-${process.pid}.log`) : "";
113
+ let DEBUG_LOG_PATH = EARLY_LOG_PATH; // Starts with early log, moved to project dir on session_start
114
+ function asyncLog(msg: string) {
115
+ if (!ASYNC_DEBUG || !DEBUG_LOG_PATH) return;
116
+ try { fs.appendFileSync(DEBUG_LOG_PATH, `${new Date().toISOString()} ${msg}\n`); } catch {}
117
+ }
118
+
119
+ const asyncState: AsyncState = {
120
+ jobs: new Map(),
121
+ poller: null,
122
+ watcher: null,
123
+ lastCtx: null,
124
+ };
125
+
126
+ let lastWidgetKey = "";
127
+ function updateAsyncWidget() {
128
+ if (!asyncState.lastCtx?.hasUI) return;
129
+ try {
130
+ const ctx = asyncState.lastCtx;
131
+ const running = Array.from(asyncState.jobs.values()).filter(j => j.status === "running" || j.status === "queued");
132
+ const names = running.map(j => j.name || j.agent).join(", ");
133
+ const widgetKey = `${running.length}:${names}`;
134
+ const changed = widgetKey !== lastWidgetKey;
135
+ lastWidgetKey = widgetKey;
136
+ if (running.length > 0) {
137
+ setSlot("async", ctx.ui.theme.fg("warning", `⏳ async: ${running.length}`), ctx);
138
+ } else if (changed) {
139
+ setSlot("async", ctx.ui.theme.fg("muted", `💤 async: 0`), ctx);
140
+ }
141
+ // Always emit to pidash — browser may have reconnected and needs fresh state
142
+ pi.events.emit("pidash:async-status", {
143
+ count: running.length,
144
+ agents: names,
145
+ jobs: running.map(j => ({
146
+ id: j.id,
147
+ name: j.name || j.agent,
148
+ agent: j.agent,
149
+ task: j.task,
150
+ status: j.status,
151
+ startedAt: j.startedAt,
152
+ })),
153
+ });
154
+ } catch { /* stale ctx after session replacement */ }
155
+ }
156
+
157
+ function ensureAsyncPoller() {
158
+ if (asyncState.poller) return;
159
+ asyncState.poller = setInterval(() => {
160
+ try {
161
+ if (!asyncState.lastCtx?.hasUI) return;
162
+ } catch {
163
+ // ctx is stale (session ended) — stop polling
164
+ if (asyncState.poller) { clearInterval(asyncState.poller); asyncState.poller = null; }
165
+ return;
166
+ }
167
+ if (asyncState.jobs.size === 0) {
168
+ updateAsyncWidget();
169
+ return;
170
+ }
171
+
172
+ for (const job of asyncState.jobs.values()) {
173
+ if (job.status === "complete" || job.status === "failed") continue;
174
+ const status = readAsyncStatus(job.workerDir);
175
+ if (status) {
176
+ job.status = status.state;
177
+ job.updatedAt = status.lastUpdate ?? Date.now();
178
+ if (status.exitCode !== undefined) job.exitCode = status.exitCode;
179
+
180
+ // Check if process is actually alive — clean up zombies
181
+ if (job.status === "running" && status.pid) {
182
+ try { process.kill(status.pid, 0); } catch {
183
+ // Process exited — check if it wrote a result file first
184
+ const resultFilePath = path.join(ASYNC_RESULTS_DIR, `${job.id}.json`);
185
+ if (fs.existsSync(resultFilePath)) {
186
+ // Result file exists — ingest it so output/durationMs are populated
187
+ try {
188
+ const data = JSON.parse(fs.readFileSync(resultFilePath, "utf-8"));
189
+ job.status = data.success ? "complete" : "failed";
190
+ job.output = data.output;
191
+ job.exitCode = data.exitCode;
192
+ job.durationMs = data.durationMs;
193
+ job.updatedAt = Date.now();
194
+ if (job.agent.startsWith("code-reviewer-")) {
195
+ const findings = countFindings(typeof data.output === "string" ? data.output : "");
196
+ try { recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings); } catch {}
197
+ }
198
+ try { fs.unlinkSync(resultFilePath); } catch {}
199
+ } catch {
200
+ job.status = "failed";
201
+ job.output = "Process exited before result could be read";
202
+ job.durationMs = Date.now() - job.startedAt;
203
+ job.updatedAt = Date.now();
204
+ if (job.agent.startsWith("code-reviewer-")) {
205
+ try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch {}
206
+ }
207
+ }
208
+ } else {
209
+ job.status = "failed";
210
+ job.output = "Process exited without producing results";
211
+ job.durationMs = Date.now() - job.startedAt;
212
+ job.updatedAt = Date.now();
213
+ // Record killed reviewer as having 0 findings — prevents permanent commit block
214
+ if (job.agent.startsWith("code-reviewer-")) {
215
+ try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch { /* best-effort */ }
216
+ }
217
+ }
218
+ // Check if this completes a group — deliver remaining siblings' results
219
+ if (job.groupId) {
220
+ const groupJobs = Array.from(asyncState.jobs.values()).filter(j => j.groupId === job.groupId);
221
+ const pending = groupJobs.filter(j => j.status !== "complete" && j.status !== "failed");
222
+ if (pending.length === 0) deliverGroupResults(groupJobs);
223
+ }
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ // Fallback: check for result files the watcher may have missed
230
+ try {
231
+ const files = fs.readdirSync(ASYNC_RESULTS_DIR).filter(f => f.endsWith(".json"));
232
+ for (const file of files) {
233
+ processResultFile(path.join(ASYNC_RESULTS_DIR, file));
234
+ }
235
+ } catch (e: any) { console.debug("[async-agents] result file scan failed:", e?.message || e); }
236
+
237
+ // Remove completed/failed jobs older than 30s (skip undelivered group members)
238
+ for (const [id, job] of asyncState.jobs.entries()) {
239
+ if ((job.status === "complete" || job.status === "failed")
240
+ && Date.now() - job.updatedAt > 30000
241
+ && (job.delivered || !job.groupId)) {
242
+ asyncState.jobs.delete(id);
243
+ }
244
+ }
245
+ updateAsyncWidget();
246
+ }, ASYNC_POLL_INTERVAL_MS);
247
+ if (asyncState.poller.unref) asyncState.poller.unref();
248
+ }
249
+
250
+ /** Deliver all results from a completed group as a single combined message. */
251
+ let groupDeliveryInProgress = new Set<string>();
252
+ async function deliverGroupResults(groupJobs: AsyncJob[]) {
253
+ // Guard against duplicate concurrent invocations
254
+ const gid = groupJobs[0]?.groupId;
255
+ if (gid && groupDeliveryInProgress.has(gid)) return;
256
+ if (gid) groupDeliveryInProgress.add(gid);
257
+ try {
258
+ if (!asyncState.lastCtx) return;
259
+
260
+ // Ingest any unprocessed result files — zombie/kill paths may trigger delivery
261
+ // before processResultFile() has read all group members' outputs.
262
+ // Wait up to 2s total (not per-job) for all missing result files.
263
+ const lateIngestedIds = new Set<string>();
264
+ // Wait for non-failed, non-fireAndForget jobs that haven't been ingested yet
265
+ const waitJobs = groupJobs.filter(j => j.output === undefined && !j.fireAndForget && j.status !== "failed");
266
+ if (waitJobs.length > 0) {
267
+ await waitForResultFiles(ASYNC_RESULTS_DIR, waitJobs.map(j => j.id), 2000);
268
+ }
269
+ // Ingest ALL jobs with missing output (including failed ones that may have a result file)
270
+ const uningestedJobs = groupJobs.filter(j => j.output === undefined && !j.fireAndForget);
271
+ for (const j of uningestedJobs) {
272
+ if (j.output !== undefined) continue;
273
+ const rp = path.join(ASYNC_RESULTS_DIR, `${j.id}.json`);
274
+ try {
275
+ const data = JSON.parse(fs.readFileSync(rp, "utf-8"));
276
+ j.output = data.output;
277
+ j.exitCode = data.exitCode;
278
+ j.durationMs = data.durationMs;
279
+ if (data.success !== undefined) j.status = data.success ? "complete" : "failed";
280
+ lateIngestedIds.add(j.id);
281
+ asyncLog(`deliverGroupResults: late-ingested result for ${j.id}`);
282
+ } catch (e: any) { asyncLog(`deliverGroupResults: late-ingest failed for ${j.id}: ${e?.message}`); }
283
+ }
284
+
285
+ // Track reviewer completions for late-ingested code-reviewer jobs only
286
+ // (jobs already processed by processResultFile were tracked there)
287
+ for (const j of groupJobs) {
288
+ if (!j.agent.startsWith("code-reviewer-") || !lateIngestedIds.has(j.id)) continue;
289
+ try {
290
+ const output = typeof j.output === "string" ? j.output : "";
291
+ const findings = countFindings(output);
292
+ // -1 means invalid JSON output — treat conservatively as having findings
293
+ recordReviewerResult(jobCwd(j), j.agent, findings < 0 ? 1 : findings);
294
+ } catch { /* best-effort */ }
295
+ }
296
+
297
+ // Skip delivery if ALL jobs in group are fire-and-forget
298
+ if (groupJobs.every(j => j.fireAndForget)) {
299
+ for (const j of groupJobs) {
300
+ if (j.onComplete) {
301
+ try { j.onComplete(); } catch (e: any) { asyncLog(`onComplete callback failed for ${j.id}: ${e?.message}`); }
302
+ }
303
+ j.delivered = true;
304
+ }
305
+ // Clean up result files for fire-and-forget groups
306
+ for (const j of groupJobs) {
307
+ const rp = path.join(ASYNC_RESULTS_DIR, `${j.id}.json`);
308
+ try { fs.unlinkSync(rp); } catch (e: any) { asyncLog(`unlink failed ${rp}: ${e?.message}`); }
309
+ }
310
+ return;
311
+ }
312
+
313
+ // Emit lifecycle events for pi-tasks RPC bridge (one per job)
314
+ for (const j of groupJobs) {
315
+ if (j.status === "complete") {
316
+ pi.events.emit("subagents:completed", { id: j.id, result: j.output || "" });
317
+ } else if (j.status === "failed") {
318
+ pi.events.emit("subagents:failed", { id: j.id, error: j.output || "Agent failed", result: j.output || "", status: "failed" });
319
+ }
320
+ }
321
+
322
+ const sections: string[] = [];
323
+ for (const j of groupJobs) {
324
+ if (j.fireAndForget) { j.delivered = true; continue; }
325
+ const resultStatus = j.status === "complete" ? "✅ completed" : "❌ failed";
326
+ const displayName = j.name || j.agent;
327
+ const output = (j.output || "").slice(0, 3000);
328
+ let autoCompleteError = "";
329
+ // Auto-complete linked task directly in the store file (no AI involvement)
330
+ if (j.taskId && j.taskId !== "-1" && j.status === "complete" && j.cwd) {
331
+ try {
332
+ const completed = await autoCompleteTask(j.taskId, j.projectCwd || j.cwd, j.sessionId);
333
+ asyncLog(`auto-completed task #${j.taskId}: ${completed}`);
334
+ } catch (e: any) {
335
+ autoCompleteError = `\n\n⚠️ Failed to auto-complete task #${j.taskId}: ${e?.message}. Run TaskUpdate(taskId="${j.taskId}", status="completed") manually.`;
336
+ asyncLog(`auto-complete failed for task #${j.taskId}: ${e?.message}`);
337
+ }
338
+ }
339
+ const duration = j.durationMs || (j.updatedAt ? j.updatedAt - j.startedAt : 0);
340
+ sections.push(`## Async Agent Result: ${displayName} ${resultStatus}\n\nTask: ${j.task}\nDuration: ${formatDuration(duration)}\n\n${output}${autoCompleteError}`);
341
+ j.delivered = true;
342
+ }
343
+
344
+ if (sections.length > 0) {
345
+ pi.sendMessage({
346
+ customType: "async-agent-result",
347
+ content: sections.join("\n\n---\n\n"),
348
+ display: true,
349
+ }, { triggerTurn: true, deliverAs: "followUp" });
350
+ }
351
+
352
+ // Clean up result files for group members now that delivery succeeded
353
+ for (const j of groupJobs) {
354
+ const rp = path.join(ASYNC_RESULTS_DIR, `${j.id}.json`);
355
+ try { fs.unlinkSync(rp); } catch (e: any) { asyncLog(`unlink failed ${rp}: ${e?.message}`); }
356
+ }
357
+ } finally {
358
+ if (gid) groupDeliveryInProgress.delete(gid);
359
+ }
360
+ }
361
+
362
+ async function processResultFile(resultPath: string) {
363
+ try {
364
+ const data = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
365
+ const job = asyncState.jobs.get(data.id);
366
+ asyncLog(`processResultFile: ${path.basename(resultPath)} job=${!!job} delivered=${job?.delivered} hasCtx=${!!asyncState.lastCtx} fireAndForget=${job?.fireAndForget} groupId=${job?.groupId}`);
367
+ if (!job) return;
368
+ if (job.delivered) return; // Already delivered to user
369
+ // Skip if already ingested (grouped jobs stay on disk until group delivers)
370
+ if (job.output !== undefined) return;
371
+
372
+ // Update job state immediately (before group check)
373
+ job.status = data.success ? "complete" : "failed";
374
+ job.output = data.output;
375
+ job.exitCode = data.exitCode;
376
+ job.durationMs = data.durationMs;
377
+ job.updatedAt = Date.now();
378
+
379
+ // Notify terminal per-agent (lightweight, non-conversational)
380
+ const displayName = job.name || data.agent;
381
+ terminalNotify("pi", `Async agent ${displayName} ${data.success ? "completed" : "failed"} (${formatDuration(data.durationMs)})`);
382
+
383
+ // Track reviewer completion for review loop enforcement
384
+ if (job.agent.startsWith("code-reviewer-")) {
385
+ try {
386
+ const output = typeof data.output === "string" ? data.output : JSON.stringify(data.output ?? "");
387
+ const findings = countFindings(output);
388
+ // -1 means invalid JSON output — treat conservatively as having findings
389
+ recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings);
390
+ } catch { /* best-effort */ }
391
+ // Clear reviewer session if context usage > 80% to prevent overflow on next cycle
392
+ // Use model context window from the agent's effective model.
393
+ // If agent uses a custom model, we pass its context window via the config.
394
+ // Fall back to orchestrator's model context window.
395
+ const contextWindow = data.contextWindow || asyncState.lastCtx?.model?.contextWindow || 0;
396
+ if (data.lastUsage?.totalTokens && data.lastUsage.totalTokens > 0 && contextWindow > 0) {
397
+ const pct = (data.lastUsage.totalTokens / contextWindow) * 100;
398
+ if (pct > 80) {
399
+ const sessId = `async-${job.agent}-${djb2Hash(job.agent + ':' + (jobCwd(job)) + ':' + (asyncState.lastCtx?.sessionManager?.getSessionId?.() || '')).toString(36)}`;
400
+ try {
401
+ // Find and delete the session file to force fresh on next cycle
402
+ const sessDir = path.join(os.homedir(), '.pi', 'agent', 'sessions');
403
+ const cwdKey = (jobCwd(job)).replace(/\//g, '-').replace(/^-/, '--') + '--';
404
+ const sessPath = path.join(sessDir, cwdKey);
405
+ const files = fs.existsSync(sessPath) ? fs.readdirSync(sessPath) : [];
406
+ for (const f of files) {
407
+ if (f.includes(sessId)) {
408
+ fs.unlinkSync(path.join(sessPath, f));
409
+ asyncLog(`Cleared session for ${job.agent} (context ${Math.round(pct)}%)`);
410
+ break;
411
+ }
412
+ }
413
+ } catch { /* best-effort */ }
414
+ }
415
+ }
416
+ }
417
+
418
+ // Track test agent completion for review state test tracking
419
+ if (job.agent === "test-automator" || job.agent === "test-runner") {
420
+ try {
421
+ if (data.success) {
422
+ markTestsPassed(jobCwd(job));
423
+ } else {
424
+ markTestsFailed(jobCwd(job));
425
+ }
426
+ } catch (e: any) { console.debug(`[async-agents] markTests(Passed|Failed) failed for ${job.agent}: ${e?.message}`); }
427
+ }
428
+
429
+ // Clean up result file — for grouped jobs, defer to deliverGroupResults
430
+ if (!job.groupId) {
431
+ try { fs.unlinkSync(resultPath); } catch (e: any) { asyncLog(`unlink failed ${resultPath}: ${e?.message}`); }
432
+ }
433
+
434
+ // Group-aware delivery: hold results until ALL jobs in the group are done
435
+ if (job.groupId) {
436
+ const groupJobs = Array.from(asyncState.jobs.values()).filter(j => j.groupId === job.groupId);
437
+ const pending = groupJobs.filter(j => j.status !== "complete" && j.status !== "failed");
438
+ asyncLog(`group ${job.groupId}: ${groupJobs.length} total, ${pending.length} pending`);
439
+ if (pending.length > 0) {
440
+ // Not all group members done yet — hold delivery
441
+ updateAsyncWidget();
442
+ return;
443
+ }
444
+ // All group members done — deliver combined result
445
+ deliverGroupResults(groupJobs);
446
+ updateAsyncWidget();
447
+ return;
448
+ }
449
+
450
+ // Emit lifecycle events for pi-tasks RPC bridge
451
+ if (data.success) {
452
+ pi.events.emit("subagents:completed", { id: job.id, result: data.output || "" });
453
+ } else {
454
+ pi.events.emit("subagents:failed", { id: job.id, error: data.output || "Agent failed", result: data.output || "", status: "failed" });
455
+ }
456
+
457
+ // Non-grouped job: deliver immediately (existing behavior)
458
+ if (asyncState.lastCtx && !job.fireAndForget) {
459
+ const resultStatus = data.success ? "✅ completed" : "❌ failed";
460
+ let autoCompleteError = "";
461
+ // Auto-complete linked task directly in the store file (no AI involvement)
462
+ if (job.taskId && job.taskId !== "-1" && data.success && job.cwd) {
463
+ try {
464
+ const completed = await autoCompleteTask(job.taskId, job.projectCwd || job.cwd, job.sessionId);
465
+ asyncLog(`auto-completed task #${job.taskId}: ${completed}`);
466
+ } catch (e: any) {
467
+ autoCompleteError = `\n\n⚠️ Failed to auto-complete task #${job.taskId}: ${e?.message}. Run TaskUpdate(taskId="${job.taskId}", status="completed") manually.`;
468
+ asyncLog(`auto-complete failed for task #${job.taskId}: ${e?.message}`);
469
+ }
470
+ }
471
+ const maxOutput = 3000 - autoCompleteError.length;
472
+ const output = (data.output || "").slice(0, Math.max(maxOutput, 500));
473
+ pi.sendMessage({
474
+ customType: "async-agent-result",
475
+ content: `## Async Agent Result: ${displayName} ${resultStatus}\n\nTask: ${data.task}\nDuration: ${formatDuration(data.durationMs)}\n\n${output}${autoCompleteError}`,
476
+ display: true,
477
+ }, { triggerTurn: true, deliverAs: "followUp" });
478
+ }
479
+
480
+ // Invoke onComplete callback if registered (e.g., dreaming → rebuildAndOrganize)
481
+ if (job.onComplete) {
482
+ try { job.onComplete(); } catch (e: any) { asyncLog(`onComplete callback failed for ${job.id}: ${e?.message}`); }
483
+ }
484
+ job.delivered = true;
485
+ // Result file already deleted above (non-grouped path)
486
+ updateAsyncWidget();
487
+ } catch (e: any) {
488
+ asyncLog(`processResultFile ERROR: ${e.message}`);
489
+ }
490
+ }
491
+
492
+ let watchedDir: string | null = null;
493
+ function startResultWatcher() {
494
+ // Rebind watcher if results dir changed (session switch)
495
+ if (asyncState.watcher && watchedDir === ASYNC_RESULTS_DIR) return;
496
+ if (asyncState.watcher) { asyncState.watcher.close(); asyncState.watcher = null; }
497
+ watchedDir = ASYNC_RESULTS_DIR;
498
+ try {
499
+ fs.mkdirSync(ASYNC_RESULTS_DIR, { recursive: true, mode: 0o700 });
500
+ asyncState.watcher = fs.watch(ASYNC_RESULTS_DIR, (ev, file) => {
501
+ if (ev !== "rename" || !file) return;
502
+ const fileName = file.toString();
503
+ if (!fileName.endsWith(".json")) return;
504
+ const resultPath = path.join(ASYNC_RESULTS_DIR, fileName);
505
+ setTimeout(() => processResultFile(resultPath), 100);
506
+ });
507
+ if (asyncState.watcher.unref) asyncState.watcher.unref();
508
+ } catch (e: any) { console.debug("[async-agents] watcher setup failed:", e?.message || e); }
509
+ }
510
+
511
+ function spawnAsyncAgent(
512
+ agentName: string,
513
+ task: string,
514
+ cwd: string,
515
+ agents: AgentConfig[],
516
+ options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: () => void; persistSession?: boolean },
517
+ ): { id: string; error?: string; model?: string } {
518
+ const agent = agents.find(a => a.name === agentName);
519
+ if (!agent) return { id: "", error: `Unknown agent: "${agentName}"` };
520
+
521
+ const id = `${agentName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
522
+ const workerDir = path.join(PROJECT_TMP_DIR, id);
523
+ const resultPath = path.join(ASYNC_RESULTS_DIR, `${id}.json`);
524
+
525
+ fs.mkdirSync(workerDir, { recursive: true });
526
+ fs.mkdirSync(ASYNC_RESULTS_DIR, { recursive: true, mode: 0o700 });
527
+
528
+ // Write session marker so restore can match jobs to sessions
529
+ let parentStartTime = "";
530
+ for (let attempt = 0; attempt < 3; attempt++) {
531
+ try {
532
+ parentStartTime = parseProcStartTime(fs.readFileSync(`/proc/${process.pid}/stat`, "utf-8")) || "";
533
+ if (parentStartTime) break;
534
+ } catch { /* retry */ }
535
+ }
536
+ if (!parentStartTime) {
537
+ console.debug("[async-agents] WARNING: could not read /proc/self/stat starttime after 3 attempts");
538
+ }
539
+ fs.writeFileSync(path.join(workerDir, "session.json"), JSON.stringify({
540
+ resultsDir: ASYNC_RESULTS_DIR,
541
+ fireAndForget: options?.fireAndForget || false,
542
+ parentPid: process.pid,
543
+ parentStartTime,
544
+ taskId: options?.taskId || null,
545
+ cwd,
546
+ projectCwd: asyncState.lastCtx?.sessionManager?.getCwd?.() || null,
547
+ sessionId: asyncState.lastCtx?.sessionManager?.getSessionId?.() || null,
548
+ }), { mode: 0o600 });
549
+
550
+ // Track reviewer spawn BEFORE building args — addReviewerPending sets status to
551
+ // in_progress, so all reviewers in the same batch see consistent state.
552
+ if (agentName.startsWith("code-reviewer-")) {
553
+ try { addReviewerPending(cwd, agentName); } catch { /* best-effort */ }
554
+ }
555
+
556
+ // Build pi args
557
+ // Reviewers persist sessions during the review loop (cycle 2+) for cross-cycle context.
558
+ // Fresh session on first cycle (needs_review/none), reuse on subsequent cycles (has_findings).
559
+ let persistSession = options?.persistSession === true;
560
+ if (!persistSession && agentName.startsWith("code-reviewer-")) {
561
+ const reviewState = readReviewState(cwd);
562
+ persistSession = reviewState.status === "has_findings" || reviewState.status === "in_progress";
563
+ }
564
+ const piArgs: string[] = ["--mode", "json", "-p", "-nc"];
565
+ if (!persistSession) piArgs.push("--no-session");
566
+ // Deterministic session ID for provider cache affinity + optional session reuse.
567
+ // When persistSession is true, omits --no-session so the session persists to disk.
568
+ // Same agent + cwd gets the same session ID across calls.
569
+ const parentSessionId = asyncState.lastCtx?.sessionManager?.getSessionId?.() || '';
570
+ const sessionIdSource = persistSession
571
+ ? agentName + ':' + cwd + ':' + parentSessionId
572
+ : agentName + ':' + task.slice(0, 100);
573
+ const deterministicSessionId = `async-${agentName}-${djb2Hash(sessionIdSource).toString(36)}`;
574
+ piArgs.push("--session-id", deterministicSessionId);
575
+ const { model: effectiveModel, provider: effectiveProvider } = resolveAgentModelProvider(agentName, agent, options?.parentModelId, options?.parentProvider, cwd);
576
+ if (effectiveModel) piArgs.push("--model", effectiveModel);
577
+ if (effectiveProvider) piArgs.push("--provider", effectiveProvider);
578
+ if (agent.tools?.length) piArgs.push("--tools", agent.tools.join(","));
579
+
580
+ if (agent.systemPrompt?.trim()) {
581
+ const promptPath = path.join(workerDir, "system-prompt.md");
582
+ fs.writeFileSync(promptPath, agent.systemPrompt, { mode: 0o600 });
583
+ piArgs.push("--append-system-prompt", promptPath);
584
+ }
585
+
586
+ piArgs.push(`Task: ${task}`);
587
+
588
+ const inv = getPiInvocation(piArgs);
589
+
590
+ // Write config for the runner
591
+ const configPath = path.join(PROJECT_TMP_DIR, `async-cfg-${id}.json`);
592
+ fs.writeFileSync(configPath, JSON.stringify({
593
+ id,
594
+ agent: agentName,
595
+ task,
596
+ cwd,
597
+ model: effectiveModel,
598
+ contextWindow: asyncState.lastCtx?.model?.contextWindow || 0,
599
+ resultPath,
600
+ workerDir,
601
+ sessionId: `${process.pid}:${process.cwd()}`,
602
+ piCommand: inv.command,
603
+ piArgs: inv.args,
604
+ }), { mode: 0o600 });
605
+
606
+ // Find the runner script
607
+ const runnerPath = path.join(path.dirname(new URL(import.meta.url).pathname), "async-runner.ts");
608
+
609
+ // Find jiti for TypeScript execution
610
+ let jitiCliPath: string | undefined;
611
+ // Strategy 1: resolve from pi's own require context
612
+ try {
613
+ const piPkgDir = path.dirname(require.resolve("@earendil-works/pi-coding-agent/package.json"));
614
+ const candidate = path.join(piPkgDir, "node_modules/jiti/lib/jiti-cli.mjs");
615
+ if (fs.existsSync(candidate)) jitiCliPath = candidate;
616
+ } catch (e: any) {
617
+ asyncLog(`jiti strategy 1 (require.resolve) failed: ${e?.message || e}`);
618
+ }
619
+ // Strategy 2: resolve from the pi binary (works for global npm installs)
620
+ if (!jitiCliPath) {
621
+ try {
622
+ const piScript = process.argv[1];
623
+ if (piScript) {
624
+ const realPath = fs.realpathSync(piScript);
625
+ // pi script -> dist/cli.js, package root is 2 levels up
626
+ const piPkgDir = path.dirname(path.dirname(realPath));
627
+ const candidate = path.join(piPkgDir, "node_modules/jiti/lib/jiti-cli.mjs");
628
+ if (fs.existsSync(candidate)) jitiCliPath = candidate;
629
+ }
630
+ } catch (e: any) {
631
+ asyncLog(`jiti strategy 2 (process.argv) failed: ${e?.message || e}`);
632
+ }
633
+ }
634
+
635
+ const spawnArgs = jitiCliPath
636
+ ? [jitiCliPath, runnerPath, configPath]
637
+ : [runnerPath, configPath];
638
+
639
+ // Resolve base branch for reviewers so they diff against the correct target
640
+ const spawnEnv: Record<string, string | undefined> = {
641
+ ...process.env,
642
+ PI_SUBAGENT_CHILD: "1",
643
+ PI_AGENT_NAME: agentName,
644
+ };
645
+ if (agentName.startsWith("code-reviewer-") || agentName === "test-automator" || agentName === "test-runner") {
646
+ try {
647
+ const prBase = spawnSync("gh", ["pr", "view", "--json", "baseRefName", "--jq", ".baseRefName"], {
648
+ cwd, timeout: 5000, encoding: "utf-8",
649
+ });
650
+ if (prBase.status === 0 && prBase.stdout?.trim()) {
651
+ spawnEnv.PI_REVIEW_BASE_BRANCH = prBase.stdout.trim();
652
+ spawnEnv.PI_HAS_PR = "true";
653
+ } else {
654
+ spawnEnv.PI_REVIEW_BASE_BRANCH = getMainBranch(cwd) || "main";
655
+ // Only set PI_HAS_PR=false if gh confirmed no PR exists.
656
+ // Other failures (timeout, auth, network) default to true to avoid skipping PR checks.
657
+ const noPr = prBase.stderr?.includes("no pull requests found") || false;
658
+ spawnEnv.PI_HAS_PR = noPr ? "false" : "true";
659
+ }
660
+ } catch {
661
+ spawnEnv.PI_REVIEW_BASE_BRANCH = getMainBranch(cwd) || "main";
662
+ // Exception (e.g., gh not found) — default to true to avoid skipping PR checks
663
+ spawnEnv.PI_HAS_PR = "true";
664
+ }
665
+ }
666
+
667
+ const proc = spawn(process.execPath, spawnArgs, {
668
+ cwd,
669
+ stdio: "ignore",
670
+ windowsHide: true,
671
+ env: spawnEnv,
672
+ });
673
+
674
+ // Track the job
675
+ const job: AsyncJob = {
676
+ id,
677
+ agent: agentName,
678
+ name: options?.name,
679
+ task: task.slice(0, 200),
680
+ status: "queued",
681
+ workerDir,
682
+ startedAt: Date.now(),
683
+ updatedAt: Date.now(),
684
+ fireAndForget: options?.fireAndForget,
685
+ onComplete: options?.onComplete,
686
+ groupId: options?.groupId,
687
+ taskId: options?.taskId,
688
+ cwd,
689
+ projectCwd: asyncState.lastCtx?.sessionManager?.getCwd?.(),
690
+ sessionId: asyncState.lastCtx?.sessionManager?.getSessionId?.(),
691
+ model: effectiveModel,
692
+ };
693
+ asyncState.jobs.set(id, job);
694
+
695
+ // addReviewerPending already called above (before piArgs construction)
696
+
697
+ updateAsyncWidget();
698
+ ensureAsyncPoller();
699
+ startResultWatcher();
700
+
701
+ // Auto-mark linked task as in_progress AFTER successful spawn
702
+ if (options?.taskId && options.taskId !== "-1") {
703
+ autoMarkInProgress(options.taskId, asyncState.lastCtx?.sessionManager?.getCwd?.() || cwd, asyncState.lastCtx?.sessionManager?.getSessionId?.() || undefined)
704
+ .catch(() => {}); // best-effort, don't block spawn
705
+ }
706
+
707
+ return { id, model: effectiveModel };
708
+ }
709
+
710
+ // Start result watcher on session start
711
+ pi.on("session_start", (_event, ctx) => {
712
+ asyncState.lastCtx = ctx;
713
+
714
+ // Set project-scoped dir first (getProjectTmpDir creates it if missing)
715
+ PROJECT_TMP_DIR = getProjectTmpDir(ctx.cwd);
716
+ // Export as env var so prompts/CLI commands can reference it
717
+ process.env.PROJECT_TMP_DIR = PROJECT_TMP_DIR;
718
+
719
+ // Set results dir — PID-scoped under project dir
720
+ ASYNC_RESULTS_DIR = path.join(PROJECT_TMP_DIR, sessionResultsDirName());
721
+ const projectLogPath = path.join(PROJECT_TMP_DIR, "debug.log");
722
+ // Move early startup logs to project dir
723
+ if (EARLY_LOG_PATH && fs.existsSync(EARLY_LOG_PATH)) {
724
+ try {
725
+ fs.appendFileSync(projectLogPath, fs.readFileSync(EARLY_LOG_PATH, "utf-8"));
726
+ fs.unlinkSync(EARLY_LOG_PATH);
727
+ } catch {}
728
+ }
729
+ DEBUG_LOG_PATH = projectLogPath;
730
+
731
+ // Zombie cleanup: scan project dir for dead agents
732
+ try {
733
+ for (const entry of fs.readdirSync(PROJECT_TMP_DIR)) {
734
+ const jobDir = path.join(PROJECT_TMP_DIR, entry);
735
+ try {
736
+ if (!fs.statSync(jobDir).isDirectory()) continue;
737
+ const markerPath = path.join(jobDir, "session.json");
738
+ // Only process dirs that have session.json (agent dirs)
739
+ if (!fs.existsSync(markerPath)) continue;
740
+ const marker = JSON.parse(fs.readFileSync(markerPath, "utf-8"));
741
+ // Skip our own session's agents
742
+ if (marker.resultsDir === ASYNC_RESULTS_DIR) continue;
743
+ const parentPid = marker.parentPid;
744
+ const parentStartTime = marker.parentStartTime;
745
+ if (!parentPid || !parentStartTime) {
746
+ // Missing identity — can't safely verify, skip
747
+ continue;
748
+ }
749
+ // Check if parent pi process is alive via /proc/PID/stat starttime
750
+ try {
751
+ const stat = fs.readFileSync(`/proc/${parentPid}/stat`, "utf-8");
752
+ const currentStartTime = parseProcStartTime(stat);
753
+ if (!currentStartTime) continue; // parse failed — can't verify, skip conservatively
754
+ if (currentStartTime === parentStartTime) continue; // alive, same process
755
+ } catch {} // /proc not found = dead
756
+ // Parent dead or PID reused — zombie, delete
757
+ try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch {}
758
+ } catch {} // skip unreadable dirs
759
+ }
760
+ } catch (e: any) { console.debug("[async-agents] zombie cleanup failed:", e?.message?.slice(0, 100)); }
761
+
762
+ asyncLog(`session_start: resultsDir=${path.basename(ASYNC_RESULTS_DIR)}`);
763
+
764
+ // Restore jobs from status files in PROJECT_TMP_DIR that belong to this session
765
+ try {
766
+ for (const entry of fs.readdirSync(PROJECT_TMP_DIR)) {
767
+ if (entry.startsWith("session-") || entry.startsWith("pid-")) continue;
768
+ const jobDir = path.join(PROJECT_TMP_DIR, entry);
769
+ const status = readAsyncStatus(jobDir);
770
+ if (!status) continue;
771
+ // Check if this job belongs to our session
772
+ let marker: any;
773
+ try {
774
+ marker = JSON.parse(fs.readFileSync(path.join(jobDir, "session.json"), "utf-8"));
775
+ if (marker.resultsDir !== ASYNC_RESULTS_DIR) continue;
776
+ } catch {
777
+ continue;
778
+ }
779
+ const id = status.runId;
780
+ if (!id || asyncState.jobs.has(id)) continue;
781
+ const isAlive = status.pid ? (() => { try { process.kill(status.pid, 0); return true; } catch { return false; } })() : false;
782
+ const isComplete = status.state === "complete" || status.state === "failed";
783
+ // Always restore — poller's zombie check will mark dead ones as failed
784
+ if (isAlive || isComplete || status.state === "running") {
785
+ const job: AsyncJob = {
786
+ id,
787
+ agent: status.agent || "unknown",
788
+ task: (status.task || "").slice(0, 200),
789
+ status: isComplete ? status.state : "running",
790
+ workerDir: path.join(PROJECT_TMP_DIR, entry),
791
+ startedAt: status.startedAt || Date.now(),
792
+ updatedAt: status.lastUpdate || Date.now(),
793
+ exitCode: status.exitCode,
794
+ durationMs: status.endedAt ? status.endedAt - status.startedAt : undefined,
795
+ fireAndForget: marker.fireAndForget || false,
796
+ taskId: marker.taskId || undefined,
797
+ cwd: marker.cwd || undefined,
798
+ projectCwd: marker.projectCwd || undefined,
799
+ sessionId: marker.sessionId || undefined,
800
+ model: status.model || marker.model || undefined,
801
+ };
802
+ asyncState.jobs.set(id, job);
803
+ asyncLog(`restored job: ${id} state=${job.status}`);
804
+ }
805
+ }
806
+ } catch (e: any) { console.debug("[async-agents] job restore failed:", e?.message || e); }
807
+
808
+ // Start poller if we have jobs or unprocessed result files
809
+ let hasResultFiles = false;
810
+ try {
811
+ hasResultFiles = fs.existsSync(ASYNC_RESULTS_DIR) && fs.readdirSync(ASYNC_RESULTS_DIR).some(f => f.endsWith(".json"));
812
+ } catch (e: any) { console.debug("[async-agents] result files check failed:", e?.message || e); }
813
+ if (asyncState.jobs.size > 0 || hasResultFiles) {
814
+ ensureAsyncPoller();
815
+ }
816
+ updateAsyncWidget(); // Always set status (shows "0 async agents" when idle)
817
+
818
+ startResultWatcher();
819
+ });
820
+
821
+ // Clean up on shutdown
822
+ pi.on("session_shutdown", () => {
823
+ asyncLog(`shutdown: jobs=${asyncState.jobs.size}`);
824
+ if (asyncState.poller) { clearInterval(asyncState.poller); asyncState.poller = null; }
825
+ if (asyncState.watcher) { asyncState.watcher.close(); asyncState.watcher = null; }
826
+ });
827
+
828
+ // /async-status — fullscreen overlay list → live output detail
829
+ async function handleAsyncStatus(ctx: any): Promise<void> {
830
+ if (!ctx.hasUI) return;
831
+ await openAsyncStatusOverlay(ctx, {
832
+ listJobs: () => Array.from(asyncState.jobs.values()),
833
+ killJob: (id) => {
834
+ killAsyncAgent(id);
835
+ },
836
+ formatDuration,
837
+ readLiveStatus: (workerDir) => readAsyncStatus(workerDir),
838
+ });
839
+ }
840
+
841
+ // /async-status command
842
+ pi.registerCommand("async-status", {
843
+ description: "Fullscreen overlay: list async agents, view live output, kill with x",
844
+ handler: async (_args, ctx) => handleAsyncStatus(ctx),
845
+ });
846
+
847
+ // Kill an async agent by name, id prefix, or "all"
848
+ function killAsyncAgent(target: string): { killed: string[]; errors: string[] } {
849
+ const killed: string[] = [];
850
+ const errors: string[] = [];
851
+ const running = Array.from(asyncState.jobs.values()).filter(
852
+ (j) => j.status === "running" || j.status === "queued",
853
+ );
854
+
855
+ if (running.length === 0) {
856
+ errors.push("No running async agents.");
857
+ return { killed, errors };
858
+ }
859
+
860
+ const targets = target.toLowerCase() === "all"
861
+ ? running
862
+ : running.filter(j =>
863
+ (j.name && j.name.toLowerCase() === target.toLowerCase()) ||
864
+ j.id.startsWith(target) ||
865
+ j.agent.toLowerCase() === target.toLowerCase()
866
+ );
867
+
868
+ if (targets.length === 0) {
869
+ errors.push(`No matching async agent for: ${target}`);
870
+ return { killed, errors };
871
+ }
872
+
873
+ for (const job of targets) {
874
+ const status = readAsyncStatus(job.workerDir);
875
+ if (status?.pid) {
876
+ try {
877
+ const tree = execFileSync("pstree", ["-p", String(status.pid)], { encoding: "utf-8", timeout: 3000 });
878
+ const matches = tree.match(/\((\d+)\)/g);
879
+ const allPids = matches ? [...new Set(matches.map((m: string) => parseInt(m.slice(1, -1), 10)))] : [status.pid];
880
+ for (const pid of allPids) {
881
+ try { process.kill(pid, "SIGKILL"); } catch {}
882
+ }
883
+ } catch {
884
+ try { process.kill(status.pid, "SIGKILL"); } catch {}
885
+ if (status.childPid) try { process.kill(status.childPid, "SIGKILL"); } catch {}
886
+ }
887
+ }
888
+ const label = job.name || job.agent;
889
+ killed.push(label);
890
+ job.status = "failed";
891
+ job.updatedAt = Date.now();
892
+ // Record killed reviewer as having 0 findings — prevents permanent commit block
893
+ if (job.agent.startsWith("code-reviewer-")) {
894
+ try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch { /* best-effort */ }
895
+ }
896
+ // Check if this completes a group — deliver remaining siblings' results
897
+ if (job.groupId) {
898
+ const groupJobs = Array.from(asyncState.jobs.values()).filter(j => j.groupId === job.groupId);
899
+ const pending = groupJobs.filter(j => j.status !== "complete" && j.status !== "failed");
900
+ if (pending.length === 0) {
901
+ deliverGroupResults(groupJobs);
902
+ }
903
+ }
904
+ // Delay cleanup — skip auto-delete for undelivered group members (let reaper handle them)
905
+ if (!job.groupId || job.delivered) {
906
+ setTimeout(() => { asyncState.jobs.delete(job.id); updateAsyncWidget(); }, 5000);
907
+ }
908
+ }
909
+
910
+ updateAsyncWidget();
911
+ return { killed, errors };
912
+ }
913
+
914
+ // /async-kill handler — extracted for readability (closure access preserved)
915
+ async function handleAsyncKill(args: string, ctx: any): Promise<void> {
916
+ // If arg provided, kill directly without interactive selection
917
+ if (args) {
918
+ const { killed, errors } = killAsyncAgent(args);
919
+ if (killed.length > 0) {
920
+ ctx.ui.notify(`Killed: ${killed.join(", ")}`, "info");
921
+ }
922
+ if (errors.length > 0) {
923
+ ctx.ui.notify(errors.join("\n"), "warning");
924
+ }
925
+ return;
926
+ }
927
+
928
+ if (!ctx.hasUI) return;
929
+ // Same overlay as /async-status, scoped to running/queued
930
+ await openAsyncStatusOverlay(ctx, {
931
+ title: "Kill async agents",
932
+ emptyMessage: "No running async agents.",
933
+ footerHints: "↑↓/jk select · Enter view · x kill · Esc close",
934
+ listJobs: () =>
935
+ Array.from(asyncState.jobs.values()).filter(
936
+ (j) => j.status === "running" || j.status === "queued",
937
+ ),
938
+ killJob: (id) => {
939
+ killAsyncAgent(id);
940
+ },
941
+ formatDuration,
942
+ readLiveStatus: (workerDir) => readAsyncStatus(workerDir),
943
+ });
944
+ }
945
+
946
+ // /async-kill command — accepts name/id/"all" or interactive overlay
947
+ pi.registerCommand("async-kill", {
948
+ description:
949
+ "Kill async agent(s) — /async-kill <name|id|all> or overlay picker",
950
+ handler: async (_args, ctx) => handleAsyncKill((_args || "").trim(), ctx),
951
+ });
952
+
953
+ // Handle async-kill from pidash browser UI
954
+ pi.events.on("pidash:async-kill", (target: unknown) => {
955
+ if (typeof target === "string") {
956
+ killAsyncAgent(target);
957
+ }
958
+ });
959
+
960
+ // ── pi-subagents RPC compatibility bridge ──────────────────────────────
961
+ // Implements the subagents:rpc:* protocol so pi-tasks' TaskExecute
962
+ // can spawn our agents. Protocol version 2 matches @tintinweb/pi-subagents.
963
+
964
+ const RPC_PROTOCOL_VERSION = 2;
965
+
966
+ /** Handle an RPC request: parse params, run fn, reply on scoped channel. */
967
+ function handleRpc<P extends { requestId: string }>(
968
+ channel: string,
969
+ fn: (params: P) => unknown | Promise<unknown>,
970
+ ): () => void {
971
+ return pi.events.on(channel, async (raw: unknown) => {
972
+ const params = raw as P;
973
+ try {
974
+ const data = await fn(params);
975
+ const reply: { success: true; data?: unknown } = { success: true };
976
+ if (data !== undefined) reply.data = data;
977
+ pi.events.emit(`${channel}:reply:${params.requestId}`, reply);
978
+ } catch (err: any) {
979
+ pi.events.emit(`${channel}:reply:${params.requestId}`, {
980
+ success: false, error: err?.message ?? String(err),
981
+ });
982
+ }
983
+ });
984
+ }
985
+
986
+ // Ping — returns protocol version
987
+ handleRpc("subagents:rpc:ping", () => {
988
+ return { version: RPC_PROTOCOL_VERSION };
989
+ });
990
+
991
+ // Spawn — create an async agent from pi-tasks' TaskExecute
992
+ handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>(
993
+ "subagents:rpc:spawn", ({ type, prompt, options }) => {
994
+ const ctx = asyncState.lastCtx;
995
+ if (!ctx) throw new Error("No active session");
996
+ if (!type || typeof type !== "string") throw new Error("Missing or invalid 'type' parameter");
997
+ if (!prompt || typeof prompt !== "string") throw new Error("Missing or invalid 'prompt' parameter");
998
+
999
+ const cwd = options?.cwd || ctx.cwd;
1000
+ const discovery = discoverAgents(cwd, "both");
1001
+ const agents = discovery.agents;
1002
+
1003
+ // Find agent by type (case-insensitive, fall back to "worker")
1004
+ const agentName = agents.find(a => a.name.toLowerCase() === type.toLowerCase())?.name
1005
+ || agents.find(a => a.name === "worker")?.name
1006
+ || type;
1007
+
1008
+ const result = spawnAsyncAgent(agentName, prompt, cwd, agents, {
1009
+ name: options?.description || agentName,
1010
+ taskId: "-1", // pi-tasks manages its own task linkage via agentTaskMap
1011
+ fireAndForget: false,
1012
+ });
1013
+
1014
+ if (result.error) throw new Error(result.error);
1015
+ return { id: result.id };
1016
+ },
1017
+ );
1018
+
1019
+ // Stop — kill a running async agent
1020
+ handleRpc<{ requestId: string; agentId: string }>(
1021
+ "subagents:rpc:stop", ({ agentId }) => {
1022
+ const { killed, errors } = killAsyncAgent(agentId);
1023
+ if (killed.length === 0) throw new Error(errors[0] || "Agent not found");
1024
+ },
1025
+ );
1026
+
1027
+ // Emit subagents:ready so pi-tasks can discover us
1028
+ pi.on("session_start", () => {
1029
+ pi.events.emit("subagents:ready", {});
1030
+ });
1031
+
1032
+ return {
1033
+ spawnAsyncAgent,
1034
+ killAsyncAgent,
1035
+ getAsyncJobs: () => Array.from(asyncState.jobs.values()).filter(j => j.status === "running" || j.status === "queued"),
1036
+ };
1037
+ }