@ferris1225/pi-subagents 4.1.6 → 4.1.8
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 +27 -21
- package/agents/reviewer.md +2 -2
- package/agents/worker.md +4 -0
- package/package.json +1 -1
- package/src/announcements.ts +22 -67
- package/src/background.ts +5 -10
- package/src/config.ts +9 -170
- package/src/dispatch.ts +9 -10
- package/src/fixloop.ts +36 -21
- package/src/index.ts +1 -1
- package/src/models.ts +16 -0
- package/src/prompt.ts +3 -4
- package/src/runtime.ts +3 -6
- package/src/setup.ts +0 -41
- package/src/spawn.ts +1 -1
- package/src/thread-lifecycle.ts +1 -4
package/README.md
CHANGED
|
@@ -119,7 +119,10 @@ The runtime deliberately keeps delegation conservative:
|
|
|
119
119
|
- `worker` and `cleaner` remain distinct write-capable entry roles. Each updates
|
|
120
120
|
existing README/docs/examples/comments directly affected by its change. After
|
|
121
121
|
success, one enabled independent `reviewer` gate runs, with the existing bounded
|
|
122
|
-
worker ↔ reviewer fix loop for `REVIEW_FAIL`.
|
|
122
|
+
worker ↔ reviewer fix loop for `REVIEW_FAIL`. Every gate finding carries a
|
|
123
|
+
concrete fix instruction; the worker implements those instructions or ships a
|
|
124
|
+
sounder fix with an explicit pushback, and re-review judges the resulting code —
|
|
125
|
+
only open findings and defects the fix itself introduced can continue the loop.
|
|
123
126
|
- When `documenter` is enabled, every managed reviewer gate is asked for a
|
|
124
127
|
standalone `DOCUMENTATION: CLEAN` or `DOCUMENTATION: NEEDED` line. Only
|
|
125
128
|
`REVIEW_PASS` can authorize the final sync: NEEDED includes
|
|
@@ -204,10 +207,10 @@ subagent({
|
|
|
204
207
|
});
|
|
205
208
|
```
|
|
206
209
|
|
|
207
|
-
Independent tasks run up to
|
|
208
|
-
contain at most that many tasks and is rejected if it
|
|
209
|
-
background work from separate calls waits in the
|
|
210
|
-
busy.
|
|
210
|
+
Independent tasks run up to a fixed limit of `4` concurrent sub-agent processes.
|
|
211
|
+
One parallel call may contain at most that many tasks and is rejected if it
|
|
212
|
+
exceeds the limit. Accepted background work from separate calls waits in the
|
|
213
|
+
shared queue when all slots are busy.
|
|
211
214
|
|
|
212
215
|
### Run an independent quality gate
|
|
213
216
|
|
|
@@ -225,20 +228,26 @@ immediately, while NEEDED (or a missing marker on that passing gate) runs one
|
|
|
225
228
|
final docs sync. A failure uses the bounded loop:
|
|
226
229
|
|
|
227
230
|
```text
|
|
228
|
-
reviewer → worker
|
|
231
|
+
reviewer → worker applies each fix instruction (or rebuts with a sounder fix)
|
|
232
|
+
→ reviewer re-reviews the result → …
|
|
229
233
|
REVIEW_PASS ─┬─ CLEAN → deliver
|
|
230
234
|
└─ NEEDED/missing → documenter
|
|
231
235
|
```
|
|
232
236
|
|
|
233
237
|
Each step gets a fresh model context. The chain shares the same code state and
|
|
234
238
|
passes every full reviewer and worker report forward; it does not reuse one
|
|
235
|
-
context window.
|
|
239
|
+
context window. Re-review converges instead of re-auditing: it rules on every
|
|
240
|
+
previous finding once, judges the code as it now stands (a sound worker fix
|
|
241
|
+
counts even when it deviates from the instruction), and adds new findings only
|
|
242
|
+
for defects the fix round's own edits introduced or exposed — issues unrelated
|
|
243
|
+
to those edits belong to a fresh gate, not to the loop. Internal children bypass top-level lifecycle policy, so they
|
|
236
244
|
cannot recursively start another chain. Gate reviewers keep documentation drift
|
|
237
245
|
out of the code verdict while `documenter` is enabled by recording it under
|
|
238
246
|
`## Documentation notes`; with documenter disabled, drift is a normal finding.
|
|
239
247
|
|
|
240
|
-
|
|
241
|
-
|
|
248
|
+
The loop is fixed at two worker fix rounds (each fix is re-reviewed); disabling
|
|
249
|
+
the `worker` agent is the way to turn fixes off. The post-writer review gate still
|
|
250
|
+
runs regardless, and only a terminal `REVIEW_PASS` can decide whether docs
|
|
242
251
|
sync is needed. Generic audits and read-only reviews are advisory: they omit
|
|
243
252
|
`VERDICT` and documentation machine markers, remain read-only, and never trigger
|
|
244
253
|
edits.
|
|
@@ -495,8 +504,8 @@ subagent({
|
|
|
495
504
|
|
|
496
505
|
## Configuration
|
|
497
506
|
|
|
498
|
-
The wizard covers enabled agents, per-agent models and thinking,
|
|
499
|
-
|
|
507
|
+
The wizard covers enabled agents, per-agent models and thinking, and the idle
|
|
508
|
+
timeout:
|
|
500
509
|
|
|
501
510
|
```text
|
|
502
511
|
/subagents-setup
|
|
@@ -526,8 +535,6 @@ Configuration is stored at `~/.pi/agent/pi-subagents.json` and follows
|
|
|
526
535
|
"maxResultLines": 80,
|
|
527
536
|
"proactiveInjection": true,
|
|
528
537
|
"agentScope": "user",
|
|
529
|
-
"maxConcurrency": 4,
|
|
530
|
-
"maxFixRounds": 2,
|
|
531
538
|
"idleTimeoutSec": 90
|
|
532
539
|
}
|
|
533
540
|
```
|
|
@@ -541,16 +548,15 @@ Configuration is stored at `~/.pi/agent/pi-subagents.json` and follows
|
|
|
541
548
|
| `maxResultLines` | Lines kept in a completion message before the full result moves to a temporary artifact. Default `80`. |
|
|
542
549
|
| `proactiveInjection` | Teach the main model when and how to delegate. Default `true`. |
|
|
543
550
|
| `agentScope` | Discover `user`, `project`, or `both` agent directories. Default `user`. |
|
|
544
|
-
| `maxConcurrency` | Running process limit and maximum tasks in one parallel call, from `1` to `16`. Default `4`. |
|
|
545
|
-
| `maxFixRounds` | Maximum worker fixes after `REVIEW_FAIL`; each fix is re-reviewed. After terminal `REVIEW_PASS`, docs sync runs only for `DOCUMENTATION: NEEDED` or a missing marker. `0` disables fixes but not the post-writer gate or conditional/reviewer-disabled docs behavior. Default `2`. |
|
|
546
551
|
| `idleTimeoutSec` | Seconds without RPC output before termination. `0` disables the watchdog. Default `90`. |
|
|
547
552
|
|
|
548
|
-
Invalid values fall back safely.
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
553
|
+
Invalid values fall back safely. Keys from older versions (including the former
|
|
554
|
+
`maxConcurrency` and `maxFixRounds` tuning options, now fixed at `4` concurrent
|
|
555
|
+
processes and `2` fix rounds) are dropped automatically and the normalized
|
|
556
|
+
shape is saved back. At session start, per-agent model overrides that Pi no
|
|
557
|
+
longer reports as available are removed with a one-time notice; those agents
|
|
558
|
+
fall back to the current main model until you re-pick them in
|
|
559
|
+
`/subagents-setup`.
|
|
554
560
|
|
|
555
561
|
## Custom and overridden agents
|
|
556
562
|
|
package/agents/reviewer.md
CHANGED
|
@@ -43,7 +43,7 @@ You are a senior, adversarial code reviewer. Find genuine defects and risks rath
|
|
|
43
43
|
- Return only the review result. Do not repeat the task brief, summarize the implementation, narrate inspection/tool chronology, or explain a root cause when no finding depends on it. Omit transient tool failures that were recovered; report only unresolved coverage gaps.
|
|
44
44
|
- Stay independent of `worker`, `cleaner`, and `documenter`; fix nothing yourself. When a final documenter is available, documentation drift is not a code-gate finding: record it in a short `## Documentation notes` section and carry it forward on re-review. When documenter is disabled, drift is a normal gate finding.
|
|
45
45
|
- Every gate (never an advisory review) must classify documentation on its own standalone machine line. Emit `DOCUMENTATION: NEEDED` and include `## Documentation notes` when a sync is needed; otherwise emit `DOCUMENTATION: CLEAN`. Runtime treats a missing marker conservatively as NEEDED. Do not emit this marker for advisory output.
|
|
46
|
-
- In a gate, every code/test finding enters auto-fix, with no severity tiers. A direct REVIEW_PASS is final for code: CLEAN delivers directly, while NEEDED/missing runs one conditional documentation sync without reopening the gate. On re-review,
|
|
46
|
+
- In a gate, every code/test finding enters auto-fix, with no severity tiers, and every gate finding must end with a concrete fix instruction — what to change, where, and how to verify the fix — because a worker implements exactly those instructions unless it can justify a sounder fix and push back. A direct REVIEW_PASS is final for code: CLEAN delivers directly, while NEEDED/missing runs one conditional documentation sync without reopening the gate. On re-review, judge the code as it now stands: a finding is resolved when the pending diff fixes it soundly, whether or not the worker followed your instruction. Rule on each open finding once, concretely adjudicate worker pushback, add only defects the fix introduced or exposed — never issues unrelated to this round's edits — and never re-open a verified resolution.
|
|
47
47
|
- Advisory findings never enter auto-fix; the caller decides whether to authorize later implementation or cleanup.
|
|
48
48
|
|
|
49
49
|
## Output
|
|
@@ -64,7 +64,7 @@ For a gate review:
|
|
|
64
64
|
## Files Reviewed
|
|
65
65
|
- path/to/file.ts
|
|
66
66
|
## Findings
|
|
67
|
-
- file.ts:42 — concrete issue and why it breaks
|
|
67
|
+
- file.ts:42 — concrete issue and why it breaks — Fix: the change to make and how to verify it
|
|
68
68
|
(Write "None" when no finding remains.)
|
|
69
69
|
## Documentation notes
|
|
70
70
|
- exact stale surface and required correction
|
package/agents/worker.md
CHANGED
|
@@ -34,6 +34,10 @@ Never commit, push, publish, tag, release, or bump a package version. The parent
|
|
|
34
34
|
- You cannot dispatch sub-agents (children are leaf processes with no `subagent` tool). When the
|
|
35
35
|
brief lacks context that needs broad code discovery, state concretely what an `explorer` should
|
|
36
36
|
retrieve for the caller — do not guess.
|
|
37
|
+
- In an auto-fix round, apply your own judgment to the reviewer's fix instructions: implement each
|
|
38
|
+
instruction when it is sound; when it is wrong, out of scope, or a sounder fix exists, implement
|
|
39
|
+
your fix and push back in your report — cite the finding, refute the instruction's reasoning,
|
|
40
|
+
and describe what you shipped instead. A deviation without reasoning will be re-opened.
|
|
37
41
|
- The parent runtime automatically runs one enabled `reviewer` gate after a successful top-level worker and preserves the bounded worker/reviewer fix loop. A final `documenter` runs only when that terminal review reports documentation drift or omits its marker (or when reviewer is disabled). Report a complete handoff, but do not ask the caller to duplicate downstream roles. Never treat your own verification as the final gate.
|
|
38
42
|
|
|
39
43
|
## Output format
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.8",
|
|
4
4
|
"description": "A managed sub-agent team for pi: specialized roles, pre-commit documentation sync, retained threads, auto-fix chains, model fallback, and Git worktree isolation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/announcements.ts
CHANGED
|
@@ -1,82 +1,37 @@
|
|
|
1
|
-
/** Session-start recovery and
|
|
1
|
+
/** Session-start recovery, stale-config migration, and widget installation. */
|
|
2
2
|
|
|
3
|
-
import { stat } from "node:fs/promises";
|
|
4
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
CLEANER_INHERITED_FEATURE,
|
|
8
|
-
DOCUMENTER_AUTO_ENABLED_FEATURE,
|
|
9
|
-
DOCUMENTER_INHERITED_FEATURE,
|
|
10
|
-
loadConfig,
|
|
11
|
-
saveConfig,
|
|
12
|
-
} from "./config.ts";
|
|
4
|
+
import { loadConfig, saveConfig } from "./config.ts";
|
|
5
|
+
import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
|
|
13
6
|
import { announceRecoveryRecords } from "./recovery.ts";
|
|
14
7
|
import type { SubagentRuntime } from "./runtime.ts";
|
|
15
8
|
import { pruneResultArtifacts } from "./spawn.ts";
|
|
16
9
|
import { installActiveRunsWidget } from "./widget.ts";
|
|
17
10
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
// enabledAgents check keeps the notice silent when the user already
|
|
27
|
-
// disabled cleaner (e.g. via full setup) before it could fire.
|
|
28
|
-
key: "cleanerAutoEnabledNotice",
|
|
29
|
-
condition: (config) =>
|
|
30
|
-
config.announcedFeatures.includes(CLEANER_AUTO_ENABLED_FEATURE) &&
|
|
31
|
-
config.enabledAgents.includes("cleaner"),
|
|
32
|
-
// The inheritance clause matches reality: its stamp is only set when the
|
|
33
|
-
// upgrade actually copied reviewer model/thinking settings.
|
|
34
|
-
message: (config) =>
|
|
35
|
-
config.announcedFeatures.includes(CLEANER_INHERITED_FEATURE)
|
|
36
|
-
? "pi-subagents: the built-in cleaner agent was enabled by default and inherited your reviewer model/thinking settings. Run /subagents-setup to adjust or disable it."
|
|
37
|
-
: "pi-subagents: the built-in cleaner agent was enabled by default. Run /subagents-setup to adjust or disable it.",
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
key: "documenterAutoEnabledNotice",
|
|
41
|
-
condition: (config) =>
|
|
42
|
-
config.announcedFeatures.includes(DOCUMENTER_AUTO_ENABLED_FEATURE) &&
|
|
43
|
-
config.enabledAgents.includes("documenter"),
|
|
44
|
-
message: (config) =>
|
|
45
|
-
config.announcedFeatures.includes(DOCUMENTER_INHERITED_FEATURE)
|
|
46
|
-
? "pi-subagents: the new documenter agent was enabled for your existing config and inherited your explorer model/thinking settings. It synchronizes comments and README/docs before commit; run /subagents-setup to adjust or disable it."
|
|
47
|
-
: "pi-subagents: the new documenter agent was enabled for your existing config. It synchronizes comments and README/docs before commit; run /subagents-setup to adjust or disable it.",
|
|
48
|
-
},
|
|
49
|
-
];
|
|
50
|
-
|
|
51
|
-
async function announceNewFeatures(
|
|
52
|
-
ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } },
|
|
11
|
+
/**
|
|
12
|
+
* One-time-per-stale-override migration: keep agent model selections Pi still
|
|
13
|
+
* reports as available, drop the rest back to dynamic main-model routing, and
|
|
14
|
+
* tell the user what was removed. Saving the cleaned config is what makes it
|
|
15
|
+
* one-time — the dropped refs no longer exist to re-trigger the notice.
|
|
16
|
+
*/
|
|
17
|
+
async function migrateUnavailableAgentModels(
|
|
18
|
+
ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } } & Parameters<typeof availableModelsInScope>[0],
|
|
53
19
|
runtime: SubagentRuntime,
|
|
54
20
|
): Promise<void> {
|
|
55
21
|
try {
|
|
56
|
-
let configExists = true;
|
|
57
|
-
try {
|
|
58
|
-
await stat(runtime.configPath);
|
|
59
|
-
} catch {
|
|
60
|
-
configExists = false;
|
|
61
|
-
}
|
|
62
|
-
if (!configExists) return;
|
|
63
|
-
|
|
64
22
|
const config = await loadConfig(runtime.configPath);
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
},
|
|
75
|
-
runtime.configPath,
|
|
23
|
+
const overrides = Object.entries(config.agentModels);
|
|
24
|
+
if (overrides.length === 0) return;
|
|
25
|
+
const { kept, dropped } = filterUnavailableModelOverrides(config.agentModels, availableModelsInScope(ctx));
|
|
26
|
+
if (dropped.length === 0) return;
|
|
27
|
+
await saveConfig({ ...config, agentModels: kept }, runtime.configPath);
|
|
28
|
+
const list = dropped.map(({ agent, ref }) => `${agent}: ${ref}`).join(", ");
|
|
29
|
+
ctx.ui.notify(
|
|
30
|
+
`pi-subagents: removed stale agent model overrides that are no longer available (${list}). Those agents now follow the current main model; run /subagents-setup to re-pick.`,
|
|
31
|
+
"warning",
|
|
76
32
|
);
|
|
77
|
-
for (const announcement of pending) ctx.ui.notify(announcement.message(config), "info");
|
|
78
33
|
} catch {
|
|
79
|
-
/*
|
|
34
|
+
/* migration failures are non-fatal */
|
|
80
35
|
}
|
|
81
36
|
}
|
|
82
37
|
|
|
@@ -84,8 +39,8 @@ export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime
|
|
|
84
39
|
pi.on("session_start", async (_event, ctx) => {
|
|
85
40
|
pruneResultArtifacts();
|
|
86
41
|
await announceRecoveryRecords(runtime.configPath, ctx);
|
|
42
|
+
await migrateUnavailableAgentModels(ctx, runtime);
|
|
87
43
|
if (ctx.mode !== "tui") return;
|
|
88
44
|
installActiveRunsWidget(ctx);
|
|
89
|
-
await announceNewFeatures(ctx, runtime);
|
|
90
45
|
});
|
|
91
46
|
}
|
package/src/background.ts
CHANGED
|
@@ -27,6 +27,11 @@ interface PendingTask {
|
|
|
27
27
|
onError?: (error: unknown) => void | Promise<void>;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** How many sub-agent processes may run at once, and how many tasks one
|
|
31
|
+
* parallel `subagent` call may contain. Fixed by design: the queue sheds load
|
|
32
|
+
* by waiting, so the knob bought nothing worth its maintenance. */
|
|
33
|
+
export const MAX_CONCURRENT_SUBAGENTS = 4;
|
|
34
|
+
|
|
30
35
|
export class BackgroundTaskQueue {
|
|
31
36
|
private concurrency: number;
|
|
32
37
|
private readonly pending: PendingTask[] = [];
|
|
@@ -39,16 +44,6 @@ export class BackgroundTaskQueue {
|
|
|
39
44
|
this.concurrency = Math.max(1, concurrency);
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
/**
|
|
43
|
-
* Update the concurrency limit (e.g. after a config change). Raising it
|
|
44
|
-
* immediately starts more queued work; lowering it takes effect as running
|
|
45
|
-
* tasks finish — already-running tasks are never interrupted.
|
|
46
|
-
*/
|
|
47
|
-
setConcurrency(concurrency: number): void {
|
|
48
|
-
this.concurrency = Math.max(1, concurrency);
|
|
49
|
-
this.drain();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
47
|
enqueue(task: BackgroundTask, onCancelled?: () => void, onError?: (error: unknown) => void | Promise<void>): AbortController {
|
|
53
48
|
const controller = new AbortController();
|
|
54
49
|
let complete!: () => void;
|
package/src/config.ts
CHANGED
|
@@ -4,57 +4,23 @@
|
|
|
4
4
|
* Config lives at <agentDir>/pi-subagents.json (agentDir defaults to ~/.pi/agent
|
|
5
5
|
* and honors PI_CODING_AGENT_DIR). Parsing is defensive: invalid fields fall back
|
|
6
6
|
* to defaults instead of throwing, so a hand-edited or partially-written file can
|
|
7
|
-
* never break the extension at runtime.
|
|
8
|
-
*
|
|
9
|
-
* Schema upgrades happen transparently on load: a config written by an older
|
|
10
|
-
* version (missing newer keys or containing invalid
|
|
11
|
-
* values) is normalized and persisted back with the new fields filled in.
|
|
7
|
+
* never break the extension at runtime. Unknown keys from older versions are
|
|
8
|
+
* dropped and the normalized shape persisted back on load.
|
|
12
9
|
*/
|
|
13
10
|
|
|
14
11
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
15
|
-
import { readFileSync } from "node:fs";
|
|
16
12
|
import { dirname, join } from "node:path";
|
|
17
13
|
import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
18
14
|
|
|
19
15
|
/** Full catalog of agents shipped with the package (selectable in /subagents-setup). */
|
|
20
16
|
export const BUILTIN_AGENT_NAMES = ["explorer", "worker", "cleaner", "documenter", "reviewer"] as const;
|
|
21
17
|
|
|
22
|
-
/** Agents enabled out of the box on a fresh install.
|
|
23
|
-
* explicit setup choice; existing non-empty configs receive it via migration. */
|
|
18
|
+
/** Agents enabled out of the box on a fresh install. */
|
|
24
19
|
export const DEFAULT_ENABLED_AGENTS: readonly string[] = ["explorer", "worker", "cleaner", "reviewer"];
|
|
25
20
|
|
|
26
21
|
export const AGENT_SCOPE_VALUES = ["user", "project", "both"] as const;
|
|
27
22
|
export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
|
|
28
23
|
|
|
29
|
-
const LEGACY_EXPLORER_NAME = "explore";
|
|
30
|
-
const EXPLORER_NAME = "explorer";
|
|
31
|
-
const CLEANER_NAME = "cleaner";
|
|
32
|
-
const DOCUMENTER_NAME = "documenter";
|
|
33
|
-
const REVIEWER_NAME = "reviewer";
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Stamps recorded in `announcedFeatures` by the one-time upgrade that defaults
|
|
37
|
-
* cleaner on for configs written before it shipped. The first marks a config as
|
|
38
|
-
* processed, so a later deliberate disable is not undone; the second records
|
|
39
|
-
* that cleaner was actually injected, so session start can tell the user once;
|
|
40
|
-
* the third records that reviewer model/thinking settings were actually copied,
|
|
41
|
-
* so that notice never claims an inheritance that did not happen.
|
|
42
|
-
*/
|
|
43
|
-
export const CLEANER_DEFAULTED_FEATURE = "cleanerDefaulted";
|
|
44
|
-
export const CLEANER_AUTO_ENABLED_FEATURE = "cleanerAutoEnabled";
|
|
45
|
-
export const CLEANER_INHERITED_FEATURE = "cleanerInheritedReviewer";
|
|
46
|
-
|
|
47
|
-
/** One-time upgrade stamps for the pre-commit documenter role. Existing
|
|
48
|
-
* non-empty configs gain it before reviewer and inherit explorer routing; fresh
|
|
49
|
-
* installs keep it off until setup explicitly enables it. */
|
|
50
|
-
export const DOCUMENTER_DEFAULTED_FEATURE = "documenterDefaulted";
|
|
51
|
-
export const DOCUMENTER_AUTO_ENABLED_FEATURE = "documenterAutoEnabled";
|
|
52
|
-
export const DOCUMENTER_INHERITED_FEATURE = "documenterInheritedExplorer";
|
|
53
|
-
|
|
54
|
-
function migrateAgentName(name: string): string {
|
|
55
|
-
return name === LEGACY_EXPLORER_NAME ? EXPLORER_NAME : name;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
24
|
/** Thinking levels accepted by pi's `--thinking` option. */
|
|
59
25
|
export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
60
26
|
export type ThinkingLevel = (typeof THINKING_LEVEL_VALUES)[number];
|
|
@@ -67,20 +33,6 @@ export const MAX_RESULT_LINES_LIMIT = 2000;
|
|
|
67
33
|
|
|
68
34
|
const CONFIG_FILE_NAME = "pi-subagents.json";
|
|
69
35
|
|
|
70
|
-
/** How many sub-agent processes may run at once, and how many tasks one parallel `subagent` call may contain. Default: 4. */
|
|
71
|
-
export const DEFAULT_MAX_CONCURRENCY = 4;
|
|
72
|
-
/** Upper bound accepted for maxConcurrency (defensive clamp). */
|
|
73
|
-
export const MAX_CONCURRENCY_LIMIT = 16;
|
|
74
|
-
/**
|
|
75
|
-
* Maximum worker fixes after REVIEW_FAIL. Each fix is followed by a reviewer
|
|
76
|
-
* re-review; this cap does not suppress the post-writer review gate or its
|
|
77
|
-
* conditional/reviewer-disabled documentation fallback. 0 disables fixes.
|
|
78
|
-
* Default: 2.
|
|
79
|
-
*/
|
|
80
|
-
export const DEFAULT_MAX_FIX_ROUNDS = 2;
|
|
81
|
-
/** Upper bound accepted for maxFixRounds (defensive clamp). 0 disables the loop. */
|
|
82
|
-
export const MAX_FIX_ROUNDS_LIMIT = 5;
|
|
83
|
-
|
|
84
36
|
/**
|
|
85
37
|
* Default idle timeout in seconds: a sub-agent whose stdout (JSON event stream)
|
|
86
38
|
* goes silent for this long is terminated; a selected model then hands the
|
|
@@ -112,27 +64,12 @@ export interface SubagentsConfig {
|
|
|
112
64
|
proactiveInjection: boolean;
|
|
113
65
|
/** Which agent directories to discover from. Default: "user". */
|
|
114
66
|
agentScope: AgentScope;
|
|
115
|
-
/** Max sub-agent processes running at once (extra work queues) and the max tasks
|
|
116
|
-
* one parallel `subagent` call may contain. Default: 4. */
|
|
117
|
-
maxConcurrency: number;
|
|
118
|
-
/**
|
|
119
|
-
* Maximum worker fixes after REVIEW_FAIL. Every fix receives the full review,
|
|
120
|
-
* then a re-review runs; any documentation sync selected after the terminal
|
|
121
|
-
* healthy review does not consume this budget. 0 disables fixes. Default: 2.
|
|
122
|
-
*/
|
|
123
|
-
maxFixRounds: number;
|
|
124
67
|
/**
|
|
125
68
|
* Idle timeout in seconds: a sub-agent whose stdout (JSON event stream) goes
|
|
126
69
|
* silent for this long is terminated; a configured agent model then hands
|
|
127
70
|
* off to the current main model. 0 disables the idle watchdog. Default: 90.
|
|
128
71
|
*/
|
|
129
72
|
idleTimeoutSec: number;
|
|
130
|
-
/**
|
|
131
|
-
* One-time feature announcements already shown to the user, plus schema
|
|
132
|
-
* upgrade stamps (e.g. the cleaner default-enable upgrade). Persisted so
|
|
133
|
-
* notices and migrations never repeat.
|
|
134
|
-
*/
|
|
135
|
-
announcedFeatures: string[];
|
|
136
73
|
}
|
|
137
74
|
|
|
138
75
|
export const DEFAULT_CONFIG: SubagentsConfig = {
|
|
@@ -143,10 +80,7 @@ export const DEFAULT_CONFIG: SubagentsConfig = {
|
|
|
143
80
|
maxResultLines: DEFAULT_MAX_RESULT_LINES,
|
|
144
81
|
proactiveInjection: true,
|
|
145
82
|
agentScope: "user",
|
|
146
|
-
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
|
147
|
-
maxFixRounds: DEFAULT_MAX_FIX_ROUNDS,
|
|
148
83
|
idleTimeoutSec: DEFAULT_IDLE_TIMEOUT_SEC,
|
|
149
|
-
announcedFeatures: [],
|
|
150
84
|
};
|
|
151
85
|
|
|
152
86
|
export function getConfigPath(agentDir: string = getAgentDir()): string {
|
|
@@ -186,48 +120,30 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
|
|
|
186
120
|
const names = raw.enabledAgents.filter(
|
|
187
121
|
(name): name is string => typeof name === "string" && name.trim().length > 0,
|
|
188
122
|
);
|
|
189
|
-
// An explicitly empty array is honored
|
|
190
|
-
|
|
191
|
-
config.enabledAgents = [...new Set(names.map((name) => migrateAgentName(name.trim())))];
|
|
123
|
+
// An explicitly empty array is honored; duplicates collapse.
|
|
124
|
+
config.enabledAgents = [...new Set(names.map((name) => name.trim()))];
|
|
192
125
|
}
|
|
193
126
|
|
|
194
127
|
if (isRecord(raw.agentModels)) {
|
|
195
|
-
const
|
|
196
|
-
// A valid explicit new key wins regardless of JSON property order.
|
|
197
|
-
for (const [rawKey, value] of entries) {
|
|
128
|
+
for (const [rawKey, value] of Object.entries(raw.agentModels)) {
|
|
198
129
|
const key = rawKey.trim();
|
|
199
|
-
if (key !==
|
|
130
|
+
if (key !== "" && isModelReference(value)) {
|
|
200
131
|
config.agentModels[key] = value.trim();
|
|
201
132
|
}
|
|
202
133
|
}
|
|
203
|
-
if (!Object.hasOwn(config.agentModels, EXPLORER_NAME)) {
|
|
204
|
-
const legacy = entries.find(([key, value]) =>
|
|
205
|
-
key.trim() === LEGACY_EXPLORER_NAME && isModelReference(value)
|
|
206
|
-
);
|
|
207
|
-
if (legacy && isModelReference(legacy[1])) config.agentModels[EXPLORER_NAME] = legacy[1].trim();
|
|
208
|
-
}
|
|
209
134
|
}
|
|
210
135
|
|
|
211
136
|
if (isRecord(raw.agentThinkingLevels)) {
|
|
212
|
-
const
|
|
213
|
-
for (const [rawKey, value] of entries) {
|
|
137
|
+
for (const [rawKey, value] of Object.entries(raw.agentThinkingLevels)) {
|
|
214
138
|
const key = rawKey.trim();
|
|
215
139
|
if (
|
|
216
|
-
key !==
|
|
140
|
+
key !== "" &&
|
|
217
141
|
typeof value === "string" &&
|
|
218
142
|
(THINKING_LEVEL_VALUES as readonly string[]).includes(value)
|
|
219
143
|
) {
|
|
220
144
|
config.agentThinkingLevels[key] = value as ThinkingLevel;
|
|
221
145
|
}
|
|
222
146
|
}
|
|
223
|
-
if (!Object.hasOwn(config.agentThinkingLevels, EXPLORER_NAME)) {
|
|
224
|
-
const legacy = entries.find(([key, value]) =>
|
|
225
|
-
key.trim() === LEGACY_EXPLORER_NAME &&
|
|
226
|
-
typeof value === "string" &&
|
|
227
|
-
(THINKING_LEVEL_VALUES as readonly string[]).includes(value)
|
|
228
|
-
);
|
|
229
|
-
if (legacy) config.agentThinkingLevels[EXPLORER_NAME] = legacy[1] as ThinkingLevel;
|
|
230
|
-
}
|
|
231
147
|
}
|
|
232
148
|
|
|
233
149
|
if (typeof raw.notifyOnReviewPass === "boolean") {
|
|
@@ -245,75 +161,11 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
|
|
|
245
161
|
config.agentScope = raw.agentScope;
|
|
246
162
|
}
|
|
247
163
|
|
|
248
|
-
const maxConcurrency = clampCount(raw.maxConcurrency, MAX_CONCURRENCY_LIMIT);
|
|
249
|
-
if (maxConcurrency !== undefined) config.maxConcurrency = maxConcurrency;
|
|
250
|
-
|
|
251
|
-
// 0 disables worker fixes, not the independent post-writer review gate or
|
|
252
|
-
// conditional/reviewer-disabled documentation fallback.
|
|
253
|
-
if (typeof raw.maxFixRounds === "number" && Number.isFinite(raw.maxFixRounds)) {
|
|
254
|
-
config.maxFixRounds = Math.max(0, Math.min(MAX_FIX_ROUNDS_LIMIT, Math.round(raw.maxFixRounds)));
|
|
255
|
-
}
|
|
256
|
-
|
|
257
164
|
// 0 disables the idle watchdog; otherwise clamp to [0, upper].
|
|
258
165
|
if (typeof raw.idleTimeoutSec === "number" && Number.isFinite(raw.idleTimeoutSec)) {
|
|
259
166
|
config.idleTimeoutSec = Math.max(0, Math.min(IDLE_TIMEOUT_SEC_LIMIT, Math.round(raw.idleTimeoutSec)));
|
|
260
167
|
}
|
|
261
168
|
|
|
262
|
-
if (Array.isArray(raw.announcedFeatures)) {
|
|
263
|
-
config.announcedFeatures = raw.announcedFeatures.filter(
|
|
264
|
-
(feature): feature is string => typeof feature === "string" && feature.trim().length > 0,
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// One-time upgrade for configs written before cleaner shipped: a non-empty
|
|
269
|
-
// explicit enabledAgents list gets cleaner defaulted on (inserted before
|
|
270
|
-
// reviewer, matching the fresh-install order) and inherits the reviewer's
|
|
271
|
-
// configured model and thinking level — its closest peer. The stamps make
|
|
272
|
-
// the upgrade idempotent and keep a later deliberate disable from being undone.
|
|
273
|
-
if (!config.announcedFeatures.includes(CLEANER_DEFAULTED_FEATURE)) {
|
|
274
|
-
config.announcedFeatures.push(CLEANER_DEFAULTED_FEATURE);
|
|
275
|
-
if (config.enabledAgents.length > 0 && !config.enabledAgents.includes(CLEANER_NAME)) {
|
|
276
|
-
const reviewerIndex = config.enabledAgents.indexOf(REVIEWER_NAME);
|
|
277
|
-
config.enabledAgents.splice(reviewerIndex === -1 ? config.enabledAgents.length : reviewerIndex, 0, CLEANER_NAME);
|
|
278
|
-
config.announcedFeatures.push(CLEANER_AUTO_ENABLED_FEATURE);
|
|
279
|
-
let inherited = false;
|
|
280
|
-
if (!config.agentModels[CLEANER_NAME] && config.agentModels[REVIEWER_NAME]) {
|
|
281
|
-
config.agentModels[CLEANER_NAME] = config.agentModels[REVIEWER_NAME];
|
|
282
|
-
inherited = true;
|
|
283
|
-
}
|
|
284
|
-
if (!config.agentThinkingLevels[CLEANER_NAME] && config.agentThinkingLevels[REVIEWER_NAME]) {
|
|
285
|
-
config.agentThinkingLevels[CLEANER_NAME] = config.agentThinkingLevels[REVIEWER_NAME];
|
|
286
|
-
inherited = true;
|
|
287
|
-
}
|
|
288
|
-
// An old explicit list may have no reviewer overrides to copy; only the
|
|
289
|
-
// copied case is stamped so the one-time notice stays accurate.
|
|
290
|
-
if (inherited) config.announcedFeatures.push(CLEANER_INHERITED_FEATURE);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
if (!config.announcedFeatures.includes(DOCUMENTER_DEFAULTED_FEATURE)) {
|
|
295
|
-
config.announcedFeatures.push(DOCUMENTER_DEFAULTED_FEATURE);
|
|
296
|
-
if (config.enabledAgents.length > 0 && !config.enabledAgents.includes(DOCUMENTER_NAME)) {
|
|
297
|
-
const reviewerIndex = config.enabledAgents.indexOf(REVIEWER_NAME);
|
|
298
|
-
config.enabledAgents.splice(
|
|
299
|
-
reviewerIndex === -1 ? config.enabledAgents.length : reviewerIndex,
|
|
300
|
-
0,
|
|
301
|
-
DOCUMENTER_NAME,
|
|
302
|
-
);
|
|
303
|
-
config.announcedFeatures.push(DOCUMENTER_AUTO_ENABLED_FEATURE);
|
|
304
|
-
let inherited = false;
|
|
305
|
-
if (!config.agentModels[DOCUMENTER_NAME] && config.agentModels[EXPLORER_NAME]) {
|
|
306
|
-
config.agentModels[DOCUMENTER_NAME] = config.agentModels[EXPLORER_NAME];
|
|
307
|
-
inherited = true;
|
|
308
|
-
}
|
|
309
|
-
if (!config.agentThinkingLevels[DOCUMENTER_NAME] && config.agentThinkingLevels[EXPLORER_NAME]) {
|
|
310
|
-
config.agentThinkingLevels[DOCUMENTER_NAME] = config.agentThinkingLevels[EXPLORER_NAME];
|
|
311
|
-
inherited = true;
|
|
312
|
-
}
|
|
313
|
-
if (inherited) config.announcedFeatures.push(DOCUMENTER_INHERITED_FEATURE);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
169
|
return config;
|
|
318
170
|
}
|
|
319
171
|
|
|
@@ -323,7 +175,6 @@ function defaultConfig(): SubagentsConfig {
|
|
|
323
175
|
enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
|
|
324
176
|
agentModels: {},
|
|
325
177
|
agentThinkingLevels: {},
|
|
326
|
-
announcedFeatures: [],
|
|
327
178
|
};
|
|
328
179
|
}
|
|
329
180
|
|
|
@@ -364,18 +215,6 @@ export async function loadConfig(configPath: string = getConfigPath()): Promise<
|
|
|
364
215
|
return config;
|
|
365
216
|
}
|
|
366
217
|
|
|
367
|
-
/**
|
|
368
|
-
* Synchronous load for the extension's init-time decisions (e.g. the recursion
|
|
369
|
-
* guard). Runs before any async context is available; never migrates or saves.
|
|
370
|
-
*/
|
|
371
|
-
export function loadConfigSync(configPath: string = getConfigPath()): SubagentsConfig {
|
|
372
|
-
try {
|
|
373
|
-
return normalizeConfig(JSON.parse(readFileSync(configPath, "utf8")));
|
|
374
|
-
} catch {
|
|
375
|
-
return defaultConfig();
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
218
|
/**
|
|
380
219
|
* Save config atomically (temp file + rename) serialized through pi's per-file
|
|
381
220
|
* mutation queue so concurrent writers cannot interleave.
|
package/src/dispatch.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { realpath } from "node:fs/promises";
|
|
|
15
15
|
import { resolve } from "node:path";
|
|
16
16
|
import { Type } from "typebox";
|
|
17
17
|
import { discoverAgents, resolveAgentTools, type AgentConfig } from "./agents.ts";
|
|
18
|
+
import { MAX_CONCURRENT_SUBAGENTS } from "./background.ts";
|
|
18
19
|
import { loadConfig } from "./config.ts";
|
|
19
20
|
import { formatUsage, queuedResult } from "./format.ts";
|
|
20
21
|
import {
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
buildFixTaskBrief,
|
|
24
25
|
buildReReviewBrief,
|
|
25
26
|
documentationDisposition,
|
|
27
|
+
MAX_FIX_ROUNDS,
|
|
26
28
|
type ChainStep,
|
|
27
29
|
type ManagedWorkflowOutcome,
|
|
28
30
|
} from "./fixloop.ts";
|
|
@@ -193,8 +195,6 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
193
195
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
194
196
|
monitor.beginTurn();
|
|
195
197
|
const config = await loadConfig(runtime.configPath);
|
|
196
|
-
// Pick up concurrency changes from /subagents-setup without a restart.
|
|
197
|
-
runtime.backgroundQueue.setConcurrency(config.maxConcurrency);
|
|
198
198
|
|
|
199
199
|
// Finished runs leave the active monitor immediately. Their final findings
|
|
200
200
|
// are sent as a custom message that starts a follow-up turn.
|
|
@@ -493,19 +493,19 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
493
493
|
): Promise<{ lastReview?: SingleResult; lastWorker?: SingleResult }> => {
|
|
494
494
|
let lastReviewer = triggeringReviewer;
|
|
495
495
|
const outcome: { lastReview?: SingleResult; lastWorker?: SingleResult } = {};
|
|
496
|
-
for (let round = 1; round <=
|
|
496
|
+
for (let round = 1; round <= MAX_FIX_ROUNDS; round++) {
|
|
497
497
|
if (!canContinue()) break;
|
|
498
|
-
const fixRelation = `fix ${round}/${
|
|
498
|
+
const fixRelation = `fix ${round}/${MAX_FIX_ROUNDS}`;
|
|
499
499
|
const workerResult = await launchStep(
|
|
500
500
|
"worker",
|
|
501
|
-
buildFixTaskBrief(lastReviewer, round,
|
|
501
|
+
buildFixTaskBrief(lastReviewer, round, MAX_FIX_ROUNDS),
|
|
502
502
|
`fix round ${round}`,
|
|
503
503
|
{ timelineRelation: fixRelation, childRelation: fixRelation },
|
|
504
504
|
);
|
|
505
505
|
if (isFailedResult(workerResult) || !canContinue()) break;
|
|
506
506
|
outcome.lastWorker = workerResult;
|
|
507
507
|
|
|
508
|
-
const reReviewRelation = `re-review ${round}/${
|
|
508
|
+
const reReviewRelation = `re-review ${round}/${MAX_FIX_ROUNDS}`;
|
|
509
509
|
const reviewResult = await launchStep(
|
|
510
510
|
"reviewer",
|
|
511
511
|
buildReReviewBrief(lastReviewer, round, workerResult, {
|
|
@@ -586,8 +586,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
586
586
|
!isFailedResult(gateReview) &&
|
|
587
587
|
canContinue() &&
|
|
588
588
|
reviewVerdict(getResultOutput(gateReview)) === "fail" &&
|
|
589
|
-
enabled("worker")
|
|
590
|
-
request.config.maxFixRounds > 0
|
|
589
|
+
enabled("worker")
|
|
591
590
|
) {
|
|
592
591
|
fixOutcome = await runFixRounds(gateReview);
|
|
593
592
|
}
|
|
@@ -620,12 +619,12 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
620
619
|
// Sub-agents intentionally detach from the foreground turn. This makes the
|
|
621
620
|
// editor available immediately; completion messages later wake the main agent.
|
|
622
621
|
if (params.tasks && params.tasks.length > 0) {
|
|
623
|
-
if (params.tasks.length >
|
|
622
|
+
if (params.tasks.length > MAX_CONCURRENT_SUBAGENTS) {
|
|
624
623
|
return {
|
|
625
624
|
content: [
|
|
626
625
|
{
|
|
627
626
|
type: "text",
|
|
628
|
-
text: `Too many parallel tasks (${params.tasks.length}). Max is ${
|
|
627
|
+
text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_CONCURRENT_SUBAGENTS}.`,
|
|
629
628
|
},
|
|
630
629
|
],
|
|
631
630
|
details: makeDetails("parallel", true)([]),
|
package/src/fixloop.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Successful top-level worker/cleaner runs continue through an independent
|
|
5
5
|
* code review gate; bounded worker → reviewer fix rounds close its findings.
|
|
6
|
+
* Gate findings carry concrete fix instructions that the worker implements
|
|
7
|
+
* unless it can justify a sounder fix and push back; re-review adjudicates on
|
|
8
|
+
* the resulting code (open findings plus fix-introduced defects only) so the
|
|
9
|
+
* rounds converge instead of re-auditing from scratch.
|
|
6
10
|
* Reviewers classify documentation drift explicitly, so the low-cost final
|
|
7
11
|
* documenter runs only when needed (or conservatively when an older/custom
|
|
8
12
|
* reviewer omits the marker). Direct passing/failing gates use the same policy.
|
|
@@ -14,7 +18,13 @@
|
|
|
14
18
|
import { isWriteCapableAgent, type AgentConfig } from "./agents.ts";
|
|
15
19
|
import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } from "./spawn.ts";
|
|
16
20
|
import { formatUsageCompact, sumUsage } from "./monitor.ts";
|
|
17
|
-
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Worker fixes allowed after REVIEW_FAIL. Each fix is followed by a reviewer
|
|
24
|
+
* re-review; this cap does not suppress the post-writer review gate or its
|
|
25
|
+
* conditional/reviewer-disabled documentation fallback.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_FIX_ROUNDS = 2;
|
|
18
28
|
|
|
19
29
|
/**
|
|
20
30
|
* Whether a completed result should trigger the auto-fix loop instead of being
|
|
@@ -23,8 +33,7 @@ import type { SubagentsConfig } from "./config.ts";
|
|
|
23
33
|
* normally. Loop-internal re-review results never reach this path (they are
|
|
24
34
|
* awaited inside the loop, not delivered through the completion flow).
|
|
25
35
|
*/
|
|
26
|
-
export function shouldTriggerFixLoop(result: SingleResult
|
|
27
|
-
if (config.maxFixRounds <= 0) return false;
|
|
36
|
+
export function shouldTriggerFixLoop(result: SingleResult): boolean {
|
|
28
37
|
if (result.agent !== "reviewer") return false;
|
|
29
38
|
if (isFailedResult(result)) return false;
|
|
30
39
|
// A dispatch crash (spawn infra, delivery API, ...) is never a real review
|
|
@@ -93,8 +102,8 @@ export function canStartManagedWorkflow(
|
|
|
93
102
|
if (isWriteCapableAgent(agent)) return true;
|
|
94
103
|
if (agent.name === "reviewer") {
|
|
95
104
|
// Hold a stable diff snapshot against every discoverable writer even when
|
|
96
|
-
// this review is advisory
|
|
97
|
-
//
|
|
105
|
+
// this review is advisory. Classification happens only after the read-only
|
|
106
|
+
// child returns, too late to acquire the lane safely.
|
|
98
107
|
return availability.writer;
|
|
99
108
|
}
|
|
100
109
|
return false;
|
|
@@ -104,7 +113,6 @@ export function canStartManagedWorkflow(
|
|
|
104
113
|
* machine verdict is advisory and cannot start any write-capable child. */
|
|
105
114
|
export function getManagedWorkflowPlan(
|
|
106
115
|
result: SingleResult,
|
|
107
|
-
config: SubagentsConfig,
|
|
108
116
|
availability: WorkflowAgentAvailability,
|
|
109
117
|
): ManagedWorkflowPlan | undefined {
|
|
110
118
|
if (result.parked || result.dispatchFailed || isFailedResult(result)) return undefined;
|
|
@@ -131,7 +139,7 @@ export function getManagedWorkflowPlan(
|
|
|
131
139
|
) {
|
|
132
140
|
return { kind: "review-pass-sync", initialRelation: "pre-documentation review" };
|
|
133
141
|
}
|
|
134
|
-
if (verdict === "fail" && availability.worker && shouldTriggerFixLoop(result
|
|
142
|
+
if (verdict === "fail" && availability.worker && shouldTriggerFixLoop(result)) {
|
|
135
143
|
return { kind: "auto-fix", initialRelation: "initial review" };
|
|
136
144
|
}
|
|
137
145
|
return undefined;
|
|
@@ -139,8 +147,9 @@ export function getManagedWorkflowPlan(
|
|
|
139
147
|
|
|
140
148
|
/**
|
|
141
149
|
* Build the worker task brief for one fix round from a reviewer's findings.
|
|
142
|
-
* The worker gets the full review text
|
|
143
|
-
*
|
|
150
|
+
* The worker gets the full review text — findings plus their fix instructions
|
|
151
|
+
* — and closes every finding either by implementing the instruction or by
|
|
152
|
+
* shipping a sounder fix with an explicit per-finding pushback.
|
|
144
153
|
*/
|
|
145
154
|
export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, maxRounds: number): string {
|
|
146
155
|
const review = getResultOutput(reviewerResult);
|
|
@@ -153,13 +162,15 @@ export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, m
|
|
|
153
162
|
review,
|
|
154
163
|
`---`,
|
|
155
164
|
``,
|
|
156
|
-
`
|
|
157
|
-
`
|
|
165
|
+
`Each finding in the reviewer's report carries a fix instruction. Close EVERY finding — there is no severity triage; all of them get fixed.`,
|
|
166
|
+
`You own the fix: when an instruction is factually wrong, clearly out of scope, or a sounder fix exists, implement YOUR fix instead,`,
|
|
167
|
+
`then push back explicitly per finding — cite it, refute the instruction's reasoning, and state what you shipped instead.`,
|
|
168
|
+
`A pushback without a working alternative or concrete reasoning will be re-opened.`,
|
|
158
169
|
`Do NOT refactor unrelated code beyond what the findings require.`,
|
|
159
170
|
`Synchronize any existing README/docs/examples/comments directly affected by your fixes; do not broaden into standalone documentation maintenance.`,
|
|
160
171
|
`Do NOT commit, push, publish, tag, or release; do not bump versions. The parent chain still owns re-review and any conditional final documentation sync.`,
|
|
161
172
|
`After editing, run the project's format/build/tests when they exist and report`,
|
|
162
|
-
`exactly what you changed (paths + short rationale) so a reviewer can verify.`,
|
|
173
|
+
`exactly what you changed (paths + short rationale) plus any pushback, so a reviewer can verify.`,
|
|
163
174
|
remaining > 0
|
|
164
175
|
? `A reviewer will re-review your changes automatically after you finish.`
|
|
165
176
|
: `This is the last auto-fix round; the workflow conditionally runs any needed final documentation sync and then delivers.`,
|
|
@@ -304,6 +315,8 @@ export function buildFinalReviewBrief(
|
|
|
304
315
|
``,
|
|
305
316
|
`Run \`git status\` and \`git diff\` and inspect the actual pending code; the report is context, not proof.`,
|
|
306
317
|
`Remain read-only. Verify correctness, regressions, and tests.`,
|
|
318
|
+
`Attach a concrete fix instruction to EVERY gate finding: what to change, where, and how to verify the fix.`,
|
|
319
|
+
`A worker will implement your instructions unless it can justify a sounder fix and push back, so make each instruction specific enough to act on.`,
|
|
307
320
|
...(options.documenterPending
|
|
308
321
|
? [
|
|
309
322
|
`A conditional documentation sync is available AFTER this gate, so documentation drift is not a code-gate finding.`,
|
|
@@ -321,10 +334,11 @@ export function buildFinalReviewBrief(
|
|
|
321
334
|
|
|
322
335
|
/**
|
|
323
336
|
* The re-review brief handed to the reviewer after a worker fix round. Includes
|
|
324
|
-
* the prior review and worker report so the reviewer can adjudicate
|
|
337
|
+
* the prior review and worker report so the reviewer can adjudicate pushback
|
|
325
338
|
* instead of restating findings. The convergence contract keeps rounds from
|
|
326
|
-
* ping-ponging:
|
|
327
|
-
*
|
|
339
|
+
* ping-ponging: judge the resulting code (not instruction obedience), rule on
|
|
340
|
+
* the open findings once, add only defects this round's edits introduced,
|
|
341
|
+
* never re-open a verified resolution.
|
|
328
342
|
*/
|
|
329
343
|
export function buildReReviewBrief(
|
|
330
344
|
reviewerResult: SingleResult,
|
|
@@ -342,16 +356,17 @@ export function buildReReviewBrief(
|
|
|
342
356
|
review,
|
|
343
357
|
`---`,
|
|
344
358
|
``,
|
|
345
|
-
`The worker's report (what it changed, plus any
|
|
359
|
+
`The worker's report (what it changed, plus any pushback where it replaced your fix instruction with its own fix):`,
|
|
346
360
|
`---`,
|
|
347
361
|
workerReport,
|
|
348
362
|
`---`,
|
|
349
363
|
``,
|
|
350
|
-
`Rule on EVERY previous finding: resolved, or still open.
|
|
351
|
-
`
|
|
352
|
-
`
|
|
353
|
-
`
|
|
354
|
-
`
|
|
364
|
+
`Rule on EVERY previous finding: resolved, or still open. Judge the code as it now stands — a finding is`,
|
|
365
|
+
`resolved when the pending diff fixes it soundly, whether or not the worker followed your fix instruction.`,
|
|
366
|
+
`A finding the worker pushed back on must be adjudicated ONCE — accept the worker's fix unless you can`,
|
|
367
|
+
`concretely refute its reasoning; never simply restate the finding for another round.`,
|
|
368
|
+
`Run \`git diff\` to see what changed, then add NEW findings only for defects this round's edits introduced or exposed.`,
|
|
369
|
+
`Re-review never opens findings unrelated to this round's edits; issues the earlier review missed belong to a fresh gate.`,
|
|
355
370
|
`Do NOT re-open a finding you verified as resolved.`,
|
|
356
371
|
...(options.documenterPending
|
|
357
372
|
? [
|
package/src/index.ts
CHANGED
|
@@ -86,7 +86,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
86
86
|
enabledNames: config.enabledAgents,
|
|
87
87
|
projectTrusted: ctx.isProjectTrusted?.() === true,
|
|
88
88
|
});
|
|
89
|
-
const directive = buildDelegationDirective(agents
|
|
89
|
+
const directive = buildDelegationDirective(agents);
|
|
90
90
|
if (!directive) return undefined;
|
|
91
91
|
return { systemPrompt: `${event.systemPrompt}\n${directive}` };
|
|
92
92
|
});
|
package/src/models.ts
CHANGED
|
@@ -86,6 +86,22 @@ export function findModelByRef(
|
|
|
86
86
|
return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** Split persisted agent model overrides into the ones Pi still reports as
|
|
90
|
+
* available and the stale ones. Stale refs are dropped at session start (with
|
|
91
|
+
* a user notice) so the config never carries models that can no longer run. */
|
|
92
|
+
export function filterUnavailableModelOverrides(
|
|
93
|
+
agentModels: Record<string, string>,
|
|
94
|
+
models: readonly Model<Api>[],
|
|
95
|
+
): { kept: Record<string, string>; dropped: Array<{ agent: string; ref: string }> } {
|
|
96
|
+
const kept: Record<string, string> = {};
|
|
97
|
+
const dropped: Array<{ agent: string; ref: string }> = [];
|
|
98
|
+
for (const [agent, ref] of Object.entries(agentModels)) {
|
|
99
|
+
if (findModelByRef(models, ref)) kept[agent] = ref;
|
|
100
|
+
else dropped.push({ agent, ref });
|
|
101
|
+
}
|
|
102
|
+
return { kept, dropped };
|
|
103
|
+
}
|
|
104
|
+
|
|
89
105
|
/**
|
|
90
106
|
* Resolve one agent's runtime route:
|
|
91
107
|
*
|
package/src/prompt.ts
CHANGED
|
@@ -13,7 +13,6 @@ function bullets(lines: readonly string[]): string {
|
|
|
13
13
|
|
|
14
14
|
export function buildDelegationDirective(
|
|
15
15
|
agents: AgentConfig[],
|
|
16
|
-
options: { maxFixRounds?: number } = {},
|
|
17
16
|
): string {
|
|
18
17
|
if (agents.length === 0) return "";
|
|
19
18
|
|
|
@@ -24,7 +23,7 @@ export function buildDelegationDirective(
|
|
|
24
23
|
const hasDocumenter = agents.some((agent) => agent.name === "documenter");
|
|
25
24
|
const hasReviewer = agents.some((agent) => agent.name === "reviewer");
|
|
26
25
|
const hasMultiple = agents.length > 1;
|
|
27
|
-
const autoFixEnabled = hasWorker
|
|
26
|
+
const autoFixEnabled = hasWorker;
|
|
28
27
|
const codeWriterNames = [
|
|
29
28
|
...(hasWorker ? ["worker"] : []),
|
|
30
29
|
...(hasCleaner ? ["cleaner"] : []),
|
|
@@ -101,10 +100,10 @@ export function buildDelegationDirective(
|
|
|
101
100
|
? [
|
|
102
101
|
...(hasDocumenter
|
|
103
102
|
? [
|
|
104
|
-
`A direct REVIEW_PASS with DOCUMENTATION: CLEAN delivers immediately; NEEDED or a missing marker runs one documentation sync. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps bounded worker/reviewer auto-fix, with docs considered only after its terminal REVIEW_PASS." : "cannot start fixes while worker
|
|
103
|
+
`A direct REVIEW_PASS with DOCUMENTATION: CLEAN delivers immediately; NEEDED or a missing marker runs one documentation sync. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps bounded worker/reviewer auto-fix, with docs considered only after its terminal REVIEW_PASS." : "cannot start fixes while worker is disabled."}`,
|
|
105
104
|
]
|
|
106
105
|
: []),
|
|
107
|
-
"Resolve every gate finding; do not bypass the
|
|
106
|
+
"Resolve every gate finding; do not bypass the auto-fix/re-review cap. A reviewer report without a standalone VERDICT is advisory and cannot trigger writes.",
|
|
108
107
|
"Use multi-model cross-review only when explicitly requested or for genuinely high-risk security, unsafe/FFI, persistence-migration, or concurrency changes.",
|
|
109
108
|
]
|
|
110
109
|
: []),
|
package/src/runtime.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { rmSync } from "node:fs";
|
|
13
|
-
import { BackgroundTaskQueue } from "./background.ts";
|
|
13
|
+
import { BackgroundTaskQueue, MAX_CONCURRENT_SUBAGENTS } from "./background.ts";
|
|
14
14
|
import {
|
|
15
15
|
completionGroupTriggersTurn,
|
|
16
16
|
createCompletionBatcher,
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type CompletionBatcher,
|
|
20
20
|
type CompletionMessageItem,
|
|
21
21
|
} from "./completion.ts";
|
|
22
|
-
import {
|
|
22
|
+
import { type ThinkingLevel } from "./config.ts";
|
|
23
23
|
import { isRunActiveStatus, monitor } from "./monitor.ts";
|
|
24
24
|
import {
|
|
25
25
|
persistRecoveryRecords,
|
|
@@ -124,10 +124,7 @@ export interface SubagentRuntime {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
|
|
127
|
-
|
|
128
|
-
// async load runs per tool call.
|
|
129
|
-
const initialConfig = loadConfigSync(configPath);
|
|
130
|
-
const backgroundQueue = new BackgroundTaskQueue(initialConfig.maxConcurrency);
|
|
127
|
+
const backgroundQueue = new BackgroundTaskQueue(MAX_CONCURRENT_SUBAGENTS);
|
|
131
128
|
|
|
132
129
|
const runtime: SubagentRuntime = {
|
|
133
130
|
configPath,
|
package/src/setup.ts
CHANGED
|
@@ -13,13 +13,9 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
|
13
13
|
import {
|
|
14
14
|
AGENT_SCOPE_VALUES,
|
|
15
15
|
BUILTIN_AGENT_NAMES,
|
|
16
|
-
CLEANER_DEFAULTED_FEATURE,
|
|
17
|
-
DOCUMENTER_DEFAULTED_FEATURE,
|
|
18
16
|
DEFAULT_CONFIG,
|
|
19
17
|
DEFAULT_ENABLED_AGENTS,
|
|
20
18
|
DEFAULT_IDLE_TIMEOUT_SEC,
|
|
21
|
-
DEFAULT_MAX_CONCURRENCY,
|
|
22
|
-
DEFAULT_MAX_FIX_ROUNDS,
|
|
23
19
|
DEFAULT_THINKING_LEVEL,
|
|
24
20
|
type AgentScope,
|
|
25
21
|
type SubagentsConfig,
|
|
@@ -243,8 +239,6 @@ async function pickInjection(ctx: ExtensionCommandContext, current: boolean): Pr
|
|
|
243
239
|
return choice.startsWith("On");
|
|
244
240
|
}
|
|
245
241
|
|
|
246
|
-
const CONCURRENCY_STEPS = [1, 2, 3, 4, 6, 8, 12, 16];
|
|
247
|
-
const FIX_ROUNDS_STEPS = [0, 1, 2, 3, 5];
|
|
248
242
|
const IDLE_TIMEOUT_STEPS = [0, 30, 60, 90, 120, 180, 300, 600];
|
|
249
243
|
|
|
250
244
|
async function pickCount(
|
|
@@ -299,22 +293,6 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
299
293
|
if (injection === undefined) return false;
|
|
300
294
|
const scope = await pickScope(ctx, base.agentScope);
|
|
301
295
|
if (scope === undefined) return false;
|
|
302
|
-
const maxConcurrency = await pickCount(
|
|
303
|
-
ctx,
|
|
304
|
-
"Max sub-agents running at once?",
|
|
305
|
-
CONCURRENCY_STEPS,
|
|
306
|
-
base.maxConcurrency,
|
|
307
|
-
DEFAULT_MAX_CONCURRENCY,
|
|
308
|
-
);
|
|
309
|
-
if (maxConcurrency === undefined) return false;
|
|
310
|
-
const maxFixRounds = await pickCount(
|
|
311
|
-
ctx,
|
|
312
|
-
"Reviewer worker-fix rounds? (0 = no automatic fixes)",
|
|
313
|
-
FIX_ROUNDS_STEPS,
|
|
314
|
-
base.maxFixRounds,
|
|
315
|
-
DEFAULT_MAX_FIX_ROUNDS,
|
|
316
|
-
);
|
|
317
|
-
if (maxFixRounds === undefined) return false;
|
|
318
296
|
const idleTimeoutSec = await pickCount(
|
|
319
297
|
ctx,
|
|
320
298
|
"Idle timeout in seconds? (0 = disabled)",
|
|
@@ -333,16 +311,7 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
333
311
|
maxResultLines: base.maxResultLines,
|
|
334
312
|
proactiveInjection: injection,
|
|
335
313
|
agentScope: scope,
|
|
336
|
-
maxConcurrency,
|
|
337
|
-
maxFixRounds,
|
|
338
314
|
idleTimeoutSec,
|
|
339
|
-
// Full setup is an explicit decision point: mark role-enable migrations as
|
|
340
|
-
// processed so the user's saved selection is kept as-is.
|
|
341
|
-
announcedFeatures: [...new Set([
|
|
342
|
-
...base.announcedFeatures,
|
|
343
|
-
CLEANER_DEFAULTED_FEATURE,
|
|
344
|
-
DOCUMENTER_DEFAULTED_FEATURE,
|
|
345
|
-
])],
|
|
346
315
|
};
|
|
347
316
|
await saveConfig(next, configPath);
|
|
348
317
|
ctx.ui.notify(`pi-subagents configured with Auto thinking. Saved to ${configPath}`, "info");
|
|
@@ -357,8 +326,6 @@ async function updateRuntimeSetting(
|
|
|
357
326
|
const choice = await ctx.ui.select("Runtime setting", [
|
|
358
327
|
"Proactive injection",
|
|
359
328
|
"Agent scope",
|
|
360
|
-
"Max concurrency",
|
|
361
|
-
"Reviewer worker-fix rounds",
|
|
362
329
|
"Idle timeout",
|
|
363
330
|
]);
|
|
364
331
|
if (choice === undefined) return undefined;
|
|
@@ -371,14 +338,6 @@ async function updateRuntimeSetting(
|
|
|
371
338
|
const value = await pickScope(ctx, config.agentScope);
|
|
372
339
|
if (value === undefined) continue;
|
|
373
340
|
next.agentScope = value;
|
|
374
|
-
} else if (choice.startsWith("Max concurrency")) {
|
|
375
|
-
const value = await pickCount(ctx, "Max sub-agents running at once?", CONCURRENCY_STEPS, config.maxConcurrency, DEFAULT_MAX_CONCURRENCY);
|
|
376
|
-
if (value === undefined) continue;
|
|
377
|
-
next.maxConcurrency = value;
|
|
378
|
-
} else if (choice.startsWith("Reviewer")) {
|
|
379
|
-
const value = await pickCount(ctx, "Reviewer worker-fix rounds?", FIX_ROUNDS_STEPS, config.maxFixRounds, DEFAULT_MAX_FIX_ROUNDS);
|
|
380
|
-
if (value === undefined) continue;
|
|
381
|
-
next.maxFixRounds = value;
|
|
382
341
|
} else {
|
|
383
342
|
const value = await pickCount(ctx, "Idle timeout in seconds?", IDLE_TIMEOUT_STEPS, config.idleTimeoutSec, DEFAULT_IDLE_TIMEOUT_SEC);
|
|
384
343
|
if (value === undefined) continue;
|
package/src/spawn.ts
CHANGED
|
@@ -272,7 +272,7 @@ export function isRetryableStartupFailure(result: SingleResult, durationMs: numb
|
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
|
|
275
|
-
return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or
|
|
275
|
+
return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or dispatch fewer sub-agents at once.`;
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
|
package/src/thread-lifecycle.ts
CHANGED
|
@@ -720,7 +720,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
720
720
|
if (!ownsResumeReservation(thread, reservation)) {
|
|
721
721
|
throw new Error(`Run #${runId} changed while resume configuration was loading.`);
|
|
722
722
|
}
|
|
723
|
-
runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
|
|
724
723
|
const currentAgents = discoverAgents(currentCtx.cwd, {
|
|
725
724
|
scope: currentConfig.agentScope,
|
|
726
725
|
enabledNames: currentConfig.enabledAgents,
|
|
@@ -888,7 +887,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
888
887
|
if (!ownsFork()) throw new Error(`Run #${runId} changed while its retained session was being forked.`);
|
|
889
888
|
const currentConfig = await loadConfig(runtime.configPath);
|
|
890
889
|
if (!ownsFork()) throw new Error(`Run #${runId} changed while fork configuration was loading.`);
|
|
891
|
-
runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
|
|
892
890
|
const currentAgents = discoverAgents(currentCtx.cwd, {
|
|
893
891
|
scope: currentConfig.agentScope,
|
|
894
892
|
enabledNames: currentConfig.enabledAgents,
|
|
@@ -971,7 +969,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
971
969
|
let activeIdleTimeoutMs = runConfig.idleTimeoutSec * 1000;
|
|
972
970
|
try {
|
|
973
971
|
const startConfig = await loadConfig(runtime.configPath);
|
|
974
|
-
runtime.backgroundQueue.setConcurrency(startConfig.maxConcurrency);
|
|
975
972
|
const resolvedStart = resolveDispatchModelRoute(agent, startConfig, runCtx);
|
|
976
973
|
activeRoute = isolation === "worktree"
|
|
977
974
|
? { ...resolvedStart, agent: withWorktreeSystemPrompt(resolvedStart.agent) }
|
|
@@ -1064,7 +1061,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
1064
1061
|
|
|
1065
1062
|
if (thread.retireOnSettle) runtime.retireThreadSession(thread);
|
|
1066
1063
|
let workflowOutcome: ManagedWorkflowOutcome | undefined;
|
|
1067
|
-
const workflowPlan = getManagedWorkflowPlan(result,
|
|
1064
|
+
const workflowPlan = getManagedWorkflowPlan(result, workflowAvailability);
|
|
1068
1065
|
if (workflowPlan && runtime.sessionActive) {
|
|
1069
1066
|
thread.state = "running";
|
|
1070
1067
|
// The stable parent row now represents workflow ownership, not whichever
|