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,129 @@
1
+ /**
2
+ * Async LLM capability — whether the parent session can host detached LLM async agents.
3
+ *
4
+ * acpx providers are registered by `extensions/acpx-provider` as `acpx-${agent}` for each
5
+ * entry in `acpx_agents` settings. Child pi processes skip acpx registration
6
+ * (PI_SUBAGENT_CHILD=1), so async children cannot use those parent models.
7
+ *
8
+ * Capability gate: any provider id starting with `acpx-` → supportsAsyncLlm false.
9
+ * Registration list (`isAcpxProvider`) still comes from `acpx_agents` settings.
10
+ *
11
+ * See: https://github.com/myk-org/pi-config/issues/647
12
+ */
13
+
14
+ import { asStringArray, getSetting } from "./project-settings.js";
15
+
16
+ export interface AsyncLlmSidecar {
17
+ provider: string;
18
+ model: string;
19
+ }
20
+
21
+ /**
22
+ * Provider ids we register for configured acpx agents (`acpx-cursor`, …).
23
+ * Source of truth: `acpx_agents` in project/global settings (same list acpx-provider uses).
24
+ */
25
+ export function getRegisteredAcpxProviders(cwd: string): string[] {
26
+ // Defensive: stale getSetting may return non-array (issue #651).
27
+ return asStringArray(getSetting(cwd, "acpx_agents")).map(
28
+ (agent) => `acpx-${agent}`,
29
+ );
30
+ }
31
+
32
+ /** True when provider is one we registered via acpx-provider for this project. */
33
+ export function isAcpxProvider(
34
+ provider: string | null | undefined,
35
+ cwd: string,
36
+ ): boolean {
37
+ if (typeof provider !== "string" || !provider) return false;
38
+ return getRegisteredAcpxProviders(cwd).includes(provider);
39
+ }
40
+
41
+ /** True when provider id uses the acpx-* namespace (registered or not). */
42
+ export function isAcpxProviderId(
43
+ provider: string | null | undefined,
44
+ ): boolean {
45
+ return typeof provider === "string" && provider.startsWith("acpx-");
46
+ }
47
+
48
+ /**
49
+ * Whether detached LLM async agents can inherit the parent model/provider.
50
+ * Native / cli-* : true. Any `acpx-*` provider id: false (children skip acpx load).
51
+ * `cwd` kept for API stability / settings lookups elsewhere.
52
+ */
53
+ export function supportsAsyncLlm(
54
+ provider: string | null | undefined,
55
+ _cwd: string,
56
+ ): boolean {
57
+ return !isAcpxProviderId(provider);
58
+ }
59
+
60
+ /**
61
+ * Sidecar model for must-async LLM work (dream, fireAndForget) when parent is acpx.
62
+ * Both provider and model must be set. Returns null if incomplete or sidecar is acpx-*.
63
+ */
64
+ export function getAsyncLlmSidecar(cwd: string): AsyncLlmSidecar | null {
65
+ const provider = getSetting(cwd, "async_llm_provider");
66
+ const model = getSetting(cwd, "async_llm_model");
67
+ if (
68
+ typeof provider === "string" &&
69
+ provider.trim() &&
70
+ typeof model === "string" &&
71
+ model.trim()
72
+ ) {
73
+ const p = provider.trim();
74
+ if (isAcpxProviderId(p)) return null;
75
+ return { provider: p, model: model.trim() };
76
+ }
77
+ return null;
78
+ }
79
+
80
+ export type AsyncDispatchDecision =
81
+ | { action: "keep-async" }
82
+ | { action: "coerce-sync"; note: string }
83
+ | { action: "sidecar-async"; sidecar: AsyncLlmSidecar; note: string }
84
+ | { action: "skip"; note: string };
85
+
86
+ /**
87
+ * Decide how to handle an async LLM request given parent provider + settings.
88
+ * Pure decision helper for subagent/dream/cron (code-enforced paths).
89
+ *
90
+ * Pass `parentSupportsAsyncLlm` when the caller already computed capability from the
91
+ * parent session cwd — do not re-derive it from a different (task) cwd.
92
+ * `cwd` is used only for sidecar settings lookup.
93
+ */
94
+ export function decideAsyncLlmDispatch(opts: {
95
+ parentProvider?: string | null;
96
+ cwd: string;
97
+ /** fireAndForget / dream / cron — must stay detached when possible */
98
+ mustAsync?: boolean;
99
+ /** Explicit parent capability; when set, skips supportsAsyncLlm(parent, cwd). */
100
+ parentSupportsAsyncLlm?: boolean;
101
+ }): AsyncDispatchDecision {
102
+ const asyncOk =
103
+ opts.parentSupportsAsyncLlm !== undefined
104
+ ? opts.parentSupportsAsyncLlm
105
+ : supportsAsyncLlm(opts.parentProvider, opts.cwd);
106
+ if (asyncOk) {
107
+ return { action: "keep-async" };
108
+ }
109
+ const sidecar = getAsyncLlmSidecar(opts.cwd);
110
+ if (opts.mustAsync) {
111
+ if (!sidecar) {
112
+ return {
113
+ action: "skip",
114
+ note:
115
+ "Skipped must-async LLM work: parent is acpx and " +
116
+ "async_llm_provider/async_llm_model are not set (or sidecar is acpx-*).",
117
+ };
118
+ }
119
+ return {
120
+ action: "sidecar-async",
121
+ sidecar,
122
+ note: `Using async_llm sidecar ${sidecar.provider}/${sidecar.model} (parent is acpx).`,
123
+ };
124
+ }
125
+ return {
126
+ action: "coerce-sync",
127
+ note: "Coerced async→sync: parent provider does not support async LLM (acpx).",
128
+ };
129
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Async subagent runner — spawned as a detached process.
3
+ * Reads config from a JSON file, runs pi in print mode,
4
+ * writes status + result files for the parent to poll.
5
+ */
6
+
7
+ import { spawn } from "node:child_process";
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { createRequire } from "node:module";
11
+ import { getSetting } from "./project-settings.js";
12
+
13
+ interface RunConfig {
14
+ id: string;
15
+ agent: string;
16
+ task: string;
17
+ cwd: string;
18
+ model?: string;
19
+ contextWindow?: number;
20
+ tools?: string[];
21
+ systemPrompt?: string;
22
+ resultPath: string;
23
+ workerDir: string;
24
+ sessionId?: string;
25
+ piCommand: string;
26
+ piArgs: string[];
27
+ }
28
+
29
+ interface StatusPayload {
30
+ runId: string;
31
+ state: "queued" | "running" | "complete" | "failed";
32
+ agent: string;
33
+ task: string;
34
+ startedAt: number;
35
+ endedAt?: number;
36
+ lastUpdate: number;
37
+ pid: number;
38
+ childPid?: number;
39
+ cwd: string;
40
+ exitCode?: number | null;
41
+ error?: string;
42
+ outputLines: number;
43
+ }
44
+
45
+ function writeJson(filePath: string, data: object): void {
46
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
47
+ const tmp = `${filePath}.${process.pid}.tmp`;
48
+ try {
49
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
50
+ fs.renameSync(tmp, filePath);
51
+ } finally {
52
+ try { fs.unlinkSync(tmp); } catch {}
53
+ }
54
+ }
55
+
56
+ async function run(config: RunConfig): Promise<void> {
57
+ const statusPath = path.join(config.workerDir, "status.json");
58
+ const outputPath = path.join(config.workerDir, "output.log");
59
+ const startedAt = Date.now();
60
+
61
+ const status: StatusPayload = {
62
+ runId: config.id,
63
+ state: "running",
64
+ agent: config.agent,
65
+ task: config.task.slice(0, 200),
66
+ startedAt,
67
+ lastUpdate: startedAt,
68
+ pid: process.pid,
69
+ cwd: config.cwd,
70
+ outputLines: 0,
71
+ };
72
+ writeJson(statusPath, status);
73
+ let lastUsage: any = null;
74
+
75
+ const outputStream = fs.createWriteStream(outputPath, { flags: "w" });
76
+
77
+ // Connect to pidash server to stream events
78
+ const pidashPort = getSetting(config.cwd, "pidash_port");
79
+ let pidashWs: any = null;
80
+ const pidashLog = path.join(config.workerDir, "pidash-ws.log");
81
+ fs.writeFileSync(pidashLog, `START port=${pidashPort} id=${config.id}\n`);
82
+ try {
83
+ const _require = createRequire(import.meta.url);
84
+ fs.appendFileSync(pidashLog, `require ok, importing ws\n`);
85
+ const WebSocket = _require("ws");
86
+ fs.appendFileSync(pidashLog, `ws loaded, connecting...\n`);
87
+ pidashWs = new WebSocket(`ws://127.0.0.1:${pidashPort}/ws/async`);
88
+ pidashWs.on("open", () => {
89
+ fs.appendFileSync(pidashLog, `connected!\n`);
90
+ pidashWs.send(JSON.stringify({
91
+ type: "async_register",
92
+ id: config.id,
93
+ agent: config.agent,
94
+ task: config.task.slice(0, 200),
95
+ cwd: config.cwd,
96
+ sessionId: config.sessionId,
97
+ }));
98
+ });
99
+ pidashWs.on("error", (e: any) => { fs.appendFileSync(pidashLog, `error: ${e.message}\n`); });
100
+ } catch (e: any) {
101
+ fs.appendFileSync(pidashLog, `CATCH: ${e.message}\n${e.stack}\n`);
102
+ }
103
+
104
+ let lineCount = 0;
105
+ let finalOutput = "";
106
+ // Keep a small tail for fallback when no message_end is found
107
+ const MAX_TAIL = 4000;
108
+ let stdoutTail = "";
109
+ // Buffer for incomplete lines across chunk boundaries
110
+ let lineBuf = "";
111
+
112
+ const exitCode = await new Promise<number | null>((resolve) => {
113
+ const proc = spawn(config.piCommand, config.piArgs, {
114
+ cwd: config.cwd,
115
+ stdio: ["ignore", "pipe", "pipe"],
116
+ });
117
+
118
+ // Store child PID in status file so /async-kill can find it
119
+ if (proc.pid) {
120
+ status.childPid = proc.pid;
121
+ writeJson(statusPath, status);
122
+ }
123
+
124
+ proc.stdout.on("data", (chunk: Buffer) => {
125
+ const text = chunk.toString();
126
+ outputStream.write(text);
127
+
128
+ // Keep only the last MAX_TAIL chars for fallback
129
+ stdoutTail += text;
130
+ if (stdoutTail.length > MAX_TAIL * 2) {
131
+ stdoutTail = stdoutTail.slice(-MAX_TAIL);
132
+ }
133
+
134
+ // Buffer partial lines across chunk boundaries
135
+ lineBuf += text;
136
+ const lines = lineBuf.split("\n");
137
+ // Last element is incomplete (no trailing newline) — keep in buffer
138
+ lineBuf = lines.pop() || "";
139
+
140
+ for (const line of lines) {
141
+ const trimmed = line.trim();
142
+ if (!trimmed) continue;
143
+ lineCount++;
144
+
145
+ try {
146
+ const ev = JSON.parse(trimmed);
147
+
148
+ // Extract final output and usage from message_end events inline
149
+ if (ev.type === "message_end" && ev.message?.role === "assistant") {
150
+ for (const p of ev.message.content || []) {
151
+ if (p.type === "text") finalOutput = p.text;
152
+ }
153
+ if (ev.message.usage) lastUsage = ev.message.usage;
154
+ }
155
+
156
+ // Forward events to pidash
157
+ if (pidashWs?.readyState === 1) {
158
+ pidashWs.send(JSON.stringify({ type: "async_event", id: config.id, event: ev }));
159
+ }
160
+ } catch (e: any) { console.debug("[async-runner] event processing failed:", e?.message || e); }
161
+ }
162
+
163
+ // Update status periodically (every ~10 lines)
164
+ if (lineCount % 10 === 0) {
165
+ status.lastUpdate = Date.now();
166
+ status.outputLines = lineCount;
167
+ writeJson(statusPath, status);
168
+ }
169
+ });
170
+
171
+ proc.stderr.on("data", (chunk: Buffer) => {
172
+ outputStream.write(chunk.toString());
173
+ });
174
+
175
+ proc.on("close", (code) => {
176
+ resolve(code);
177
+ });
178
+
179
+ proc.on("error", (err) => {
180
+ status.error = err.message;
181
+ resolve(1);
182
+ });
183
+ });
184
+
185
+ const endedAt = Date.now();
186
+
187
+ // Process any remaining buffered line
188
+ if (lineBuf.trim()) {
189
+ try {
190
+ const ev = JSON.parse(lineBuf.trim());
191
+ if (ev.type === "message_end" && ev.message?.role === "assistant") {
192
+ for (const p of ev.message.content || []) {
193
+ if (p.type === "text") finalOutput = p.text;
194
+ }
195
+ }
196
+ } catch (e: any) { console.debug("[async-runner] final line parse failed:", e?.message || e); }
197
+ }
198
+
199
+ // Write final status
200
+ status.state = exitCode === 0 ? "complete" : "failed";
201
+ status.endedAt = endedAt;
202
+ status.lastUpdate = endedAt;
203
+ status.exitCode = exitCode;
204
+ status.outputLines = lineCount;
205
+ writeJson(statusPath, status);
206
+
207
+ // Notify pidash of completion and close
208
+ if (pidashWs?.readyState === 1) {
209
+ pidashWs.send(JSON.stringify({ type: "async_complete", id: config.id, success: exitCode === 0 }));
210
+ pidashWs.close();
211
+ }
212
+
213
+ // For code reviewers: validate output is JSON, retry if not
214
+ if (config.agent.startsWith("code-reviewer-") && exitCode === 0) {
215
+ let validJson = false;
216
+ let retryOutput = finalOutput || stdoutTail.slice(-2000);
217
+ let jsonRetryCount = 0;
218
+ while (!validJson) {
219
+ jsonRetryCount++;
220
+ try {
221
+ let cleaned = retryOutput.trim();
222
+ if (cleaned.startsWith("```")) {
223
+ cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
224
+ }
225
+ const parsed = JSON.parse(cleaned);
226
+ if (parsed && Array.isArray(parsed.findings)) {
227
+ validJson = true;
228
+ finalOutput = cleaned; // Use the cleaned JSON as final output
229
+ }
230
+ } catch { /* not valid JSON */ }
231
+
232
+ if (!validJson) {
233
+ // After 3 retries, stop and let the user decide
234
+ if (jsonRetryCount >= 3) {
235
+ finalOutput = JSON.stringify({
236
+ findings: [{
237
+ severity: "CRITICAL",
238
+ file: "<reviewer-output>",
239
+ line: 0,
240
+ description: `Reviewer ${config.agent} failed to return valid JSON after ${jsonRetryCount} attempts. Raw output included in this result. User action required.`,
241
+ suggestion: "Re-run the reviewer or check the agent prompt.",
242
+ }],
243
+ _jsonRetryExhausted: true,
244
+ _rawOutput: retryOutput.slice(0, 1000),
245
+ });
246
+ break;
247
+ }
248
+ // Re-run with same session, asking for JSON
249
+ const retryArgs = config.piArgs.slice(0, -1); // Remove the last arg (the task)
250
+ retryArgs.push("Your output is not valid JSON. Return ONLY a raw JSON object: {\"findings\": [...]}. No markdown fences, no text before or after.");
251
+ const retryCode = await new Promise<number | null>((resolve) => {
252
+ const retryProc = spawn(config.piCommand, retryArgs, {
253
+ cwd: config.cwd,
254
+ stdio: ["ignore", "pipe", "pipe"],
255
+ });
256
+ let retryBuf = "";
257
+ retryProc.stdout.on("data", (chunk: Buffer) => {
258
+ retryBuf += chunk.toString();
259
+ outputStream.write(chunk.toString());
260
+ });
261
+ retryProc.stderr.on("data", (chunk: Buffer) => {
262
+ outputStream.write(chunk.toString());
263
+ });
264
+ retryProc.on("close", (code) => {
265
+ // Extract final output from the retry
266
+ for (const line of retryBuf.split("\n")) {
267
+ try {
268
+ const ev = JSON.parse(line.trim());
269
+ if (ev.type === "message_end" && ev.message?.role === "assistant") {
270
+ for (const p of ev.message.content || []) {
271
+ if (p.type === "text") retryOutput = p.text;
272
+ }
273
+ }
274
+ } catch { /* skip */ }
275
+ }
276
+ resolve(code);
277
+ });
278
+ retryProc.on("error", () => resolve(1));
279
+ });
280
+ if (retryCode !== 0) break; // pi itself failed, stop retrying
281
+ }
282
+ }
283
+ }
284
+
285
+ // End the output stream after all retries are done
286
+ outputStream.end();
287
+
288
+ // Write result for the watcher to pick up
289
+ writeJson(config.resultPath, {
290
+ id: config.id,
291
+ agent: config.agent,
292
+ task: config.task,
293
+ success: exitCode === 0,
294
+ output: finalOutput || stdoutTail.slice(-2000),
295
+ exitCode,
296
+ startedAt,
297
+ endedAt,
298
+ durationMs: endedAt - startedAt,
299
+ cwd: config.cwd,
300
+ sessionId: config.sessionId,
301
+ workerDir: config.workerDir,
302
+ lastUsage: lastUsage || null,
303
+ contextWindow: config.contextWindow || 0,
304
+ });
305
+ }
306
+
307
+ // Entry point
308
+ const configPath = process.argv[2];
309
+ if (!configPath) {
310
+ console.error("Usage: async-runner.ts <config.json>");
311
+ process.exit(1);
312
+ }
313
+
314
+ try {
315
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8")) as RunConfig;
316
+ try { fs.unlinkSync(configPath); } catch {}
317
+ run(config).catch((err) => {
318
+ console.error("Async runner error:", err);
319
+ process.exit(1);
320
+ });
321
+ } catch (err) {
322
+ console.error("Failed to read config:", err);
323
+ process.exit(1);
324
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Pure parsers for async agent output.log JSONL — no TUI deps (unit-testable).
3
+ */
4
+
5
+ /** Parse one JSONL event line from async output.log into display text. */
6
+ export function parseAsyncOutputLine(raw: string): string | null {
7
+ try {
8
+ const ev = JSON.parse(raw);
9
+ if (ev.type === "message_update" && ev.assistantMessageEvent) {
10
+ const ae = ev.assistantMessageEvent;
11
+ if (ae.type === "text_delta" && ae.delta) return ae.delta;
12
+ return null;
13
+ }
14
+ if (ev.type === "tool_execution_start") {
15
+ const name = ev.toolName || "tool";
16
+ const cmd = ev.args?.command
17
+ ? ` ${String(ev.args.command).slice(0, 80)}`
18
+ : "";
19
+ return `\n→ ${name}${cmd}`;
20
+ }
21
+ if (ev.type === "tool_execution_end") {
22
+ const text = ev.result?.content?.[0]?.text || "";
23
+ const prefix = ev.isError ? "✗" : "✓";
24
+ return `\n${prefix} ${String(text).slice(0, 200)}`;
25
+ }
26
+ if (ev.type === "agent_end") return "\n--- Agent finished ---";
27
+ return null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * /async-status fullscreen overlay — list dashboard → live output detail.
3
+ */
4
+
5
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { parseAsyncOutputLine } from "./async-status-parse.js";
9
+ import {
10
+ openListDetailOverlay,
11
+ OverlayScrollDetail,
12
+ } from "./overlay-dashboard.js";
13
+
14
+ export { parseAsyncOutputLine } from "./async-status-parse.js";
15
+
16
+ export interface AsyncStatusJobView {
17
+ id: string;
18
+ agent: string;
19
+ name?: string;
20
+ task: string;
21
+ status: string;
22
+ workerDir: string;
23
+ startedAt: number;
24
+ durationMs?: number;
25
+ model?: string;
26
+ }
27
+
28
+ export interface AsyncStatusUiDeps {
29
+ listJobs: () => AsyncStatusJobView[];
30
+ killJob: (id: string) => void;
31
+ formatDuration: (ms: number) => string;
32
+ /** Optional live status.json reader for detail header. */
33
+ readLiveStatus?: (workerDir: string) => { state?: string } | null;
34
+ title?: string;
35
+ emptyMessage?: string;
36
+ footerHints?: string;
37
+ }
38
+
39
+ function jobTitle(job: AsyncStatusJobView): string {
40
+ return job.name || job.agent;
41
+ }
42
+
43
+ function isActive(status: string): boolean {
44
+ return status === "running" || status === "queued";
45
+ }
46
+
47
+ function statusGlyph(status: string, theme: Theme): string {
48
+ switch (status) {
49
+ case "running":
50
+ case "queued":
51
+ return theme.fg("warning", "■");
52
+ case "complete":
53
+ return "\x1b[32m■\x1b[0m";
54
+ case "failed":
55
+ return theme.fg("error", "■");
56
+ default:
57
+ return theme.fg("muted", "■");
58
+ }
59
+ }
60
+
61
+ function statusWord(status: string, theme: Theme): string {
62
+ switch (status) {
63
+ case "running":
64
+ return theme.fg("warning", "running");
65
+ case "queued":
66
+ return theme.fg("warning", "queued");
67
+ case "complete":
68
+ return "\x1b[32mdone\x1b[0m";
69
+ case "failed":
70
+ return theme.fg("error", "failed");
71
+ default:
72
+ return theme.fg("muted", status);
73
+ }
74
+ }
75
+
76
+ function elapsedMs(job: AsyncStatusJobView): number {
77
+ if (job.durationMs != null && !isActive(job.status)) return job.durationMs;
78
+ return Date.now() - job.startedAt;
79
+ }
80
+
81
+ /**
82
+ * Open fullscreen async-status picker. Loops list → detail until user closes list.
83
+ */
84
+ export async function openAsyncStatusOverlay(
85
+ ctx: ExtensionCommandContext,
86
+ deps: AsyncStatusUiDeps,
87
+ ): Promise<void> {
88
+ await openListDetailOverlay(ctx, {
89
+ emptyMessage:
90
+ deps.emptyMessage ?? "No async agents running or recently completed.",
91
+ listSpec: {
92
+ title: deps.title ?? "Async agents",
93
+ countLabel: (jobs) => `${jobs.length} job${jobs.length === 1 ? "" : "s"}`,
94
+ borderTitle: (jobs) => {
95
+ const active = jobs.filter((j) => isActive(j.status)).length;
96
+ return `jobs · ${active} active / ${jobs.length}`;
97
+ },
98
+ footerHints:
99
+ deps.footerHints ?? "↑↓/jk select · Enter view · x kill · Esc close",
100
+ listItems: () => deps.listJobs(),
101
+ rowParts: (job, theme) => {
102
+ const shortId = job.id.length > 8 ? job.id.slice(-8) : job.id;
103
+ const taskPreview =
104
+ job.task.length > 40 ? `${job.task.slice(0, 40)}…` : job.task;
105
+ return {
106
+ glyph: statusGlyph(job.status, theme),
107
+ title: jobTitle(job),
108
+ idLabel: theme.fg("dim", shortId),
109
+ rightParts: [
110
+ theme.fg("muted", job.agent),
111
+ ...(job.model ? [theme.fg("dim", job.model)] : []),
112
+ theme.fg("dim", taskPreview),
113
+ theme.fg("muted", deps.formatDuration(elapsedMs(job))),
114
+ statusWord(job.status, theme),
115
+ ],
116
+ };
117
+ },
118
+ onX: (job) => {
119
+ if (isActive(job.status)) deps.killJob(job.id);
120
+ },
121
+ },
122
+ createDetail: (job, tui, theme, done) => {
123
+ const lines: string[] = [];
124
+ let filePos = 0;
125
+ let textBuffer = "";
126
+ let lastLoggedError = "";
127
+
128
+ const readNewContent = (): boolean => {
129
+ try {
130
+ const content = fs.readFileSync(
131
+ path.join(job.workerDir, "output.log"),
132
+ "utf-8",
133
+ );
134
+ if (content.length <= filePos) return false;
135
+ const newContent = content.slice(filePos);
136
+ filePos = content.length;
137
+ textBuffer += newContent;
138
+
139
+ const parts = textBuffer.split("\n");
140
+ textBuffer = parts.pop() || "";
141
+ let changed = false;
142
+ for (const part of parts) {
143
+ if (!part.trim()) continue;
144
+ const parsed = parseAsyncOutputLine(part);
145
+ if (parsed === null) continue;
146
+ for (const l of parsed.split("\n")) lines.push(l);
147
+ changed = true;
148
+ }
149
+ return changed;
150
+ } catch (e: any) {
151
+ if (e?.code === "ENOENT") return false;
152
+ const msg = e?.message || String(e);
153
+ if (msg !== lastLoggedError) {
154
+ lastLoggedError = msg;
155
+ console.debug("[async-status-ui] live output read failed:", msg);
156
+ }
157
+ return false;
158
+ }
159
+ };
160
+
161
+ const jobId = job.id;
162
+ return new OverlayScrollDetail(
163
+ tui,
164
+ theme,
165
+ {
166
+ followTail: true,
167
+ emptyBody: "(no output yet)",
168
+ footerHints:
169
+ "↑↓/jk scroll · PgUp/PgDn · Home/End · x kill · Esc back",
170
+ pollMs: 500,
171
+ onPoll: readNewContent,
172
+ onX: () => {
173
+ const current = deps.listJobs().find((j) => j.id === jobId);
174
+ if (current && isActive(current.status)) {
175
+ deps.killJob(jobId);
176
+ return true;
177
+ }
178
+ return false;
179
+ },
180
+ getHeader: (t) => {
181
+ const live = deps.readLiveStatus?.(job.workerDir);
182
+ const current = deps.listJobs().find((j) => j.id === jobId) || job;
183
+ const state = live?.state || current.status;
184
+ const dur = deps.formatDuration(elapsedMs(current));
185
+ return (
186
+ `${statusGlyph(state, t)} ` +
187
+ t.fg(
188
+ "accent",
189
+ t.bold(`${jobTitle(current)} · ${current.id.slice(-8)}`),
190
+ ) +
191
+ t.fg("muted", ` · ${state} · ${dur}`) +
192
+ t.fg("dim", ` · ${current.task.slice(0, 40)}`)
193
+ );
194
+ },
195
+ getBodyLines: () => lines,
196
+ },
197
+ done,
198
+ );
199
+ },
200
+ });
201
+ }