@ryan_nookpi/pi-extension-subagent 0.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/LICENSE +21 -0
- package/README.md +242 -0
- package/above-widget.ts +20 -0
- package/agents.ts +233 -0
- package/claude-args.ts +103 -0
- package/claude-sdk-runner.ts +223 -0
- package/claude-sdk-stream-adapter.ts +49 -0
- package/claude-sidecar-writer.ts +107 -0
- package/claude-stream-parser.ts +400 -0
- package/cli.ts +549 -0
- package/commands.ts +2295 -0
- package/config.ts +119 -0
- package/constants.ts +115 -0
- package/context-limits.ts +132 -0
- package/display-task.ts +98 -0
- package/escalation.ts +159 -0
- package/format.ts +188 -0
- package/group-pending.ts +90 -0
- package/index.ts +200 -0
- package/invocation-queue.ts +29 -0
- package/live-preview.ts +39 -0
- package/package.json +95 -0
- package/persisted-session.ts +185 -0
- package/replay.ts +461 -0
- package/retry.ts +164 -0
- package/run-utils.ts +203 -0
- package/runner.ts +979 -0
- package/session.ts +319 -0
- package/store.ts +143 -0
- package/tool-execute.ts +1932 -0
- package/tool-render.ts +145 -0
- package/types.ts +193 -0
- package/utils/agent-utils.ts +315 -0
- package/utils/format-utils.ts +255 -0
- package/utils/short-label.ts +78 -0
- package/utils/string-utils.ts +229 -0
- package/utils/time-utils.ts +105 -0
- package/widget.ts +294 -0
package/retry.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { getFinalOutput } from "./runner.js";
|
|
2
|
+
import type { SingleResult } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export const MAX_SUBAGENT_AUTO_RETRIES = 3;
|
|
5
|
+
const RETRY_DELAYS_MS = [2_000, 5_000, 10_000] as const;
|
|
6
|
+
|
|
7
|
+
export interface RetryDecision {
|
|
8
|
+
retryable: boolean;
|
|
9
|
+
reason?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RetryScheduleInfo {
|
|
13
|
+
retryIndex: number;
|
|
14
|
+
maxRetries: number;
|
|
15
|
+
delayMs: number;
|
|
16
|
+
reason: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface InvokeWithAutoRetryOptions {
|
|
20
|
+
invoke: () => Promise<SingleResult>;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
maxRetries?: number;
|
|
23
|
+
onRetryScheduled?: (info: RetryScheduleInfo) => void | Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildFailureText(result: SingleResult): string {
|
|
27
|
+
return [
|
|
28
|
+
result.errorMessage,
|
|
29
|
+
result.stderr,
|
|
30
|
+
getFinalOutput(result.messages),
|
|
31
|
+
result.stopReason ? `stopReason=${result.stopReason}` : "",
|
|
32
|
+
]
|
|
33
|
+
.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
34
|
+
.join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TRANSIENT_FAILURE_PATTERNS: RegExp[] = [
|
|
38
|
+
/\bnetwork\b/i,
|
|
39
|
+
/\bfetch failed\b/i,
|
|
40
|
+
/\bconnection reset\b/i,
|
|
41
|
+
/\bsocket hang up\b/i,
|
|
42
|
+
/\btemporar(?:y|ily) unavailable\b/i,
|
|
43
|
+
/\btemporar(?:y|ily) failure\b/i,
|
|
44
|
+
/\btimeout\b/i,
|
|
45
|
+
/\btimed out\b/i,
|
|
46
|
+
/\bETIMEDOUT\b/i,
|
|
47
|
+
/\bECONNRESET\b/i,
|
|
48
|
+
/\bECONNREFUSED\b/i,
|
|
49
|
+
/\bENOTFOUND\b/i,
|
|
50
|
+
/\bEHOSTUNREACH\b/i,
|
|
51
|
+
/\b503\b/i,
|
|
52
|
+
/\b502\b/i,
|
|
53
|
+
/\b504\b/i,
|
|
54
|
+
/\b429\b/i,
|
|
55
|
+
/\brate limit\b/i,
|
|
56
|
+
/\btoo many requests\b/i,
|
|
57
|
+
/\boverloaded\b/i,
|
|
58
|
+
/\bover capacity\b/i,
|
|
59
|
+
/\bservice unavailable\b/i,
|
|
60
|
+
/\bserver_error\b/i,
|
|
61
|
+
/\bserver error\b/i,
|
|
62
|
+
/an error occurred while processing your request/i,
|
|
63
|
+
/\bupstream\b/i,
|
|
64
|
+
/\bTLS\b/i,
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
function matchesTransientPattern(text: string): boolean {
|
|
68
|
+
return TRANSIENT_FAILURE_PATTERNS.some((pattern) => pattern.test(text));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function diagnoseRetryableResult(result: SingleResult): RetryDecision {
|
|
72
|
+
if (result.stopReason === "aborted") return { retryable: false };
|
|
73
|
+
|
|
74
|
+
const finalOutput = getFinalOutput(result.messages).trim();
|
|
75
|
+
if (result.exitCode === 0 && result.stopReason !== "error" && finalOutput) {
|
|
76
|
+
return { retryable: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const failureText = buildFailureText(result);
|
|
80
|
+
if (!failureText) return { retryable: false };
|
|
81
|
+
if (!matchesTransientPattern(failureText)) return { retryable: false };
|
|
82
|
+
|
|
83
|
+
const firstLine = failureText
|
|
84
|
+
.split("\n")
|
|
85
|
+
.map((line) => line.trim())
|
|
86
|
+
.find(Boolean);
|
|
87
|
+
return {
|
|
88
|
+
retryable: true,
|
|
89
|
+
reason: firstLine || "Transient network/service failure detected.",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function diagnoseRetryableError(error: unknown): RetryDecision {
|
|
94
|
+
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error ?? "");
|
|
95
|
+
if (!text || !matchesTransientPattern(text)) return { retryable: false };
|
|
96
|
+
return { retryable: true, reason: text };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function waitWithAbort(delayMs: number, signal?: AbortSignal): Promise<void> {
|
|
100
|
+
if (delayMs <= 0) return;
|
|
101
|
+
await new Promise<void>((resolve, reject) => {
|
|
102
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
103
|
+
const cleanup = () => {
|
|
104
|
+
if (timer) clearTimeout(timer);
|
|
105
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
106
|
+
};
|
|
107
|
+
const onAbort = () => {
|
|
108
|
+
cleanup();
|
|
109
|
+
reject(new Error("Subagent retry aborted"));
|
|
110
|
+
};
|
|
111
|
+
if (signal?.aborted) {
|
|
112
|
+
onAbort();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
timer = setTimeout(() => {
|
|
116
|
+
cleanup();
|
|
117
|
+
resolve();
|
|
118
|
+
}, delayMs);
|
|
119
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function invokeWithAutoRetry({
|
|
124
|
+
invoke,
|
|
125
|
+
signal,
|
|
126
|
+
maxRetries = MAX_SUBAGENT_AUTO_RETRIES,
|
|
127
|
+
onRetryScheduled,
|
|
128
|
+
}: InvokeWithAutoRetryOptions): Promise<{ result: SingleResult; retryCount: number }> {
|
|
129
|
+
let retryCount = 0;
|
|
130
|
+
|
|
131
|
+
while (true) {
|
|
132
|
+
try {
|
|
133
|
+
const result = await invoke();
|
|
134
|
+
const retryDecision = diagnoseRetryableResult(result);
|
|
135
|
+
if (!retryDecision.retryable || retryCount >= maxRetries) {
|
|
136
|
+
return { result, retryCount };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
retryCount += 1;
|
|
140
|
+
const delayMs = RETRY_DELAYS_MS[Math.min(retryCount - 1, RETRY_DELAYS_MS.length - 1)] ?? 2_000;
|
|
141
|
+
await onRetryScheduled?.({
|
|
142
|
+
retryIndex: retryCount,
|
|
143
|
+
maxRetries,
|
|
144
|
+
delayMs,
|
|
145
|
+
reason: retryDecision.reason || "Transient network/service failure detected.",
|
|
146
|
+
});
|
|
147
|
+
await waitWithAbort(delayMs, signal);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (signal?.aborted) throw error;
|
|
150
|
+
const retryDecision = diagnoseRetryableError(error);
|
|
151
|
+
if (!retryDecision.retryable || retryCount >= maxRetries) throw error;
|
|
152
|
+
|
|
153
|
+
retryCount += 1;
|
|
154
|
+
const delayMs = RETRY_DELAYS_MS[Math.min(retryCount - 1, RETRY_DELAYS_MS.length - 1)] ?? 2_000;
|
|
155
|
+
await onRetryScheduled?.({
|
|
156
|
+
retryIndex: retryCount,
|
|
157
|
+
maxRetries,
|
|
158
|
+
delayMs,
|
|
159
|
+
reason: retryDecision.reason || "Transient network/service failure detected.",
|
|
160
|
+
});
|
|
161
|
+
await waitWithAbort(delayMs, signal);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
package/run-utils.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for formatting and managing subagent command runs.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from commands.ts to eliminate duplicated run-summary and
|
|
5
|
+
* run-history-trimming logic. Output format is intentionally kept
|
|
6
|
+
* identical to the original inline implementations.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { SubagentStore } from "./store.js";
|
|
11
|
+
import type { CommandRunState } from "./types.js";
|
|
12
|
+
import { updateCommandRunsWidget, type WidgetRenderCtx } from "./widget.js";
|
|
13
|
+
|
|
14
|
+
export interface RemoveRunOptions {
|
|
15
|
+
ctx?: unknown;
|
|
16
|
+
pi?: ExtensionAPI;
|
|
17
|
+
abortIfRunning?: boolean;
|
|
18
|
+
reason?: string;
|
|
19
|
+
persistRemovedEntry?: boolean;
|
|
20
|
+
updateWidget?: boolean;
|
|
21
|
+
removalReason?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RemoveRunResult {
|
|
25
|
+
removed: boolean;
|
|
26
|
+
aborted: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TrimCommandRunHistoryOptions {
|
|
30
|
+
maxRuns?: number;
|
|
31
|
+
ctx?: unknown;
|
|
32
|
+
pi?: ExtensionAPI;
|
|
33
|
+
updateWidget?: boolean;
|
|
34
|
+
removalReason?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ClearFinishedRunsOptions {
|
|
38
|
+
ctx?: unknown;
|
|
39
|
+
pi?: ExtensionAPI;
|
|
40
|
+
updateWidget?: boolean;
|
|
41
|
+
persistRemovedEntry?: boolean;
|
|
42
|
+
removalReason?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One-line summary of a command run.
|
|
47
|
+
*
|
|
48
|
+
* Format: `#<id> [<status>] <agent> ctx:<contextMode> turn:<turnCount> <elapsed>s tools:<toolCalls>`
|
|
49
|
+
*/
|
|
50
|
+
export function formatCommandRunSummary(run: CommandRunState): string {
|
|
51
|
+
const elapsedSec = Math.max(0, Math.round(run.elapsedMs / 1000));
|
|
52
|
+
const contextLabel = run.contextMode === "main" ? "main" : "isolated";
|
|
53
|
+
return `#${run.id} [${run.status}] ${run.agent} ctx:${contextLabel} turn:${run.turnCount ?? 1} ${elapsedSec}s tools:${run.toolCalls}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Return the most recent run matching the optional status filter.
|
|
58
|
+
* Runs are ordered by descending ID (newest first).
|
|
59
|
+
* If no filter is given, the newest run overall is returned.
|
|
60
|
+
*/
|
|
61
|
+
export function getLatestRun(
|
|
62
|
+
store: SubagentStore,
|
|
63
|
+
statusFilter?: CommandRunState["status"] | CommandRunState["status"][],
|
|
64
|
+
): CommandRunState | undefined {
|
|
65
|
+
const runs = Array.from(store.commandRuns.values()).sort((a, b) => b.id - a.id);
|
|
66
|
+
if (!statusFilter) return runs[0];
|
|
67
|
+
const allowed = Array.isArray(statusFilter) ? statusFilter : [statusFilter];
|
|
68
|
+
return runs.find((r) => allowed.includes(r.status));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Remove a run from the in-memory store with optional abort/persist side-effects.
|
|
73
|
+
* This is the single deletion path used by commands/tool/trim logic.
|
|
74
|
+
* Also cleans up the globalLiveRuns registry to prevent leaks.
|
|
75
|
+
*/
|
|
76
|
+
export function removeRun(store: SubagentStore, runId: number, options: RemoveRunOptions = {}): RemoveRunResult {
|
|
77
|
+
const run = store.commandRuns.get(runId);
|
|
78
|
+
if (!run) return { removed: false, aborted: false };
|
|
79
|
+
|
|
80
|
+
const abortIfRunning = options.abortIfRunning ?? true;
|
|
81
|
+
const persistRemovedEntry = options.persistRemovedEntry ?? true;
|
|
82
|
+
const shouldUpdateWidget = options.updateWidget ?? true;
|
|
83
|
+
let aborted = false;
|
|
84
|
+
|
|
85
|
+
run.removed = true;
|
|
86
|
+
|
|
87
|
+
// Abort via globalLiveRuns if the view state lost its controller reference.
|
|
88
|
+
const globalEntry = store.globalLiveRuns.get(runId);
|
|
89
|
+
const controller = run.abortController ?? globalEntry?.abortController;
|
|
90
|
+
|
|
91
|
+
if (abortIfRunning && run.status === "running" && controller) {
|
|
92
|
+
const reason = options.reason ?? "Aborting by remove...";
|
|
93
|
+
run.lastLine = reason;
|
|
94
|
+
run.lastOutput = reason;
|
|
95
|
+
controller.abort();
|
|
96
|
+
aborted = true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
run.abortController = undefined;
|
|
100
|
+
// Do NOT delete from commandRuns — keep the entry with removed:true so that
|
|
101
|
+
// /sub:history can still display it within the current session.
|
|
102
|
+
// The entry is re-hydrated from JSONL on session reload via subagent-removed entries.
|
|
103
|
+
store.globalLiveRuns.delete(runId);
|
|
104
|
+
|
|
105
|
+
if (persistRemovedEntry && options.pi && run.deliveryMode !== "humanOnly") {
|
|
106
|
+
const payload: Record<string, unknown> = { runId };
|
|
107
|
+
if (options.removalReason) payload.reason = options.removalReason;
|
|
108
|
+
try {
|
|
109
|
+
options.pi.appendEntry("subagent-removed", payload);
|
|
110
|
+
} catch {
|
|
111
|
+
/* ignore append failures */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (shouldUpdateWidget) {
|
|
116
|
+
updateCommandRunsWidget(store, options.ctx as WidgetRenderCtx | undefined);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { removed: true, aborted };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Trim completed/errored command runs so that the store never exceeds
|
|
124
|
+
* `maxRuns` entries. Oldest finished runs are removed first; running
|
|
125
|
+
* runs are never evicted.
|
|
126
|
+
*
|
|
127
|
+
* Returns the run IDs that were evicted.
|
|
128
|
+
*/
|
|
129
|
+
export function clearFinishedRuns(store: SubagentStore, options: ClearFinishedRunsOptions = {}): number[] {
|
|
130
|
+
const removedRunIds: number[] = [];
|
|
131
|
+
|
|
132
|
+
for (const run of Array.from(store.commandRuns.values())) {
|
|
133
|
+
if (run.removed || run.status === "running") continue;
|
|
134
|
+
const globalEntry = store.globalLiveRuns.get(run.id);
|
|
135
|
+
if (globalEntry?.pendingCompletion) continue;
|
|
136
|
+
|
|
137
|
+
const result = removeRun(store, run.id, {
|
|
138
|
+
ctx: options.ctx,
|
|
139
|
+
pi: options.pi,
|
|
140
|
+
abortIfRunning: false,
|
|
141
|
+
updateWidget: false,
|
|
142
|
+
persistRemovedEntry: options.persistRemovedEntry,
|
|
143
|
+
removalReason: options.removalReason,
|
|
144
|
+
});
|
|
145
|
+
if (result.removed) removedRunIds.push(run.id);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if ((options.updateWidget ?? false) && removedRunIds.length > 0) {
|
|
149
|
+
updateCommandRunsWidget(store, options.ctx as WidgetRenderCtx | undefined);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return removedRunIds;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function trimCommandRunHistory(
|
|
156
|
+
store: SubagentStore,
|
|
157
|
+
options: number | TrimCommandRunHistoryOptions = 10,
|
|
158
|
+
): number[] {
|
|
159
|
+
const maxRuns = typeof options === "number" ? options : (options.maxRuns ?? 10);
|
|
160
|
+
const shouldUpdateWidget = typeof options === "number" ? false : (options.updateWidget ?? false);
|
|
161
|
+
|
|
162
|
+
const completed = Array.from(store.commandRuns.values())
|
|
163
|
+
.filter((run) => {
|
|
164
|
+
if (run.removed) return false; // already removed — skip
|
|
165
|
+
if (run.status === "running") return false;
|
|
166
|
+
// Never evict runs with pending cross-session completions.
|
|
167
|
+
const globalEntry = store.globalLiveRuns.get(run.id);
|
|
168
|
+
if (globalEntry?.pendingCompletion) return false;
|
|
169
|
+
return true;
|
|
170
|
+
})
|
|
171
|
+
.sort((a, b) => a.id - b.id);
|
|
172
|
+
|
|
173
|
+
// Count only active (non-removed) runs — commandRuns.size includes removed entries.
|
|
174
|
+
let activeCount = Array.from(store.commandRuns.values()).filter((r) => !r.removed).length;
|
|
175
|
+
|
|
176
|
+
const removedRunIds: number[] = [];
|
|
177
|
+
while (activeCount > maxRuns && completed.length > 0) {
|
|
178
|
+
const oldest = completed.shift();
|
|
179
|
+
if (!oldest) continue;
|
|
180
|
+
|
|
181
|
+
const result = removeRun(store, oldest.id, {
|
|
182
|
+
ctx: typeof options === "number" ? undefined : options.ctx,
|
|
183
|
+
pi: typeof options === "number" ? undefined : options.pi,
|
|
184
|
+
abortIfRunning: false,
|
|
185
|
+
updateWidget: false,
|
|
186
|
+
persistRemovedEntry: true,
|
|
187
|
+
removalReason: typeof options === "number" ? undefined : options.removalReason,
|
|
188
|
+
});
|
|
189
|
+
if (result.removed) {
|
|
190
|
+
removedRunIds.push(oldest.id);
|
|
191
|
+
activeCount--;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (shouldUpdateWidget && removedRunIds.length > 0) {
|
|
196
|
+
updateCommandRunsWidget(
|
|
197
|
+
store,
|
|
198
|
+
(typeof options === "number" ? undefined : options.ctx) as WidgetRenderCtx | undefined,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return removedRunIds;
|
|
203
|
+
}
|