@bacnh85/pi-subagent 0.9.0 → 0.9.1
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/CHANGELOG.md +7 -0
- package/README.md +6 -6
- package/extensions/index.ts +18 -7
- package/extensions/runner.ts +86 -195
- package/extensions/security.ts +3 -4
- package/extensions/service.ts +4 -1
- package/extensions/thread-viewer.ts +8 -0
- package/extensions/threads.ts +26 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.1 (2026-07-16)
|
|
4
|
+
|
|
5
|
+
### Activity-aware timeouts
|
|
6
|
+
|
|
7
|
+
- Child `timeout` values now define a sliding inactivity window (three minutes by default); real SDK lifecycle events reset it while a fixed 20-minute hard cap remains.
|
|
8
|
+
- `/agent` distinguishes real activity from transport heartbeats and reports idle versus hard timeouts.
|
|
9
|
+
|
|
3
10
|
## 0.9.0 (2026-07-16)
|
|
4
11
|
|
|
5
12
|
### Model routing
|
package/README.md
CHANGED
|
@@ -86,12 +86,12 @@ Unknown or misspelled tool names produce clear diagnostics. Duplicate tool names
|
|
|
86
86
|
|
|
87
87
|
Every child execution receives a timeout:
|
|
88
88
|
|
|
89
|
-
- **Default:**
|
|
90
|
-
- **
|
|
91
|
-
-
|
|
92
|
-
- Timeout
|
|
93
|
-
-
|
|
94
|
-
-
|
|
89
|
+
- **Default inactivity window:** 3 minutes (`DEFAULT_TIMEOUT_MS`); real SDK lifecycle activity resets it.
|
|
90
|
+
- **Absolute cap:** 20 minutes for every child, even when active.
|
|
91
|
+
- **Maximum requested inactivity window:** 60 minutes (`MAX_TIMEOUT_MS`); values must be positive integers.
|
|
92
|
+
- Timeout diagnostics distinguish `Idle timeout` from `Hard timeout` and parent cancellation.
|
|
93
|
+
- 30-second progress heartbeats only keep the parent transport alive; they never reset inactivity.
|
|
94
|
+
- `/agent` shows last real activity and the remaining idle window; parallel tasks and chain steps may have per-item windows.
|
|
95
95
|
|
|
96
96
|
### Output safety
|
|
97
97
|
|
package/extensions/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { Type } from "typebox";
|
|
|
32
32
|
|
|
33
33
|
import { type AgentColor, type AgentConfig, type AgentScope, discoverAgents, formatAgentList, getModelCandidates, invalidateAgentCache } from "./agents.ts";
|
|
34
34
|
import {
|
|
35
|
+
type SubAgentProgress,
|
|
35
36
|
type SubAgentResult,
|
|
36
37
|
getFinalOutput,
|
|
37
38
|
getResultOutput,
|
|
@@ -94,14 +95,14 @@ const TaskItem = Type.Object({
|
|
|
94
95
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
95
96
|
task: Type.String({ description: "Task to delegate to the agent" }),
|
|
96
97
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
97
|
-
timeout: Type.Optional(Type.Number({ description: "
|
|
98
|
+
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in milliseconds for this task; real child activity resets it (default 3 minutes, absolute cap 20 minutes)" })),
|
|
98
99
|
});
|
|
99
100
|
|
|
100
101
|
const ChainItem = Type.Object({
|
|
101
102
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
102
103
|
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
|
|
103
104
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
104
|
-
timeout: Type.Optional(Type.Number({ description: "
|
|
105
|
+
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in milliseconds for this step; real child activity resets it (default 3 minutes, absolute cap 20 minutes)" })),
|
|
105
106
|
});
|
|
106
107
|
|
|
107
108
|
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
|
@@ -126,7 +127,7 @@ const SubagentParams = Type.Object({
|
|
|
126
127
|
// Project-agent confirmation is enforced via trusted configuration.
|
|
127
128
|
// See Security model section in README.
|
|
128
129
|
cwd: Type.Optional(Type.String({ description: "Working directory (single mode, must be inside workspace)" })),
|
|
129
|
-
timeout: Type.Optional(Type.Number({ description: "Global timeout in milliseconds
|
|
130
|
+
timeout: Type.Optional(Type.Number({ description: "Global inactivity timeout in milliseconds (default 3 minutes; real activity resets it; fixed 20-minute absolute cap)" })),
|
|
130
131
|
instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
|
|
131
132
|
abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
|
|
132
133
|
});
|
|
@@ -208,6 +209,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
208
209
|
instructions: request.instructions,
|
|
209
210
|
signal: request.signal,
|
|
210
211
|
onMessage: (result) => threadStore.updateThread(thread.id, { result }),
|
|
212
|
+
onProgress: (progress) => { threadStore.updateProgress(thread.id, progress); request.onProgress?.(progress); },
|
|
211
213
|
}).then((result) => {
|
|
212
214
|
threadStore.updateThread(thread.id, {
|
|
213
215
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
@@ -520,7 +522,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
520
522
|
parentSignal?: AbortSignal,
|
|
521
523
|
timeoutMs?: number,
|
|
522
524
|
onProgress?: (partial: SubAgentResult) => void,
|
|
525
|
+
onActivity?: (progress: SubAgentProgress) => void,
|
|
523
526
|
heartbeatDetails?: () => SubagentDetails,
|
|
527
|
+
onHeartbeat?: () => void,
|
|
524
528
|
isReadOnly?: boolean,
|
|
525
529
|
): Promise<SubAgentResult> {
|
|
526
530
|
const agent = agents.find((a) => a.name === agentName);
|
|
@@ -582,10 +586,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
582
586
|
};
|
|
583
587
|
}
|
|
584
588
|
|
|
585
|
-
const stopHeartbeat = onUpdate ? startHeartbeat(() =>
|
|
586
|
-
|
|
587
|
-
details: heartbeatDetails?.() ?? makeDetails("single")([])
|
|
588
|
-
})
|
|
589
|
+
const stopHeartbeat = onUpdate ? startHeartbeat(() => {
|
|
590
|
+
onHeartbeat?.();
|
|
591
|
+
onUpdate({ content: [{ type: "text", text: `Subagent ${agentName} is still running…` }], details: heartbeatDetails?.() ?? makeDetails("single")([]) });
|
|
592
|
+
}) : undefined;
|
|
589
593
|
try {
|
|
590
594
|
return await runSubAgent({
|
|
591
595
|
cwd: safeCwd,
|
|
@@ -602,6 +606,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
602
606
|
agentName,
|
|
603
607
|
thinkingLevel: agent.thinking,
|
|
604
608
|
onMessage: onProgress,
|
|
609
|
+
onProgress: onActivity,
|
|
605
610
|
});
|
|
606
611
|
} finally {
|
|
607
612
|
stopHeartbeat?.();
|
|
@@ -628,7 +633,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
628
633
|
step.agent, taskWithContext, step.cwd,
|
|
629
634
|
signal, step.timeout ?? params.timeout,
|
|
630
635
|
(partial) => threadStore.updateThread(thread.id, { result: partial }),
|
|
636
|
+
(progress) => threadStore.updateProgress(thread.id, progress),
|
|
631
637
|
() => makeDetails("chain")(results),
|
|
638
|
+
() => threadStore.refreshHeartbeat(thread.id),
|
|
632
639
|
);
|
|
633
640
|
threadStore.updateThread(thread.id, {
|
|
634
641
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
@@ -782,7 +789,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
782
789
|
t.agent, t.task, t.cwd,
|
|
783
790
|
parallelController.signal, t.timeout ?? params.timeout,
|
|
784
791
|
(partial) => threadStore.updateThread(parallelThreads[index].id, { result: partial }),
|
|
792
|
+
(progress) => threadStore.updateProgress(parallelThreads[index].id, progress),
|
|
785
793
|
() => makeDetails("parallel")([...allResults]),
|
|
794
|
+
() => threadStore.refreshHeartbeat(parallelThreads[index].id),
|
|
786
795
|
);
|
|
787
796
|
allResults[index] = result;
|
|
788
797
|
threadStore.updateThread(parallelThreads[index].id, {
|
|
@@ -838,7 +847,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
838
847
|
params.agent, params.task, params.cwd,
|
|
839
848
|
signal, params.timeout,
|
|
840
849
|
(partial) => threadStore.updateThread(thread.id, { result: partial }),
|
|
850
|
+
(progress) => threadStore.updateProgress(thread.id, progress),
|
|
841
851
|
() => makeDetails("single")([]),
|
|
852
|
+
() => threadStore.refreshHeartbeat(thread.id),
|
|
842
853
|
);
|
|
843
854
|
threadStore.updateThread(thread.id, {
|
|
844
855
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
package/extensions/runner.ts
CHANGED
|
@@ -45,6 +45,18 @@ export interface UsageStats {
|
|
|
45
45
|
turns: number;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export const DEFAULT_INACTIVITY_TIMEOUT_MS = 3 * 60 * 1000;
|
|
49
|
+
export const HARD_TIMEOUT_MS = 20 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
export interface SubAgentProgress {
|
|
52
|
+
label: string;
|
|
53
|
+
at: number;
|
|
54
|
+
elapsedMs: number;
|
|
55
|
+
inactivityDeadline: number;
|
|
56
|
+
hardDeadline: number;
|
|
57
|
+
result: SubAgentResult;
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
export interface SubAgentResult {
|
|
49
61
|
agent: string;
|
|
50
62
|
task: string;
|
|
@@ -81,231 +93,110 @@ export async function runSubAgent(options: {
|
|
|
81
93
|
agentName?: string;
|
|
82
94
|
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
83
95
|
onMessage?: (partialResult: SubAgentResult) => void;
|
|
84
|
-
|
|
96
|
+
onProgress?: (progress: SubAgentProgress) => void;
|
|
85
97
|
timeoutMs?: number;
|
|
98
|
+
hardTimeoutMs?: number;
|
|
86
99
|
}): Promise<SubAgentResult> {
|
|
87
100
|
const {
|
|
88
|
-
cwd,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
tools,
|
|
92
|
-
model,
|
|
93
|
-
authStorage,
|
|
94
|
-
modelRegistry,
|
|
95
|
-
signal,
|
|
96
|
-
agentName = "subagent",
|
|
97
|
-
thinkingLevel = "off",
|
|
98
|
-
onMessage,
|
|
99
|
-
timeoutMs,
|
|
101
|
+
cwd, systemPrompt, task, tools, model, authStorage, modelRegistry, signal,
|
|
102
|
+
agentName = "subagent", thinkingLevel = "off", onMessage, onProgress,
|
|
103
|
+
timeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS, hardTimeoutMs = HARD_TIMEOUT_MS,
|
|
100
104
|
} = options;
|
|
101
|
-
|
|
102
105
|
const result: SubAgentResult = {
|
|
103
|
-
agent: agentName,
|
|
104
|
-
task,
|
|
105
|
-
exitCode: 0,
|
|
106
|
-
messages: [],
|
|
107
|
-
stderr: "",
|
|
106
|
+
agent: agentName, task, exitCode: 0, messages: [], stderr: "",
|
|
108
107
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
109
|
-
model: `${model.provider}/${model.id}`,
|
|
110
|
-
status: undefined,
|
|
108
|
+
model: `${model.provider}/${model.id}`, status: undefined,
|
|
111
109
|
};
|
|
112
|
-
|
|
113
|
-
// Build a minimal resource loader. The sub-agent sees ONLY the agent's
|
|
114
|
-
// system prompt — no pi defaults, no AGENTS.md, no extensions, no skills.
|
|
115
110
|
const resourceLoader: ResourceLoader = {
|
|
116
111
|
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
|
117
|
-
getSkills: () => ({ skills: [], diagnostics: [] }),
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
121
|
-
getSystemPrompt: () => systemPrompt,
|
|
122
|
-
getAppendSystemPrompt: () => [],
|
|
123
|
-
extendResources: () => {},
|
|
124
|
-
reload: async () => {},
|
|
112
|
+
getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
|
113
|
+
getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
114
|
+
getSystemPrompt: () => systemPrompt, getAppendSystemPrompt: () => [], extendResources: () => {}, reload: async () => {},
|
|
125
115
|
};
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
let
|
|
134
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
116
|
+
const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: true, maxRetries: 1 } });
|
|
117
|
+
const startedAt = Date.now();
|
|
118
|
+
let inactivityDeadline = startedAt + timeoutMs;
|
|
119
|
+
const hardDeadline = startedAt + hardTimeoutMs;
|
|
120
|
+
let timeoutKind: "idle" | "hard" | undefined;
|
|
121
|
+
const timeoutController = new AbortController();
|
|
122
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
123
|
+
let hardTimer: ReturnType<typeof setTimeout> | undefined;
|
|
135
124
|
let cleanupCombined: (() => void) | undefined;
|
|
136
|
-
|
|
125
|
+
const clearTimers = () => { if (idleTimer) clearTimeout(idleTimer); if (hardTimer) clearTimeout(hardTimer); };
|
|
126
|
+
const armIdle = () => {
|
|
127
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
128
|
+
inactivityDeadline = Date.now() + timeoutMs;
|
|
129
|
+
idleTimer = setTimeout(() => { timeoutKind = "idle"; timeoutController.abort(new Error(`Idle timeout after ${timeoutMs}ms`)); }, timeoutMs);
|
|
130
|
+
};
|
|
131
|
+
const snapshot = (label: string): SubAgentProgress => ({ label, at: Date.now(), elapsedMs: Date.now() - startedAt, inactivityDeadline, hardDeadline, result: { ...result, messages: [...result.messages] } });
|
|
137
132
|
try {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (timeoutMs && timeoutMs > 0) {
|
|
143
|
-
timeoutController = new AbortController();
|
|
144
|
-
timeoutId = setTimeout(() => timeoutController!.abort(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
145
|
-
signalsToCombine.push(timeoutController.signal);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const { signal: combinedSignal, cleanup: cleanupCb } = createCombinedAbortSignal(signalsToCombine);
|
|
149
|
-
cleanupCombined = cleanupCb;
|
|
150
|
-
|
|
133
|
+
armIdle();
|
|
134
|
+
hardTimer = setTimeout(() => { timeoutKind = "hard"; timeoutController.abort(new Error(`Hard timeout after ${hardTimeoutMs}ms`)); }, hardTimeoutMs);
|
|
135
|
+
const { signal: combinedSignal, cleanup } = createCombinedAbortSignal([signal, timeoutController.signal]);
|
|
136
|
+
cleanupCombined = cleanup;
|
|
151
137
|
if (combinedSignal.aborted) {
|
|
152
138
|
result.exitCode = 1;
|
|
153
|
-
const
|
|
154
|
-
result.stopReason =
|
|
155
|
-
result.errorMessage =
|
|
156
|
-
result.status = classifyStopReason(result.stopReason, !
|
|
157
|
-
cleanupCombined?.();
|
|
158
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
139
|
+
const timedOut = timeoutController.signal.aborted && !signal?.aborted;
|
|
140
|
+
result.stopReason = timedOut ? "timeout" : "aborted";
|
|
141
|
+
result.errorMessage = timedOut ? `${timeoutKind === "idle" ? "Idle" : "Hard"} timeout after ${timeoutKind === "idle" ? timeoutMs : hardTimeoutMs}ms` : "Sub-agent aborted before start";
|
|
142
|
+
result.status = classifyStopReason(result.stopReason, !timedOut, timedOut);
|
|
159
143
|
return result;
|
|
160
144
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
model,
|
|
165
|
-
thinkingLevel,
|
|
166
|
-
authStorage,
|
|
167
|
-
modelRegistry,
|
|
168
|
-
resourceLoader,
|
|
169
|
-
tools,
|
|
170
|
-
sessionManager: SessionManager.inMemory(cwd),
|
|
171
|
-
settingsManager,
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
let cleanupAbort: (() => void) | undefined;
|
|
175
|
-
let cleanupEventAbort: (() => void) | undefined;
|
|
176
|
-
let abortedBySignal = false;
|
|
177
|
-
let timedOut = false;
|
|
178
|
-
let eventUnsubscribe: (() => void) | undefined;
|
|
179
|
-
|
|
145
|
+
const { session } = await createAgentSession({ cwd, model, thinkingLevel, authStorage, modelRegistry, resourceLoader, tools, sessionManager: SessionManager.inMemory(cwd), settingsManager });
|
|
146
|
+
let unsubscribe: (() => void) | undefined;
|
|
147
|
+
let removeAbort: (() => void) | undefined;
|
|
180
148
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
};
|
|
185
|
-
if (combinedSignal.aborted) {
|
|
186
|
-
abortedBySignal = true;
|
|
187
|
-
timedOut = timeoutController?.signal.aborted === true && signal?.aborted !== true;
|
|
188
|
-
onAbort();
|
|
189
|
-
return result;
|
|
190
|
-
}
|
|
191
|
-
combinedSignal.addEventListener("abort", onAbort, { once: true });
|
|
192
|
-
cleanupAbort = () => combinedSignal.removeEventListener("abort", onAbort);
|
|
193
|
-
|
|
194
|
-
// Collect all messages and usage stats from events
|
|
195
|
-
const eventPromise = new Promise<void>((resolve, reject) => {
|
|
196
|
-
let settled = false;
|
|
197
|
-
const finish = (fn: () => void) => {
|
|
198
|
-
if (settled) return;
|
|
199
|
-
settled = true;
|
|
200
|
-
fn();
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
let unsubscribe: (() => void) | undefined;
|
|
149
|
+
const eventDone = new Promise<void>((resolve, reject) => {
|
|
150
|
+
let done = false;
|
|
151
|
+
const finish = (fn: () => void) => { if (!done) { done = true; unsubscribe?.(); fn(); } };
|
|
204
152
|
unsubscribe = session.subscribe((event) => {
|
|
205
153
|
try {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
result.usage.cost += msg.usage.cost?.total || 0;
|
|
217
|
-
result.usage.contextTokens = msg.usage.totalTokens || 0;
|
|
218
|
-
}
|
|
219
|
-
if (!result.model && msg.model) {
|
|
220
|
-
result.model = `${msg.provider || "?"}/${msg.model}`;
|
|
221
|
-
}
|
|
222
|
-
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
223
|
-
result.errorMessage = msg.errorMessage;
|
|
224
|
-
}
|
|
225
|
-
// Collect all messages for extraction
|
|
226
|
-
result.messages.push(msg as unknown as Message);
|
|
227
|
-
if (onMessage) onMessage({ ...result, messages: [...result.messages] });
|
|
228
|
-
break;
|
|
229
|
-
}
|
|
230
|
-
case "agent_end": {
|
|
231
|
-
if (event.willRetry) break;
|
|
232
|
-
// agent_end carries all messages; use them if we haven't collected
|
|
233
|
-
if (result.messages.length === 0 && event.messages) {
|
|
234
|
-
result.messages = event.messages as unknown as Message[];
|
|
235
|
-
}
|
|
236
|
-
finish(() => {
|
|
237
|
-
unsubscribe?.();
|
|
238
|
-
resolve();
|
|
239
|
-
});
|
|
240
|
-
break;
|
|
154
|
+
// Any SDK session lifecycle event is actual child activity, unlike a parent heartbeat.
|
|
155
|
+
armIdle(); onProgress?.(snapshot(event.type));
|
|
156
|
+
if (event.type === "message_end") {
|
|
157
|
+
const msg = event.message as AgentMessage;
|
|
158
|
+
if (msg.role === "assistant") {
|
|
159
|
+
result.usage.turns++;
|
|
160
|
+
if (msg.usage) { result.usage.input += msg.usage.input || 0; result.usage.output += msg.usage.output || 0; result.usage.cacheRead += msg.usage.cacheRead || 0; result.usage.cacheWrite += msg.usage.cacheWrite || 0; result.usage.cost += msg.usage.cost?.total || 0; result.usage.contextTokens = msg.usage.totalTokens || 0; }
|
|
161
|
+
if (!result.model && msg.model) result.model = `${msg.provider || "?"}/${msg.model}`;
|
|
162
|
+
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
163
|
+
result.errorMessage = msg.errorMessage;
|
|
241
164
|
}
|
|
165
|
+
result.messages.push(msg as unknown as Message);
|
|
166
|
+
onMessage?.({ ...result, messages: [...result.messages] });
|
|
167
|
+
} else if (event.type === "agent_end" && !event.willRetry) {
|
|
168
|
+
if (!result.messages.length && event.messages) result.messages = event.messages as unknown as Message[];
|
|
169
|
+
finish(resolve);
|
|
242
170
|
}
|
|
243
|
-
} catch (
|
|
244
|
-
finish(() => {
|
|
245
|
-
unsubscribe?.();
|
|
246
|
-
reject(err);
|
|
247
|
-
});
|
|
248
|
-
}
|
|
171
|
+
} catch (error) { finish(() => reject(error)); }
|
|
249
172
|
});
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const onAbortResolve = () => {
|
|
254
|
-
finish(() => {
|
|
255
|
-
result.exitCode = 1;
|
|
256
|
-
if (!result.errorMessage) result.errorMessage = "Sub-agent aborted";
|
|
257
|
-
unsubscribe?.();
|
|
258
|
-
resolve();
|
|
259
|
-
});
|
|
260
|
-
};
|
|
261
|
-
combinedSignal.addEventListener("abort", onAbortResolve, { once: true });
|
|
262
|
-
cleanupEventAbort = () => combinedSignal.removeEventListener("abort", onAbortResolve);
|
|
173
|
+
const abort = () => finish(resolve);
|
|
174
|
+
combinedSignal.addEventListener("abort", abort, { once: true });
|
|
175
|
+
removeAbort = () => combinedSignal.removeEventListener("abort", abort);
|
|
263
176
|
});
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
abortedBySignal = combinedSignal.aborted && !timedOut;
|
|
273
|
-
|
|
274
|
-
if (timedOut) {
|
|
275
|
-
result.stopReason = "timeout";
|
|
276
|
-
result.errorMessage = `Timeout after ${timeoutMs}ms`;
|
|
277
|
-
} else if (abortedBySignal) {
|
|
278
|
-
result.stopReason = "aborted";
|
|
279
|
-
result.errorMessage ||= "Sub-agent aborted";
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Classify canonical status and keep the legacy exit code consistent.
|
|
177
|
+
const abortSession = () => session.abort();
|
|
178
|
+
combinedSignal.addEventListener("abort", abortSession, { once: true });
|
|
179
|
+
const removeSessionAbort = () => combinedSignal.removeEventListener("abort", abortSession);
|
|
180
|
+
await Promise.race([session.prompt(task), eventDone]);
|
|
181
|
+
removeSessionAbort();
|
|
182
|
+
const timedOut = timeoutController.signal.aborted && !signal?.aborted;
|
|
183
|
+
if (timedOut) { result.stopReason = "timeout"; result.errorMessage = `${timeoutKind === "idle" ? "Idle" : "Hard"} timeout after ${timeoutKind === "idle" ? timeoutMs : hardTimeoutMs}ms`; }
|
|
184
|
+
else if (combinedSignal.aborted) { result.stopReason = "aborted"; result.errorMessage ||= "Sub-agent aborted"; }
|
|
283
185
|
result.status = classifyStopReason(result.stopReason, result.stopReason === "aborted", result.stopReason === "timeout");
|
|
284
186
|
result.exitCode = result.status === "success" || result.status === "partial" ? 0 : 1;
|
|
285
|
-
|
|
286
187
|
return result;
|
|
287
188
|
} finally {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
cleanupCombined();
|
|
291
|
-
eventUnsubscribe?.();
|
|
292
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
293
|
-
try {
|
|
294
|
-
session.dispose();
|
|
295
|
-
} catch {
|
|
296
|
-
// Best-effort cleanup
|
|
297
|
-
}
|
|
189
|
+
unsubscribe?.(); removeAbort?.();
|
|
190
|
+
try { session.dispose(); } catch { /* best effort */ }
|
|
298
191
|
}
|
|
299
|
-
} catch (
|
|
300
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
192
|
+
} catch (error) {
|
|
301
193
|
result.exitCode = 1;
|
|
302
|
-
result.errorMessage = message;
|
|
303
|
-
|
|
304
|
-
result.status = classifyStopReason(
|
|
305
|
-
// Ensure cleanup runs even when the outer try fails before the inner finally.
|
|
306
|
-
cleanupCombined?.();
|
|
307
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
194
|
+
result.errorMessage = error instanceof Error ? error.message : String(error);
|
|
195
|
+
result.stopReason ||= "error";
|
|
196
|
+
result.status = classifyStopReason(result.stopReason, false, false);
|
|
308
197
|
return result;
|
|
198
|
+
} finally {
|
|
199
|
+
clearTimers(); cleanupCombined?.();
|
|
309
200
|
}
|
|
310
201
|
}
|
|
311
202
|
|
package/extensions/security.ts
CHANGED
|
@@ -28,11 +28,10 @@ export const MUTATION_TOOLS: readonly string[] = ["edit", "write"];
|
|
|
28
28
|
export const EXECUTION_TOOLS: readonly string[] = ["bash"];
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Default timeout
|
|
32
|
-
*
|
|
33
|
-
* the allowed range.
|
|
31
|
+
* Default inactivity timeout. Real SDK lifecycle activity resets this window;
|
|
32
|
+
* the runner separately enforces a fixed 20-minute absolute cap.
|
|
34
33
|
*/
|
|
35
|
-
export const DEFAULT_TIMEOUT_MS =
|
|
34
|
+
export const DEFAULT_TIMEOUT_MS = 3 * 60 * 1_000; // 3 minutes
|
|
36
35
|
|
|
37
36
|
/**
|
|
38
37
|
* Absolute maximum timeout. Any requested value above this cap is rejected
|
package/extensions/service.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AuthStorage, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type AgentConfig, getModelCandidates } from "./agents.ts";
|
|
3
|
-
import { runSubAgent, type SubAgentResult } from "./runner.ts";
|
|
3
|
+
import { runSubAgent, type SubAgentProgress, type SubAgentResult } from "./runner.ts";
|
|
4
4
|
import { resolveModel } from "./model.ts";
|
|
5
5
|
import {
|
|
6
6
|
validateAgentTools,
|
|
@@ -23,6 +23,7 @@ export interface SubagentRunRequest {
|
|
|
23
23
|
signal?: AbortSignal;
|
|
24
24
|
accept?: () => boolean;
|
|
25
25
|
respond: (response: SubagentRunResponse) => void;
|
|
26
|
+
onProgress?: (progress: SubAgentProgress) => void;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export type SubagentRunResponse =
|
|
@@ -38,6 +39,7 @@ export async function runNamedAgent(options: {
|
|
|
38
39
|
instructions?: string;
|
|
39
40
|
signal?: AbortSignal;
|
|
40
41
|
onMessage?: (result: SubAgentResult) => void;
|
|
42
|
+
onProgress?: (progress: SubAgentProgress) => void;
|
|
41
43
|
}): Promise<SubAgentResult> {
|
|
42
44
|
const { model, attempted } = await resolveModel(getModelCandidates(options.agent), options.ctx.model, options.ctx.modelRegistry);
|
|
43
45
|
if (!model) throw new Error(`No model resolved for agent "${options.agent.name}" (tried: ${attempted.join(", ") || "none"})`);
|
|
@@ -88,6 +90,7 @@ export async function runNamedAgent(options: {
|
|
|
88
90
|
agentName: options.agent.name,
|
|
89
91
|
thinkingLevel: options.agent.thinking,
|
|
90
92
|
onMessage: options.onMessage,
|
|
93
|
+
onProgress: options.onProgress,
|
|
91
94
|
});
|
|
92
95
|
return result;
|
|
93
96
|
} finally {
|
|
@@ -146,6 +146,14 @@ export class ThreadViewer {
|
|
|
146
146
|
lines.push(truncateToWidth(t.fg("dim", this.thread.task), width));
|
|
147
147
|
lines.push("");
|
|
148
148
|
|
|
149
|
+
if (status === "running") {
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
const elapsed = Math.floor((now - this.thread.createdAt) / 1000);
|
|
152
|
+
const activity = this.thread.lastActivityAt ? `${Math.floor((now - this.thread.lastActivityAt) / 1000)}s ago (${this.thread.lastActivityLabel})` : "none yet";
|
|
153
|
+
const idleMs = this.thread.inactivityDeadline ? this.thread.inactivityDeadline - now : 0;
|
|
154
|
+
const idle = this.thread.inactivityDeadline ? `${Math.max(0, Math.ceil(idleMs / 1000))}s remaining` : "pending";
|
|
155
|
+
lines.push(truncateToWidth(t.fg(idleMs < 30_000 ? "warning" : "muted", `Elapsed ${elapsed}s · last activity ${activity} · idle ${idle}`), width));
|
|
156
|
+
}
|
|
149
157
|
if (status === "running" && (!result || result.messages.length === 0)) {
|
|
150
158
|
lines.push(truncateToWidth(t.fg("muted", "(waiting for first message...)"), width));
|
|
151
159
|
} else if (result) {
|
package/extensions/threads.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Supports subscriptions so UIs can react to thread status changes.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { SubAgentResult } from "./runner.ts";
|
|
9
|
+
import type { SubAgentProgress, SubAgentResult } from "./runner.ts";
|
|
10
10
|
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
12
|
// Types
|
|
@@ -26,6 +26,11 @@ export interface SubagentThread {
|
|
|
26
26
|
color?: string;
|
|
27
27
|
createdAt: number;
|
|
28
28
|
updatedAt: number;
|
|
29
|
+
lastActivityAt?: number;
|
|
30
|
+
lastActivityLabel?: string;
|
|
31
|
+
lastHeartbeatAt?: number;
|
|
32
|
+
inactivityDeadline?: number;
|
|
33
|
+
hardDeadline?: number;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
// ---------------------------------------------------------------------------
|
|
@@ -84,6 +89,26 @@ export class ThreadStore {
|
|
|
84
89
|
this.notify();
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
updateProgress(id: string, progress: SubAgentProgress): void {
|
|
93
|
+
const thread = this.threads.get(id);
|
|
94
|
+
if (!thread) return;
|
|
95
|
+
thread.result = progress.result;
|
|
96
|
+
thread.lastActivityAt = progress.at;
|
|
97
|
+
thread.lastActivityLabel = progress.label;
|
|
98
|
+
thread.inactivityDeadline = progress.inactivityDeadline;
|
|
99
|
+
thread.hardDeadline = progress.hardDeadline;
|
|
100
|
+
thread.updatedAt = Date.now();
|
|
101
|
+
this.notify();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
refreshHeartbeat(id: string): void {
|
|
105
|
+
const thread = this.threads.get(id);
|
|
106
|
+
if (!thread) return;
|
|
107
|
+
thread.lastHeartbeatAt = Date.now();
|
|
108
|
+
thread.updatedAt = thread.lastHeartbeatAt;
|
|
109
|
+
this.notify();
|
|
110
|
+
}
|
|
111
|
+
|
|
87
112
|
getThread(id: string): SubagentThread | undefined {
|
|
88
113
|
return this.threads.get(id);
|
|
89
114
|
}
|
package/package.json
CHANGED