@ferris1225/pi-subagents 4.1.1 → 4.1.3

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/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
@@ -17,9 +17,10 @@ import { dirname, join } from "node:path";
17
17
  import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
18
18
 
19
19
  /** Full catalog of agents shipped with the package (selectable in /subagents-setup). */
20
- export const BUILTIN_AGENT_NAMES = ["explorer", "worker", "cleaner", "reviewer"] as const;
20
+ export const BUILTIN_AGENT_NAMES = ["explorer", "worker", "cleaner", "documenter", "reviewer"] as const;
21
21
 
22
- /** Agents enabled out of the box on a fresh install. Explicit configured lists are preserved. */
22
+ /** Agents enabled out of the box on a fresh install. Documenter remains an
23
+ * explicit setup choice; existing non-empty configs receive it via migration. */
23
24
  export const DEFAULT_ENABLED_AGENTS: readonly string[] = ["explorer", "worker", "cleaner", "reviewer"];
24
25
 
25
26
  export const AGENT_SCOPE_VALUES = ["user", "project", "both"] as const;
@@ -28,6 +29,7 @@ export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
28
29
  const LEGACY_EXPLORER_NAME = "explore";
29
30
  const EXPLORER_NAME = "explorer";
30
31
  const CLEANER_NAME = "cleaner";
32
+ const DOCUMENTER_NAME = "documenter";
31
33
  const REVIEWER_NAME = "reviewer";
32
34
 
33
35
  /**
@@ -42,6 +44,13 @@ export const CLEANER_DEFAULTED_FEATURE = "cleanerDefaulted";
42
44
  export const CLEANER_AUTO_ENABLED_FEATURE = "cleanerAutoEnabled";
43
45
  export const CLEANER_INHERITED_FEATURE = "cleanerInheritedReviewer";
44
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
+
45
54
  function migrateAgentName(name: string): string {
46
55
  return name === LEGACY_EXPLORER_NAME ? EXPLORER_NAME : name;
47
56
  }
@@ -63,9 +72,9 @@ export const DEFAULT_MAX_CONCURRENCY = 4;
63
72
  /** Upper bound accepted for maxConcurrency (defensive clamp). */
64
73
  export const MAX_CONCURRENCY_LIMIT = 16;
65
74
  /**
66
- * How many automatic worker→reviewer fix rounds run when a reviewer returns
67
- * REVIEW_FAIL before waking the main agent. 0 disables the auto-fix loop
68
- * (the main agent is woken to dispatch fixes itself). Default: 2.
75
+ * Maximum worker fixes after REVIEW_FAIL. Each fix is followed by optional
76
+ * documenter and reviewer; this cap does not suppress the initial post-writer
77
+ * documentation/final-review workflow. 0 disables fixes. Default: 2.
69
78
  */
70
79
  export const DEFAULT_MAX_FIX_ROUNDS = 2;
71
80
  /** Upper bound accepted for maxFixRounds (defensive clamp). 0 disables the loop. */
@@ -88,8 +97,8 @@ export interface SubagentsConfig {
88
97
  /** Optional per-agent thinking preference. Runtime clamps it to the effective model's supported levels. */
89
98
  agentThinkingLevels: Record<string, ThinkingLevel>;
90
99
  /**
91
- * When a review passes (REVIEW_PASS verdict), deliver it without waking the
92
- * main agent. Disabled by default so passing reviews still resume orchestration.
100
+ * When a standalone review passes (REVIEW_PASS verdict), deliver it without
101
+ * waking the main agent. Managed workflows always wake once at final delivery.
93
102
  */
94
103
  notifyOnReviewPass: boolean;
95
104
  /**
@@ -106,11 +115,9 @@ export interface SubagentsConfig {
106
115
  * one parallel `subagent` call may contain. Default: 4. */
107
116
  maxConcurrency: number;
108
117
  /**
109
- * Auto-fix rounds when a reviewer returns REVIEW_FAIL: the extension dispatches
110
- * a worker (briefed with the review's concrete findings) then a reviewer
111
- * re-review, repeating up to this many times before waking the main agent with
112
- * the full chain. 0 disables it (the main agent handles fixes itself).
113
- * Default: 2.
118
+ * Maximum worker fixes after REVIEW_FAIL. Every fix receives the full review,
119
+ * then enabled documenter/reviewer stages run. Initial post-writer docs/review
120
+ * do not consume this budget. 0 disables fixes. Default: 2.
114
121
  */
115
122
  maxFixRounds: number;
116
123
  /**
@@ -240,7 +247,7 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
240
247
  const maxConcurrency = clampCount(raw.maxConcurrency, MAX_CONCURRENCY_LIMIT);
241
248
  if (maxConcurrency !== undefined) config.maxConcurrency = maxConcurrency;
242
249
 
243
- // 0 disables the auto-fix loop (main agent handles fixes itself).
250
+ // 0 disables worker fixes, not the initial managed docs/review workflow.
244
251
  if (typeof raw.maxFixRounds === "number" && Number.isFinite(raw.maxFixRounds)) {
245
252
  config.maxFixRounds = Math.max(0, Math.min(MAX_FIX_ROUNDS_LIMIT, Math.round(raw.maxFixRounds)));
246
253
  }
@@ -282,6 +289,29 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
282
289
  }
283
290
  }
284
291
 
292
+ if (!config.announcedFeatures.includes(DOCUMENTER_DEFAULTED_FEATURE)) {
293
+ config.announcedFeatures.push(DOCUMENTER_DEFAULTED_FEATURE);
294
+ if (config.enabledAgents.length > 0 && !config.enabledAgents.includes(DOCUMENTER_NAME)) {
295
+ const reviewerIndex = config.enabledAgents.indexOf(REVIEWER_NAME);
296
+ config.enabledAgents.splice(
297
+ reviewerIndex === -1 ? config.enabledAgents.length : reviewerIndex,
298
+ 0,
299
+ DOCUMENTER_NAME,
300
+ );
301
+ config.announcedFeatures.push(DOCUMENTER_AUTO_ENABLED_FEATURE);
302
+ let inherited = false;
303
+ if (!config.agentModels[DOCUMENTER_NAME] && config.agentModels[EXPLORER_NAME]) {
304
+ config.agentModels[DOCUMENTER_NAME] = config.agentModels[EXPLORER_NAME];
305
+ inherited = true;
306
+ }
307
+ if (!config.agentThinkingLevels[DOCUMENTER_NAME] && config.agentThinkingLevels[EXPLORER_NAME]) {
308
+ config.agentThinkingLevels[DOCUMENTER_NAME] = config.agentThinkingLevels[EXPLORER_NAME];
309
+ inherited = true;
310
+ }
311
+ if (inherited) config.announcedFeatures.push(DOCUMENTER_INHERITED_FEATURE);
312
+ }
313
+ }
314
+
285
315
  return config;
286
316
  }
287
317