@ferris1225/pi-subagents 4.1.15 → 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.
package/src/format.ts CHANGED
@@ -1,175 +1,179 @@
1
- /**
2
- * Pure formatting/result helpers shared by the subagent tool and the lookup
3
- * tools (subagent_wait/status): usage rendering, completion blocks, synthetic
4
- * result constructors, and run-id matching.
5
- */
6
-
7
- import type { AgentConfig } from "./agents.ts";
8
- import { formatTaskSummary } from "./monitor.ts";
9
- import { emptyUsage } from "./rpc-run.ts";
10
- import {
11
- getResultOutput,
12
- isFailedResult,
13
- truncateResultOutput,
14
- writeResultArtifact,
15
- type SingleResult,
16
- type UsageStats,
17
- } from "./spawn.ts";
18
-
19
- export function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
20
- return {
21
- agent: agent.name,
22
- task,
23
- exitCode: -1,
24
- messages: [],
25
- stderr: "",
26
- usage: emptyUsage(),
27
- model: agent.model,
28
- ...(thinking ? { thinking } : {}),
29
- };
30
- }
31
-
32
- export function failedStartResult(agentName: string, task: string, errorMessage: string): SingleResult {
33
- return {
34
- agent: agentName,
35
- task,
36
- exitCode: 1,
37
- messages: [],
38
- stderr: errorMessage,
39
- usage: emptyUsage(),
40
- errorMessage,
41
- dispatchFailed: true,
42
- };
43
- }
44
-
45
- /** Failed result for a background task that crashed with an exception (spawn
46
- * infra, delivery API, ...) instead of returning a normal result. */
47
- export function dispatchFailedResult(agent: AgentConfig, task: string, error: unknown, thinking?: string): SingleResult {
48
- const errorMessage = error instanceof Error ? error.message : String(error);
49
- return {
50
- ...queuedResult(agent, task, thinking),
51
- exitCode: 1,
52
- stderr: errorMessage,
53
- stopReason: "error",
54
- errorMessage,
55
- dispatchFailed: true,
56
- };
57
- }
58
-
59
- function formatTokens(count: number): string {
60
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
61
- if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
62
- return String(count);
63
- }
64
-
65
- export function formatUsage(usage: UsageStats): string {
66
- const parts: string[] = [];
67
- if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
68
- if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
69
- if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
70
- if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
71
- if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
72
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
73
- return parts.join(" ");
74
- }
75
-
76
- export interface CompletionFormatOptions {
77
- /** Include individual failed-tool errors. Reserved for explicit status lookup. */
78
- failedToolDetails?: boolean;
79
- }
80
-
81
- export function formatCompletionBlock(
82
- result: SingleResult,
83
- maxResultLines: number,
84
- cwd?: string,
85
- options: CompletionFormatOptions = {},
86
- ): string {
87
- const failed = isFailedResult(result);
88
- const failedTools = result.failedTools ?? [];
89
- const status = failed
90
- ? "failed"
91
- : options.failedToolDetails && failedTools.length > 0
92
- ? `completed with ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}`
93
- : "completed";
94
- const usage = formatUsage(result.usage);
95
- const output = getResultOutput(result);
96
- const { text, truncated } = truncateResultOutput(output, maxResultLines);
97
- const fallbackNote = result.modelFallbackFrom
98
- ? ` (selected model ${result.modelFallbackFrom} failed main ${result.model ?? "dynamic default"})`
99
- : "";
100
- const startupRetryNote = result.startupRetries
101
- ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
102
- : "";
103
- const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
104
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
105
- if (result.isolation === "worktree") {
106
- const isolation =
107
- result.integrationStatus === "integrated"
108
- ? "worktree · changes integrated into the original working tree"
109
- : result.integrationStatus === "no_changes"
110
- ? "worktree · no changes; temporary worktree removed"
111
- : result.integrationStatus === "retained"
112
- ? result.integrationApplied
113
- ? "worktree · changes applied, but cleanup failed; recovery artifacts retained"
114
- : "worktree · integration failed; recovery artifacts retained"
115
- : "worktree · isolated";
116
- lines.push(`Isolation: ${isolation}`);
117
- if (result.integrationWorktreePath) lines.push(`Retained worktree: ${result.integrationWorktreePath}`);
118
- if (result.integrationPatchPath) lines.push(`Retained patch: ${result.integrationPatchPath}`);
119
- if (result.integrationError) lines.push(`Integration error: ${result.integrationError}`);
120
- lines.push("");
121
- }
122
- lines.push(text);
123
- // Failed-tool diagnostics are deliberate opt-in via subagent_status: agents
124
- // report their own verification in the output above, and a transient failed
125
- // call (no-match grep, rejected edit) is noise in an automatic delivery.
126
- if (options.failedToolDetails && failedTools.length > 0) {
127
- lines.push(
128
- "",
129
- `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}:`,
130
- ...failedTools.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
131
- );
132
- }
133
- if (truncated) {
134
- // The full text lives on disk so the main agent can read it on demand.
135
- lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, result.projectCwd ?? cwd)})`);
136
- }
137
- return lines.join("\n");
138
- }
139
-
140
- /** Instruction appended to a model-level failure: the sub-agent's provider never
141
- * produced usable output (or the run stalled), so the task is handed back to the
142
- * main window instead of being left as a dead failure. When the run preserved a
143
- * session with earlier work (and the run id is known), steer the main agent to
144
- * RESUME it in-context once a model is available, instead of re-dispatching
145
- * fresh (which would re-scan everything). */
146
- export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
147
- const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
148
- const detail = result.errorMessage?.trim();
149
- const cause = detail
150
- ? `its model/provider call failed (${detail})`
151
- : "its model was unavailable or failed (or the run stalled)";
152
- const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
153
- const recovery = sessionPreserved
154
- ? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent_control with { action: "resume", id: ${opts!.runId} } to CONTINUE it in-context (it keeps the same run id and does not re-scan), or execute the task in the main window with your own tools.`
155
- : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
156
- return `The sub-agent could not complete this task: ${cause}${retry}.${recovery}`;
157
- }
158
-
159
- /** Instruction appended whenever a failing gate verdict is delivered: the
160
- * findings return to the main agent, which owns the fix decision — the runtime
161
- * never auto-fixes. Stating it at this exact decision point keeps the main
162
- * agent from relaying the findings to the user and stopping. */
163
- export function reviewFailFollowUpNote(): string {
164
- return "This gate failed and the findings are yours to resolve now: fix them inline or dispatch a worker briefed with these fix instructions, then re-verify the change. Ask the user only before a genuinely destructive or scope-changing fix; do not deliver while a finding stands.";
165
- }
166
-
167
- /** Resolve a run-id request to actual ids: an exact numeric match always wins
168
- * (so "1" never fans out to 10, 11, …); only when no exact match exists does a
169
- * prefix match run, as a convenience for partial ids. Keeps single-digit lookups
170
- * from returning — or, for subagent_stop, acting on — a whole prefix family. */
171
- export function matchRunIds(ids: number[], requested: string): number[] {
172
- const exact = ids.filter((id) => String(id) === requested);
173
- if (exact.length > 0) return exact;
174
- return ids.filter((id) => String(id).startsWith(requested));
175
- }
1
+ /**
2
+ * Pure formatting/result helpers shared by the subagent tool and the lookup
3
+ * tools (subagent_wait/status): usage rendering, completion blocks, synthetic
4
+ * result constructors, and run-id matching.
5
+ */
6
+
7
+ import type { AgentConfig } from "./agents.ts";
8
+ import { formatTaskSummary } from "./monitor.ts";
9
+ import { emptyUsage } from "./rpc-run.ts";
10
+ import {
11
+ getResultOutput,
12
+ isFailedResult,
13
+ truncateResultOutput,
14
+ writeResultArtifact,
15
+ type SingleResult,
16
+ type UsageStats,
17
+ } from "./spawn.ts";
18
+
19
+ export function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
20
+ return {
21
+ agent: agent.name,
22
+ task,
23
+ exitCode: -1,
24
+ messages: [],
25
+ stderr: "",
26
+ usage: emptyUsage(),
27
+ model: agent.model,
28
+ ...(thinking ? { thinking } : {}),
29
+ };
30
+ }
31
+
32
+ export function failedStartResult(agentName: string, task: string, errorMessage: string): SingleResult {
33
+ return {
34
+ agent: agentName,
35
+ task,
36
+ exitCode: 1,
37
+ messages: [],
38
+ stderr: errorMessage,
39
+ usage: emptyUsage(),
40
+ errorMessage,
41
+ dispatchFailed: true,
42
+ };
43
+ }
44
+
45
+ /** Failed result for a background task that crashed with an exception (spawn
46
+ * infra, delivery API, ...) instead of returning a normal result. */
47
+ export function dispatchFailedResult(agent: AgentConfig, task: string, error: unknown, thinking?: string): SingleResult {
48
+ const errorMessage = error instanceof Error ? error.message : String(error);
49
+ return {
50
+ ...queuedResult(agent, task, thinking),
51
+ exitCode: 1,
52
+ stderr: errorMessage,
53
+ stopReason: "error",
54
+ errorMessage,
55
+ dispatchFailed: true,
56
+ };
57
+ }
58
+
59
+ function formatTokens(count: number): string {
60
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
61
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
62
+ return String(count);
63
+ }
64
+
65
+ export function formatUsage(usage: UsageStats): string {
66
+ const parts: string[] = [];
67
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
68
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
69
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
70
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
71
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
72
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
73
+ return parts.join(" ");
74
+ }
75
+
76
+ export interface CompletionFormatOptions {
77
+ /** Include individual failed-tool errors. Reserved for explicit status lookup. */
78
+ failedToolDetails?: boolean;
79
+ /** Project-scoped directory the full result is written to when the output
80
+ * is truncated: <projectRoot>/results. */
81
+ resultRoot?: string;
82
+ }
83
+
84
+ export function formatCompletionBlock(
85
+ result: SingleResult,
86
+ maxResultLines: number,
87
+ options: CompletionFormatOptions = {},
88
+ ): string {
89
+ const failed = isFailedResult(result);
90
+ const failedTools = result.failedTools ?? [];
91
+ const status = failed
92
+ ? "failed"
93
+ : options.failedToolDetails && failedTools.length > 0
94
+ ? `completed with ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}`
95
+ : "completed";
96
+ const usage = formatUsage(result.usage);
97
+ const output = getResultOutput(result);
98
+ const { text, truncated } = truncateResultOutput(output, maxResultLines);const fallbackNote = result.modelFallbackFrom
99
+ ? ` (selected model ${result.modelFallbackFrom} failed → main ${result.model ?? "dynamic default"})`
100
+ : "";
101
+ const startupRetryNote = result.startupRetries
102
+ ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
103
+ : "";
104
+ const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
105
+ const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
106
+ if (result.isolation === "worktree") {
107
+ const isolation =
108
+ result.integrationStatus === "integrated"
109
+ ? "worktree · changes integrated into the original working tree"
110
+ : result.integrationStatus === "no_changes"
111
+ ? "worktree · no changes; temporary worktree removed"
112
+ : result.integrationStatus === "retained"
113
+ ? result.integrationApplied
114
+ ? "worktree · changes applied, but cleanup failed; recovery artifacts retained"
115
+ : "worktree · integration failed; recovery artifacts retained"
116
+ : "worktree · isolated";
117
+ lines.push(`Isolation: ${isolation}`);
118
+ if (result.integrationWorktreePath) lines.push(`Retained worktree: ${result.integrationWorktreePath}`);
119
+ if (result.integrationPatchPath) lines.push(`Retained patch: ${result.integrationPatchPath}`);
120
+ if (result.integrationError) lines.push(`Integration error: ${result.integrationError}`);
121
+ lines.push("");
122
+ }
123
+ lines.push(text);
124
+ // Failed-tool diagnostics are deliberate opt-in via subagent_status: agents
125
+ // report their own verification in the output above, and a transient failed
126
+ // call (no-match grep, rejected edit) is noise in an automatic delivery.
127
+ if (options.failedToolDetails && failedTools.length > 0) {
128
+ lines.push(
129
+ "",
130
+ `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}:`,
131
+ ...failedTools.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
132
+ );
133
+ }
134
+ if (truncated) {
135
+ // The full text lives on disk so the main agent can read it on demand.
136
+ const artifact = options.resultRoot
137
+ ? writeResultArtifact(output, result.agent, options.resultRoot)
138
+ : "(result root unavailable)";
139
+ lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${artifact})`);
140
+ }
141
+ return lines.join("\n");
142
+ }
143
+
144
+ /** Instruction appended to a model-level failure: the sub-agent's provider never
145
+ * produced usable output (or the run stalled), so the task is handed back to the
146
+ * main window instead of being left as a dead failure. When the run preserved a
147
+ * session with earlier work (and the run id is known), steer the main agent to
148
+ * RESUME it in-context once a model is available, instead of re-dispatching
149
+ * fresh (which would re-scan everything). */
150
+ export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
151
+ const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
152
+ const detail = result.errorMessage?.trim();
153
+ const cause = detail
154
+ ? `its model/provider call failed (${detail})`
155
+ : "its model was unavailable or failed (or the run stalled)";
156
+ const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
157
+ const recovery = sessionPreserved
158
+ ? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent_control with { action: "resume", id: ${opts!.runId} } to CONTINUE it in-context (it keeps the same run id and does not re-scan), or execute the task in the main window with your own tools.`
159
+ : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
160
+ return `The sub-agent could not complete this task: ${cause}${retry}.${recovery}`;
161
+ }
162
+
163
+ /** Instruction appended whenever a failing gate verdict is delivered: the
164
+ * findings return to the main agent, which owns the fix decision the runtime
165
+ * never auto-fixes. Stating it at this exact decision point keeps the main
166
+ * agent from relaying the findings to the user and stopping. */
167
+ export function reviewFailFollowUpNote(): string {
168
+ return "This gate failed and the findings are yours to resolve now: fix them inline or dispatch a worker briefed with these fix instructions, then re-verify the change. Ask the user only before a genuinely destructive or scope-changing fix; do not deliver while a finding stands.";
169
+ }
170
+
171
+ /** Resolve a run-id request to actual ids: an exact numeric match always wins
172
+ * (so "1" never fans out to 10, 11, …); only when no exact match exists does a
173
+ * prefix match run, as a convenience for partial ids. Keeps single-digit lookups
174
+ * from returning — or, for subagent_stop, acting on — a whole prefix family. */
175
+ export function matchRunIds(ids: number[], requested: string): number[] {
176
+ const exact = ids.filter((id) => String(id) === requested);
177
+ if (exact.length > 0) return exact;
178
+ return ids.filter((id) => String(id).startsWith(requested));
179
+ }
package/src/monitor.ts CHANGED
@@ -759,11 +759,13 @@ export function statusIcon(status: RunStatus, theme: Theme): string {
759
759
  }
