@ferris1225/pi-subagents 4.1.13 → 4.1.16

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.
@@ -1,54 +1,59 @@
1
- /** Session-start recovery, stale-config migration, and widget installation. */
2
-
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { loadConfig, saveConfig } from "./config.ts";
5
- import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
6
- import { announceRecoveryRecords } from "./recovery.ts";
7
- import type { SubagentRuntime } from "./runtime.ts";
8
- import { pruneResultArtifacts } from "./spawn.ts";
9
- import { installActiveRunsWidget } from "./widget.ts";
10
-
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],
19
- runtime: SubagentRuntime,
20
- ): Promise<void> {
21
- try {
22
- const config = await loadConfig(runtime.configPath);
23
- const overrides = Object.entries(config.agentModels);
24
- if (overrides.length === 0) return;
25
- const { kept, dropped } = filterUnavailableModelOverrides(config.agentModels, availableModelsInScope(ctx));
26
- if (dropped.length === 0) return;
27
- await saveConfig({ ...config, agentModels: kept }, runtime.configPath);
28
- const list = dropped.map(({ agent, ref }) => `${agent}: ${ref}`).join(", ");
29
- ctx.ui.notify(
30
- `pi-subagents: removed stale agent model overrides that are no longer available (${list}). Those agents now follow the current main model; run /subagents-setup to re-pick.`,
31
- "warning",
32
- );
33
- } catch {
34
- /* migration failures are non-fatal */
35
- }
36
- }
37
-
38
- export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime): void {
39
- pi.on("session_start", async (_event, ctx) => {
40
- pruneResultArtifacts();
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
- }
51
- if (ctx.mode !== "tui") return;
52
- installActiveRunsWidget(ctx);
53
- });
54
- }
1
+ /** Session-start recovery, stale-config migration, and widget installation. */
2
+
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { existsSync } from "node:fs";
5
+ import { loadConfig, saveConfig } from "./config.ts";
6
+ import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
7
+ import { announceRecoveryRecords } from "./recovery.ts";
8
+ import type { SubagentRuntime } from "./runtime.ts";
9
+ import { installActiveRunsWidget } from "./widget.ts";
10
+
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],
19
+ runtime: SubagentRuntime,
20
+ ): Promise<void> {
21
+ try {
22
+ const config = await loadConfig(runtime.configPath);
23
+ const overrides = Object.entries(config.agentModels);
24
+ if (overrides.length === 0) return;
25
+ const { kept, dropped } = filterUnavailableModelOverrides(config.agentModels, availableModelsInScope(ctx));
26
+ if (dropped.length === 0) return;
27
+ await saveConfig({ ...config, agentModels: kept }, runtime.configPath);
28
+ const list = dropped.map(({ agent, ref }) => `${agent}: ${ref}`).join(", ");
29
+ ctx.ui.notify(
30
+ `pi-subagents: removed stale agent model overrides that are no longer available (${list}). Those agents now follow the current main model; run /subagents-setup to re-pick.`,
31
+ "warning",
32
+ );
33
+ } catch {
34
+ /* migration failures are non-fatal */
35
+ }
36
+ }
37
+
38
+ export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime): void {
39
+ pi.on("session_start", async (_event, ctx) => {
40
+ if (!existsSync(runtime.configPath)) {
41
+ ctx.ui.notify(
42
+ "pi-subagents: no configuration yet — run /subagents-setup to pick agents, models, and thinking strengths. Defaults (all five agents on the main model) apply until then.",
43
+ "info",
44
+ );
45
+ }
46
+ await announceRecoveryRecords(runtime.configPath, ctx);
47
+ await migrateUnavailableAgentModels(ctx, runtime);
48
+ if (!runtime.restoredNotified && runtime.restoredRunIds.length > 0) {
49
+ runtime.restoredNotified = true;
50
+ const ids = runtime.restoredRunIds.map((id) => `#${id}`).join(", ");
51
+ ctx.ui.notify(
52
+ `pi-subagents: restored ${runtime.restoredRunIds.length} interrupted thread${runtime.restoredRunIds.length === 1 ? "" : "s"} from the previous session (${ids}). subagent_status lists them; subagent_control resume continues one.`,
53
+ "info",
54
+ );
55
+ }
56
+ if (ctx.mode !== "tui") return;
57
+ installActiveRunsWidget(ctx);
58
+ });
59
+ }
package/src/background.ts CHANGED
@@ -9,7 +9,9 @@
9
9
  * the user and the main agent instead of it vanishing into the queue.
10
10
  */
11
11
 
12
- export type BackgroundTask = (signal: AbortSignal) => Promise<void>;
12
+ import { cpus } from "node:os";
13
+
14
+ export type BackgroundTask = (signal: AbortSignal, controller: AbortController) => Promise<void>;
13
15
 
