@ferris1225/pi-subagents 4.0.0 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -15
- package/agents/cleaner.md +41 -41
- package/agents/reviewer.md +70 -70
- package/package.json +1 -1
- package/src/announcements.ts +17 -7
- package/src/completion.ts +160 -160
- package/src/config.ts +79 -8
- package/src/index.ts +93 -93
- package/src/models.ts +189 -189
- package/src/recovery.ts +145 -145
- package/src/session-fork.ts +80 -80
- package/src/setup.ts +15 -1
package/src/completion.ts
CHANGED
|
@@ -1,160 +1,160 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Smart batching for successful background completions.
|
|
3
|
-
*
|
|
4
|
-
* A short debounce coalesces sibling runs while a max-wait timer, measured from
|
|
5
|
-
* the first item in the open group, bounds delivery latency. Failures are
|
|
6
|
-
* intentionally handled by the caller: flush held successes, then emit the
|
|
7
|
-
* failure directly so it is never delayed.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } from "./spawn.ts";
|
|
11
|
-
import { formatUsageCompact, sumUsage } from "./monitor.ts";
|
|
12
|
-
import type { UsageStats } from "./rpc-run.ts";
|
|
13
|
-
|
|
14
|
-
export interface CompletionBatchTimings {
|
|
15
|
-
debounceMs: number;
|
|
16
|
-
maxWaitMs: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export const DEFAULT_COMPLETION_BATCH_TIMINGS: CompletionBatchTimings = {
|
|
20
|
-
debounceMs: 150,
|
|
21
|
-
maxWaitMs: 1_000,
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
25
|
-
|
|
26
|
-
function unrefHandle(handle: TimerHandle): void {
|
|
27
|
-
if (
|
|
28
|
-
handle &&
|
|
29
|
-
typeof handle === "object" &&
|
|
30
|
-
"unref" in handle &&
|
|
31
|
-
typeof (handle as { unref: unknown }).unref === "function"
|
|
32
|
-
) {
|
|
33
|
-
(handle as { unref: () => void }).unref();
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface CompletionBatcherOptions<T> {
|
|
38
|
-
emit: (items: T[]) => void;
|
|
39
|
-
timings?: Partial<CompletionBatchTimings>;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface CompletionBatcher<T> {
|
|
43
|
-
/** Add an item to the current debounced group. */
|
|
44
|
-
push(item: T): void;
|
|
45
|
-
/** Emit any held items immediately as one group. */
|
|
46
|
-
flush(): void;
|
|
47
|
-
/** Clear timers and return held items without emitting them. */
|
|
48
|
-
dispose(): T[];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function createCompletionBatcher<T>(options: CompletionBatcherOptions<T>): CompletionBatcher<T> {
|
|
52
|
-
const timings = { ...DEFAULT_COMPLETION_BATCH_TIMINGS, ...options.timings };
|
|
53
|
-
let pending: T[] = [];
|
|
54
|
-
let debounceTimer: TimerHandle | null = null;
|
|
55
|
-
let maxWaitTimer: TimerHandle | null = null;
|
|
56
|
-
|
|
57
|
-
const clearTimers = (): void => {
|
|
58
|
-
if (debounceTimer !== null) {
|
|
59
|
-
clearTimeout(debounceTimer);
|
|
60
|
-
debounceTimer = null;
|
|
61
|
-
}
|
|
62
|
-
if (maxWaitTimer !== null) {
|
|
63
|
-
clearTimeout(maxWaitTimer);
|
|
64
|
-
maxWaitTimer = null;
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const emitGroup = (): void => {
|
|
69
|
-
clearTimers();
|
|
70
|
-
if (pending.length === 0) return;
|
|
71
|
-
const items = pending;
|
|
72
|
-
pending = [];
|
|
73
|
-
options.emit(items);
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
return {
|
|
77
|
-
push(item: T): void {
|
|
78
|
-
pending.push(item);
|
|
79
|
-
|
|
80
|
-
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
|
81
|
-
debounceTimer = setTimeout(emitGroup, timings.debounceMs);
|
|
82
|
-
unrefHandle(debounceTimer);
|
|
83
|
-
|
|
84
|
-
if (maxWaitTimer === null) {
|
|
85
|
-
maxWaitTimer = setTimeout(emitGroup, timings.maxWaitMs);
|
|
86
|
-
unrefHandle(maxWaitTimer);
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
flush: emitGroup,
|
|
90
|
-
dispose(): T[] {
|
|
91
|
-
clearTimers();
|
|
92
|
-
const abandoned = pending;
|
|
93
|
-
pending = [];
|
|
94
|
-
return abandoned;
|
|
95
|
-
},
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export interface CompletionMessageItem {
|
|
100
|
-
agent: string;
|
|
101
|
-
block: string;
|
|
102
|
-
triggerTurn: boolean;
|
|
103
|
-
/** Final usage of the underlying run (or chain); aggregated into the group totals. */
|
|
104
|
-
usage?: UsageStats;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Keep the established single-result shape; add a group header and an aggregate
|
|
108
|
-
* token/cost footer only for real groups. */
|
|
109
|
-
export function formatCompletionMessage(items: readonly CompletionMessageItem[]): string {
|
|
110
|
-
if (items.length === 0) return "";
|
|
111
|
-
if (items.length === 1) return items[0].block;
|
|
112
|
-
const agents = items.map((item) => item.agent).join(", ");
|
|
113
|
-
const withUsage = items.filter((item) => item.usage !== undefined);
|
|
114
|
-
const totals = withUsage.length > 0 ? formatUsageCompact(sumUsage(withUsage.map((item) => item.usage!))) : "";
|
|
115
|
-
const footer = totals ? `\n\nTotals: ${items.length} runs · ${totals}` : "";
|
|
116
|
-
return `### Subagents completed (${items.length}): ${agents}\n\n${items.map((item) => item.block).join("\n\n")}${footer}`;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** A grouped completion wakes the main agent when any member requires a turn. */
|
|
120
|
-
export function completionGroupTriggersTurn(items: readonly CompletionMessageItem[]): boolean {
|
|
121
|
-
return items.some((item) => item.triggerTurn);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Passing reviewer notifications may opt out of waking; every other result wakes. */
|
|
125
|
-
export function completionTriggersTurn(result: SingleResult, notifyOnReviewPass: boolean): boolean {
|
|
126
|
-
if (isFailedResult(result)) return true;
|
|
127
|
-
return !(
|
|
128
|
-
notifyOnReviewPass &&
|
|
129
|
-
result.agent === "reviewer" &&
|
|
130
|
-
reviewVerdict(getResultOutput(result)) === "pass"
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Minimal shape of an active run, for the "others still running" footer. Kept
|
|
135
|
-
* decoupled from the monitor's RunView so this stays a pure, easily tested
|
|
136
|
-
* formatter; the caller maps its live runs into this shape. */
|
|
137
|
-
export interface ActiveRunFoot {
|
|
138
|
-
id: number;
|
|
139
|
-
agent: string;
|
|
140
|
-
/** Optional content label (task-derived) shown next to the agent name. */
|
|
141
|
-
label?: string;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Footer appended to a completion message when OTHER runs are still active, so
|
|
146
|
-
* the main agent does not declare the overall task done prematurely. A result
|
|
147
|
-
* arriving for one run does not mean sibling runs are finished; naming them
|
|
148
|
-
* gives the main agent concrete, in-context awareness to keep waiting.
|
|
149
|
-
*
|
|
150
|
-
* Returns "" when nothing is active (the common, single-run case stays quiet).
|
|
151
|
-
*/
|
|
152
|
-
export function formatActiveRunsFooter(runs: readonly ActiveRunFoot[], maxListed = 4): string {
|
|
153
|
-
if (runs.length === 0) return "";
|
|
154
|
-
const listed = runs.slice(0, maxListed);
|
|
155
|
-
const items = listed
|
|
156
|
-
.map((run) => `#${run.id} ${run.agent}${run.label ? `·${run.label}` : ""}`)
|
|
157
|
-
.join(", ");
|
|
158
|
-
const more = runs.length > listed.length ? `, +${runs.length - listed.length} more` : "";
|
|
159
|
-
return `\n\n⚠ ${runs.length} other run${runs.length === 1 ? "" : "s"} still active: ${items}${more}. Do not conclude the overall task yet — wait for their results (they wake you automatically) or check subagent_status.`;
|
|
160
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Smart batching for successful background completions.
|
|
3
|
+
*
|
|
4
|
+
* A short debounce coalesces sibling runs while a max-wait timer, measured from
|
|
5
|
+
* the first item in the open group, bounds delivery latency. Failures are
|
|
6
|
+
* intentionally handled by the caller: flush held successes, then emit the
|
|
7
|
+
* failure directly so it is never delayed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } from "./spawn.ts";
|
|
11
|
+
import { formatUsageCompact, sumUsage } from "./monitor.ts";
|
|
12
|
+
import type { UsageStats } from "./rpc-run.ts";
|
|
13
|
+
|
|
14
|
+
export interface CompletionBatchTimings {
|
|
15
|
+
debounceMs: number;
|
|
16
|
+
maxWaitMs: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_COMPLETION_BATCH_TIMINGS: CompletionBatchTimings = {
|
|
20
|
+
debounceMs: 150,
|
|
21
|
+
maxWaitMs: 1_000,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
25
|
+
|
|
26
|
+
function unrefHandle(handle: TimerHandle): void {
|
|
27
|
+
if (
|
|
28
|
+
handle &&
|
|
29
|
+
typeof handle === "object" &&
|
|
30
|
+
"unref" in handle &&
|
|
31
|
+
typeof (handle as { unref: unknown }).unref === "function"
|
|
32
|
+
) {
|
|
33
|
+
(handle as { unref: () => void }).unref();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CompletionBatcherOptions<T> {
|
|
38
|
+
emit: (items: T[]) => void;
|
|
39
|
+
timings?: Partial<CompletionBatchTimings>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CompletionBatcher<T> {
|
|
43
|
+
/** Add an item to the current debounced group. */
|
|
44
|
+
push(item: T): void;
|
|
45
|
+
/** Emit any held items immediately as one group. */
|
|
46
|
+
flush(): void;
|
|
47
|
+
/** Clear timers and return held items without emitting them. */
|
|
48
|
+
dispose(): T[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createCompletionBatcher<T>(options: CompletionBatcherOptions<T>): CompletionBatcher<T> {
|
|
52
|
+
const timings = { ...DEFAULT_COMPLETION_BATCH_TIMINGS, ...options.timings };
|
|
53
|
+
let pending: T[] = [];
|
|
54
|
+
let debounceTimer: TimerHandle | null = null;
|
|
55
|
+
let maxWaitTimer: TimerHandle | null = null;
|
|
56
|
+
|
|
57
|
+
const clearTimers = (): void => {
|
|
58
|
+
if (debounceTimer !== null) {
|
|
59
|
+
clearTimeout(debounceTimer);
|
|
60
|
+
debounceTimer = null;
|
|
61
|
+
}
|
|
62
|
+
if (maxWaitTimer !== null) {
|
|
63
|
+
clearTimeout(maxWaitTimer);
|
|
64
|
+
maxWaitTimer = null;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const emitGroup = (): void => {
|
|
69
|
+
clearTimers();
|
|
70
|
+
if (pending.length === 0) return;
|
|
71
|
+
const items = pending;
|
|
72
|
+
pending = [];
|
|
73
|
+
options.emit(items);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
push(item: T): void {
|
|
78
|
+
pending.push(item);
|
|
79
|
+
|
|
80
|
+
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
|
81
|
+
debounceTimer = setTimeout(emitGroup, timings.debounceMs);
|
|
82
|
+
unrefHandle(debounceTimer);
|
|
83
|
+
|
|
84
|
+
if (maxWaitTimer === null) {
|
|
85
|
+
maxWaitTimer = setTimeout(emitGroup, timings.maxWaitMs);
|
|
86
|
+
unrefHandle(maxWaitTimer);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
flush: emitGroup,
|
|
90
|
+
dispose(): T[] {
|
|
91
|
+
clearTimers();
|
|
92
|
+
const abandoned = pending;
|
|
93
|
+
pending = [];
|
|
94
|
+
return abandoned;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface CompletionMessageItem {
|
|
100
|
+
agent: string;
|
|
101
|
+
block: string;
|
|
102
|
+
triggerTurn: boolean;
|
|
103
|
+
/** Final usage of the underlying run (or chain); aggregated into the group totals. */
|
|
104
|
+
usage?: UsageStats;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Keep the established single-result shape; add a group header and an aggregate
|
|
108
|
+
* token/cost footer only for real groups. */
|
|
109
|
+
export function formatCompletionMessage(items: readonly CompletionMessageItem[]): string {
|
|
110
|
+
if (items.length === 0) return "";
|
|
111
|
+
if (items.length === 1) return items[0].block;
|
|
112
|
+
const agents = items.map((item) => item.agent).join(", ");
|
|
113
|
+
const withUsage = items.filter((item) => item.usage !== undefined);
|
|
114
|
+
const totals = withUsage.length > 0 ? formatUsageCompact(sumUsage(withUsage.map((item) => item.usage!))) : "";
|
|
115
|
+
const footer = totals ? `\n\nTotals: ${items.length} runs · ${totals}` : "";
|
|
116
|
+
return `### Subagents completed (${items.length}): ${agents}\n\n${items.map((item) => item.block).join("\n\n")}${footer}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A grouped completion wakes the main agent when any member requires a turn. */
|
|
120
|
+
export function completionGroupTriggersTurn(items: readonly CompletionMessageItem[]): boolean {
|
|
121
|
+
return items.some((item) => item.triggerTurn);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Passing reviewer notifications may opt out of waking; every other result wakes. */
|
|
125
|
+
export function completionTriggersTurn(result: SingleResult, notifyOnReviewPass: boolean): boolean {
|
|
126
|
+
if (isFailedResult(result)) return true;
|
|
127
|
+
return !(
|
|
128
|
+
notifyOnReviewPass &&
|
|
129
|
+
result.agent === "reviewer" &&
|
|
130
|
+
reviewVerdict(getResultOutput(result)) === "pass"
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Minimal shape of an active run, for the "others still running" footer. Kept
|
|
135
|
+
* decoupled from the monitor's RunView so this stays a pure, easily tested
|
|
136
|
+
* formatter; the caller maps its live runs into this shape. */
|
|
137
|
+
export interface ActiveRunFoot {
|
|
138
|
+
id: number;
|
|
139
|
+
agent: string;
|
|
140
|
+
/** Optional content label (task-derived) shown next to the agent name. */
|
|
141
|
+
label?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Footer appended to a completion message when OTHER runs are still active, so
|
|
146
|
+
* the main agent does not declare the overall task done prematurely. A result
|
|
147
|
+
* arriving for one run does not mean sibling runs are finished; naming them
|
|
148
|
+
* gives the main agent concrete, in-context awareness to keep waiting.
|
|
149
|
+
*
|
|
150
|
+
* Returns "" when nothing is active (the common, single-run case stays quiet).
|
|
151
|
+
*/
|
|
152
|
+
export function formatActiveRunsFooter(runs: readonly ActiveRunFoot[], maxListed = 4): string {
|
|
153
|
+
if (runs.length === 0) return "";
|
|
154
|
+
const listed = runs.slice(0, maxListed);
|
|
155
|
+
const items = listed
|
|
156
|
+
.map((run) => `#${run.id} ${run.agent}${run.label ? `·${run.label}` : ""}`)
|
|
157
|
+
.join(", ");
|
|
158
|
+
const more = runs.length > listed.length ? `, +${runs.length - listed.length} more` : "";
|
|
159
|
+
return `\n\n⚠ ${runs.length} other run${runs.length === 1 ? "" : "s"} still active: ${items}${more}. Do not conclude the overall task yet — wait for their results (they wake you automatically) or check subagent_status.`;
|
|
160
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -25,6 +25,27 @@ export const DEFAULT_ENABLED_AGENTS: readonly string[] = ["explorer", "worker",
|
|
|
25
25
|
export const AGENT_SCOPE_VALUES = ["user", "project", "both"] as const;
|
|
26
26
|
export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
|
|
27
27
|
|
|
28
|
+
const LEGACY_EXPLORER_NAME = "explore";
|
|
29
|
+
const EXPLORER_NAME = "explorer";
|
|
30
|
+
const CLEANER_NAME = "cleaner";
|
|
31
|
+
const REVIEWER_NAME = "reviewer";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Stamps recorded in `announcedFeatures` by the one-time upgrade that defaults
|
|
35
|
+
* cleaner on for configs written before it shipped. The first marks a config as
|
|
36
|
+
* processed, so a later deliberate disable is not undone; the second records
|
|
37
|
+
* that cleaner was actually injected, so session start can tell the user once;
|
|
38
|
+
* the third records that reviewer model/thinking settings were actually copied,
|
|
39
|
+
* so that notice never claims an inheritance that did not happen.
|
|
40
|
+
*/
|
|
41
|
+
export const CLEANER_DEFAULTED_FEATURE = "cleanerDefaulted";
|
|
42
|
+
export const CLEANER_AUTO_ENABLED_FEATURE = "cleanerAutoEnabled";
|
|
43
|
+
export const CLEANER_INHERITED_FEATURE = "cleanerInheritedReviewer";
|
|
44
|
+
|
|
45
|
+
function migrateAgentName(name: string): string {
|
|
46
|
+
return name === LEGACY_EXPLORER_NAME ? EXPLORER_NAME : name;
|
|
47
|
+
}
|
|
48
|
+
|
|
28
49
|
/** Thinking levels accepted by pi's `--thinking` option. */
|
|
29
50
|
export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
30
51
|
export type ThinkingLevel = (typeof THINKING_LEVEL_VALUES)[number];
|
|
@@ -99,8 +120,9 @@ export interface SubagentsConfig {
|
|
|
99
120
|
*/
|
|
100
121
|
idleTimeoutSec: number;
|
|
101
122
|
/**
|
|
102
|
-
* One-time feature announcements already shown to the user
|
|
103
|
-
* the
|
|
123
|
+
* One-time feature announcements already shown to the user, plus schema
|
|
124
|
+
* upgrade stamps (e.g. the cleaner default-enable upgrade). Persisted so
|
|
125
|
+
* notices and migrations never repeat.
|
|
104
126
|
*/
|
|
105
127
|
announcedFeatures: string[];
|
|
106
128
|
}
|
|
@@ -156,25 +178,48 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
|
|
|
156
178
|
const names = raw.enabledAgents.filter(
|
|
157
179
|
(name): name is string => typeof name === "string" && name.trim().length > 0,
|
|
158
180
|
);
|
|
159
|
-
// An explicitly empty array is honored
|
|
160
|
-
config
|
|
181
|
+
// An explicitly empty array is honored. Rename the former built-in key and
|
|
182
|
+
// deduplicate when a config already contains both spellings.
|
|
183
|
+
config.enabledAgents = [...new Set(names.map((name) => migrateAgentName(name.trim())))];
|
|
161
184
|
}
|
|
162
185
|
|
|
163
186
|
if (isRecord(raw.agentModels)) {
|
|
164
|
-
|
|
165
|
-
|
|
187
|
+
const entries = Object.entries(raw.agentModels);
|
|
188
|
+
// A valid explicit new key wins regardless of JSON property order.
|
|
189
|
+
for (const [rawKey, value] of entries) {
|
|
190
|
+
const key = rawKey.trim();
|
|
191
|
+
if (key !== LEGACY_EXPLORER_NAME && isModelReference(value)) {
|
|
192
|
+
config.agentModels[key] = value.trim();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!Object.hasOwn(config.agentModels, EXPLORER_NAME)) {
|
|
196
|
+
const legacy = entries.find(([key, value]) =>
|
|
197
|
+
key.trim() === LEGACY_EXPLORER_NAME && isModelReference(value)
|
|
198
|
+
);
|
|
199
|
+
if (legacy && isModelReference(legacy[1])) config.agentModels[EXPLORER_NAME] = legacy[1].trim();
|
|
166
200
|
}
|
|
167
201
|
}
|
|
168
202
|
|
|
169
203
|
if (isRecord(raw.agentThinkingLevels)) {
|
|
170
|
-
|
|
204
|
+
const entries = Object.entries(raw.agentThinkingLevels);
|
|
205
|
+
for (const [rawKey, value] of entries) {
|
|
206
|
+
const key = rawKey.trim();
|
|
171
207
|
if (
|
|
208
|
+
key !== LEGACY_EXPLORER_NAME &&
|
|
172
209
|
typeof value === "string" &&
|
|
173
210
|
(THINKING_LEVEL_VALUES as readonly string[]).includes(value)
|
|
174
211
|
) {
|
|
175
|
-
config.agentThinkingLevels[key
|
|
212
|
+
config.agentThinkingLevels[key] = value as ThinkingLevel;
|
|
176
213
|
}
|
|
177
214
|
}
|
|
215
|
+
if (!Object.hasOwn(config.agentThinkingLevels, EXPLORER_NAME)) {
|
|
216
|
+
const legacy = entries.find(([key, value]) =>
|
|
217
|
+
key.trim() === LEGACY_EXPLORER_NAME &&
|
|
218
|
+
typeof value === "string" &&
|
|
219
|
+
(THINKING_LEVEL_VALUES as readonly string[]).includes(value)
|
|
220
|
+
);
|
|
221
|
+
if (legacy) config.agentThinkingLevels[EXPLORER_NAME] = legacy[1] as ThinkingLevel;
|
|
222
|
+
}
|
|
178
223
|
}
|
|
179
224
|
|
|
180
225
|
if (typeof raw.notifyOnReviewPass === "boolean") {
|
|
@@ -211,6 +256,32 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
|
|
|
211
256
|
);
|
|
212
257
|
}
|
|
213
258
|
|
|
259
|
+
// One-time upgrade for configs written before cleaner shipped: a non-empty
|
|
260
|
+
// explicit enabledAgents list gets cleaner defaulted on (inserted before
|
|
261
|
+
// reviewer, matching the fresh-install order) and inherits the reviewer's
|
|
262
|
+
// configured model and thinking level — its closest peer. The stamps make
|
|
263
|
+
// the upgrade idempotent and keep a later deliberate disable from being undone.
|
|
264
|
+
if (!config.announcedFeatures.includes(CLEANER_DEFAULTED_FEATURE)) {
|
|
265
|
+
config.announcedFeatures.push(CLEANER_DEFAULTED_FEATURE);
|
|
266
|
+
if (config.enabledAgents.length > 0 && !config.enabledAgents.includes(CLEANER_NAME)) {
|
|
267
|
+
const reviewerIndex = config.enabledAgents.indexOf(REVIEWER_NAME);
|
|
268
|
+
config.enabledAgents.splice(reviewerIndex === -1 ? config.enabledAgents.length : reviewerIndex, 0, CLEANER_NAME);
|
|
269
|
+
config.announcedFeatures.push(CLEANER_AUTO_ENABLED_FEATURE);
|
|
270
|
+
let inherited = false;
|
|
271
|
+
if (!config.agentModels[CLEANER_NAME] && config.agentModels[REVIEWER_NAME]) {
|
|
272
|
+
config.agentModels[CLEANER_NAME] = config.agentModels[REVIEWER_NAME];
|
|
273
|
+
inherited = true;
|
|
274
|
+
}
|
|
275
|
+
if (!config.agentThinkingLevels[CLEANER_NAME] && config.agentThinkingLevels[REVIEWER_NAME]) {
|
|
276
|
+
config.agentThinkingLevels[CLEANER_NAME] = config.agentThinkingLevels[REVIEWER_NAME];
|
|
277
|
+
inherited = true;
|
|
278
|
+
}
|
|
279
|
+
// An old explicit list may have no reviewer overrides to copy; only the
|
|
280
|
+
// copied case is stamped so the one-time notice stays accurate.
|
|
281
|
+
if (inherited) config.announcedFeatures.push(CLEANER_INHERITED_FEATURE);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
214
285
|
return config;
|
|
215
286
|
}
|
|
216
287
|
|
package/src/index.ts
CHANGED
|
@@ -1,93 +1,93 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pi-subagents — focused sub-agent delegation for pi.
|
|
3
|
-
*
|
|
4
|
-
* Assembly point: builds the shared runtime and registers everything.
|
|
5
|
-
* The heavy lifting lives in focused modules:
|
|
6
|
-
* - dispatch.ts — the `subagent` tool contract and auto-fix chain
|
|
7
|
-
* - thread-lifecycle.ts — queued generations, resume/fork, isolation settlement
|
|
8
|
-
* - tools.ts — subagent_control / subagent_wait / status / stop
|
|
9
|
-
* - announcements.ts — session-start recovery, notices, and widget install
|
|
10
|
-
* - widget.ts — active-only TUI run status
|
|
11
|
-
* - runtime.ts — shared per-session state
|
|
12
|
-
*
|
|
13
|
-
* Also registers the `/subagents-setup` command and a `before_agent_start` hook
|
|
14
|
-
* that injects a delegation directive into the parent system prompt so the main
|
|
15
|
-
* model uses the tool proactively.
|
|
16
|
-
*
|
|
17
|
-
* The tool is not registered inside child sub-agent processes, which prevents
|
|
18
|
-
* runaway recursion and keeps child context windows clean.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
22
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
23
|
-
import { discoverAgents } from "./agents.ts";
|
|
24
|
-
import { registerAnnouncements } from "./announcements.ts";
|
|
25
|
-
import { getConfigPath, loadConfig } from "./config.ts";
|
|
26
|
-
import { registerSubagentTool } from "./dispatch.ts";
|
|
27
|
-
import { matchRunIds } from "./format.ts";
|
|
28
|
-
import { buildDelegationDirective } from "./prompt.ts";
|
|
29
|
-
import { createRuntime } from "./runtime.ts";
|
|
30
|
-
import { runSetup } from "./setup.ts";
|
|
31
|
-
import { currentSubagentDepth } from "./spawn.ts";
|
|
32
|
-
import { registerLookupTools } from "./tools.ts";
|
|
33
|
-
import { clearActiveRunsWidget } from "./widget.ts";
|
|
34
|
-
|
|
35
|
-
export { matchRunIds };
|
|
36
|
-
|
|
37
|
-
export default function (pi: ExtensionAPI): void {
|
|
38
|
-
const configPath = getConfigPath(getAgentDir());
|
|
39
|
-
const runtime = createRuntime(pi, configPath);
|
|
40
|
-
|
|
41
|
-
// Recursion guard: sub-agent children are leaf processes. The `subagent` tool is
|
|
42
|
-
// excluded from their toolset at spawn (--exclude-tools); this check is defense
|
|
43
|
-
// in depth so a child can never expose the tool back to its model, even if
|
|
44
|
-
// another extension ignores the depth marker.
|
|
45
|
-
if (currentSubagentDepth() >= 1) {
|
|
46
|
-
pi.registerCommand("subagents-setup", {
|
|
47
|
-
description: "Configure pi-subagents (unavailable in nested sub-agent processes)",
|
|
48
|
-
handler: async (_args, ctx) => {
|
|
49
|
-
ctx.ui.notify("pi-subagents setup is unavailable in nested sub-agent processes.", "warning");
|
|
50
|
-
},
|
|
51
|
-
});
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
pi.registerMessageRenderer("subagent-result", (message, _options, theme) =>
|
|
56
|
-
new Text(
|
|
57
|
-
`${theme.fg("toolTitle", theme.bold("subagent result"))}\n${message.content}`,
|
|
58
|
-
0,
|
|
59
|
-
0,
|
|
60
|
-
),
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
pi.on("session_shutdown", async (_event, ctx) => {
|
|
64
|
-
clearActiveRunsWidget(ctx);
|
|
65
|
-
await runtime.shutdown();
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
registerSubagentTool(pi, runtime);
|
|
69
|
-
registerLookupTools(pi, runtime);
|
|
70
|
-
|
|
71
|
-
pi.registerCommand("subagents-setup", {
|
|
72
|
-
description: "Configure pi-subagents: agents, selected models, capability-aware thinking, and runtime settings",
|
|
73
|
-
handler: async (_args, ctx) => {
|
|
74
|
-
await runSetup(ctx, configPath);
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
registerAnnouncements(pi, runtime);
|
|
79
|
-
|
|
80
|
-
// Proactive dispatch: inject the delegation directive into the parent system prompt.
|
|
81
|
-
pi.on("before_agent_start", async (event, ctx) => {
|
|
82
|
-
const config = await loadConfig(configPath);
|
|
83
|
-
if (!config.proactiveInjection) return undefined;
|
|
84
|
-
const { agents } = discoverAgents(ctx.cwd, {
|
|
85
|
-
scope: config.agentScope,
|
|
86
|
-
enabledNames: config.enabledAgents,
|
|
87
|
-
projectTrusted: ctx.isProjectTrusted?.() === true,
|
|
88
|
-
});
|
|
89
|
-
const directive = buildDelegationDirective(agents);
|
|
90
|
-
if (!directive) return undefined;
|
|
91
|
-
return { systemPrompt: `${event.systemPrompt}\n${directive}` };
|
|
92
|
-
});
|
|
93
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* pi-subagents — focused sub-agent delegation for pi.
|
|
3
|
+
*
|
|
4
|
+
* Assembly point: builds the shared runtime and registers everything.
|
|
5
|
+
* The heavy lifting lives in focused modules:
|
|
6
|
+
* - dispatch.ts — the `subagent` tool contract and auto-fix chain
|
|
7
|
+
* - thread-lifecycle.ts — queued generations, resume/fork, isolation settlement
|
|
8
|
+
* - tools.ts — subagent_control / subagent_wait / status / stop
|
|
9
|
+
* - announcements.ts — session-start recovery, notices, and widget install
|
|
10
|
+
* - widget.ts — active-only TUI run status
|
|
11
|
+
* - runtime.ts — shared per-session state
|
|
12
|
+
*
|
|
13
|
+
* Also registers the `/subagents-setup` command and a `before_agent_start` hook
|
|
14
|
+
* that injects a delegation directive into the parent system prompt so the main
|
|
15
|
+
* model uses the tool proactively.
|
|
16
|
+
*
|
|
17
|
+
* The tool is not registered inside child sub-agent processes, which prevents
|
|
18
|
+
* runaway recursion and keeps child context windows clean.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
23
|
+
import { discoverAgents } from "./agents.ts";
|
|
24
|
+
import { registerAnnouncements } from "./announcements.ts";
|
|
25
|
+
import { getConfigPath, loadConfig } from "./config.ts";
|
|
26
|
+
import { registerSubagentTool } from "./dispatch.ts";
|
|
27
|
+
import { matchRunIds } from "./format.ts";
|
|
28
|
+
import { buildDelegationDirective } from "./prompt.ts";
|
|
29
|
+
import { createRuntime } from "./runtime.ts";
|
|
30
|
+
import { runSetup } from "./setup.ts";
|
|
31
|
+
import { currentSubagentDepth } from "./spawn.ts";
|
|
32
|
+
import { registerLookupTools } from "./tools.ts";
|
|
33
|
+
import { clearActiveRunsWidget } from "./widget.ts";
|
|
34
|
+
|
|
35
|
+
export { matchRunIds };
|
|
36
|
+
|
|
37
|
+
export default function (pi: ExtensionAPI): void {
|
|
38
|
+
const configPath = getConfigPath(getAgentDir());
|
|
39
|
+
const runtime = createRuntime(pi, configPath);
|
|
40
|
+
|
|
41
|
+
// Recursion guard: sub-agent children are leaf processes. The `subagent` tool is
|
|
42
|
+
// excluded from their toolset at spawn (--exclude-tools); this check is defense
|
|
43
|
+
// in depth so a child can never expose the tool back to its model, even if
|
|
44
|
+
// another extension ignores the depth marker.
|
|
45
|
+
if (currentSubagentDepth() >= 1) {
|
|
46
|
+
pi.registerCommand("subagents-setup", {
|
|
47
|
+
description: "Configure pi-subagents (unavailable in nested sub-agent processes)",
|
|
48
|
+
handler: async (_args, ctx) => {
|
|
49
|
+
ctx.ui.notify("pi-subagents setup is unavailable in nested sub-agent processes.", "warning");
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
pi.registerMessageRenderer("subagent-result", (message, _options, theme) =>
|
|
56
|
+
new Text(
|
|
57
|
+
`${theme.fg("toolTitle", theme.bold("subagent result"))}\n${message.content}`,
|
|
58
|
+
0,
|
|
59
|
+
0,
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
64
|
+
clearActiveRunsWidget(ctx);
|
|
65
|
+
await runtime.shutdown();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
registerSubagentTool(pi, runtime);
|
|
69
|
+
registerLookupTools(pi, runtime);
|
|
70
|
+
|
|
71
|
+
pi.registerCommand("subagents-setup", {
|
|
72
|
+
description: "Configure pi-subagents: agents, selected models, capability-aware thinking, and runtime settings",
|
|
73
|
+
handler: async (_args, ctx) => {
|
|
74
|
+
await runSetup(ctx, configPath);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
registerAnnouncements(pi, runtime);
|
|
79
|
+
|
|
80
|
+
// Proactive dispatch: inject the delegation directive into the parent system prompt.
|
|
81
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
82
|
+
const config = await loadConfig(configPath);
|
|
83
|
+
if (!config.proactiveInjection) return undefined;
|
|
84
|
+
const { agents } = discoverAgents(ctx.cwd, {
|
|
85
|
+
scope: config.agentScope,
|
|
86
|
+
enabledNames: config.enabledAgents,
|
|
87
|
+
projectTrusted: ctx.isProjectTrusted?.() === true,
|
|
88
|
+
});
|
|
89
|
+
const directive = buildDelegationDirective(agents);
|
|
90
|
+
if (!directive) return undefined;
|
|
91
|
+
return { systemPrompt: `${event.systemPrompt}\n${directive}` };
|
|
92
|
+
});
|
|
93
|
+
}
|