@ferris1225/pi-subagents 4.1.7 → 4.1.9
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 +94 -65
- package/agents/cleaner.md +13 -14
- package/agents/documenter.md +10 -17
- package/agents/explorer.md +6 -16
- package/agents/reviewer.md +28 -29
- package/agents/worker.md +14 -33
- package/package.json +1 -1
- package/src/announcements.ts +30 -67
- package/src/background.ts +25 -12
- package/src/config.ts +9 -170
- package/src/dispatch.ts +721 -747
- package/src/durable.ts +336 -0
- package/src/fixloop.ts +37 -37
- package/src/format.ts +1 -8
- package/src/index.ts +8 -1
- package/src/models.ts +16 -0
- package/src/monitor.ts +28 -29
- package/src/prompt.ts +7 -8
- package/src/rpc-run.ts +22 -228
- package/src/runtime.ts +72 -50
- package/src/session-fork.ts +7 -2
- package/src/setup.ts +0 -41
- package/src/spawn.ts +32 -29
- package/src/temp-hygiene.ts +194 -0
- package/src/thread-lifecycle.ts +1410 -1327
- package/src/tools.ts +21 -108
- package/src/widget.ts +3 -3
- package/src/worktree.ts +144 -4
package/agents/worker.md
CHANGED
|
@@ -7,38 +7,20 @@ thinking: high
|
|
|
7
7
|
# balance quality against cost. No `tools` field => inherits all tools (full capability).
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
You are a worker agent with full capabilities
|
|
10
|
+
You are a worker agent with full capabilities in an isolated context window. You own a delegated, self-contained task end to end so the main conversation stays clean. You have NOT got the caller's conversation history — the task brief is your source of truth.
|
|
11
11
|
|
|
12
|
-
##
|
|
13
|
-
|
|
12
|
+
## Procedure
|
|
13
|
+
1. **Context.** Read the brief fully. Read referenced files before editing, and referenced images (screenshots, mockups, designs) too — the model receives them as attachments when it supports vision. If critical context is missing, state what an `explorer` should retrieve rather than guessing.
|
|
14
|
+
2. **Plan.** Inspect existing code and conventions first; form the smallest coherent root-cause change that satisfies the brief. Prefer the design that deletes complexity rather than rearranges it — if a simpler structure (fewer branches, modes, or layers) achieves the goal, take it, and decompose rather than push a file toward ~1000 lines. For a large task, note files to touch, order, and risks before editing. No unrelated refactors or standalone documentation work unless the brief asks.
|
|
15
|
+
3. **Implement.** Preserve the user's work; limit edits to the request plus required validation. Follow the project's error handling, naming, and style. Synchronize existing README/docs, examples, and comments directly affected by your change; do not defer obvious drift to another role.
|
|
16
|
+
4. **Verify.** Run the project's format/build/tests when they exist (e.g. `tsc --noEmit`, the test runner). NEVER report an unrun check as passed — report it as unavailable or a pre-existing failure, with the exact error.
|
|
17
|
+
5. **Handoff.** Return only the concrete outcome. Do not repeat the task brief, plan, root-cause investigation, or tool chronology. Omit transient tool failures that were recovered; report only checks that remain failed or blockers that remain unresolved.
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
### Phase 3 — Implement
|
|
22
|
-
Make the change. Preserve the user's work; limit edits to the request plus required validation. Follow the project's existing error handling, naming, and style. Synchronize any existing README/docs, examples, API comments, docstrings, and explanatory comments directly affected by your change; do not defer obvious drift to another role.
|
|
23
|
-
|
|
24
|
-
### Phase 4 — Verify
|
|
25
|
-
Run the project's format/build/tests when they exist (e.g. `tsc --noEmit`, the test runner). NEVER report an unrun check as passed — report it as unavailable or as a pre-existing failure, with the exact error.
|
|
26
|
-
|
|
27
|
-
### Phase 5 — Handoff
|
|
28
|
-
Return only the concrete outcome so the caller can verify it and, if needed, hand it to a `reviewer`. Do not repeat the task brief, plan, root-cause investigation, or tool chronology. Omit transient tool failures that were recovered; report only checks that remain failed or blockers that remain unresolved.
|
|
29
|
-
|
|
30
|
-
## Release boundary
|
|
31
|
-
Never commit, push, publish, tag, release, or bump a package version. The parent workflow owns the independent review gate, any conditional final documentation sync, and every release action—even when repository instructions normally automate release after green checks.
|
|
32
|
-
|
|
33
|
-
## Collaboration
|
|
34
|
-
- You cannot dispatch sub-agents (children are leaf processes with no `subagent` tool). When the
|
|
35
|
-
brief lacks context that needs broad code discovery, state concretely what an `explorer` should
|
|
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.
|
|
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.
|
|
19
|
+
## Boundaries
|
|
20
|
+
- Never commit, push, publish, tag, release, or bump a package version. The parent workflow owns the independent review gate, the conditional final documentation sync, and every release action — even when repository instructions normally automate release after green checks.
|
|
21
|
+
- Children are leaf processes: you cannot dispatch sub-agents. When the brief needs broad discovery, state what an `explorer` should retrieve; do not guess.
|
|
22
|
+
- In an auto-fix round, apply the reviewer's fix instructions: implement each when it is sound; when it is wrong, out of scope, or a sounder fix exists, ship your fix and push back in your report — cite the finding, refute the instruction's reasoning, and describe what you shipped instead. A deviation without reasoning will be re-opened.
|
|
23
|
+
- Do not ask the caller to duplicate downstream roles, and never treat your own verification as the final gate.
|
|
42
24
|
|
|
43
25
|
## Output format
|
|
44
26
|
## Completed
|
|
@@ -48,9 +30,8 @@ What was done, in a few lines.
|
|
|
48
30
|
## Verification
|
|
49
31
|
Which checks you ACTUALLY ran and their result (e.g. `tsc --noEmit` clean; `vitest` 12 passed). State explicitly anything you could not run and why.
|
|
50
32
|
## Notes (only when material)
|
|
51
|
-
Unresolved blockers, rejected requirements, or decisions the caller must know. For a reviewer handoff: exact
|
|
33
|
+
Unresolved blockers, rejected requirements, or decisions the caller must know. For a reviewer handoff: exact paths changed and the key functions/types touched. Omit the section when there is nothing actionable to add.
|
|
52
34
|
|
|
53
35
|
Keep the final response comfortably below the 80-line delivery cap unless the result genuinely requires more.
|
|
54
36
|
|
|
55
|
-
|
|
56
|
-
Root-cause fixes over patches. No unrelated churn. Honest verification — an unrun check is never a passed check.
|
|
37
|
+
Root-cause fixes over patches; no unrelated churn; an unrun check is never a passed check.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.9",
|
|
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
|
-
|
|
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",
|
|
68
32
|
);
|
|
69
|
-
if (pending.length === 0) return;
|
|
70
|
-
await saveConfig(
|
|
71
|
-
{
|
|
72
|
-
...config,
|
|
73
|
-
announcedFeatures: [...config.announcedFeatures, ...pending.map((announcement) => announcement.key)],
|
|
74
|
-
},
|
|
75
|
-
runtime.configPath,
|
|
76
|
-
);
|
|
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,16 @@ 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);
|
|
43
|
+
if (!runtime.restoredNotified && runtime.restoredRunIds.length > 0) {
|
|
44
|
+
runtime.restoredNotified = true;
|
|
45
|
+
const ids = runtime.restoredRunIds.map((id) => `#${id}`).join(", ");
|
|
46
|
+
ctx.ui.notify(
|
|
47
|
+
`pi-subagents: restored ${runtime.restoredRunIds.length} resumable thread${runtime.restoredRunIds.length === 1 ? "" : "s"} from the previous session (${ids}). subagent_status lists them; subagent_control resume continues one.`,
|
|
48
|
+
"info",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
87
51
|
if (ctx.mode !== "tui") return;
|
|
88
52
|
installActiveRunsWidget(ctx);
|
|
89
|
-
await announceNewFeatures(ctx, runtime);
|
|
90
53
|
});
|
|
91
54
|
}
|
package/src/background.ts
CHANGED
|
@@ -27,10 +27,21 @@ 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. Only manually
|
|
33
|
+
* dispatched top-level generations hold slots; runtime-initiated managed
|
|
34
|
+
* continuations (gate reviews, auto-fix rounds, documentation sync) suspend
|
|
35
|
+
* their task's slot so they never starve manual dispatches. */
|
|
36
|
+
export const MAX_CONCURRENT_SUBAGENTS = 4;
|
|
37
|
+
|
|
30
38
|
export class BackgroundTaskQueue {
|
|
31
39
|
private concurrency: number;
|
|
32
40
|
private readonly pending: PendingTask[] = [];
|
|
33
41
|
private readonly active = new Set<AbortController>();
|
|
42
|
+
/** Active tasks that no longer count toward the concurrency limit. They keep
|
|
43
|
+
* every other guarantee: abortable, awaited by waitForTask/waitForIdle. */
|
|
44
|
+
private readonly suspended = new Set<AbortController>();
|
|
34
45
|
private readonly completions = new WeakMap<AbortController, Promise<void>>();
|
|
35
46
|
private readonly idleWaiters = new Set<() => void>();
|
|
36
47
|
private stopped = false;
|
|
@@ -39,16 +50,6 @@ export class BackgroundTaskQueue {
|
|
|
39
50
|
this.concurrency = Math.max(1, concurrency);
|
|
40
51
|
}
|
|
41
52
|
|
|
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
53
|
enqueue(task: BackgroundTask, onCancelled?: () => void, onError?: (error: unknown) => void | Promise<void>): AbortController {
|
|
53
54
|
const controller = new AbortController();
|
|
54
55
|
let complete!: () => void;
|
|
@@ -76,6 +77,16 @@ export class BackgroundTaskQueue {
|
|
|
76
77
|
return this.completions.get(controller) ?? Promise.resolve();
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/** Stop counting a running task toward the concurrency limit. Its body keeps
|
|
81
|
+
* running under the same abort signal; completion still releases everything
|
|
82
|
+
* waitForTask/waitForIdle promise. Frees a slot for queued work immediately. */
|
|
83
|
+
suspend(controller: AbortController | undefined): void {
|
|
84
|
+
if (!controller || this.stopped) return;
|
|
85
|
+
if (!this.active.delete(controller)) return;
|
|
86
|
+
this.suspended.add(controller);
|
|
87
|
+
this.drain();
|
|
88
|
+
}
|
|
89
|
+
|
|
79
90
|
/** Cancel one queued/running task. Queued entries are removed immediately;
|
|
80
91
|
* active entries resolve waitForTask only after their body and error handler
|
|
81
92
|
* have quiesced and the concurrency slot has been released. */
|
|
@@ -94,7 +105,7 @@ export class BackgroundTaskQueue {
|
|
|
94
105
|
|
|
95
106
|
/** Resolve once no queued or running task remains. */
|
|
96
107
|
waitForIdle(): Promise<void> {
|
|
97
|
-
if (this.pending.length === 0 && this.active.size === 0) return Promise.resolve();
|
|
108
|
+
if (this.pending.length === 0 && this.active.size === 0 && this.suspended.size === 0) return Promise.resolve();
|
|
98
109
|
return new Promise<void>((resolve) => this.idleWaiters.add(resolve));
|
|
99
110
|
}
|
|
100
111
|
|
|
@@ -109,6 +120,7 @@ export class BackgroundTaskQueue {
|
|
|
109
120
|
entry.complete();
|
|
110
121
|
}
|
|
111
122
|
for (const controller of this.active) controller.abort();
|
|
123
|
+
for (const controller of this.suspended) controller.abort();
|
|
112
124
|
this.resolveIdleWaiters();
|
|
113
125
|
}
|
|
114
126
|
|
|
@@ -150,6 +162,7 @@ export class BackgroundTaskQueue {
|
|
|
150
162
|
})
|
|
151
163
|
.finally(() => {
|
|
152
164
|
this.active.delete(entry.controller);
|
|
165
|
+
this.suspended.delete(entry.controller);
|
|
153
166
|
entry.complete();
|
|
154
167
|
this.drain();
|
|
155
168
|
this.resolveIdleWaiters();
|
|
@@ -158,7 +171,7 @@ export class BackgroundTaskQueue {
|
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
private resolveIdleWaiters(): void {
|
|
161
|
-
if (this.pending.length > 0 || this.active.size > 0) return;
|
|
174
|
+
if (this.pending.length > 0 || this.active.size > 0 || this.suspended.size > 0) return;
|
|
162
175
|
for (const resolve of this.idleWaiters) resolve();
|
|
163
176
|
this.idleWaiters.clear();
|
|
164
177
|
}
|
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.
|