@mystilleef/pi-subagent 0.3.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.
@@ -0,0 +1,233 @@
1
+ import {
2
+ isStatusOnlyFailure,
3
+ isStatusOnlySuccess,
4
+ makeToolPreview,
5
+ normalizeSummaryValue,
6
+ normalizeTerminalSentence,
7
+ TOOL_PREVIEW_MAX_CHARS,
8
+ truncateText,
9
+ } from "./normalize.js";
10
+ import type { SubagentDetails } from "./types.js";
11
+
12
+ export type ProgressStatus = "running" | "success" | "error" | "cancelled";
13
+
14
+ export interface SubagentProgressState {
15
+ requestId: string;
16
+ agent: string;
17
+ taskPreview: string;
18
+ status: ProgressStatus;
19
+ startTime: number;
20
+ durationMs?: number;
21
+ lastToolPreview?: string;
22
+ toolCount: number;
23
+ inputTokens?: number;
24
+ outputTokens?: number;
25
+ contextTokens?: number;
26
+ contextWindowTokens?: number;
27
+ finalOutput?: string;
28
+ errorText?: string;
29
+ }
30
+
31
+ const store = new Map<string, SubagentProgressState>();
32
+
33
+ export function createProgressState(
34
+ requestId: string,
35
+ agent: string,
36
+ task: string,
37
+ ): void {
38
+ store.set(requestId, {
39
+ requestId,
40
+ agent,
41
+ taskPreview: makeTaskPreview(task),
42
+ status: "running",
43
+ startTime: Date.now(),
44
+ toolCount: 0,
45
+ });
46
+ }
47
+
48
+ export function getProgressState(
49
+ requestId: string,
50
+ ): SubagentProgressState | undefined {
51
+ return store.get(requestId);
52
+ }
53
+
54
+ export function patchProgressState(
55
+ requestId: string,
56
+ patch: Partial<SubagentProgressState>,
57
+ ): void {
58
+ const state = store.get(requestId);
59
+ if (!state) return;
60
+ if (state.status !== "running") {
61
+ store.set(requestId, {
62
+ ...state,
63
+ ...patch,
64
+ lastToolPreview: undefined,
65
+ });
66
+ return;
67
+ }
68
+ store.set(requestId, { ...state, ...patch });
69
+ }
70
+
71
+ function storeTerminalProgressState(
72
+ requestId: string,
73
+ patch: Partial<SubagentProgressState>,
74
+ ): void {
75
+ const state = store.get(requestId);
76
+ if (!state) return;
77
+ const durationMs = state.durationMs ?? Date.now() - state.startTime;
78
+ store.set(requestId, { ...state, ...patch, durationMs });
79
+ }
80
+
81
+ export function finalizeProgressState(
82
+ requestId: string,
83
+ finalOutput: string,
84
+ ): void {
85
+ storeTerminalProgressState(requestId, {
86
+ status: "success",
87
+ finalOutput: makeProgressFinalOutput(finalOutput),
88
+ lastToolPreview: undefined,
89
+ });
90
+ }
91
+
92
+ export function failProgressState(requestId: string, errorText: string): void {
93
+ const sentence = deriveFailureTerminalSentence(errorText);
94
+ storeTerminalProgressState(requestId, {
95
+ status: "error",
96
+ errorText: sentence,
97
+ lastToolPreview: undefined,
98
+ });
99
+ }
100
+
101
+ export function cancelProgressState(requestId: string, reason?: string): void {
102
+ storeTerminalProgressState(requestId, {
103
+ status: "cancelled",
104
+ lastToolPreview: undefined,
105
+ ...(reason !== undefined
106
+ ? { errorText: normalizeTerminalSentence(reason) }
107
+ : {}),
108
+ });
109
+ }
110
+
111
+ export function clearProgressState(requestId: string): void {
112
+ store.delete(requestId);
113
+ }
114
+ export function resetProgressStore(): void {
115
+ store.clear();
116
+ }
117
+
118
+ export function makeTaskPreview(task: string): string {
119
+ const flat = normalizeSummaryValue(task);
120
+ return flat || "(agent default)";
121
+ }
122
+
123
+ function makeProgressFinalOutput(finalOutput: string): string {
124
+ const lines = finalOutput
125
+ .split(/\r?\n/)
126
+ .map((l) => l.trim())
127
+ .filter(Boolean);
128
+ const outcomeLine = lines.find((line) => /^Outcome:\s*/i.test(line));
129
+ const selected = outcomeLine ?? lines[0] ?? "";
130
+ const normalized = normalizeTerminalSentence(selected);
131
+ if (!normalized) return "";
132
+ if (isStatusOnlySuccess(normalized)) return "completed task";
133
+ return normalized;
134
+ }
135
+
136
+ function deriveFailureTerminalSentence(errorText: string): string {
137
+ const source = extractProgressSemanticErrorLine(errorText);
138
+ if (!source || source === "Large unstructured error output omitted.")
139
+ return source;
140
+ const normalized = normalizeTerminalSentence(source);
141
+ if (!normalized || isStatusOnlyFailure(normalized)) return "task failed";
142
+ return normalized;
143
+ }
144
+
145
+ function extractProgressSemanticErrorLine(errorText: string): string {
146
+ const lines = errorText
147
+ .split(/\r?\n/)
148
+ .map((line) => normalizeSummaryValue(line))
149
+ .filter(Boolean);
150
+ const statusLine = lines.find((line) =>
151
+ /^(?:status|error|check):\s+/i.test(line),
152
+ );
153
+ if (statusLine) return statusLine;
154
+ const semanticLine = lines.find((line) =>
155
+ isMeaningfulProgressErrorLine(line),
156
+ );
157
+ return semanticLine ?? "Large unstructured error output omitted.";
158
+ }
159
+
160
+ function isMeaningfulProgressErrorLine(line: string): boolean {
161
+ if (/^(?:at\s+|traceback\b|stack\b|\{|")/i.test(line)) return false;
162
+ if (/^(?:debug|info|warn|warning|stderr|stdout|raw log):\s*/i.test(line))
163
+ return false;
164
+ if (/^Agent \S+:\s*\S+$/i.test(line)) return false;
165
+ if (/^[\w.-]+:\d+:\d+/.test(line)) return false;
166
+ return /[A-Za-z]/.test(line) && /\s/.test(line);
167
+ }
168
+
169
+ export interface DetailsProgress {
170
+ lastToolPreview?: string;
171
+ newToolCallIds: string[];
172
+ }
173
+
174
+ export function extractProgressFromDetails(
175
+ details: SubagentDetails,
176
+ seenToolCallIds: Set<string>,
177
+ ): DetailsProgress {
178
+ const newToolCallIds: string[] = [];
179
+ let lastToolPreview: string | undefined;
180
+ const results = Array.isArray(details.results) ? details.results : [];
181
+ for (const result of results) {
182
+ if (result.progress) {
183
+ for (const toolCall of result.progress.toolCalls) {
184
+ if (!isDerivedToolCall(toolCall)) continue;
185
+ lastToolPreview = truncateText(
186
+ normalizeSummaryValue(toolCall.preview),
187
+ TOOL_PREVIEW_MAX_CHARS,
188
+ );
189
+ if (seenToolCallIds.has(toolCall.id)) continue;
190
+ seenToolCallIds.add(toolCall.id);
191
+ newToolCallIds.push(toolCall.id);
192
+ }
193
+ continue;
194
+ }
195
+ const messages = Array.isArray(result.messages) ? result.messages : [];
196
+ for (const msg of messages) {
197
+ if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
198
+ for (const part of msg.content) {
199
+ if (isToolCallPart(part)) {
200
+ lastToolPreview = makeToolPreview(part.name, part.arguments);
201
+ if (seenToolCallIds.has(part.id)) continue;
202
+ seenToolCallIds.add(part.id);
203
+ newToolCallIds.push(part.id);
204
+ }
205
+ }
206
+ }
207
+ }
208
+ return { lastToolPreview, newToolCallIds };
209
+ }
210
+
211
+ function isDerivedToolCall(part: unknown): part is {
212
+ id: string;
213
+ preview: string;
214
+ } {
215
+ if (typeof part !== "object" || part === null) return false;
216
+ const maybe = part as { id?: unknown; preview?: unknown };
217
+ return typeof maybe.id === "string" && typeof maybe.preview === "string";
218
+ }
219
+
220
+ export function isToolCallPart(part: unknown): part is {
221
+ type: "toolCall";
222
+ id: string;
223
+ name: string;
224
+ arguments?: Record<string, unknown>;
225
+ } {
226
+ if (typeof part !== "object" || part === null) return false;
227
+ const maybe = part as { type?: unknown; id?: unknown; name?: unknown };
228
+ return (
229
+ maybe.type === "toolCall" &&
230
+ typeof maybe.id === "string" &&
231
+ typeof maybe.name === "string"
232
+ );
233
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Subagent progress rendering and formatting.
3
+ *
4
+ * Aggregates progress-state management (re-exported from `progress-state.js`),
5
+ * elapsed/token formatters, and the live TUI progress component that renders
6
+ * subagent execution status inline in the parent agent's output stream.
7
+ *
8
+ * `renderSubagentProgress` hooks into the pi message pipeline. It produces a
9
+ * `DynamicSubagentProgressText` component that re-reads the progress store on
10
+ * each render tick, so updates from child process streaming appear instantly
11
+ * without explicit message-passing.
12
+ *
13
+ * All progress state lives in the store managed by `progress-state.js`.
14
+ * This module is purely presentational — it queries state and formats output.
15
+ *
16
+ * @module progress
17
+ */
18
+
19
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
20
+ import type { Component } from "@earendil-works/pi-tui";
21
+ import { Text } from "@earendil-works/pi-tui";
22
+ import {
23
+ getProgressState,
24
+ type ProgressStatus,
25
+ type SubagentProgressState,
26
+ } from "./progress-state.js";
27
+ import type { SubagentTheme, ThemeBg } from "./ui.js";
28
+
29
+ const STATUS_COLOR: Record<ProgressStatus, ThemeColor> = {
30
+ success: "success",
31
+ error: "error",
32
+ cancelled: "error",
33
+ running: "accent",
34
+ };
35
+
36
+ const STATUS_ICON: Record<ProgressStatus, string> = {
37
+ success: "✓",
38
+ error: "✗",
39
+ cancelled: "⊘",
40
+ running: "⟳",
41
+ };
42
+
43
+ const STATUS_BG: Record<ProgressStatus, ThemeBg> = {
44
+ success: "toolSuccessBg",
45
+ error: "toolErrorBg",
46
+ cancelled: "toolErrorBg",
47
+ running: "toolPendingBg",
48
+ };
49
+
50
+ export { makeToolPreview } from "./normalize.js";
51
+ export {
52
+ cancelProgressState,
53
+ clearProgressState,
54
+ createProgressState,
55
+ extractProgressFromDetails,
56
+ failProgressState,
57
+ finalizeProgressState,
58
+ getProgressState,
59
+ makeTaskPreview,
60
+ type ProgressStatus,
61
+ patchProgressState,
62
+ resetProgressStore,
63
+ type SubagentProgressState,
64
+ } from "./progress-state.js";
65
+
66
+ /**
67
+ * Format a millisecond duration for compact display.
68
+ * Renders sub-minute durations as decimal seconds (`45.2s`),
69
+ * longer durations as minutes and whole seconds (`2m 15s`).
70
+ */
71
+ export function formatElapsed(ms: number): string {
72
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
73
+ const mins = Math.floor(ms / 60000);
74
+ const secs = Math.floor((ms % 60000) / 1000);
75
+ return `${mins}m ${secs}s`;
76
+ }
77
+
78
+ /**
79
+ * Format a raw token count for compact inline display.
80
+ * Values below 1000 rendered as-is. Larger counts use `k`
81
+ * or `M` suffixes with one decimal place, stripping trailing `.0`.
82
+ */
83
+ export function formatTokenCount(count: number): string {
84
+ if (count < 1000) return String(count);
85
+ const unit = count >= 1_000_000 ? "M" : "k";
86
+ const divisor = count >= 1_000_000 ? 1_000_000 : 1000;
87
+ return `${trimTrailingZero((count / divisor).toFixed(1))}${unit}`;
88
+ }
89
+
90
+ /**
91
+ * Format the one-line statistics header for a subagent progress display.
92
+ * Includes tool count, context window usage, and elapsed time.
93
+ * When the subagent is still running (`durationMs` unset), elapsed is
94
+ * computed live from `startTime`.
95
+ *
96
+ * @returns Single line ending in `\n`, e.g. `"3 tools · 45% ctx · 12.3s\n"`
97
+ */
98
+ export function formatHeaderStats(state: SubagentProgressState): string {
99
+ const elapsedMs = state.durationMs ?? Date.now() - state.startTime;
100
+ const toolLabel = state.toolCount === 1 ? "tool" : "tools";
101
+ return `${state.toolCount} ${toolLabel} · ${formatContextPercent(state)} ctx · ${formatElapsed(elapsedMs)}\n`;
102
+ }
103
+
104
+ function formatContextPercent(state: SubagentProgressState): string {
105
+ const d = state.contextWindowTokens;
106
+ if (!d || d <= 0 || !Number.isFinite(d)) return "--%";
107
+ const n = state.contextTokens;
108
+ if (!n || n <= 0 || !Number.isFinite(n)) return "0%";
109
+ return `${Math.round((n / d) * 100)}%`;
110
+ }
111
+
112
+ function trimTrailingZero(value: string): string {
113
+ return value.endsWith(".0") ? value.slice(0, -2) : value;
114
+ }
115
+
116
+ /**
117
+ * Create a live-updating TUI progress component from a pi message.
118
+ *
119
+ * Called by the pi message renderer for messages with a `requestId` in
120
+ * their `details`. Returns `undefined` when no progress state exists for
121
+ * the request (e.g. before streaming starts or after cleanup).
122
+ *
123
+ * The returned `DynamicSubagentProgressText` component re-reads the
124
+ * progress store on every render tick, so tool counts, context usage,
125
+ * and output update in real time as the child process streams.
126
+ *
127
+ * @param message - A pi message object. Must carry `details.requestId`.
128
+ * @param options - `expanded` controls whether the collapsed or full
129
+ * progress view is rendered.
130
+ * @param theme - Subagent color theme for styling the output.
131
+ * @returns A dynamic TUI component, or `undefined` if no active state.
132
+ */
133
+ export function renderSubagentProgress(
134
+ message: {
135
+ customType?: string;
136
+ content?: unknown;
137
+ display?: boolean;
138
+ details?: unknown;
139
+ },
140
+ options: { expanded: boolean },
141
+ theme: SubagentTheme,
142
+ ): Component | undefined {
143
+ const details = message.details as { requestId?: string } | undefined;
144
+ const requestId = details?.requestId;
145
+ if (!requestId || !getProgressState(requestId)) return undefined;
146
+ return new DynamicSubagentProgressText(requestId, options, theme);
147
+ }
148
+
149
+ class DynamicSubagentProgressText implements Component {
150
+ constructor(
151
+ private readonly requestId: string,
152
+ private readonly options: { expanded: boolean },
153
+ private readonly theme: SubagentTheme,
154
+ ) {}
155
+ invalidate(): void {}
156
+ render(width: number): string[] {
157
+ const state = getProgressState(this.requestId);
158
+ if (!state) return [];
159
+ const text = formatProgressText(this.requestId, this.options, this.theme);
160
+ const bg = getProgressBackground(state.status);
161
+ return text
162
+ ? new Text(text, 1, 1, (line) => this.theme.bg(bg, line)).render(width)
163
+ : [];
164
+ }
165
+ }
166
+
167
+ function getProgressBackground(status: ProgressStatus): ThemeBg {
168
+ return STATUS_BG[status];
169
+ }
170
+
171
+ function formatProgressText(
172
+ requestId: string,
173
+ options: { expanded: boolean },
174
+ theme: SubagentTheme,
175
+ ): string | undefined {
176
+ const state = getProgressState(requestId);
177
+ if (!state) return undefined;
178
+ const status = state.status;
179
+ const header = `${theme.fg(STATUS_COLOR[status], STATUS_ICON[status])} ${theme.fg("toolTitle", theme.bold(state.agent))} ${theme.fg("dim", `[${status}]`)} ${theme.fg("muted", formatHeaderStats(state))}`;
180
+ if (status === "running") {
181
+ const toolLine = state.lastToolPreview
182
+ ? `\n ${formatRunningToolPreview(state.lastToolPreview, theme)}`
183
+ : "";
184
+ const taskLine = options.expanded
185
+ ? `\n ${theme.fg("dim", state.taskPreview)}`
186
+ : "";
187
+ return header + toolLine + taskLine;
188
+ }
189
+ if (status === "error" || status === "cancelled") {
190
+ const errorLine = state.errorText
191
+ ? `\n ${theme.fg("error", state.errorText)}`
192
+ : "";
193
+ const taskLine = options.expanded
194
+ ? `\n ${theme.fg("dim", state.taskPreview)}`
195
+ : "";
196
+ return header + errorLine + taskLine;
197
+ }
198
+ if (status === "success") {
199
+ const output = state.finalOutput?.trim().split("\n")[0] ?? "";
200
+ if (!options.expanded) {
201
+ return output ? `${header}\n ${theme.fg("toolOutput", output)}` : header;
202
+ }
203
+ const outputSection = output
204
+ ? `\n${theme.fg("muted", "─── Output ───")}\n${theme.fg("toolOutput", output)}`
205
+ : `\n${theme.fg("muted", "(no output)")}`;
206
+ return `${header}\n ${theme.fg("dim", state.taskPreview)}${outputSection}`;
207
+ }
208
+ return header;
209
+ }
210
+
211
+ function formatRunningToolPreview(
212
+ preview: string,
213
+ theme: SubagentTheme,
214
+ ): string {
215
+ const separatorIndex = preview.indexOf(":");
216
+ const arrow = theme.fg("muted", "→");
217
+ if (separatorIndex === -1) return `${arrow} ${theme.fg("accent", preview)}`;
218
+ const toolName = preview.slice(0, separatorIndex);
219
+ const rest = preview.slice(separatorIndex);
220
+ return `${arrow} ${theme.fg("accent", toolName)}${theme.fg("dim", rest)}`;
221
+ }
@@ -0,0 +1,10 @@
1
+ export const SUBAGENT_RESULT_CONTRACT = `
2
+ - Always present results unchanged to the main agent.
3
+ - End your final response with exactly one line:
4
+ - Outcome: <short, single, compact lower-case sentence>.
5
+ - Outcome summarizes the result of your task in a single sentence.
6
+ `;
7
+
8
+ export function appendSubagentResultContract(prompt: string): string {
9
+ return `${prompt}\n\n${SUBAGENT_RESULT_CONTRACT}`;
10
+ }
@@ -0,0 +1,97 @@
1
+ import { TOOL_RESULT_FAILED_MESSAGE } from "./process.js";
2
+ import {
3
+ extractProgressFromDetails,
4
+ getProgressState,
5
+ patchProgressState,
6
+ } from "./progress.js";
7
+ import {
8
+ formatSubagentResultForParent,
9
+ summarizeFeedbackUiFinalOutput,
10
+ } from "./summary.js";
11
+ import type {
12
+ SingleResult,
13
+ SubagentDetails,
14
+ SubagentToolResult,
15
+ } from "./types.js";
16
+ import { detectMessageError } from "./utils.js";
17
+
18
+ export function hasSubagentFailed(result: SingleResult): boolean {
19
+ return (
20
+ result.exitCode !== 0 ||
21
+ result.stopReason === "error" ||
22
+ result.stopReason === "aborted" ||
23
+ Boolean(result.errorMessage?.trim()) ||
24
+ detectMessageError(result.messages ?? [])
25
+ );
26
+ }
27
+
28
+ export function createSubagentError(result: SingleResult): Error {
29
+ const formatted = formatSubagentResultForParent(result);
30
+ const errorMessage = result.errorMessage?.trim();
31
+ if (errorMessage && errorMessage !== TOOL_RESULT_FAILED_MESSAGE)
32
+ return new Error(`Agent ${result.stopReason || "failed"}: ${errorMessage}`);
33
+ const msg =
34
+ formatted ||
35
+ result.stderr ||
36
+ errorMessage ||
37
+ result.finalOutput ||
38
+ "(no output)";
39
+ return new Error(`Agent ${result.stopReason || "failed"}: ${msg}`);
40
+ }
41
+
42
+ export function sanitizeDetailsForDisplay(
43
+ details: SubagentDetails,
44
+ includeMessages = false,
45
+ ): SubagentDetails {
46
+ return {
47
+ ...details,
48
+ results: details.results.map(({ messages, termination, ...result }) => ({
49
+ ...result,
50
+ stderr: includeMessages ? result.stderr : "",
51
+ ...(includeMessages ? { messages, termination } : {}),
52
+ })),
53
+ };
54
+ }
55
+
56
+ export function patchProgressFromDetails(
57
+ requestId: string,
58
+ details: SubagentDetails,
59
+ seenToolCallIds: Set<string>,
60
+ ): void {
61
+ const latestResult = details.results[0];
62
+ const { newToolCallIds, lastToolPreview } = extractProgressFromDetails(
63
+ details,
64
+ seenToolCallIds,
65
+ );
66
+ const current = getProgressState(requestId);
67
+ if (!current) return;
68
+ const patch: Record<string, unknown> = {
69
+ toolCount: current.toolCount + newToolCallIds.length,
70
+ };
71
+ if (lastToolPreview) patch.lastToolPreview = lastToolPreview;
72
+ if (latestResult?.usage) {
73
+ patch.inputTokens = latestResult.usage.input;
74
+ patch.outputTokens = latestResult.usage.output;
75
+ patch.contextTokens = latestResult.usage.contextTokens;
76
+ patch.contextWindowTokens = latestResult.usage.contextWindowTokens;
77
+ }
78
+ patchProgressState(
79
+ requestId,
80
+ patch as Parameters<typeof patchProgressState>[1],
81
+ );
82
+ }
83
+
84
+ export function getSubagentText(result: SubagentToolResult): string {
85
+ return (result.content[0] as { text?: string })?.text ?? "";
86
+ }
87
+
88
+ export function getResultDisplayText(result: SubagentToolResult): string {
89
+ return result.details.results[0]?.finalOutput ?? getSubagentText(result);
90
+ }
91
+
92
+ export function getFeedbackSummaryText(result: SubagentToolResult): string {
93
+ const rawFinalOutput = result.details.results[0]?.finalOutput;
94
+ if (rawFinalOutput?.trim())
95
+ return summarizeFeedbackUiFinalOutput(rawFinalOutput);
96
+ return getSubagentText(result).trim() || "(no output)";
97
+ }
@@ -0,0 +1,46 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { startSubagentJob } from "./subagent-orchestrator.js";
6
+
7
+ export function parseRunArgs(
8
+ args: string,
9
+ ): { agentName: string; task: string; debug: boolean } | undefined {
10
+ const input = args.trim();
11
+ if (!input) return undefined;
12
+ const debug = input.startsWith("--debug ");
13
+ const command = debug ? input.slice("--debug ".length).trim() : input;
14
+ if (!command) return undefined;
15
+ const firstSpace = command.indexOf(" ");
16
+ if (firstSpace === -1) return { agentName: command, task: "", debug };
17
+ return {
18
+ agentName: command.slice(0, firstSpace),
19
+ task: command.slice(firstSpace + 1).trim(),
20
+ debug,
21
+ };
22
+ }
23
+
24
+ export async function runCommandHandler(
25
+ pi: ExtensionAPI,
26
+ ctx: ExtensionContext,
27
+ args: string,
28
+ ): Promise<void> {
29
+ const parsed = parseRunArgs(args);
30
+ if (!parsed) {
31
+ ctx.ui.notify("Usage: /run <agent> [task]", "error");
32
+ return;
33
+ }
34
+ const { agentName, task, debug } = parsed;
35
+ const result = await startSubagentJob(
36
+ pi,
37
+ ctx,
38
+ { agent: agentName, task, debug },
39
+ ctx.signal,
40
+ );
41
+ if (result.kind === "not_found") {
42
+ ctx.ui.notify(`Unknown agent: ${agentName}`, "error");
43
+ } else if (result.kind === "cancelled") {
44
+ ctx.ui.notify("Cancelled", "info");
45
+ }
46
+ }
@@ -0,0 +1,53 @@
1
+ export type RunJob = {
2
+ requestId: string;
3
+ agentName: string;
4
+ controller: AbortController;
5
+ startedAt: number;
6
+ cancelReason?: string;
7
+ };
8
+
9
+ const jobs = new Map<string, RunJob>();
10
+
11
+ export function registerRunJob(job: RunJob): RunJob {
12
+ jobs.set(job.requestId, job);
13
+ return job;
14
+ }
15
+
16
+ export function getRunJob(requestId: string): Readonly<RunJob> | undefined {
17
+ return jobs.get(requestId);
18
+ }
19
+
20
+ export function listRunJobs(): readonly Readonly<RunJob>[] {
21
+ return [...jobs.values()];
22
+ }
23
+
24
+ export function removeRunJob(requestId: string): boolean {
25
+ return jobs.delete(requestId);
26
+ }
27
+
28
+ function abortRunJob(job: RunJob, reason: string): void {
29
+ if (job.controller.signal.aborted) return;
30
+ job.cancelReason = reason;
31
+ job.controller.abort(new Error(reason));
32
+ }
33
+
34
+ export function cancelRunJob(requestId: string, reason = "Cancelled"): boolean {
35
+ const job = jobs.get(requestId);
36
+ if (!job) return false;
37
+ abortRunJob(job, reason);
38
+ return true;
39
+ }
40
+
41
+ export function cancelAllRunJobs(reason = "Cancelled"): number {
42
+ let count = 0;
43
+ for (const job of jobs.values()) {
44
+ abortRunJob(job, reason);
45
+ count += 1;
46
+ }
47
+ return count;
48
+ }
49
+
50
+ export function clearRunJobsForTests(): void {
51
+ jobs.clear();
52
+ }
53
+ export const resetRunRegistry = clearRunJobsForTests;