@ferris1225/pi-subagents 2.0.2 → 2.1.0

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,177 +1,177 @@
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 relations = [
104
- result.forkedFromRunId !== undefined ? `forked from #${result.forkedFromRunId}` : undefined,
105
- (result.forkChildRunIds?.length ?? 0) > 0 ? `fork children ${result.forkChildRunIds!.map((id) => `#${id}`).join(", ")}` : undefined,
106
- ].filter((value): value is string => Boolean(value));
107
- const relationNote = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
108
- const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
109
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
110
- if (result.isolation === "worktree") {
111
- const isolation =
112
- result.integrationStatus === "integrated"
113
- ? "worktree · changes integrated into the original working tree"
114
- : result.integrationStatus === "no_changes"
115
- ? "worktree · no changes; temporary worktree removed"
116
- : result.integrationStatus === "retained"
117
- ? result.integrationApplied
118
- ? "worktree · changes applied, but cleanup failed; recovery artifacts retained"
119
- : "worktree · integration failed; recovery artifacts retained"
120
- : "worktree · isolated";
121
- lines.push(`Isolation: ${isolation}${relationNote}`);
122
- if (result.integrationWorktreePath) lines.push(`Retained worktree: ${result.integrationWorktreePath}`);
123
- if (result.integrationPatchPath) lines.push(`Retained patch: ${result.integrationPatchPath}`);
124
- if (result.integrationError) lines.push(`Integration error: ${result.integrationError}`);
125
- lines.push("");
126
- } else if (relations.length > 0) {
127
- lines.push(`Relation: ${relations.join(" · ")}`, "");
128
- }
129
- lines.push(text);
130
- // Explicit status always exposes every retained diagnostic, including when
131
- // the overall run failed or was aborted. Automatic delivery adds only a
132
- // compact pointer for otherwise-clean runs.
133
- if (options.failedToolDetails && failedTools.length > 0) {
134
- lines.push(
135
- "",
136
- `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}:`,
137
- ...failedTools.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
138
- );
139
- } else if (!failed && failedTools.length > 0) {
140
- const lookup = result.runId !== undefined ? ` · details: subagent_status #${result.runId}` : "";
141
- lines.push("", `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}${lookup}`);
142
- }
143
- if (truncated) {
144
- // The full text lives on disk so the main agent can read it on demand.
145
- lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, result.projectCwd ?? cwd)})`);
146
- }
147
- return lines.join("\n");
148
- }
149
-
150
- /** Instruction appended to a model-level failure: the sub-agent's provider never
151
- * produced usable output (or the run stalled), so the task is handed back to the
152
- * main window instead of being left as a dead failure. When the run preserved a
153
- * session with earlier work (and the run id is known), steer the main agent to
154
- * RESUME it in-context once a model is available, instead of re-dispatching
155
- * fresh (which would re-scan everything). */
156
- export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
157
- const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
158
- const detail = result.errorMessage?.trim();
159
- const cause = detail
160
- ? `its model/provider call failed (${detail})`
161
- : "its model was unavailable or failed (or the run stalled)";
162
- const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
163
- const recovery = sessionPreserved
164
- ? ` 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.`
165
- : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
166
- return `The sub-agent could not complete this task: ${cause}${retry}.${recovery}`;
167
- }
168
-
169
- /** Resolve a run-id request to actual ids: an exact numeric match always wins
170
- * (so "1" never fans out to 10, 11, …); only when no exact match exists does a
171
- * prefix match run, as a convenience for partial ids. Keeps single-digit lookups
172
- * from returning — or, for subagent_stop, acting on — a whole prefix family. */
173
- export function matchRunIds(ids: number[], requested: string): number[] {
174
- const exact = ids.filter((id) => String(id) === requested);
175
- if (exact.length > 0) return exact;
176
- return ids.filter((id) => String(id).startsWith(requested));
177
- }
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 relations = [
104
+ result.forkedFromRunId !== undefined ? `forked from #${result.forkedFromRunId}` : undefined,
105
+ (result.forkChildRunIds?.length ?? 0) > 0 ? `fork children ${result.forkChildRunIds!.map((id) => `#${id}`).join(", ")}` : undefined,
106
+ ].filter((value): value is string => Boolean(value));
107
+ const relationNote = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
108
+ const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
109
+ const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
110
+ if (result.isolation === "worktree") {
111
+ const isolation =
112
+ result.integrationStatus === "integrated"
113
+ ? "worktree · changes integrated into the original working tree"
114
+ : result.integrationStatus === "no_changes"
115
+ ? "worktree · no changes; temporary worktree removed"
116
+ : result.integrationStatus === "retained"
117
+ ? result.integrationApplied
118
+ ? "worktree · changes applied, but cleanup failed; recovery artifacts retained"
119
+ : "worktree · integration failed; recovery artifacts retained"
120
+ : "worktree · isolated";
121
+ lines.push(`Isolation: ${isolation}${relationNote}`);
122
+ if (result.integrationWorktreePath) lines.push(`Retained worktree: ${result.integrationWorktreePath}`);
123
+ if (result.integrationPatchPath) lines.push(`Retained patch: ${result.integrationPatchPath}`);
124
+ if (result.integrationError) lines.push(`Integration error: ${result.integrationError}`);
125
+ lines.push("");
126
+ } else if (relations.length > 0) {
127
+ lines.push(`Relation: ${relations.join(" · ")}`, "");
128
+ }
129
+ lines.push(text);
130
+ // Explicit status always exposes every retained diagnostic, including when
131
+ // the overall run failed or was aborted. Automatic delivery adds only a
132
+ // compact pointer for otherwise-clean runs.
133
+ if (options.failedToolDetails && failedTools.length > 0) {
134
+ lines.push(
135
+ "",
136
+ `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}:`,
137
+ ...failedTools.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
138
+ );
139
+ } else if (!failed && failedTools.length > 0) {
140
+ const lookup = result.runId !== undefined ? ` · details: subagent_status #${result.runId}` : "";
141
+ lines.push("", `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}${lookup}`);
142
+ }
143
+ if (truncated) {
144
+ // The full text lives on disk so the main agent can read it on demand.
145
+ lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, result.projectCwd ?? cwd)})`);
146
+ }
147
+ return lines.join("\n");
148
+ }
149
+
150
+ /** Instruction appended to a model-level failure: the sub-agent's provider never
151
+ * produced usable output (or the run stalled), so the task is handed back to the
152
+ * main window instead of being left as a dead failure. When the run preserved a
153
+ * session with earlier work (and the run id is known), steer the main agent to
154
+ * RESUME it in-context once a model is available, instead of re-dispatching
155
+ * fresh (which would re-scan everything). */
156
+ export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
157
+ const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
158
+ const detail = result.errorMessage?.trim();
159
+ const cause = detail
160
+ ? `its model/provider call failed (${detail})`
161
+ : "its model was unavailable or failed (or the run stalled)";
162
+ const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
163
+ const recovery = sessionPreserved
164
+ ? ` 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.`
165
+ : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
166
+ return `The sub-agent could not complete this task: ${cause}${retry}.${recovery}`;
167
+ }
168
+
169
+ /** Resolve a run-id request to actual ids: an exact numeric match always wins
170
+ * (so "1" never fans out to 10, 11, …); only when no exact match exists does a
171
+ * prefix match run, as a convenience for partial ids. Keeps single-digit lookups
172
+ * from returning — or, for subagent_stop, acting on — a whole prefix family. */
173
+ export function matchRunIds(ids: number[], requested: string): number[] {
174
+ const exact = ids.filter((id) => String(id) === requested);
175
+ if (exact.length > 0) return exact;
176
+ return ids.filter((id) => String(id).startsWith(requested));
177
+ }
package/src/index.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Assembly point: builds the shared runtime and registers everything.
5
5
  * The heavy lifting lives in focused modules:
6
- * - dispatch.ts — the `subagent` tool (spawn, auto-fix chain, vision model)
7
- * - tools.ts subagent_control / subagent_wait / status / stop
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
8
9
  * - announcements.ts — session-start recovery, notices, and widget install
9
10
  * - widget.ts — active-only TUI run status
10
11
  * - runtime.ts — shared per-session state