@ferris1225/pi-subagents 4.1.3 → 4.1.5
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 +110 -99
- package/agents/cleaner.md +3 -2
- package/agents/documenter.md +6 -6
- package/agents/reviewer.md +8 -3
- package/agents/worker.md +4 -4
- package/package.json +1 -1
- package/src/completion.ts +160 -160
- package/src/config.ts +8 -6
- package/src/dispatch.ts +173 -65
- package/src/fixloop.ts +108 -102
- package/src/models.ts +189 -189
- package/src/monitor.ts +25 -0
- package/src/prompt.ts +24 -26
- package/src/recovery.ts +145 -145
- package/src/session-fork.ts +80 -80
- package/src/thread-lifecycle.ts +19 -5
- package/src/widget.ts +59 -8
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
|
@@ -72,9 +72,10 @@ export const DEFAULT_MAX_CONCURRENCY = 4;
|
|
|
72
72
|
/** Upper bound accepted for maxConcurrency (defensive clamp). */
|
|
73
73
|
export const MAX_CONCURRENCY_LIMIT = 16;
|
|
74
74
|
/**
|
|
75
|
-
* Maximum worker fixes after REVIEW_FAIL. Each fix is followed by
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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.
|
|
78
79
|
*/
|
|
79
80
|
export const DEFAULT_MAX_FIX_ROUNDS = 2;
|
|
80
81
|
/** Upper bound accepted for maxFixRounds (defensive clamp). 0 disables the loop. */
|
|
@@ -116,8 +117,8 @@ export interface SubagentsConfig {
|
|
|
116
117
|
maxConcurrency: number;
|
|
117
118
|
/**
|
|
118
119
|
* Maximum worker fixes after REVIEW_FAIL. Every fix receives the full review,
|
|
119
|
-
* then
|
|
120
|
-
*
|
|
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.
|
|
121
122
|
*/
|
|
122
123
|
maxFixRounds: number;
|
|
123
124
|
/**
|
|
@@ -247,7 +248,8 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
|
|
|
247
248
|
const maxConcurrency = clampCount(raw.maxConcurrency, MAX_CONCURRENCY_LIMIT);
|
|
248
249
|
if (maxConcurrency !== undefined) config.maxConcurrency = maxConcurrency;
|
|
249
250
|
|
|
250
|
-
// 0 disables worker fixes, not the
|
|
251
|
+
// 0 disables worker fixes, not the independent post-writer review gate or
|
|
252
|
+
// conditional/reviewer-disabled documentation fallback.
|
|
251
253
|
if (typeof raw.maxFixRounds === "number" && Number.isFinite(raw.maxFixRounds)) {
|
|
252
254
|
config.maxFixRounds = Math.max(0, Math.min(MAX_FIX_ROUNDS_LIMIT, Math.round(raw.maxFixRounds)));
|
|
253
255
|
}
|