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.
- package/README.md +808 -0
- package/agents/api-documenter.md +32 -0
- package/agents/bash-expert.md +53 -0
- package/agents/code-reviewer-docs.md +145 -0
- package/agents/code-reviewer-guidelines.md +97 -0
- package/agents/code-reviewer-quality.md +142 -0
- package/agents/code-reviewer-security.md +107 -0
- package/agents/code-reviewer-spec.md +164 -0
- package/agents/debugger.md +63 -0
- package/agents/docker-expert.md +40 -0
- package/agents/docs-fetcher.md +48 -0
- package/agents/git-expert.md +63 -0
- package/agents/github-expert.md +83 -0
- package/agents/go-expert.md +62 -0
- package/agents/issue-reviewer-feasibility.md +60 -0
- package/agents/issue-reviewer-scope.md +63 -0
- package/agents/issue-reviewer-spec.md +77 -0
- package/agents/java-expert.md +31 -0
- package/agents/jenkins-expert.md +38 -0
- package/agents/kubernetes-expert.md +40 -0
- package/agents/planner.md +42 -0
- package/agents/python-expert.md +61 -0
- package/agents/reviewer.md +26 -0
- package/agents/scout.md +40 -0
- package/agents/security-auditor.md +254 -0
- package/agents/technical-documentation-writer.md +37 -0
- package/agents/test-automator.md +55 -0
- package/agents/test-runner.md +47 -0
- package/agents/ts-expert.md +23 -0
- package/agents/worker.md +30 -0
- package/extensions/acpx-provider/index.ts +104 -0
- package/extensions/acpx-provider/load-runtime.ts +99 -0
- package/extensions/acpx-provider/runtime-models.ts +40 -0
- package/extensions/cli-provider/agents/claude.ts +128 -0
- package/extensions/cli-provider/agents/cursor.ts +84 -0
- package/extensions/cli-provider/agents/gemini.ts +100 -0
- package/extensions/cli-provider/discover.ts +77 -0
- package/extensions/cli-provider/index.ts +57 -0
- package/extensions/cli-provider/parsers.ts +304 -0
- package/extensions/cli-provider/providers.ts +58 -0
- package/extensions/cli-provider/runner.ts +225 -0
- package/extensions/cli-provider/runtime-models.ts +32 -0
- package/extensions/cli-provider/session-reaper.ts +122 -0
- package/extensions/cli-provider/sessions.ts +533 -0
- package/extensions/cli-provider/shared/discover-cache.ts +235 -0
- package/extensions/cli-provider/types.ts +48 -0
- package/extensions/coms/coms-net-server.ts +1642 -0
- package/extensions/coms/coms-net-wrapper.ts +521 -0
- package/extensions/coms/coms-net.ts +1589 -0
- package/extensions/coms/coms-p2p.ts +1879 -0
- package/extensions/coms/coms-shared.ts +510 -0
- package/extensions/coms/coms-wrapper.ts +138 -0
- package/extensions/coms/index.ts +20 -0
- package/extensions/coms/themeMap.ts +147 -0
- package/extensions/image-gen/image-gen.ts +332 -0
- package/extensions/image-gen/index.ts +14 -0
- package/extensions/orchestrator/agents.ts +134 -0
- package/extensions/orchestrator/ask-user.ts +293 -0
- package/extensions/orchestrator/async-agents.ts +1037 -0
- package/extensions/orchestrator/async-capability.ts +129 -0
- package/extensions/orchestrator/async-runner.ts +324 -0
- package/extensions/orchestrator/async-status-parse.ts +31 -0
- package/extensions/orchestrator/async-status-ui.ts +201 -0
- package/extensions/orchestrator/async-wait.ts +36 -0
- package/extensions/orchestrator/btw.ts +210 -0
- package/extensions/orchestrator/cron-status-format.ts +126 -0
- package/extensions/orchestrator/cron-status-ui.ts +132 -0
- package/extensions/orchestrator/cron.ts +730 -0
- package/extensions/orchestrator/dreaming.ts +355 -0
- package/extensions/orchestrator/enforcement-helpers.ts +530 -0
- package/extensions/orchestrator/enforcement-rules.ts +329 -0
- package/extensions/orchestrator/enforcement.ts +828 -0
- package/extensions/orchestrator/extended-autocomplete.ts +593 -0
- package/extensions/orchestrator/git-helpers.ts +420 -0
- package/extensions/orchestrator/github-autocomplete.ts +250 -0
- package/extensions/orchestrator/icons.ts +17 -0
- package/extensions/orchestrator/index.ts +100 -0
- package/extensions/orchestrator/memory-embeddings.ts +281 -0
- package/extensions/orchestrator/memory-promotion.ts +449 -0
- package/extensions/orchestrator/memory-provenance.ts +80 -0
- package/extensions/orchestrator/memory-query-class.ts +90 -0
- package/extensions/orchestrator/memory-scoring.ts +434 -0
- package/extensions/orchestrator/memory-tools.ts +883 -0
- package/extensions/orchestrator/memory-tree.ts +212 -0
- package/extensions/orchestrator/nvim.ts +169 -0
- package/extensions/orchestrator/overlay-dashboard-utils.ts +25 -0
- package/extensions/orchestrator/overlay-dashboard.ts +512 -0
- package/extensions/orchestrator/pi-config-review-state.ts +303 -0
- package/extensions/orchestrator/preference-extractor.ts +106 -0
- package/extensions/orchestrator/project-settings.ts +468 -0
- package/extensions/orchestrator/promotion-queue.ts +311 -0
- package/extensions/orchestrator/resolve-agent-model.ts +32 -0
- package/extensions/orchestrator/review-ui.ts +204 -0
- package/extensions/orchestrator/rule-placeholders.ts +12 -0
- package/extensions/orchestrator/rules.ts +481 -0
- package/extensions/orchestrator/session-search.ts +243 -0
- package/extensions/orchestrator/session-validation.ts +374 -0
- package/extensions/orchestrator/situation-report.ts +370 -0
- package/extensions/orchestrator/status-bar.ts +31 -0
- package/extensions/orchestrator/status-line.ts +192 -0
- package/extensions/orchestrator/status.ts +110 -0
- package/extensions/orchestrator/subagent-tool.ts +1748 -0
- package/extensions/orchestrator/task-lifecycle.ts +88 -0
- package/extensions/orchestrator/utils.ts +227 -0
- package/extensions/pidash/index.ts +15 -0
- package/extensions/pidash/pidash.ts +941 -0
- package/extensions/pidiff/index.ts +13 -0
- package/extensions/pidiff/pidiff.ts +369 -0
- package/extensions/providers/acpx-driver.ts +479 -0
- package/extensions/providers/built-in-drivers.ts +52 -0
- package/extensions/providers/claude-driver.ts +319 -0
- package/extensions/providers/cursor-acpx-driver.ts +398 -0
- package/extensions/providers/cursor-cli-driver.ts +484 -0
- package/extensions/providers/gemini-driver.ts +302 -0
- package/extensions/providers/index.ts +513 -0
- package/extensions/shared/build-system-prompt.ts +57 -0
- package/extensions/shared/create-runtime-provider.ts +190 -0
- package/extensions/shared/daemon-manager.ts +248 -0
- package/extensions/shared/file-logger.ts +115 -0
- package/extensions/shared/managed-refresh.ts +211 -0
- package/extensions/shared/provider-driver.ts +295 -0
- package/extensions/shared/provider-errors.ts +305 -0
- package/extensions/shared/provider-registry.ts +388 -0
- package/extensions/shared/stream-builder.ts +275 -0
- package/extensions/shared/types.ts +20 -0
- package/extensions/shared/ui/README.md +10 -0
- package/extensions/shared/ui/badge.tsx +52 -0
- package/extensions/shared/ui/button.tsx +58 -0
- package/extensions/shared/ui/collapsible.tsx +21 -0
- package/extensions/shared/ui/dropdown-menu.tsx +266 -0
- package/extensions/shared/ui/input.tsx +20 -0
- package/extensions/shared/ui/popover.tsx +114 -0
- package/extensions/shared/ui/resizable.tsx +48 -0
- package/extensions/shared/ui/scroll-area.tsx +53 -0
- package/extensions/shared/ui/select.tsx +201 -0
- package/extensions/shared/ui/separator.tsx +25 -0
- package/extensions/shared/ui/tooltip.tsx +64 -0
- package/extensions/shared/ws-client.ts +74 -0
- package/package.json +59 -0
- package/prompts/coderabbit-rate-limit.md +95 -0
- package/prompts/create-coms-feature-manager.md +186 -0
- package/prompts/create-skill.md +128 -0
- package/prompts/domain-model.md +109 -0
- package/prompts/external-ai.md +665 -0
- package/prompts/implement-and-review.md +25 -0
- package/prompts/implement.md +26 -0
- package/prompts/issue-review.md +256 -0
- package/prompts/pr-review.md +753 -0
- package/prompts/query-db.md +154 -0
- package/prompts/refine-review.md +208 -0
- package/prompts/release.md +235 -0
- package/prompts/remember.md +33 -0
- package/prompts/review-handler-status.md +27 -0
- package/prompts/review-handler.md +677 -0
- package/prompts/review-local.md +159 -0
- package/prompts/scout-and-plan.md +21 -0
- package/rules/00-orchestrator-core.md +42 -0
- package/rules/05-issue-first-workflow.md +119 -0
- package/rules/10-agent-routing.md +58 -0
- package/rules/15-mcp-launchpad.md +46 -0
- package/rules/20-code-review-loop.md +234 -0
- package/rules/25-documentation-updates.md +15 -0
- package/rules/30-prompt-templates.md +19 -0
- package/rules/35-memory.md +240 -0
- package/rules/40-critical-rules.md +124 -0
- package/rules/45-file-preview.md +29 -0
- package/rules/50-agent-bug-reporting.md +90 -0
- package/rules/55-coms-protocol.md +63 -0
- package/rules/60-task-tracking.md +66 -0
- package/skills/conflict-resolver/SKILL.md +213 -0
- package/skills/skill-creator/SKILL.md +85 -0
- package/skills/systematic-debugging/SKILL.md +353 -0
- package/skills/test-driven-development/SKILL.md +335 -0
- package/skills/update-agents-md/SKILL.md +282 -0
- 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
|
+
}
|