@phi-code-admin/phi-code 0.81.0 → 0.82.1
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,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.82.1] - 2026-06-14
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **EXPLORE fan-out, validated against a real run.** Two parallel read-only
|
|
8
|
+
sub-explorers ran end-to-end against the live proxy in ~17s with no rate limit
|
|
9
|
+
and produced correct, merged findings. The validation surfaced one cleanup: the
|
|
10
|
+
model's `<think>` / `<thinking>` reasoning blocks leaked into the merged brief,
|
|
11
|
+
so they are now stripped before merging.
|
|
12
|
+
|
|
13
|
+
## [0.82.0] - 2026-06-14
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Opt-in parallel EXPLORE fan-out** (`/plan --fanout <description>`). Before
|
|
18
|
+
phase 1, the orchestrator runs up to **2 concurrent read-only** sub-explorers,
|
|
19
|
+
each with a narrow mandate (architecture & reusable patterns, impacted files,
|
|
20
|
+
risks & constraints), and merges their findings into the EXPLORE context. This
|
|
21
|
+
applies the perspective-diverse pattern to the front of the pipeline so the
|
|
22
|
+
downstream phases start from a fuller, more accurate map.
|
|
23
|
+
|
|
24
|
+
Guardrails (the fan-out is best-effort and never breaks a run): default OFF
|
|
25
|
+
(single-agent EXPLORE unchanged); read-only tool set only (no write/edit/bash);
|
|
26
|
+
hard concurrency cap of 2 against the single rate-limited key; **adaptive
|
|
27
|
+
fallback to sequential** the moment a sub-explorer reports a 429 / rate limit;
|
|
28
|
+
a per-explorer timeout that kills the process; and full graceful degradation to
|
|
29
|
+
the normal EXPLORE on any error, empty result, or rate limit. Experimental: not
|
|
30
|
+
yet validated against a live proxy at scale, hence opt-in.
|
|
31
|
+
|
|
3
32
|
## [0.81.0] - 2026-06-14
|
|
4
33
|
|
|
5
34
|
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
|
-
|
|
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,211 @@
|
|
|
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
|
+
/** Strip the model's reasoning blocks so they do not pollute the merged brief. */
|
|
69
|
+
export function stripThinking(text: string): string {
|
|
70
|
+
return text
|
|
71
|
+
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
|
72
|
+
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
|
73
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
74
|
+
.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Run one sub-explorer. Never throws; always resolves to an ExplorerResult. */
|
|
78
|
+
function runOneExplorer(spec: ExplorerSpec, opts: FanoutOptions): Promise<ExplorerResult> {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const args = ["--mode", "json", "-p", "--no-session"];
|
|
81
|
+
if (opts.model) args.push("--model", opts.model);
|
|
82
|
+
const tools = opts.tools && opts.tools.length > 0 ? opts.tools : READONLY_EXPLORER_TOOLS;
|
|
83
|
+
args.push("--tools", tools.join(","));
|
|
84
|
+
args.push(`Task: ${spec.prompt}`);
|
|
85
|
+
|
|
86
|
+
let proc: ReturnType<typeof spawn>;
|
|
87
|
+
try {
|
|
88
|
+
const inv = getPiInvocation(args);
|
|
89
|
+
proc = spawn(inv.command, inv.args, { cwd: opts.cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
90
|
+
} catch (e) {
|
|
91
|
+
resolve({ focus: spec.focus, text: "", ok: false, rateLimited: false, error: String(e) });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let buffer = "";
|
|
96
|
+
let finalText = "";
|
|
97
|
+
let stderr = "";
|
|
98
|
+
let errorMessage = "";
|
|
99
|
+
let settled = false;
|
|
100
|
+
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
try {
|
|
103
|
+
proc.kill();
|
|
104
|
+
} catch {
|
|
105
|
+
/* best effort */
|
|
106
|
+
}
|
|
107
|
+
}, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
108
|
+
|
|
109
|
+
const processLine = (line: string) => {
|
|
110
|
+
if (!line.trim()) return;
|
|
111
|
+
let event: any;
|
|
112
|
+
try {
|
|
113
|
+
event = JSON.parse(line);
|
|
114
|
+
} catch {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (event?.type === "message_end" && event.message?.role === "assistant") {
|
|
118
|
+
const parts = event.message.content;
|
|
119
|
+
if (Array.isArray(parts)) {
|
|
120
|
+
for (const p of parts) {
|
|
121
|
+
if (p?.type === "text" && typeof p.text === "string") finalText = p.text;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (event.message.errorMessage) errorMessage = String(event.message.errorMessage);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const finish = (timedOut: boolean) => {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
settled = true;
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
if (buffer.trim()) processLine(buffer);
|
|
133
|
+
const blob = `${finalText}\n${errorMessage}\n${stderr}`;
|
|
134
|
+
const rateLimited = isRateLimited(blob);
|
|
135
|
+
const ok = !timedOut && finalText.trim().length > 0 && !rateLimited;
|
|
136
|
+
resolve({
|
|
137
|
+
focus: spec.focus,
|
|
138
|
+
text: finalText,
|
|
139
|
+
ok,
|
|
140
|
+
rateLimited,
|
|
141
|
+
error: errorMessage || (ok ? undefined : timedOut ? "timeout" : "no output"),
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
proc.stdout?.on("data", (data) => {
|
|
146
|
+
buffer += data.toString();
|
|
147
|
+
const lines = buffer.split("\n");
|
|
148
|
+
buffer = lines.pop() || "";
|
|
149
|
+
for (const line of lines) processLine(line);
|
|
150
|
+
});
|
|
151
|
+
proc.stderr?.on("data", (data) => {
|
|
152
|
+
stderr += data.toString();
|
|
153
|
+
});
|
|
154
|
+
proc.on("error", (e) => {
|
|
155
|
+
if (settled) return;
|
|
156
|
+
settled = true;
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
resolve({ focus: spec.focus, text: "", ok: false, rateLimited: false, error: String(e) });
|
|
159
|
+
});
|
|
160
|
+
proc.on("close", () => finish(false));
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Run the sub-explorers with a hard concurrency cap of 2; if a batch hits a rate
|
|
166
|
+
* limit, drop to sequential for the rest. Returns the merged findings (markdown)
|
|
167
|
+
* plus the raw results. Best-effort: callers should fall back to the normal
|
|
168
|
+
* single-agent EXPLORE when `merged` is empty.
|
|
169
|
+
*/
|
|
170
|
+
export async function runExploreFanout(
|
|
171
|
+
specs: ExplorerSpec[],
|
|
172
|
+
opts: FanoutOptions,
|
|
173
|
+
): Promise<{ results: ExplorerResult[]; merged: string; rateLimited: boolean }> {
|
|
174
|
+
let concurrency = Math.max(1, Math.min(opts.concurrency ?? HARD_CONCURRENCY_CAP, HARD_CONCURRENCY_CAP));
|
|
175
|
+
const results: ExplorerResult[] = [];
|
|
176
|
+
let i = 0;
|
|
177
|
+
while (i < specs.length) {
|
|
178
|
+
const batch = specs.slice(i, i + concurrency);
|
|
179
|
+
const batchResults = await Promise.all(batch.map((s) => runOneExplorer(s, opts)));
|
|
180
|
+
results.push(...batchResults);
|
|
181
|
+
i += batch.length;
|
|
182
|
+
// Adaptive backoff: a rate limit means the single key is saturated, so
|
|
183
|
+
// serialize whatever is left rather than push harder.
|
|
184
|
+
if (batchResults.some((r) => r.rateLimited)) concurrency = 1;
|
|
185
|
+
}
|
|
186
|
+
const ok = results.filter((r) => r.ok && r.text.trim());
|
|
187
|
+
const merged = ok
|
|
188
|
+
.map((r) => `### Exploration — ${r.focus}\n${stripThinking(r.text)}`)
|
|
189
|
+
.filter((block) => block.trim().length > 0)
|
|
190
|
+
.join("\n\n");
|
|
191
|
+
return { results, merged, rateLimited: results.some((r) => r.rateLimited) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The default narrow-mandate explorer specs for a /plan request. */
|
|
195
|
+
export function defaultExplorerSpecs(description: string): ExplorerSpec[] {
|
|
196
|
+
const base = `Read-only exploration. Do NOT modify any file. Be concise and cite file:line.`;
|
|
197
|
+
return [
|
|
198
|
+
{
|
|
199
|
+
focus: "architecture & reusable patterns",
|
|
200
|
+
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.`,
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
focus: "impacted files",
|
|
204
|
+
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.`,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
focus: "risks & constraints",
|
|
208
|
+
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.`,
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
}
|