@phi-code-admin/phi-code 0.81.0 → 0.82.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.82.0] - 2026-06-14
4
+
5
+ ### Added
6
+
7
+ - **Opt-in parallel EXPLORE fan-out** (`/plan --fanout <description>`). Before
8
+ phase 1, the orchestrator runs up to **2 concurrent read-only** sub-explorers,
9
+ each with a narrow mandate (architecture & reusable patterns, impacted files,
10
+ risks & constraints), and merges their findings into the EXPLORE context. This
11
+ applies the perspective-diverse pattern to the front of the pipeline so the
12
+ downstream phases start from a fuller, more accurate map.
13
+
14
+ Guardrails (the fan-out is best-effort and never breaks a run): default OFF
15
+ (single-agent EXPLORE unchanged); read-only tool set only (no write/edit/bash);
16
+ hard concurrency cap of 2 against the single rate-limited key; **adaptive
17
+ fallback to sequential** the moment a sub-explorer reports a 429 / rate limit;
18
+ a per-explorer timeout that kills the process; and full graceful degradation to
19
+ the normal EXPLORE on any error, empty result, or rate limit. Experimental: not
20
+ yet validated against a live proxy at scale, hence opt-in.
21
+
3
22
  ## [0.81.0] - 2026-06-14
4
23
 
5
24
  Marathon increment 4 (prompt-only, low risk).
@@ -28,6 +28,7 @@ import {
28
28
  isTransientError,
29
29
  parsePhaseVerdict,
30
30
  } from "./providers/orchestrator-helpers.js";
31
+ import { defaultExplorerSpecs, READONLY_EXPLORER_TOOLS, runExploreFanout } from "./providers/explore-fanout.js";
31
32
 
32
33
  // ─── Types ───────────────────────────────────────────────────────────────
33
34
 
@@ -1137,10 +1138,13 @@ Tag the note with relevant keywords for vector search.
1137
1138
  return;
1138
1139
  }
1139
1140
 
1140
- const description = rawArgs;
1141
+ // Opt-in: `--fanout` runs a small read-only parallel exploration before
1142
+ // phase 1 (default OFF — single-agent EXPLORE, the safe behaviour).
1143
+ const wantFanout = /(^|\s)--fanout\b/.test(rawArgs);
1144
+ const description = rawArgs.replace(/(^|\s)--fanout\b/g, " ").trim();
1141
1145
 
1142
1146
  if (!description) {
1143
- ctx.ui.notify(`**Usage:** \`/plan <project description>\` (or \`/plan --resume\` to continue a checkpointed run)
1147
+ ctx.ui.notify(`**Usage:** \`/plan <project description>\` (or \`/plan --resume\` to continue a checkpointed run, \`/plan --fanout <desc>\` for parallel exploration)
1144
1148
 
1145
1149
  **Examples:**
1146
1150
  /plan Build a REST API for user authentication with JWT
@@ -1188,6 +1192,35 @@ Tag the note with relevant keywords for vector search.
1188
1192
 
1189
1193
  // Record orchestration start time for final summary
1190
1194
  phaseStartTime = Date.now();
1195
+
1196
+ // Opt-in parallel EXPLORE fan-out (read-only, <=2 concurrent, sequential
1197
+ // fallback on rate limit). Best-effort: any failure degrades to the normal
1198
+ // single-agent EXPLORE below by simply not enriching the instruction.
1199
+ if (wantFanout) {
1200
+ try {
1201
+ const available = ctx.modelRegistry?.getAvailable?.() || [];
1202
+ const exploreModelId = resolveModelRef(available, firstPhase.model)?.id || firstPhase.model;
1203
+ ctx.ui.notify(`\n🔭 **Parallel exploration** (read-only, up to 2 concurrent) — building a fuller map before phase 1...`, "info");
1204
+ const fan = await runExploreFanout(defaultExplorerSpecs(description), {
1205
+ model: exploreModelId,
1206
+ tools: READONLY_EXPLORER_TOOLS,
1207
+ cwd: ctx.cwd || process.cwd(),
1208
+ concurrency: 2,
1209
+ timeoutMs: 4 * 60 * 1000,
1210
+ });
1211
+ const okCount = fan.results.filter((r) => r.ok).length;
1212
+ if (fan.merged.trim()) {
1213
+ firstPhase.instruction =
1214
+ `## Parallel exploration findings (${okCount} read-only sub-explorer(s))\nUse these as a starting map; verify and synthesize them into your brief, do not trust them blindly.\n\n${fan.merged}\n\n---\n${firstPhase.instruction}`;
1215
+ ctx.ui.notify(`\n🔭 ${okCount} sub-exploration(s) merged into EXPLORE${fan.rateLimited ? " (rate limit hit — ran the rest sequentially)" : ""}.`, "info");
1216
+ } else {
1217
+ ctx.ui.notify(`\n🔭 Parallel exploration returned nothing usable${fan.rateLimited ? " (rate-limited)" : ""} — running the normal single-agent EXPLORE.`, "warning");
1218
+ }
1219
+ } catch {
1220
+ ctx.ui.notify(`\n🔭 Parallel exploration skipped (error) — running the normal single-agent EXPLORE.`, "warning");
1221
+ }
1222
+ }
1223
+
1191
1224
  // Switch model and activate agent for first phase
