@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 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:** 10 minutes (`DEFAULT_TIMEOUT_MS`)
90
- - **Maximum:** 60 minutes (`MAX_TIMEOUT_MS`)
91
- - Timeout values must be positive integers within the allowed range.
92
- - Timeout errors are distinguishable from parent cancellation.
93
- - Progress heartbeats keep the parent transport active during quiet model work; explicit timeouts remain hard deadlines.
94
- - Parallel tasks and chain steps may have per-item timeouts.
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
 
@@ -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: "Timeout in milliseconds for this task" })),
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: "Timeout in milliseconds for this step" })),
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 for all sub-agents (overridden by per-task/step timeouts)" })),
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(() => onUpdate({
586
- content: [{ type: "text", text: `Subagent ${agentName} is still running…` }],
587
- details: heartbeatDetails?.() ?? makeDetails("single")([]),
588
- })) : undefined;
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",
@@ -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
- /** Pre-validated timeout in ms. When provided, an abort signal will be created. */
96
+ onProgress?: (progress: SubAgentProgress) => void;
85
97
  timeoutMs?: number;
98
+ hardTimeoutMs?: number;
86
99
  }): Promise<SubAgentResult> {
87
100
  const {
88
- cwd,
89
- systemPrompt,
90
- task,
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
- getPrompts: () => ({ prompts: [], diagnostics: [] }),
119
- getThemes: () => ({ themes: [], diagnostics: [] }),
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 settingsManager = SettingsManager.inMemory({
128
- compaction: { enabled: false },
129
- retry: { enabled: true, maxRetries: 1 },
130
- });
131
-
132
- // Hoisted so the outer catch can clean up on early failure.
133
- let timeoutController: AbortController | undefined;
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
- // Build combined signal from parent signal and timeout
139
- const signalsToCombine: (AbortSignal | undefined | null | false)[] = [signal];
140
-
141
- // Create timeout controller
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 isTimeout = timeoutController?.signal.aborted === true && signal?.aborted !== true;
154
- result.stopReason = isTimeout ? "timeout" : "aborted";
155
- result.errorMessage = combinedSignal.reason instanceof Error ? combinedSignal.reason.message : "Sub-agent aborted before start";
156
- result.status = classifyStopReason(result.stopReason, !isTimeout, isTimeout);
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
- const { session } = await createAgentSession({
163
- cwd,
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
- // Wire combined abort signal to session
182
- const onAbort = () => {
183
- session.abort();
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
- switch (event.type) {
207
- case "message_end": {
208
- const msg = event.message as AgentMessage;
209
- if (msg.role === "assistant") {
210
- result.usage.turns++;
211
- if (msg.usage) {
212
- result.usage.input += msg.usage.input || 0;
213
- result.usage.output += msg.usage.output || 0;
214
- result.usage.cacheRead += msg.usage.cacheRead || 0;
215
- result.usage.cacheWrite += msg.usage.cacheWrite || 0;
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 (err) {
244
- finish(() => {
245
- unsubscribe?.();
246
- reject(err);
247
- });
248
- }
171
+ } catch (error) { finish(() => reject(error)); }
249
172
  });
250
- eventUnsubscribe = unsubscribe;
251
-
252
- // Resolve on abort so the eventPromise doesn't hang
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
- await Promise.race([
266
- session.prompt(task),
267
- eventPromise,
268
- ]);
269
-
270
- // Detect timeout vs. parent abort.
271
- timedOut = timeoutController?.signal.aborted === true && signal?.aborted !== true;
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
- cleanupAbort?.();
289
- cleanupEventAbort?.();
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 (err) {
300
- const message = err instanceof Error ? err.message : String(err);
192
+ } catch (error) {
301
193
  result.exitCode = 1;
302
- result.errorMessage = message;
303
- if (!result.stopReason) result.stopReason = "error";
304
- result.status = classifyStopReason("error", false, false);
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
 
@@ -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 applied to every child execution unless an explicit timeout
32
- * is provided. Children may request a shorter-but-not-longer timeout within
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 = 10 * 60 * 1_000; // 10 minutes
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
@@ -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) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "In-process subagents for Pi with isolated SDK sessions, parallel and chained delegation, and inspectable threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",