14
16
  interface PendingTask {
15
17
  task: BackgroundTask;
@@ -27,13 +29,14 @@ interface PendingTask {
27
29
  onError?: (error: unknown) => void | Promise<void>;
28
30
  }
29
31
 
30
- /** How many sub-agent processes may run at once. This paces execution only
31
- * it never rejects work, so a wider parallel `subagent` call simply queues.
32
- * Fixed by design: the queue sheds load by waiting, so the knob bought nothing
33
- * worth its maintenance. Only manually dispatched top-level generations hold
34
- * slots; runtime-initiated managed continuations (gate reviews, documentation
35
- * sync) suspend their task's slot so they never starve manual dispatches. */
36
- export const MAX_CONCURRENT_SUBAGENTS = 4;
32
+ /** How many sub-agent processes may run at once, derived from the host instead
33
+ * of being fixed: children wait on model I/O far more than on CPU, so the pool
34
+ * scales with cores while the bounds keep tiny machines usable and huge ones
35
+ * from fanning out into an API-rate-limit wall. Pacing only the queue never
36
+ * rejects work; a wider parallel `subagent` call simply waits for a slot. */
37
+ export function resolveSubagentConcurrency(cpuCount: number = cpus().length): number {
38
+ return Math.min(16, Math.max(4, Math.floor(cpuCount / 2)));
39
+ }
37
40
 
38
41
  export class BackgroundTaskQueue {
39
42
  private concurrency: number;
@@ -77,9 +80,22 @@ export class BackgroundTaskQueue {
77
80
  return this.completions.get(controller) ?? Promise.resolve();
78
81
  }
79
82
 
83
+ /** Slot count, exposed so dispatch/status output can state the real pacing
84
+ * limit instead of leaving queued work looking like an unexplained cap. */
85
+ get capacity(): number {
86
+ return this.concurrency;
87
+ }
88
+
80
89
  /** Stop counting a running task toward the concurrency limit. Its body keeps
81
90
  * running under the same abort signal; completion still releases everything
82
- * waitForTask/waitForIdle promise. Frees a slot for queued work immediately. */
91
+ * waitForTask/waitForIdle promise. Frees a slot for queued work immediately.
92
+ *
93
+ * Used by tasks whose execution is serialized elsewhere anyway (managed
94
+ * workflow continuations, shared-checkout writers waiting on the repository
95
+ * lane): letting such a task also hold a global slot would let waiters
96
+ * starve independent work that could start right away. The controller is
97
+ * handed to the task body directly, so a task can always suspend itself
98
+ * without racing the enqueue() caller's assignment. */
83
99
  suspend(controller: AbortController | undefined): void {
84
100
  if (!controller || this.stopped) return;
85
101
  if (!this.active.delete(controller)) return;
@@ -149,7 +165,7 @@ export class BackgroundTaskQueue {
149
165
  }
150
166
 
151
167
  this.active.add(entry.controller);
152
- void entry.task(entry.controller.signal)
168
+ void entry.task(entry.controller.signal, entry.controller)
153
169
  .catch(async (error: unknown) => {
154
170
  // Cancellation is not a failure: aborted work (e.g. session
155
171
  // shutdown) must never be reported as an exception.
package/src/completion.ts CHANGED
@@ -139,6 +139,9 @@ export interface ActiveRunFoot {
139
139
  agent: string;
140
140
  /** Optional content label (task-derived) shown next to the agent name. */
141
141
  label?: string;
142
+ /** True when the run is waiting for a free process slot rather than
143
+ * executing; stated so pacing is never mistaken for a stall. */
144
+ queued?: boolean;
142
145
  }
143
146
 
144
147
  /**
@@ -153,7 +156,10 @@ export function formatActiveRunsFooter(runs: readonly ActiveRunFoot[], maxListed
153
156
  if (runs.length === 0) return "";
154
157
  const listed = runs.slice(0, maxListed);
155
158
  const items = listed
156
- .map((run) => `#${run.id} ${run.agent}${run.label ? `·${run.label}` : ""}`)
159
+ .map((run) => {
160
+ const tagged = run.queued ? " (queued, starts when a slot frees)" : "";
161
+ return `#${run.id} ${run.agent}${run.label ? `·${run.label}` : ""}${tagged}`;
162
+ })
157
163
  .join(", ");
158
164
  const more = runs.length > listed.length ? `, +${runs.length - listed.length} more` : "";
159
165
  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.`;
package/src/config.ts CHANGED
@@ -16,7 +16,7 @@ import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-ag
16
16
  export const BUILTIN_AGENT_NAMES = ["explorer", "worker", "cleaner", "documenter", "reviewer"] as const;
17
17
 
18
18
  /** Agents enabled out of the box on a fresh install. */
19
- export const DEFAULT_ENABLED_AGENTS: readonly string[] = ["explorer", "worker", "cleaner", "reviewer"];
19
+ export const DEFAULT_ENABLED_AGENTS: readonly string[] = ["explorer", "worker", "cleaner", "documenter", "reviewer"];
20
20
 
21
21
  export const AGENT_SCOPE_VALUES = ["user", "project", "both"] as const;
22
22
  export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];