oira666_pi-subagent 0.2.19 → 0.2.21

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/resume.ts CHANGED
@@ -1,228 +1,227 @@
1
- import * as path from "node:path";
2
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
3
- import { parseBoolean, RESUME_PROVIDER } from "./shared.js";
4
- import { isResultError, isSubagentDetails, type SingleResult, type SubagentDetails } from "./types.js";
5
-
6
- export const SUBAGENT_RESUME_PROMPT_ENV = "PI_SUBAGENT_RESUME_PROMPT";
7
- export const SUBAGENT_RESUME_DISABLE_ENV = "PI_SUBAGENT_DISABLE_RESUME";
8
- export const SUBAGENT_SESSION_ROOT_ENV = "PI_SUBAGENT_SESSION_ROOT";
9
-
10
- type SessionEntry = ReturnType<ExtensionContext["sessionManager"]["getEntries"]>[number];
11
-
12
- export interface ResumableSubagentCall {
13
- previousToolCallId: string;
14
- tasks: Array<{ agent: string; task: string; cwd?: string }>;
15
- details?: SubagentDetails;
16
- }
17
-
18
- export const parseBooleanEnv = parseBoolean;
19
-
20
- export function getDefaultSubagentSessionRoot(ctx: ExtensionContext): string {
21
- const inheritedRoot = process.env[SUBAGENT_SESSION_ROOT_ENV];
22
- if (inheritedRoot) return inheritedRoot;
23
-
24
- const mainSessionDir = ctx.sessionManager.getSessionDir?.();
25
- if (typeof mainSessionDir === "string" && mainSessionDir.length > 0) {
26
- return path.join(mainSessionDir, "subagents");
27
- }
28
-
29
- throw new Error("Cannot determine subagent session root: sessionManager.getSessionDir() is unavailable.");
30
- }
31
-
32
- export function buildSubagentSessionDir(
33
- root: string,
34
- parentSessionId: string,
35
- toolCallId: string,
36
- index: number,
37
- ): string {
38
- const safeParent = parentSessionId.replace(/[^a-zA-Z0-9_.-]+/g, "_");
39
- const safeTool = toolCallId.replace(/[^a-zA-Z0-9_.-]+/g, "_");
40
- return path.join(root, safeParent, safeTool, String(index));
41
- }
42
-
43
- export function branchEntries(ctx: ExtensionContext): SessionEntry[] {
44
- const leafId = ctx.sessionManager.getLeafId?.();
45
- if (leafId) {
46
- const branch = ctx.sessionManager.getBranch?.(leafId);
47
- if (Array.isArray(branch)) return branch as SessionEntry[];
48
- }
49
- const entries = ctx.sessionManager.getEntries?.();
50
- return Array.isArray(entries) ? entries as SessionEntry[] : [];
51
- }
52
-
53
- function getSubagentToolCalls(message: any): Array<{ id: string; args: any }> {
54
- if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return [];
55
- const calls: Array<{ id: string; args: any }> = [];
56
- for (const part of message.content) {
57
- if (part?.type === "toolCall" && part.name === "subagent" && typeof part.id === "string") {
58
- calls.push({ id: part.id, args: part.arguments });
59
- }
60
- }
61
- return calls;
62
- }
63
-
64
- function normalizeTasks(args: any): Array<{ agent: string; task: string; cwd?: string }> | null {
65
- const rawTasks = args?.tasks;
66
- if (!Array.isArray(rawTasks) || rawTasks.length === 0) return null;
67
- const tasks: Array<{ agent: string; task: string; cwd?: string }> = [];
68
- for (const task of rawTasks) {
69
- if (typeof task?.agent !== "string" || typeof task?.task !== "string") return null;
70
- tasks.push({
71
- agent: task.agent,
72
- task: task.task,
73
- ...(typeof task.cwd === "string" ? { cwd: task.cwd } : {}),
74
- });
75
- }
76
- return tasks;
77
- }
78
-
79
- function hasUnfinishedResults(details: SubagentDetails | undefined, expectedTaskCount: number): boolean {
80
- if (!details) return true;
81
- if (details.results.length < expectedTaskCount) return true;
82
- return details.results.slice(0, expectedTaskCount).some((result) => result.exitCode === -1 || isResultError(result));
83
- }
84
-
85
- function getMessageText(message: any): string {
86
- const content = message?.content;
87
- if (typeof content === "string") return content;
88
- if (!Array.isArray(content)) return "";
89
- return content
90
- .map((part) => (part?.type === "text" && typeof part.text === "string" ? part.text : ""))
91
- .join("");
92
- }
93
-
94
- function messageHasNonEmptyText(message: any): boolean {
95
- return getMessageText(message).trim().length > 0;
96
- }
97
-
98
- function messageHasToolCall(message: any): boolean {
99
- return Array.isArray(message?.content) && message.content.some((part: any) => part?.type === "toolCall");
100
- }
101
-
102
- function isIgnorableTrailingAbortMessage(entry: any): boolean {
103
- if (entry?.type !== "message") return true;
104
- const message = entry.message;
105
- if (!message) return true;
106
-
107
- // Pi may append a final aborted/error assistant message after it has already
108
- // closed an interrupted tool with a synthetic toolResult. That message is not
109
- // user-visible progress after the subagent activity, so it must not prevent
110
- // resume detection.
111
- if (
112
- message.role === "assistant" &&
113
- (message.stopReason === "aborted" || message.stopReason === "error") &&
114
- !messageHasNonEmptyText(message) &&
115
- !messageHasToolCall(message)
116
- ) {
117
- return true;
118
- }
119
-
120
- return false;
121
- }
122
-
123
- function isResumePromptEntry(entry: any): boolean {
124
- if (entry?.type !== "message") return false;
125
- const message = entry.message;
126
- if (message?.role !== "user") return false;
127
- return /^Resuming \d+ subagents\.\.\.$/.test(getMessageText(message).trim());
128
- }
129
-
130
- function isSyntheticResumeModelChange(entry: any): boolean {
131
- return entry?.type === "model_change" && entry.provider === RESUME_PROVIDER;
132
- }
133
-
134
- function isFailedResumeAttemptTail(entries: SessionEntry[], start: number): boolean {
135
- if (start >= entries.length) return true;
136
-
137
- let sawSyntheticModel = false;
138
- let sawResumePrompt = false;
139
- let sawFailure = false;
140
-
141
- for (let i = start; i < entries.length; i++) {
142
- const entry: any = entries[i];
143
-
144
- if (isSyntheticResumeModelChange(entry)) {
145
- sawSyntheticModel = true;
146
- continue;
147
- }
148
-
149
- if (entry?.type === "thinking_level_change") continue;
150
-
151
- if (isResumePromptEntry(entry)) {
152
- if (!sawSyntheticModel) return false;
153
- sawResumePrompt = true;
154
- continue;
155
- }
156
-
157
- if (isIgnorableTrailingAbortMessage(entry)) {
158
- if (entry?.type === "message" && sawResumePrompt) sawFailure = true;
159
- continue;
160
- }
161
-
162
- return false;
163
- }
164
-
165
- return sawSyntheticModel && sawResumePrompt && sawFailure;
166
- }
167
-
168
- function hasOnlyIgnorableTrailingEntries(entries: SessionEntry[], activityOrder: number): boolean {
169
- const start = activityOrder + 1;
170
- for (let i = start; i < entries.length; i++) {
171
- if (!isIgnorableTrailingAbortMessage(entries[i])) return isFailedResumeAttemptTail(entries, start);
172
- }
173
- return true;
174
- }
175
-
176
- export function findLatestResumableSubagentCall(ctx: ExtensionContext): ResumableSubagentCall | null {
177
- const entries = branchEntries(ctx);
178
- const calls = new Map<string, { tasks: Array<{ agent: string; task: string; cwd?: string }>; order: number }>();
179
- const results = new Map<string, { details?: SubagentDetails; isError: boolean; order: number }>();
180
-
181
- entries.forEach((entry: any, order) => {
182
- if (entry?.type !== "message") return;
183
- const msg = entry.message;
184
- for (const call of getSubagentToolCalls(msg)) {
185
- const tasks = normalizeTasks(call.args);
186
- if (tasks) calls.set(call.id, { tasks, order });
187
- }
188
- if (msg?.role === "toolResult" && msg.toolName === "subagent" && typeof msg.toolCallId === "string") {
189
- results.set(msg.toolCallId, {
190
- details: isSubagentDetails(msg.details) ? msg.details : undefined,
191
- isError: msg.isError === true,
192
- order,
193
- });
194
- }
195
- });
196
-
197
- const candidates: Array<ResumableSubagentCall & { activityOrder: number }> = [];
198
- for (const [toolCallId, call] of calls) {
199
- const result = results.get(toolCallId);
200
- const unfinished = !result || result.isError || hasUnfinishedResults(result.details, call.tasks.length);
201
- if (!unfinished) continue;
202
- candidates.push({
203
- previousToolCallId: toolCallId,
204
- tasks: call.tasks,
205
- details: result?.details,
206
- activityOrder: result?.order ?? call.order,
207
- });
208
- }
209
-
210
- const latest = candidates.sort((a, b) => a.activityOrder - b.activityOrder).at(-1);
211
- if (!latest || !hasOnlyIgnorableTrailingEntries(entries, latest.activityOrder)) return null;
212
- return latest;
213
- }
214
-
215
- export function sameTasks(
216
- a: Array<{ agent: string; task: string; cwd?: string }>,
217
- b: Array<{ agent: string; task: string; cwd?: string }>,
218
- ): boolean {
219
- if (a.length !== b.length) return false;
220
- return a.every((task, index) => {
221
- const other = b[index];
222
- return task.agent === other.agent && task.task === other.task && (task.cwd ?? undefined) === (other.cwd ?? undefined);
223
- });
224
- }
225
-
226
- export function isFinishedResult(result: SingleResult | undefined): boolean {
227
- return !!result && result.exitCode === 0 && !isResultError(result);
228
- }
1
+ import * as path from "node:path";
2
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
3
+ import { parseBoolean, RESUME_PROVIDER } from "./shared.js";
4
+ import { isResultError, isSubagentDetails, type SingleResult, type SubagentDetails } from "./types.js";
5
+
6
+ export const SUBAGENT_RESUME_PROMPT_ENV = "PI_SUBAGENT_RESUME_PROMPT";
7
+ export const SUBAGENT_RESUME_DISABLE_ENV = "PI_SUBAGENT_DISABLE_RESUME";
8
+ export const SUBAGENT_SESSION_ROOT_ENV = "PI_SUBAGENT_SESSION_ROOT";
9
+
10
+ type SessionEntry = ReturnType<ExtensionContext["sessionManager"]["getEntries"]>[number];
11
+
12
+ export interface ResumableSubagentCall {
13
+ previousToolCallId: string;
14
+ tasks: Array<{ agent: string; task: string }>;
15
+ details?: SubagentDetails;
16
+ }
17
+
18
+ export const parseBooleanEnv = parseBoolean;
19
+
20
+ export function getDefaultSubagentSessionRoot(ctx: ExtensionContext): string {
21
+ const inheritedRoot = process.env[SUBAGENT_SESSION_ROOT_ENV];
22
+ if (inheritedRoot) return inheritedRoot;
23
+
24
+ const mainSessionDir = ctx.sessionManager.getSessionDir?.();
25
+ if (typeof mainSessionDir === "string" && mainSessionDir.length > 0) {
26
+ return path.join(mainSessionDir, "subagents");
27
+ }
28
+
29
+ throw new Error("Cannot determine subagent session root: sessionManager.getSessionDir() is unavailable.");
30
+ }
31
+
32
+ export function buildSubagentSessionDir(
33
+ root: string,
34
+ parentSessionId: string,
35
+ toolCallId: string,
36
+ index: number,
37
+ ): string {
38
+ const safeParent = parentSessionId.replace(/[^a-zA-Z0-9_.-]+/g, "_");
39
+ const safeTool = toolCallId.replace(/[^a-zA-Z0-9_.-]+/g, "_");
40
+ return path.join(root, safeParent, safeTool, String(index));
41
+ }
42
+
43
+ export function branchEntries(ctx: ExtensionContext): SessionEntry[] {
44
+ const leafId = ctx.sessionManager.getLeafId?.();
45
+ if (leafId) {
46
+ const branch = ctx.sessionManager.getBranch?.(leafId);
47
+ if (Array.isArray(branch)) return branch as SessionEntry[];
48
+ }
49
+ const entries = ctx.sessionManager.getEntries?.();
50
+ return Array.isArray(entries) ? entries as SessionEntry[] : [];
51
+ }
52
+
53
+ function getSubagentToolCalls(message: any): Array<{ id: string; args: any }> {
54
+ if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return [];
55
+ const calls: Array<{ id: string; args: any }> = [];
56
+ for (const part of message.content) {
57
+ if (part?.type === "toolCall" && part.name === "subagent" && typeof part.id === "string") {
58
+ calls.push({ id: part.id, args: part.arguments });
59
+ }
60
+ }
61
+ return calls;
62
+ }
63
+
64
+ function normalizeTasks(args: any): Array<{ agent: string; task: string }> | null {
65
+ const rawTasks = args?.tasks;
66
+ if (!Array.isArray(rawTasks) || rawTasks.length === 0) return null;
67
+ const tasks: Array<{ agent: string; task: string }> = [];
68
+ for (const task of rawTasks) {
69
+ if (typeof task?.agent !== "string" || typeof task?.task !== "string") return null;
70
+ tasks.push({
71
+ agent: task.agent,
72
+ task: task.task,
73
+ });
74
+ }
75
+ return tasks;
76
+ }
77
+
78
+ function hasUnfinishedResults(details: SubagentDetails | undefined, expectedTaskCount: number): boolean {
79
+ if (!details) return true;
80
+ if (details.results.length < expectedTaskCount) return true;
81
+ return details.results.slice(0, expectedTaskCount).some((result) => result.exitCode === -1 || isResultError(result));
82
+ }
83
+
84
+ function getMessageText(message: any): string {
85
+ const content = message?.content;
86
+ if (typeof content === "string") return content;
87
+ if (!Array.isArray(content)) return "";
88
+ return content
89
+ .map((part) => (part?.type === "text" && typeof part.text === "string" ? part.text : ""))
90
+ .join("");
91
+ }
92
+
93
+ function messageHasNonEmptyText(message: any): boolean {
94
+ return getMessageText(message).trim().length > 0;
95
+ }
96
+
97
+ function messageHasToolCall(message: any): boolean {
98
+ return Array.isArray(message?.content) && message.content.some((part: any) => part?.type === "toolCall");
99
+ }
100
+
101
+ function isIgnorableTrailingAbortMessage(entry: any): boolean {
102
+ if (entry?.type !== "message") return true;
103
+ const message = entry.message;
104
+ if (!message) return true;
105
+
106
+ // Pi may append a final aborted/error assistant message after it has already
107
+ // closed an interrupted tool with a synthetic toolResult. That message is not
108
+ // user-visible progress after the subagent activity, so it must not prevent
109
+ // resume detection.
110
+ if (
111
+ message.role === "assistant" &&
112
+ (message.stopReason === "aborted" || message.stopReason === "error") &&
113
+ !messageHasNonEmptyText(message) &&
114
+ !messageHasToolCall(message)
115
+ ) {
116
+ return true;
117
+ }
118
+
119
+ return false;
120
+ }
121
+
122
+ function isResumePromptEntry(entry: any): boolean {
123
+ if (entry?.type !== "message") return false;
124
+ const message = entry.message;
125
+ if (message?.role !== "user") return false;
126
+ return /^Resuming \d+ subagents\.\.\.$/.test(getMessageText(message).trim());
127
+ }
128
+
129
+ function isSyntheticResumeModelChange(entry: any): boolean {
130
+ return entry?.type === "model_change" && entry.provider === RESUME_PROVIDER;
131
+ }
132
+
133
+ function isFailedResumeAttemptTail(entries: SessionEntry[], start: number): boolean {
134
+ if (start >= entries.length) return true;
135
+
136
+ let sawSyntheticModel = false;
137
+ let sawResumePrompt = false;
138
+ let sawFailure = false;
139
+
140
+ for (let i = start; i < entries.length; i++) {
141
+ const entry: any = entries[i];
142
+
143
+ if (isSyntheticResumeModelChange(entry)) {
144
+ sawSyntheticModel = true;
145
+ continue;
146
+ }
147
+
148
+ if (entry?.type === "thinking_level_change") continue;
149
+
150
+ if (isResumePromptEntry(entry)) {
151
+ if (!sawSyntheticModel) return false;
152
+ sawResumePrompt = true;
153
+ continue;
154
+ }
155
+
156
+ if (isIgnorableTrailingAbortMessage(entry)) {
157
+ if (entry?.type === "message" && sawResumePrompt) sawFailure = true;
158
+ continue;
159
+ }
160
+
161
+ return false;
162
+ }
163
+
164
+ return sawSyntheticModel && sawResumePrompt && sawFailure;
165
+ }
166
+
167
+ function hasOnlyIgnorableTrailingEntries(entries: SessionEntry[], activityOrder: number): boolean {
168
+ const start = activityOrder + 1;
169
+ for (let i = start; i < entries.length; i++) {
170
+ if (!isIgnorableTrailingAbortMessage(entries[i])) return isFailedResumeAttemptTail(entries, start);
171
+ }
172
+ return true;
173
+ }
174
+
175
+ export function findLatestResumableSubagentCall(ctx: ExtensionContext): ResumableSubagentCall | null {
176
+ const entries = branchEntries(ctx);
177
+ const calls = new Map<string, { tasks: Array<{ agent: string; task: string }>; order: number }>();
178
+ const results = new Map<string, { details?: SubagentDetails; isError: boolean; order: number }>();
179
+
180
+ entries.forEach((entry: any, order) => {
181
+ if (entry?.type !== "message") return;
182
+ const msg = entry.message;
183
+ for (const call of getSubagentToolCalls(msg)) {
184
+ const tasks = normalizeTasks(call.args);
185
+ if (tasks) calls.set(call.id, { tasks, order });
186
+ }
187
+ if (msg?.role === "toolResult" && msg.toolName === "subagent" && typeof msg.toolCallId === "string") {
188
+ results.set(msg.toolCallId, {
189
+ details: isSubagentDetails(msg.details) ? msg.details : undefined,
190
+ isError: msg.isError === true,
191
+ order,
192
+ });
193
+ }
194
+ });
195
+
196
+ const candidates: Array<ResumableSubagentCall & { activityOrder: number }> = [];
197
+ for (const [toolCallId, call] of calls) {
198
+ const result = results.get(toolCallId);
199
+ const unfinished = !result || result.isError || hasUnfinishedResults(result.details, call.tasks.length);
200
+ if (!unfinished) continue;
201
+ candidates.push({
202
+ previousToolCallId: toolCallId,
203
+ tasks: call.tasks,
204
+ details: result?.details,
205
+ activityOrder: result?.order ?? call.order,
206
+ });
207
+ }
208
+
209
+ const latest = candidates.sort((a, b) => a.activityOrder - b.activityOrder).at(-1);
210
+ if (!latest || !hasOnlyIgnorableTrailingEntries(entries, latest.activityOrder)) return null;
211
+ return latest;
212
+ }
213
+
214
+ export function sameTasks(
215
+ a: Array<{ agent: string; task: string }>,
216
+ b: Array<{ agent: string; task: string }>,
217
+ ): boolean {
218
+ if (a.length !== b.length) return false;
219
+ return a.every((task, index) => {
220
+ const other = b[index];
221
+ return task.agent === other.agent && task.task === other.task;
222
+ });
223
+ }
224
+
225
+ export function isFinishedResult(result: SingleResult | undefined): boolean {
226
+ return !!result && result.exitCode === 0 && !isResultError(result);
227
+ }