760
760
  }
761
761
 
762
- /** User-facing status label used by tool/status rendering. */
762
+ /** User-facing status label used by tool/status rendering. Queued runs say
763
+ * so: anything vaguer ("ready") reads like an unexplained cap and made models
764
+ * stop dispatching while slots were merely pacing. */
763
765
  export function statusLabel(status: RunStatus): string {
764
766
  switch (status) {
765
767
  case "queued":
766
- return "ready";
768
+ return "queued";
767
769
  case "running":
768
770
  return "running";
769
771
  case "interrupting":
package/src/prompt.ts CHANGED
@@ -54,7 +54,7 @@ export function buildDelegationDirective(
54
54
  : []),
55
55
  ...(hasReviewer
56
56
  ? [
57
- `\`reviewer\`: read-only assessments and gates${codeWriterNames.length > 0 ? `; successful ${codeWriterNames.join("/")} runs get one fresh gate, and failing gates are fixed by the reviewer itself and re-reviewed until they pass` : ""}. Advisory output has no VERDICT and cannot authorize edits.`,
57
+ `\`reviewer\`: read-only assessments and gates${codeWriterNames.length > 0 ? `; successful ${codeWriterNames.join("/")} runs get one fresh gate, and failing gates are fixed by the reviewer itself in bounded fix/re-review rounds that converge on the fixes (a still-failing gate returns to you)` : ""}. Advisory output has no VERDICT and cannot authorize edits.`,
58
58
  ]
59
59
  : []),
60
60
  `Parallelize by default: map the todo list onto ONE \`tasks\` dispatch. One child owns one deliverable and its files; only genuinely dependent work waits for its prerequisite.`,
@@ -72,7 +72,7 @@ export function buildDelegationDirective(
72
72
  "Never report an unrun check as passed; surface unavailable checks and pre-existing failures, and inspect actual changes before reporting completion.",
73
73
  ...(hasReviewer
74
74
  ? [
75
- "A REVIEW_FAIL from a gate you dispatched directly returns its findings to you: fix them inline or via a briefed worker without waiting for the user (ask only for genuinely destructive or scope-changing fixes), then re-verify.",
75
+ "A REVIEW_FAIL from a gate you dispatched directly returns its findings to you: fix them inline or via a briefed worker without waiting for the user (ask only for genuinely destructive or scope-changing fixes), then re-verify ONCE. If the gate still fails, report the remaining findings and move on — never loop gate dispatches.",
76
76
  "Multi-model cross-review only when explicitly requested or for high-risk security, unsafe/FFI, persistence-migration, or concurrency changes.",
77
77
  ]
78
78
  : []),
package/src/runtime.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { rmSync } from "node:fs";
13
- import { BackgroundTaskQueue, MAX_CONCURRENT_SUBAGENTS } from "./background.ts";
13
+ import { resolveSubagentConcurrency, BackgroundTaskQueue } from "./background.ts";
14
14
  import {
15
15
  completionGroupTriggersTurn,
16
16
  createCompletionBatcher,
@@ -120,7 +120,7 @@ export interface SubagentRuntime {
120
120
  }
121
121
 
122
122
  export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
123
- const backgroundQueue = new BackgroundTaskQueue(MAX_CONCURRENT_SUBAGENTS);
123
+ const backgroundQueue = new BackgroundTaskQueue(resolveSubagentConcurrency());
124
124
 
125
125
  const runtime: SubagentRuntime = {
126
126
  configPath,
@@ -140,7 +140,7 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
140
140
  const active = monitor
141
141
  .getRuns()
142
142
  .filter((run) => isRunActiveStatus(run.status))
143
- .map((run) => ({ id: run.id, agent: run.agent, label: run.label }));
143
+ .map((run) => ({ id: run.id, agent: run.agent, label: run.label, queued: run.status === "queued" }));
144
144
  const message = {
145
145
  customType: "subagent-result",
146
146
  content: formatCompletionMessage(items) + formatActiveRunsFooter(active),