1192
1225
  const { modelId, warning } = await switchModelForPhase(firstPhase, ctx);
1193
1226
  activateAgent(firstPhase, ctx);
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Opt-in parallel EXPLORE fan-out for the /plan orchestrator.
3
+ *
4
+ * Spawns a small number of read-only `pi` sub-explorers, each with a narrow focus
5
+ * (architecture / impacted files / risks), and merges their findings into the
6
+ * EXPLORE phase context. This is the perspective-diverse pattern applied to the
7
+ * front of the pipeline: a more complete map of the codebase means better
8
+ * downstream plan/code/test phases.
9
+ *
10
+ * GUARDRAILS (this whole module is best-effort and NEVER throws):
11
+ * - Read-only only: sub-explorers get a read-only tool set, no write/edit/bash.
12
+ * - Hard concurrency cap of 2 (a single rate-limited proxy key is the constraint).
13
+ * - Adaptive fallback: if any sub-explorer reports a 429 / rate limit, the
14
+ * remaining ones run sequentially (concurrency drops to 1).
15
+ * - Per-explorer timeout, with the process killed on expiry.
16
+ * - Any failure (spawn error, timeout, no output, rate limit) degrades to "no
17
+ * findings" so the orchestrator simply runs the normal single-agent EXPLORE.
18
+ */
19
+
20
+ import { spawn } from "node:child_process";
21
+ import { existsSync } from "node:fs";
22
+ import { basename } from "node:path";
23
+
24
+ export interface ExplorerSpec {
25
+ focus: string;
26
+ prompt: string;
27
+ }
28
+
29
+ export interface ExplorerResult {
30
+ focus: string;
31
+ text: string;
32
+ ok: boolean;
33
+ rateLimited: boolean;
34
+ error?: string;
35
+ }
36
+
37
+ export interface FanoutOptions {
38
+ model?: string;
39
+ tools?: string[];
40
+ cwd?: string;
41
+ concurrency?: number;
42
+ timeoutMs?: number;
43
+ }
44
+
45
+ /** Read-only tools handed to every sub-explorer. */
46
+ export const READONLY_EXPLORER_TOOLS = ["read", "grep", "glob", "ls", "find", "memory_search", "memory_read"];
47
+
48
+ const HARD_CONCURRENCY_CAP = 2;
49
+ const DEFAULT_TIMEOUT_MS = 4 * 60 * 1000;
50
+
51
+ /** Re-invoke the current phi binary (so the sub-explorer is the same build). */
52
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
53
+ const currentScript = process.argv[1];
54
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
55
+ if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
56
+ return { command: process.execPath, args: [currentScript, ...args] };
57
+ }
58
+ const execName = basename(process.execPath).toLowerCase();
59
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
60
+ if (!isGenericRuntime) return { command: process.execPath, args };
61
+ return { command: "pi", args };
62
+ }
63
+
64
+ export function isRateLimited(text: string): boolean {
65
+ return /\b429\b|rate.?limit(ed)?|too many requests|overloaded|quota exceeded|resource exhausted/i.test(text);
66
+ }
67
+
68
+ /** Run one sub-explorer. Never throws; always resolves to an ExplorerResult. */
69
+ function runOneExplorer(spec: ExplorerSpec, opts: FanoutOptions): Promise<ExplorerResult> {
70
+ return new Promise((resolve) => {
71
+ const args = ["--mode", "json", "-p", "--no-session"];
72
+ if (opts.model) args.push("--model", opts.model);
73
+ const tools = opts.tools && opts.tools.length > 0 ? opts.tools : READONLY_EXPLORER_TOOLS;
74
+ args.push("--tools", tools.join(","));
75
+ args.push(`Task: ${spec.prompt}`);
76
+
77
+ let proc: ReturnType<typeof spawn>;
78
+ try {
79
+ const inv = getPiInvocation(args);
80
+ proc = spawn(inv.command, inv.args, { cwd: opts.cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
81
+ } catch (e) {
82
+ resolve({ focus: spec.focus, text: "", ok: false, rateLimited: false, error: String(e) });
83
+ return;
84
+ }
85
+
86
+ let buffer = "";
87
+ let finalText = "";
88
+ let stderr = "";
89
+ let errorMessage = "";
90
+ let settled = false;
91
+
92
+ const timer = setTimeout(() => {
93
+ try {
94
+ proc.kill();
95
+ } catch {
96
+ /* best effort */
97
+ }
98
+ }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
99
+
100
+ const processLine = (line: string) => {
101
+ if (!line.trim()) return;
102
+ let event: any;
103
+ try {
104
+ event = JSON.parse(line);
105
+ } catch {
106
+ return;
107
+ }
108
+ if (event?.type === "message_end" && event.message?.role === "assistant") {
109
+ const parts = event.message.content;
110
+ if (Array.isArray(parts)) {
111
+ for (const p of parts) {
112
+ if (p?.type === "text" && typeof p.text === "string") finalText = p.text;
113
+ }
114
+ }
115
+ if (event.message.errorMessage) errorMessage = String(event.message.errorMessage);
116
+ }
117
+ };
118
+
119
+ const finish = (timedOut: boolean) => {
120
+ if (settled) return;
121
+ settled = true;
122
+ clearTimeout(timer);
123
+ if (buffer.trim()) processLine(buffer);
124
+ const blob = `${finalText}\n${errorMessage}\n${stderr}`;
125
+ const rateLimited = isRateLimited(blob);
126
+ const ok = !timedOut && finalText.trim().length > 0 && !rateLimited;
127
+ resolve({
128
+ focus: spec.focus,
129
+ text: finalText,
130
+ ok,
131
+ rateLimited,
132
+ error: errorMessage || (ok ? undefined : timedOut ? "timeout" : "no output"),
133
+ });
134
+ };
135
+
136
+ proc.stdout?.on("data", (data) => {
137
+ buffer += data.toString();
138
+ const lines = buffer.split("\n");
139
+ buffer = lines.pop() || "";
140
+ for (const line of lines) processLine(line);
141
+ });
142
+ proc.stderr?.on("data", (data) => {
143
+ stderr += data.toString();
144
+ });
145
+ proc.on("error", (e) => {
146
+ if (settled) return;
147
+ settled = true;
148
+ clearTimeout(timer);
149
+ resolve({ focus: spec.focus, text: "", ok: false, rateLimited: false, error: String(e) });
150
+ });
151
+ proc.on("close", () => finish(false));
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Run the sub-explorers with a hard concurrency cap of 2; if a batch hits a rate
157
+ * limit, drop to sequential for the rest. Returns the merged findings (markdown)
158
+ * plus the raw results. Best-effort: callers should fall back to the normal
159
+ * single-agent EXPLORE when `merged` is empty.
160
+ */
161
+ export async function runExploreFanout(
162
+ specs: ExplorerSpec[],
163
+ opts: FanoutOptions,
164
+ ): Promise<{ results: ExplorerResult[]; merged: string; rateLimited: boolean }> {
165
+ let concurrency = Math.max(1, Math.min(opts.concurrency ?? HARD_CONCURRENCY_CAP, HARD_CONCURRENCY_CAP));
166
+ const results: ExplorerResult[] = [];
167
+ let i = 0;
168
+ while (i < specs.length) {
169
+ const batch = specs.slice(i, i + concurrency);
170
+ const batchResults = await Promise.all(batch.map((s) => runOneExplorer(s, opts)));
171
+ results.push(...batchResults);
172
+ i += batch.length;
173
+ // Adaptive backoff: a rate limit means the single key is saturated, so
174
+ // serialize whatever is left rather than push harder.
175
+ if (batchResults.some((r) => r.rateLimited)) concurrency = 1;
176
+ }
177
+ const ok = results.filter((r) => r.ok && r.text.trim());
178
+ const merged = ok.map((r) => `### Exploration — ${r.focus}\n${r.text.trim()}`).join("\n\n");
179
+ return { results, merged, rateLimited: results.some((r) => r.rateLimited) };
180
+ }
181
+
182
+ /** The default narrow-mandate explorer specs for a /plan request. */
183
+ export function defaultExplorerSpecs(description: string): ExplorerSpec[] {
184
+ const base = `Read-only exploration. Do NOT modify any file. Be concise and cite file:line.`;
185
+ return [
186
+ {
187
+ focus: "architecture & reusable patterns",
188
+ prompt: `${base}\nProject request: ${description}\nExplore the codebase architecture: tech stack, key modules/directories, the conventions and existing utilities/functions to REUSE, and how the pieces fit together.`,
189
+ },
190
+ {
191
+ focus: "impacted files",
192
+ prompt: `${base}\nProject request: ${description}\nIdentify the files and code that will be IMPACTED: grep the relevant symbols, find callers and callees, and list the exact files (file:line) that will likely need changes and why.`,
193
+ },
194
+ {
195
+ focus: "risks & constraints",
196
+ prompt: `${base}\nProject request: ${description}\nIdentify risks, edge cases, and constraints to NOT break: behaviour that must be preserved, error handling, security, and tests that cover the area.`,
197
+ },
198
+ ];
199
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phi-code-admin/phi-code",
3
- "version": "0.81.0",
3
+ "version": "0.82.0",
4
4
  "description": "Coding agent CLI with persistent memory, sub-agents, intelligent routing, and orchestration",
5
5
  "type": "module",
6
6
  "piConfig": {